orangain flavor

じっくりコトコト煮込んだみかん2。知らないことを知りたい。

2014年を振り返って

2014年を振り返って

2014年は対外的に評価される機会があって嬉しかったです。

MBSハッカソンは特にそうなんですが、新しい人との出会いがあり、これから楽しくなりそうです。

一方で個人で数ヶ月開発していたサービスはまだ世に出せていないので何とかしないといけません。

また、一番仲の良かった友人を亡くし、忘れられない年になりました。

 2015年に向けて

2015年に向けて面白い話を頂いていて、そのうちお知らせできるかもしれません。

最近はやりたいことの量の割に、使える時間が足りていないので、やりたいことを絞って一つ一つ実現していきたいです。

体力的にも色々ガタが来てる気がするので、健康にも気をつかって、使える時間を最大化していく必要があります。

特にPCを見続けると眼のリソースが消耗していると感じるので、ポッドキャストのように眼を使わずにできることを増やしていきたいです。

来年もよろしくお願いします。

Webサイトのクローラビリティをチェックする

これはクローラー/スクレイピング Advent Calendar 2014の18日目の記事です。

Webサイトをクローリング、スクレイピングしたいと思ったとき、はじめに何をするでしょうか?

私はとりあえずブラウザの開発者ツールでDOMを覗きますが、その後robots.txt利用規約をチェックします。

そういう作業を繰り返すうちに面倒になってきたので、URLを与えるだけで自動的にクローラビリティ(クロールしやすさ)をチェックするWebサービスをやっつけで作りました。

f:id:mi_kattun:20141219003040p:plain

Crawlability · Check the crawlability of web sites

適当にクロールしたいURLを入力してCheckボタンを押してみてください。

できること

クローラビリティとは言ってもまだコンセプトレベルで、以下のことができるだけです。

  • 指定したURLの情報を表示する
  • トップページの情報を表示する
  • robots.txtを表示する
  • よくあるXML SitemapのURLにアクセスして存在するか確かめる
  • 利用規約っぽいページにアクセスしてみる

もう少しいい感じに情報を取得できるようにしたいです。

中身

ソースコードGitHubに置いてあります。

https://github.com/capybala/crawlability

ちなみに内部では以前の記事で紹介したaiohttpを使っています。Python 3.3から使えるasyncioを使って並列にアクセスしています。

aiohttpはHTTPクライアントだけでなく、HTTPサーバーの機能も実験的ながら付いているので、Webインターフェイスはこれで作ってみました。

クローラーをデーモンとして動かす ― Scrapyd

この記事はクローラー/スクレイピング Advent Calendar 2014の12日目の記事です。

ScrapyPythonにおけるクローリング・スクレイピングフレームワークとして有名ですが、Scrapydという興味深い機能があるので今日はこれを紹介します。

Scrapydはその名の通り、Scrapyのデーモンです。サーバーにおいてサービスとして動作し、Scrapyで作ったクローラーのジョブ管理ができます。

多くのページをクロールするクローラーにおいては、1回の実行時間が1日を超えることもしばしばあるので、ジョブ管理は重要です。このように運用を考慮した機能まで備えているのがScrapyの特徴的なところです。

Scrapydの概要

Scrapydは簡単なWebインターフェイスを提供しており、主にcurlを使ってAPIを呼び出します。

http://Scrapydをインストールしたホスト:6800 にアクセスすると、以下のようなシンプルな画面が表示されます。

f:id:mi_kattun:20141212224359p:plain

詳しくは後述しますが、Scrapyプロジェクトをデプロイしてある状態で以下のコマンドを実行すると、ジョブを実行できます。

curl http://Scrapydをインストールしたホスト:6800/schedule.json -d project=プロジェクト名 -d spider=スパイダー名

「Jobs」というリンクをクリックした先で実行中のジョブの状況を見られます。ジョブごとにログやスクレイプしたアイテムを見ることもできます。

f:id:mi_kattun:20141212224405p:plain

Scrapydのメリット

通常クローラーをデーモンとして動かすときは、Cronを使うと思います。ScrapydはCronと競合するツールではなく、Cronと組み合わせて使えるツールです。Scrapydを使うことで、Cron単体で使うときに比べて以下のメリットが得られます。

  • ジョブの実行状況を一覧で把握できる。
  • 同時に実行するジョブ数を制限できる。
  • Cronで定期的にジョブを実行するだけでなく、アドホックに実行できる。
  • HTTPのAPIで外部から簡単に操作できる。

Scrapydの使い方

以下のイメージでサーバーにデプロイします。

f:id:mi_kattun:20141212225022p:plain

Scrapydのインストール(サーバー側)

普通にpipでインストールしてsupervisordなどで管理することもできますが、Ubuntu向けにaptのパッケージが提供されています。せっかくなのでこれを使って楽をしてみましょう。

$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 627220E7
$ echo 'deb http://archive.scrapy.org/ubuntu scrapy main' | sudo tee /etc/apt/sources.list.d/scrapy.list
$ sudo apt-get update
$ sudo apt-get install scrapyd

探せばDockerイメージやDockerfileを公開している人もいるので、それを使ってもよいでしょう。

※プロジェクトでScrapy以外のサードパーティライブラリ(例えばDBへのアダプタ)を使っている場合は、Scrapydと同じPython環境にインストールする必要があります。グローバル環境が汚れるのが気になる場合は、Virtualenv内にScrapydをインストールしてsupervisordなどで管理するのが良いでしょう。

初期設定(クライアント側)

Scrapyプロジェクトのフォルダにある scrapy.cfg[deploy] セクションにあるurlをScrapydのURLに書き換えます。

# Automatically created by: scrapy startproject
#
# For more information about the [deploy] section see:
# http://doc.scrapy.org/en/latest/topics/scrapyd.html

[settings]
default = helloscrapy.settings

[deploy]
# ここのurlを書き換える
url = http://192.168.33.10:6800/
project = helloscrapy

ブラウザで http://Scrapydをインストールしたホスト:6800 にアクセスして上で紹介したスクリーンショットのような画面が表示されることを確認しましょう。

デプロイ(クライアント側)

Scrapyプロジェクトのディレクトリで、以下のコマンドを実行するだけで現在のプロジェクトがegg化され、Scrapydにデプロイされます。

$ scrapy deploy
Packing version 1418386468
Deploying to project "helloscrapy" in http://192.168.33.10:6800/addversion.json
Server response (200):
{"status": "ok", "project": "helloscrapy", "version": "1418386468", "spiders": 2}

ジョブの実行(クライアント側)

以下のようにしてAPIを叩くことで、クローラーが動き始めます。

$ curl http://Scrapydをインストールしたホスト:6800/schedule.json -d project=プロジェクト名 -d spider=スパイダー名
{"status": "ok", "jobid": "722f9a7281f811e4b62808002743bc76"}

HTTPのAPIを叩くだけなので、Cronからも簡単に起動できます。Cronでありがちな環境変数の問題にハマることも少ないでしょう。また、外部のホストからも簡単にトリガーできるので、私はCronではなくJenkinsを使って定期的にクローラーを起動しています。

まとめ

いかがでしたでしょうか。若干使いにくいところもあるScrapydですが、クローラーの実行状況をまとめて管理できるので見通しが良くなります。Scrapydを使って快適なScrapy運用ライフを送りましょう!

Scrapydの機能はそんなに多くないですが、紹介しきれてはいないので詳しくはドキュメントをご覧ください。

http://scrapyd.readthedocs.org/en/latest/

今回使用したScrapyのバージョンは0.24ですが、Scrapydのドキュメントには古い記述が混ざっているようなのでお気をつけ下さい。

Pythonでクローリング・スクレイピングに使えるライブラリいろいろ

2016-12-09追記

Pythonクローリング&スクレイピング」という本を書きました!


これはクローラー/スクレイピング Advent Calendar 2014の7日目の記事です。

Pythonでクローリング・スクレイピングするにあたって、いろいろなライブラリがあるので一覧でまとめてみます。

以下の4つのカテゴリにわけて紹介します。

  • Webページを取得する
  • Webページからデータを抜き出す
  • Webページの自動操作
  • 総合的なフレームワーク

なんでこれが載ってないの?この説明はおかしい!などありましたらお気軽にお知らせください。なお、この記事はいろいろなライブラリを紹介することを目的にしているので、各ライブラリの細かい説明は他に譲ります。

サンプルで使用するPythonのバージョンは特に断りのない限り3.4で、一部3.xに対応していないライブラリは2.7を使います。

Webページを取得する

urllib.request

21.6. urllib.request — URL を開くための拡張可能なライブラリ — Python 3.4.2 ドキュメント

標準ライブラリです。Python 2.xではurllib2に相当します。

>>> from urllib.request import urlopen
>>> f = urlopen('http://qiita.com/advent-calendar/2014')
>>> f.code
200
>>> f.getheader('content-type')
'text/html; charset=utf-8'
>>> f.info().get_content_charset()
'utf-8'
>>> f.read()
b'<!DOCTYPE html><html xmlns:og="http://ogp.me/ns#"><head><meta charset="UTF-8" /><title>2014\xe5\xb9\xb4\xe3\x81\xaeAdvent Calendar\xe4\xb8\x80\xe8\xa6\xa7 - Qiita</title><meta charset="UTF-8" />...

requests

Requests: HTTP for Humans — Requests 2.5.0 documentation

HTTP for Humans、人間のためのHTTP Clientです。このサンプルではurllibとの違いがわかりにくいですが、Basic認証を使うなどHTTPヘッダーを扱うときには簡単さが際立ちます。

pip install requests
>>> import requests
>>> r = requests.get('http://qiita.com/advent-calendar/2014')
>>> r.status_code
200
>>> r.headers['content-type']
'text/html; charset=utf-8'
>>> r.encoding
'utf-8'
>>> r.text
'<!DOCTYPE html><html xmlns:og="http://ogp.me/ns#"><head><meta charset="UTF-8" /><title>2014年のAdvent Calendar一覧 - Qiita</title><meta charset="UTF-8" />...

aiohttp

KeepSafe/aiohttp

Python 3.3から使用可能なasyncioを使って非同期にページを取得できます。多数のWebサイトを高速にクロールしたいときに力を発揮するでしょう。

pip install aiohttp
>>> import asyncio
>>> import aiohttp
>>> def get_body(url):
...     response = yield from aiohttp.request('GET', url)
...     return (yield from response.text())
...
>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(get_body('http://qiita.com/advent-calendar/2014'))
'<!DOCTYPE html><html xmlns:og="http://ogp.me/ns#"><head><meta charset="UTF-8" /><title>2014年のAdvent Calendar一覧 - Qiita</title><meta charset="UTF-8" />...

scrapelib

sunlightlabs/scrapelib

クロール先のサーバーに負荷をかけ過ぎないように、間隔を自動的に調整したりエラー時に自動でリトライしてくれます。内部ではrequestsを使っています。

pip install scrapelib
>>> import scrapelib
>>> s = scrapelib.Scraper(requests_per_minute=10)
>>> r = s.get('http://qiita.com/advent-calendar/2014')
>>> type(r)
<class 'requests.models.Response'>
>>> r.status_code
200
>>> while True:
...     r = s.get('http://qiita.com/advent-calendar/2014')
...     r.status_code
... 

200
200
200
...

Webページからデータを抜き出す

html.parser

20.2. html.parser— HTML および XHTML のシンプルなパーサー — Python 3.4.2 ドキュメント

標準ライブラリです。SAX形式のイベント駆動型のAPIなので、あまり複雑な処理には向いていませんが、開始タグだけが必要な時なら手軽に使えます。

Python 2.xではHTMLParserというモジュール名でした。

>>> from html.parser import HTMLParser
>>> from urllib.request import urlopen
>>> class MyHTMLParser(HTMLParser):
...     def handle_starttag(self, tag, attrs):
...         if tag == 'a':
...             print(dict(attrs).get('href'))
...
>>> f = urlopen('http://qiita.com/advent-calendar/2014')
>>> parser = MyHTMLParser()
>>> parser.feed(f.read().decode('utf-8'))
/
/login?redirect_to=%2Fadvent-calendar%2F2014
/signup?redirect_to=%2Fadvent-calendar%2F2014
/advent-calendar
/advent-calendar/2014
/advent-calendar/2014/new
/advent-calendar/2014/muda/feed
/advent-calendar/2014/muda
/advent-calendar/2014/softlayer2/feed
...

lxml

lxml - Processing XML and HTML with Python

libxml2とlibxsltのPythonバインディングです。処理の高速さと機能の豊富さでは他の追随を許しません。

pip install lxml
>>> import lxml.html
>>> root = lxml.html.parse('http://qiita.com/advent-calendar/2014').getroot()
>>> root.cssselect('title')[0]
<Element title at 0x10b391c78>
>>> root.cssselect('title')[0].text
'2014年のAdvent Calendar一覧 - Qiita'
>>> for a in root.xpath('//a'):
...     print(a.get('href'))
...
/
/login?redirect_to=%2Fadvent-calendar%2F2014
/signup?redirect_to=%2Fadvent-calendar%2F2014
/advent-calendar
/advent-calendar/2014
/advent-calendar/2014/new
/advent-calendar/2014/muda/feed
/advent-calendar/2014/muda
/advent-calendar/2014/softlayer2/feed
...

BeautifulSoup4

Beautiful Soup: We called him Tortoise because he taught us.

3まではPure Pythonのため処理の遅さが弱点でしたが、4からはパーサーを選択できるので、lxmlを使えば高速に処理できます。標準ライブラリのパーサーを使えばC拡張を使えない環境でも役立ちます。

日本語ドキュメントが存在するのも、とっつきやすいでしょう。

pip install beautifulsoup4
>>> from urllib.request import urlopen
>>> from bs4 import BeautifulSoup
>>> f = urlopen('http://qiita.com/advent-calendar/2014')
>>> soup = BeautifulSoup(f)
>>> soup.title
<title>2014年のAdvent Calendar一覧 - Qiita</title>
>>> soup.title.string
'2014年のAdvent Calendar一覧 - Qiita'
>>> for a in soup.find_all('a'):
...     print(a.get('href'))
...
/
/login?redirect_to=%2Fadvent-calendar%2F2014
/signup?redirect_to=%2Fadvent-calendar%2F2014
/advent-calendar
/advent-calendar/2014
/advent-calendar/2014/new
/advent-calendar/2014/muda/feed
/advent-calendar/2014/muda
/advent-calendar/2014/softlayer2/feed
...

pyquery

pyquery 1.2.9 : Python Package Index

jQueryライクなAPIを提供するので、jQueryに慣れている人には馴染みやすいでしょう。内部ではlxmlが使われます。

pip install pyquery
>>> from pyquery import PyQuery as pq
>>> d = pq(url='http://qiita.com/advent-calendar/2014')
>>> d('title')
[<title>]
>>> d('title').text()
'2014年のAdvent Calendar一覧 - Qiita'
>>> for a in d('a').items():
...     print(a.attr('href'))
...
/
/login?redirect_to=%2Fadvent-calendar%2F2014
/signup?redirect_to=%2Fadvent-calendar%2F2014
/advent-calendar
/advent-calendar/2014
/advent-calendar/2014/new
/advent-calendar/2014/muda/feed
/advent-calendar/2014/muda
/advent-calendar/2014/softlayer2/feed

feedparser

feedparser 5.1.3 : Python Package Index

RSSなどのフィードをパースするための定番ライブラリです。フィードの種類に依らずに同じ書き方ができるため、標準ライブラリのxml.etree.ElementTreeなんかでパースするより簡単です。

pip install feedparser
>>> import feedparser
>>> d = feedparser.parse('http://qiita.com/advent-calendar/2014/crawler/feed')
>>> d.feed.title
'クローラー/スクレイピング Advent Calendarの投稿 - Qiita'
>>> for entry in d.entries:
...     print(entry.link)
...
http://blog.takuros.net/entry/2014/12/06/235232
http://blog.takuros.net/entry/2014/12/05/061034
http://happyou-info.hatenablog.com/entry/2014/12/04/005504
http://qiita.com/nezuq/items/3cc9772118ad112c18dc
http://blog.takuros.net/entry/2014/12/02/234959

Webページの自動操作

Mechanize

mechanize

PerlのWWW:MechanizeのPython版です。ログインが必要なページのスクレイピングに向いています。最終更新は2011年で、Python 3に対応していません。以下のサンプルはPython 2.7で動かしたものです。6日目のdkfjさんの記事と同様にAmazonアソシエイトから売上を取得するサンプルです。

pip install mechanize
pip install lxml  # lxmlはサンプルで使用しているだけで必須ではありません
>>> import mechanize
>>> import lxml.html
>>> br = mechanize.Browser()
>>> br.addheaders = [('User-agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36')]
>>> br.open('https://affiliate.amazon.co.jp')
<response_seek_wrapper at 0x1020b95f0 whose wrapped object = <closeable_response at 0x1020bd878 whose fp = <socket._fileobject object at 0x100d1a350>>>
>>> print(br.title())
Amazonアソシエイト(アフィリエイト)プログラムに参加しよう!
>>> br.select_form(name='sign_in')
>>> br['username'] = 'YOUR_EMAIL'
>>> br['password'] = 'YOUR_PASSWORD'
>>> response = br.submit()
>>> print(br.title())
Amazon アソシエイト(アフィリエイト) - ホーム
>>> root = lxml.html.parse(response).getroot()
>>> print(root.cssselect('#mini-report .line-item-total .data')[0].text)
¥0

selenium

selenium 2.44.0 : Python Package Index

SeleniumPythonバインディングです。FirefoxChromeなどのブラウザを自動操作したり、PhantomJSのようなヘッドレスブラウザを使うことができます。JavaScriptを使ったページにも対応できる点が強みです。

pip install selenium
>>> from selenium import webdriver
>>> driver = webdriver.Firefox()
>>> driver.get('https://affiliate.amazon.co.jp')
>>> driver.title
'Amazonアソシエイト(アフィリエイト)プログラムに参加しよう!'
>>> driver.find_element_by_name('username').send_keys('YOUR_EMAIL')
>>> driver.find_element_by_name('password').send_keys('YOUR_PASSWORD')
>>> driver.find_element_by_name('password').submit()
>>> driver.title
'Amazon アソシエイト(アフィリエイト) - ホーム'
>>> driver.find_element_by_css_selector('#mini-report .line-item-total .data').text
'¥0'

Splinter

Splinter — Splinter 0.7.0 documentation

SeleniumFirefox, Chrome, Remote, PhantomJSの各種ドライバーに加えて、zope.testbrowserなどもラップしており目的に応じて使い分けられます。シンプルで使いやすいAPIが特徴です。

pip install splinter

デフォルトではFirefox WebDriverが使われますが、以下のサンプルではPhantomJS WebDriverを使います。*1

PhantomJSがインストールされていない場合はインストールします。Mac以外の方はググってください。。

brew install phantomjs
>>> from splinter import Browser
>>> browser = Browser('phantomjs', user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36')
>>> browser.visit('https://affiliate.amazon.co.jp')
>>> browser.title.decode('utf-8')
'Amazonアソシエイト(アフィリエイト)プログラムに参加しよう!'
>>> browser.fill('username', 'YOUR_EMAIL')
>>> browser.fill('password', 'YOUR_PASSWORD')
>>> browser.find_by_value('サインイン').click()
>>> browser.title.decode('utf-8')
'Amazon アソシエイト(アフィリエイト) - ホーム'
>>> browser.find_by_css('#mini-report .line-item-total .data').text
'¥0'

総合的なフレームワーク

Scrapy

Scrapy | A Fast and Powerful Scraping and Web Crawling Framework

Pythonが誇るスクレイピングフレームワークです。Python 3への対応作業は進められていますが、現在はPython 2.7のみ対応です。

pip install scrapy

他のライブラリと違いフレームワークなので、インタラクティブシェルを使わずにファイルを作成します。

advent_spider.py

from scrapy import Spider, Item, Field

class AdventCalendar(Item):
    title = Field()

class AdventCalendarSpider(Spider):
    name = 'advent_spider'
    start_urls = ['http://qiita.com/advent-calendar/2014']

    def parse(self, response):
        return [AdventCalendar(title=e.extract()) for e in response.css('td.adventCalendar_calendarList_calendarTitle a:nth-child(2)::text')]
$ scrapy runspider advent_spider.py
...
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Crawled (200) <GET http://qiita.com/advent-calendar/2014> (referer: None)
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Scraped from <200 http://qiita.com/advent-calendar/2014>
{'title': u'1\u5186\u306b\u3082\u306a\u3089\u306a\u3044\u7121\u99c4\u306a\u6280\u8853'}
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Scraped from <200 http://qiita.com/advent-calendar/2014>
{'title': u'1\u5206\u3067\u5b9f\u73fe\u3067\u304d\u308b\u6709\u7528\u306a\u6280\u8853'}
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Scraped from <200 http://qiita.com/advent-calendar/2014>
{'title': u'2\u679a\u76ee SoftLayer '}
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Scraped from <200 http://qiita.com/advent-calendar/2014>
{'title': u'Abby'}
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Scraped from <200 http://qiita.com/advent-calendar/2014>
{'title': u'Adobe'}
2014-12-07 22:47:47+0900 [advent_spider] DEBUG: Scraped from <200 http://qiita.com/advent-calendar/2014>
{'title': u'AngularJS'}
...

このサンプルはフレームワークとしての通常の使い方ではないので、以前書いた記事もどうぞ。

PythonとかScrapyとか使ってクローリングやスクレイピングするノウハウを公開してみる! - orangain flavor

まとめ

いかがでしたでしょうか。いろいろな選択肢を知り、適切なものを選ぶ助けになれば幸いです。

複数のライブラリを紹介した3つのカテゴリにおいて1つを選ぶとしたら、個人的には以下のものが定番かと思います。

  • Webページを取得する:requests
  • Webページからデータを抜き出す:lxml
  • Webページの自動操作:selenium

今回調べて初めて知ったライブラリもありました。他にももっと良いものがありましたらぜひ教えて下さい。

*1:PhantomJSを使うことでFirefoxを使うよりもコードが複雑になり、seleniumのサンプルよりも若干複雑に見えますが、これに他意はありません。本来であればseleniumでもヘッドレスのPhantomJSを使いたかったのですが、seleniumでUser-agentヘッダを変更するのが面倒すぎたので仕方なくFirefoxを使いました。

#isucon 2014予選の延長戦をやってみた

予選の時間内では足りてないことばかりだったので、もう少し試行錯誤することにしました。

#isucon 2014の予選をほぼ一人で戦うハメになった話 - orangain flavor

目標は50000点、できれば60000点出したい。

予選終了時

  • Python実装
  • DBはRedisのみを使う
  • Cookieがないときだけnginxで静的ファイルを返す
  • Gunicornを使ったマルチプロセスモデル
  • ワーカー数10、ワークロード10

最終提出スコア: 32710

細々とした改良

  • nginxの設定を追加。
  • redis-pyのパーサーをhiredisに置き換え。
  • テンプレートエンジンを使わないよう変更。
  • アプリを見なおして、RedisのRead/Write数を削減。

スコアはあまり上がらず 32912

Gunicornのワーカーをmeinheldに置き換え

前回のエントリのコメントで id:methane 氏に教えていただいたmeinheldに置き換え、nginx―Gunicorn間でKeepAliveを有効にしました。PyPI最新の0.5.6です。

スコアは5000ほど上がって、 37223

副作用として、nginxでCookieがないときだけ静的なindex.htmlを返すことができなくなりました。

nginxでの良い設定方法がわからず、こんな感じで無理矢理やっていたのですが、meinheldを使うと400 Bad Requestが返ってくるようになりました。

    location = / {
      if ($http_cookie !~* "session") {
        root /home/isucon/webapp/public/;
        rewrite ^.* index.html break;
      }
      proxy_pass http://app;
      proxy_set_header Host $http_host;
      proxy_http_version 1.1;
      proxy_set_header Connection "";
    }
    location / {
      proxy_pass http://app;
      proxy_set_header Host $host;
      proxy_http_version 1.1;
      proxy_set_header Connection "";
    }

原因不明ですが、Varnishを使うともっと簡単に設定できるようになると聞いていたので、この時点では深追いしませんでした。

Varnish導入

Varnishを導入しました。yumで4.0.xをインストールしたつもりが、Amazon Linuxだと3.0.xしかインストールできずしばらくハマりました。

また、daemonの起動に失敗してもどこにもログが吐かれないのでだいぶ悩みました。 varnishd -C -f FILEvclを検証すれば良いことを知りました。

バックエンドとしてUnix Domain Socketに対応していないようなので、元の通りGunicornをTCP 8080で動かすようにしました。

静的ファイルはnginx、アプリはgunicornと振り分けるよう設定しました。 

スコアはさほど変わらず 37632

エラーページを静的に返すように

Varnishで簡単にキャッシュできるようになったので、ログイン失敗時に /?err=*** のページにリダイレクトするようにしました。

これは結構効いて、スコアは 43715 に。Varnish便利。

データをインメモリに持つように

ユーザー情報とBAN/Lockの情報をメモリに保持するようにしました。マルチプロセスなので、結局Redisにもアクセスする必要があり、苦労した割に効果は薄かったです。

スコアは上がらず、というかむしろ下がり 42533

FlaskからBottleに書き換え

プロファイリング結果を見るとFlaskで時間を食っていたので、さらにmicroなフレームワーク Bottle に置き換えました。

これは結構効果があり、スコアは 50386

Sessionを暗号化されていないCookieに置き換え

正直どうなのかという話ですが、Session Cookieの暗号化と検証を捨て、ユーザー名と最終ログイン情報をCookieに直接格納しました。

このとき、Cookieの値に空白が含まれるとベンチマーカーが意図したとおりに返してくれない問題に若干苦しみました。

スコアは若干上がり 51076

RedisからのReadをパイプライン化

attempt_loginの条件分岐の中で、細切れでRedisからReadしていたのを、パイプラインで一括取得するようにしました。

これによりRedisへのアクセスは、1リクエストあたり最大Read1回、Write1回の計2回で済むようになりました。

スコア: 52705

1回休み

Redisの設定を見ると、いつの間にかRedisのappendonlyが無効になっており、永続化されてない状態になっていました。

有効にしてスコアを測り直すと 51246 でした。

PyPyにしてみる

PyPyを使ったら速くなったりしないかなーと思って試してみますが、スコアは 46607 に下がったので却下。

PyPyに単純に置き換えただけで、標準以外のライブラリも普通に動くのだなと感動しました。

2014-10-05 23:34追記: 後ほどちゃんとPyPyに合わせてチューニングしたらCPythonよりも高いスコアがでました。詳しくは文末の追記を参照。

Varnishのチューニング

VarnishのCPU利用率が高いのでチューニングしてみました。

  • VARNISH_MIN_THREADS=200 → 50
  • -p thread_pools=4
  • -p session_linger=100

いまいちよくわからないもののスコアは若干上がり 53016

最新のmeinheldに置き換え

GitHubの最新版を使うと良いと教わったので、置き換えてみました。

スコアは若干上がり、 53872

まとめ

最終的なスコアは 53872 となりました。これ以上はどうすればスコアが上がるか思いつきません。

とりあえずの目標としてた50000は超えましたが、60000には届きませんでした。Pythonのマルチプロセスモデルだとこの辺が限界なんでしょうか。。

ちなみにワークロードやGunicornのワーカー数はいじってもスコアが上がらなかったので、変えてません。 

ソースコードと設定はGitHubで公開しています。

クーポンのおかげで、あまりお金をかけずに勉強できました。AWS様ありがとうございます。

f:id:mi_kattun:20141005123906p:plain

参考サイト

おまけ

GOGC=offでベンチマーク実行

$ GOGC=off ~/benchmarker bench --workload 10

スコア: 79324

静的ファイルを返さないように

<link>タグと<img>タグをdocument.writeで書きだすようにしました。

$ GOGC=off ~/benchmarker bench --workload 10

スコア: 180084

orangain/isucon2014-qualifier-python at no-static

2014-10-05 23:34追記:PyPyをちゃんと導入してみた

例によって id:methane 氏に教えて頂きました。ほんとうに感謝です。

PyPyへの最適化

Tornado 3.2をインストールし、GunicornのワーカーをTornadoWorkerに置き換えました。またC拡張であるhiredisもアンインストールしました。

さらにinitスクリプトの中で、JITのウォームアップとして25秒間ベンチマークを走らせました。

ベンチマーク実行中は、GunicornのCPU消費がCPythonに比べて格段に減り、ワーカー数を減らすほどスコアが上がるようになりました。ワーカー数1、ワークロード14で 54736 まで上がりました。

1プロセスへの最適化

1プロセスを前提とすれば、メモリにRedisと同じ内容を保持し、RedisへのReadアクセスを完全に無くすことができます。Readしないように書き換えたところ、同じくワーカー数1、ワークロード14で 55955 まで上がりました。

orangain/isucon2014-qualifier-python at pypy

PyPyを使ってみて

PyPyは今回初めてまともに使いましたが、サードパーティのライブラリを含めてエラーなどは全く無く、単純に置き換えただけ*1で正常に動作し、正直驚きました。

Amazon Linuxへのインストールこそ非公式配布のバイナリを使いましたが、非常に安定している印象を受けました*2

PyPyを使えば、Pythonでも1プロセスで高速に処理するGo言語のような戦い方ができることを確認できて良かったです。それでも来年はGoで出られる程度にはGo言語力を高めたいですが。

*1:もちろんサードパーティライブラリのインストールは必要ですが。

*2:ちなみに使用したPyPyのバージョンはCPython 2.7互換のPyPy 2.4です。

#isucon 2014の予選をほぼ一人で戦うハメになった話

まとめ

  • リモートでタイムゾーンが違うと大変
  • Pythonを選んだ
  • Redisのみを使う実装に書き換えた
  • 提出スコアは32710

準備したこと

事前にチームで昨年の問題を解きました。プロファイリング方法を全然わかってなかったので、制限時間内にほとんどスコアが伸びませんでした。1週間かけて勉強し、MySQLのスロークエリログやApache/nginxのログを集計する方法を知りました。

また、継続的にプロファイリングできるようにするのが大事だと痛感したので、以下の様なツールを作りました。

  • ベンチマークを回すと同時に、以下の様なデータを収集する。
  • 収集したデータを集計し、Jenkins上で成果物として保存し、スコアのグラフをプロットする。

f:id:mi_kattun:20140930001731p:plain

やったこと

10:00-

開始時刻になっても他の2人が現れないので、一人で作業することに。最初のところで、案内された手順でRuby実装が終了しない問題や、isuconユーザーでSSH接続できない問題などでかなり手間取ってしまいました。

ブラウザでの挙動、DBの構造、稼働中のミドルウェアを確認をした上で、事前に準備したツールでリクエストログやスロークエリログの集計などを行いました。

Python実装での初期スコアは 1534 でした。

11:00-

アプリのソースコードを読み、データ構造を変えたらBAN/Lockの判別はO(1)にできるし、JOINとかないからKVS向きだと判断しました。が、上位陣はMySQLのままで高いスコアを出していたので読みが甘かったかもしれません。

インメモリで高速に処理できてかつ永続化もできるKVS、ということでRedisを選択しました。Redisはこれまで話を聞くだけで実際に使ったことなかったですが、Pythonから簡単に使えました。tagomorisさんがISUCONは新しいことに挑戦できる場だと仰っていたのはほんとそう思います。 *1

12:00-

ご飯を食べ、Redisをインストールしました。yumでインストール出来ないのは辛かったです。Redisの使い方を勉強しながらアプリを書き換えました。ちゃんと書き換えられるか自信がなかったので、少しづつ書き換えて確認する戦略をとりました。

とりあえずlogin_logの処理とBAN/Lockのチェックだけを書き換えて、Failしつつもスコアが 8864 に上がったのが13:30頃でした。

14:00-

14:00になってようやく2人目のメンバーが参加しました。が、すぐに状況を把握できるわけではありません。。

login_logに初期データがあることに気づき、reportが通るようになったのが14:20頃。レポートの出力順をチェックされていたらどうしようとドキドキしてましたが、無事SUCCESS。スコアは 9250

Redisのデータがちゃんと永続化されているか自信がなかったので、サーバーを再起動して問題ないことを確認。

nginxで静的ファイルを返すよう設定してもらいました。

15:00-

最後までMySQLで処理していたログイン時のユーザー情報取得もRedisに移し、ついでにパスワードは平文で比較するようにして、スコアは 9623 に。

初期化処理でMySQLからRedisにデータをロードする処理が遅くてイライラしてきたので、予め初期化済みのaofファイルを用意して置き換えるようにしました。これによって軽快にベンチを回すことができるようになりました。

nginxで静的ファイルを返すとContent-Typeが正しく設定されないという罠に引っかかって二人で直しました。

16:00-

この辺でworkloadを増やすとスコアが上がることに気づくものの、cannot assign requested addressというエラーが出るようになります。

カーネルパラメータを調整し、workload 10でfailしなくなったのが16:30で、スコアは 30694

RedisをUnix Domain Socketで繋いだら速くなるかなと設定してみてもそれほど変わらず、スコアは 31059。 

nginxのworker_processesを4に増やすと 31650 に。この辺からスコアが伸びなくなります。

17:00-

nginxとgunicornをUnix Domain Socketで繋ぐも、さほど変わらず。

セッションクッキーがない場合だけnginxで静的ファイルを返すよう設定を試みましたが、二人共詳しくなかったため、なかなかうまく設定できず。

そんな中3人目がようやく登場するも、なにができるわけでもなしw もう諦めようかと言いながらも、どうにかして設定したのが 17:40。スコアは 32710。これが最終スコアになりました。

この後、いろいろなworkload、gunicornのプロセス数を試すも、スコアは上がりませんでした。高速化できる場所も思いつかずに終了しました。

反省点

後半ちゃんとボトルネックを特定できておらず、ほとんどスコアが上がらなかったのは反省点です。サーバー起動時にオンメモリにデータを持つようにすればもっと上がった気がします。

ツールを用意したものの、肝心のnginxのレスポンスタイムを出力するのを忘れていたりと全然活用できませんでした>< 自分が直前に作ったコマンドは使い慣れてないし、微妙に使いやすくないし、心の何処かで自分を信用できてなくてほんとダメでした。

というか、Redisへの書き換えに3時間ほど集中したおかげで色々吹っ飛びました。こういう時に冷静な仲間が居るといいんだろうなと思います。

まとめ

今回はじめて参加しましたが、非常に楽しい一日を過ごすことができました。運営の皆様、チームのメンバー、ありがとうございました!

*1:supervisordも去年の問題を練習で解いた時に初めて使い方を知りました。

プロフェッショナルのための実践Heroku入門 の紹介

以前公開した、 The Twelve-Factor App日本語訳が書籍に収録されるということで、一冊頂けました。ありがとうございます。せっかくなので簡単に紹介します。

f:id:mi_kattun:20140917204846j:plain

Herokuの哲学

私が初めてHerokuを触ったとき*1に感動したのはそのオープンさです。Heroku独自の仕様は少なく、Git, Procfile, Buildpackなどのオープンな仕様に加え、各言語のデファクトスタンダードな方法(Pythonであればpipとrequirements.txt)で依存関係を管理すれば良いことにとても好感を抱きました。

そんなHerokuを支える哲学として気に入った部分を2つ書籍から引用します。

Herokuはアプリケーション開発者の生産性を最大化することに常に焦点を当てています。世界を変える新しいアイデアをソフトウェアという形で世に送り出し、実際に世界を変えていくことを可能にするために、アプリケーション開発者自身がその創造力、時間、コストを本当に価値のある活動だけに100%集中できるようなプラットフォームでありたいと願っています。
Herokuは、アプリケーション開発者にとって、生産性が高い開発環境とは、彼らの手に馴染んだツールや経験値を生かすことができる環境―すなわち各アプリケーション開発者が自分たちの手元に構築している自分のための開発環境―こそがアプリケーション開発者の生産性を最大化させるのだと判断しました。

プロフェッショナル向けのガイド

もちろん本書は哲学だけでなく、実践的な書籍です。

最初の一歩であるHerokuアカウントの作成から、Dynoの冗長化・カスタムドメインSSL・データベース運用などの本番環境での利用までを丁寧に解説しているため、これから本格的に触ってみるという人にはオススメです。

また、デプロイ時のSlugコンパイラの動作やDynoの動作環境など、Herokuの具体的なアーキテクチャについては知らなかったため、勉強になりました。

最初に書いたとおり、The Twelve-Factor Appの日本語訳も掲載されています。これはHeroku上で強制されるある種の制約が、スケーラビリティやポータビリティを生み出す方法論であることを言語化して教えてくれる文書です。

目次

最後に目次を載せておきます。 

はじめに
Herokuの哲学
プロフェッショナルなアプリケーション開発者
Herokuの歴史
本書の構成

第1章 Herokuの概要
1.1 本章の内容
1.2 Herokuとは
1.3 さまざまな種類のクラウドサービス
1.4 Herokuの特徴

第2章 Herokuの利用準備
2.1 本章の内容
2.2 Heroku利用準備
2.3 アプリケーションの作成からデプロイまで
2.4 Ruby開発環境の構築
2.5 Node.js開発環境の構築
2.6 Scala開発環境の構築
2.7 Java開発環境の構築

第3章 アプリケーション開発のポイント
3.1 本章の内容
3.2 データベースの選定
3.3 外部ストレージ
3.4 レスポンス時間制限
3.5 Slug制限
3.6 IPアドレス制限
3.7 Procfile
3.8 Foremanによるローカル環境デプロイ
3.9 ステージング環境構築
3.10 ステージング環境のBasic認証設定

第4章 アドオンによる機能追加
4.1 本章の内容
4.2 アドオンとは
4.3 アドオンの基本操作
4.4 代表的なアドオンの紹介

第5章 本番環境への移行
5.1 本章の内容
5.2 Production Check
5.3 Cederスタックの利用
5.4 Dynoの冗長化
5.5 プロダクションレベルのデータベースの利用
5.6 カスタムドメインの利用
5.7 SSLの導入
5.8 カスタムエラーページの設置

第6章 Heroku Postgres
6.1 本章の内容
6.2 Heroku Postgresのサービス
6.3 基本機能
6.4 Heroku Postgresの拡張機能

第7章 トラブルシューティング
7.1 本章の内容
7.2 Herokuのオフィシャルサポート
7.3 よくあるトラブル
7.4 Herokuのエラーコード

第8章 Herokuのアーキテクチャ
8.1 本章の内容
8.2 Herokuの仕組み

第9章 The Twelve Factor App
9.1 はじめに
9.2 背景
9.3 このドキュメントの対象者
9.4 The Twelve Factors

*1:私が触ったときには既にCederスタックでした。Herokuの歴史も書かれてて興味深いです。