As Sloth As Possible

可能な限りナマケモノでありたい

iOS Advent Calendar 2011 5日目 / NSURLProtocolの使い方

どうも、「iOS Advent Calendar 2011」5日目担当のfaultierです。つい最近使ったのでNSURLProtocolネタで。

NSURLProtocolって何?

Foundationフレームワークで最初から扱えるプロトコルはhttp、https、ftp、fileの4つ。これ以外のプロトコルでの通信をNSURLConnectionやNSURLDownloadなどで扱う場合や、特定のリクエストに限って特別な処理をしたい場合などに、NSURLProtocolを継承して登録することで使えるようになる。ちなみに、他のアプリからopenURLしたときにアプリを起動させるカスタムURLスキームとはまた別なので注意。こちらはアプリ内でURL Loading Systemを使うときにだけ影響するもの。

使い方

最低限必要なのは、+canInitWithRequest:、+canonicalRequestForRequest:、-startLoading、-stopLoadingの4つ。

まずはこんな感じで、どんなリクエストのときにそのURLProtocolをが処理するのかを決める。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
    return [[[request URL] scheme] isEqualToString:@"udon"];
}

ここでYESを返すとこのクラスがインスタンス化されて通信処理に進む。NOの場合は他に登録されているURLProtocolのこのメソッドが登録時の逆順に呼ばれて行く。この場合はudonというスキーム、例えばudon://marukame/bukkake/coolみたいなURLへのリクエストの時に処理をすることになる。別にこれは独自のスキームである必要ではなく、httpやfileなどをフックすることもできるし、特定のホストや特殊なヘッダが付いている時だけ処理するようなこともできる。

次は+canonicalRequestForRequest:。リクエストをcanonicalな形に変える必要がある場合はここで弄るのだけど、特に何もすることが無ければrequestをそのまま返してやればいい。

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
    return request;
}

その後実際の通信が始まり、-startLoadingが呼ばれる。NSURLProtocolのオブジェクトは、id<NSURLProtocolClient>のclientと、NSURLRequestのrequestというプロパティを持っているので、requestからどんなリソースが必要なのかを判断して、clientに対してデータを返してやる、という形で通信の中継をする。NSURLProtocolClientのメソッドは大体NSURLConnectionのdelegateと対応しているので、NSURLConnectionでの通信を実装したことがあれば分かるはず。NSURLConnection側でキャンセルされたときはstopLoadingが呼ばれる。簡単な例としては以下のようになる。

- (void)startLoading
{
    // 本来非同期で通信するのだけど、
    // この例では単に文字列データを返すだけなので、
    // その場で返してしまう
    NSData *data = [@"うどんが食べたい" dataUsingEncoding:NSUTF8StringEncoding];
    NSDictionary *headers = [NSDictionary dictionaryWithObjectsAndKeys:
                              @"text/plain", @"Content-Type",
                              [NSString stringWithFormat:@"%d", [data length]], @"Content-Length",
                              nil];
    NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:[self.request URL]
                                                              statusCode:200
                                                             HTTPVersion:@"1.1"
                                                            headerFields:headers];
    // NSURLResponseのオブジェクトを返し、
    [self.client URLProtocol:self
          didReceiveResponse:response
          cacheStoragePolicy:NSURLCacheStorageAllowedInMemoryOnly];
    // データを渡し、
    [self.client URLProtocol:self didLoadData:data];
    // 通信を終了。
    // 通信失敗の場合は -URLProtocol:didFailWithError:を呼ぶ。
    [self.client URLProtocolDidFinishLoading:self];
}

- (void)stopLoading
{
    // この例ではキャンセルしようが無い…
    // 内部で別なNSURLConnectionを使っていたり、
    // NSOperationQueueやGCDなどで非同期処理をしている場合、
    // ここでキャンセルの処理を実行する。
}

これだけだとudonスキームはまだURL Loading Systemに登録されていないので、適宜NSURLProtocolの+registerClass:、+unregisterClass:呼んであげることで使えるようになる。その独自スキームを使うUIViewControllerとか、あるいはもうクラスのloadメソッドで登録してしまうとか。

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [NSURLProtocol registerClass:[self class]];
    });
}

使いどころ

それどういうときに使うの?http以外のプロトコルとかそんな実装しないよ?と思うかもしれないけど、意外と使い道がある。UIWebView等での通信は裏側でNSURLConnectionを使っているので、WebView内での通信をフックしてアプリ側で弄ったりできるのだ。例えばCoreDataにあるデータをJSON形式にして返すようにしておくとか、JSからのトリガーでアプリ内のバックグラウンド処理を走らせるとか、画像をファイルキャッシュしておいてオフラインでも表示するとか、複数の異なる形式のWebAPIをプロキシして同じ形で扱えるようにしたり、ということも簡単にできる。

また、逆に「特定のリクエストに限って特別な処理をする」ではなく「特定のリクエストはスルーするけど他はブロックする」のような使い方もできる。最近(というには結構前からだけど)話題になったのは、iPhoneのWebViewにこういう脆弱性がある、という話。

何故デバイスのアドレス帳や着信履歴みたいなのの生のデータにJSからアクセスできる必要があるのか良く分からないし、おかしな仕様だと思うのでバージョンアップで塞がれるだろうとは思うけど、少なくともiOS5.0まではこの問題は残っているので、「アプリ内でUIWebViewにloadHTMLStringさせて、外部から取得したHTMLをfile://等のスキームで表示させている」ような場合には対処する必要がある。簡単な対策としてはきちんとbaseURLを設定しておけばいいのだけど、そうすると今度はCSS、JS、画像などのバンドル内部に持っているリソースが使えなくなる。「外部から取得したHTMLをアプリ内のUIWebViewで表示したいけど、アプリ内の安全なリソースにはアクセスさせたい」場合に、NSURLProtocolで通信をフックする仕組みが使える。

前者の記事だと、NSURLProtocolのサブクラスでバンドル内のファイルを開いて、そのデータを返してしまう、というやり方をしている。また、後者の記事やPhoneGapの実装では、NSURLProtocolの「登録時の逆順に対応できるかどうかチェックする」「YESを返せばそこで止まり、NOを返すと次のNSURLProtocolのチェックに進む」という性質を利用して、「ホワイトリストに載ってないリクエストの場合は、canInitWithRequest:でYESを返した上でエラーなり空レスポンスなりを返し、ホワイトリストに載っている場合はNOを返して通常のhttpやfileプロトコルとして処理させる」という方法でサンドボックス外のリソースにアクセスさせないようにしている。

まとめ

  • NSURLProtocolを使えばさくっと独自スキームを定義できるよ
  • WebViewみたいに細かく制御しづらいものの内部の通信もフックできるよ
  • セキュリティ面でも意味があるよ

という感じなので、是非試してみてください。

faultierfaulist  at 16:39  | コメント( 0 )  | トラックバック( 0 )  | この記事をクリップ!

OPERA実験が気になる

件の「ニュー速実験」ことOPERA実験のニュースがなかなか面白そうで気になる。ここんとこシュタゲにハマりまくってたタイミングで「CERNが!光速を!超えた!」なんてニュースが来たら、これはwktkせざるを得ませんなドゥフフ、なんだけど、まぁそれを差し引いても驚くべき話ではある。

OPERA実験ってなんだったのかなーと思って色々見てみたのだけど、ええと、「スイスからイタリアに向けて素粒子ニュートリノを打ったら、どうやら光速を超える速度で到達しているらしい。精密に測定してるはずだけど、計測誤差の範囲を超える数値で"速い"ようだし、統計的に十分な回数試行しても再現する。なんぞこれ」っていう話だったという理解でいいのだろうか。ふむ、なんか凄そうだ。もうちょっとキーワードを追ってみる。

  • ニュートリノ、と言えば、小柴先生が史上初めて自然に発生したニュートリノを観測してノーベル物理学賞を受賞しているわけだけれど、その小柴先生のチームは超新星爆発の際発生したニュートリノは可視光とほぼ同じ速さで到達していることを観測していて、今回のOPERA実験とは矛盾する。
  • 正の質量を持つ物質をどれだけ加速させても、理論上光速を超える速度は出ないとされている。今までにそういう物質や現象は確認されていない。
  • 特殊相対論に反しない形で、虚数の質量を持ち、エネルギーを失なえば失なうほど減速し、どんなに減速しても常に超光速であり光速以下にならないタキオンの存在は理論上仮定されているが、実験によって観測された例はいまのところ無い。
  • ニュートリノは質量を持つことが実証されているので、質量0や虚数質量の粒子ではない。件のOPERA実験チームもニュートリノ振動を観測するための研究をしているらしい。

みたいな。うーん。「とりあえず他の研究機関が同様の実験をしてみるまでは結論保留で、あと他の方法でこの現象を確認する良いアイディアあったらよろ」って段階っぽい。そりゃそうだよなぁ。素人目にも、少なくとも「アインシュタインの相対論は間違っていた!」とか「CERNはタイムマシンを開発しようとしている!」みたいな報道は早計すぎんだろと思う。「これでタイムマシンも作れちゃいますねー」なんて、コメント求められた専門家が言ったんだとしても冗談半分のリップサービスか、聞いてきた記者が理解できなくてセンセーショナルなところだけ抜き出して書いちゃった感。もちろんそれでも面白そうな話題であることに違いは無いんだけども。

それにしても折角面白そうな話題なのに解説読んでも半分も理解できないの寂しい。一体どこから勉強しなおせばちゃんと理解できるんだろ。それこそタイムマシンでも使って高校生くらいからやり直さないとだめかしら。誰か相対論とか場の量子論が何言ってるか分かるようになるまでに何が必要か教えてくだしあ。

あと全然関係ないけど「Operaは光速より速いブラウザ」って誰かが言い出す、あるいはもう言ってるに76円28銭賭ける。

faultierfaulist  at 15:01  | コメント( 0 )  | トラックバック( 0 )  | この記事をクリップ!

git flowを使ってみることにした

ふと思い立ってA successful Git branching modelとかを今頃読んでみたり。

最近のお仕事、基本的に一人でiPhone/Androidアプリを作ってて同じコードを複数人で弄ることがなくて、しかも大体の案件が一月くらいかけて一から作るとか丸ごと作り直すとかそういう感じなので、割と開発プロセスとか考えずにフリーダムにやっちゃってるので、まぁあまりよろしくないなと反省をしている。かといってgitの機能を隅から隅まで使いこなしてるとか自分にとって最高にやりやすい状況になってるわけでもなく、むしろあんまり開発サイクルが上手く回ってなくて、でも人にすぐ渡せる状態かというとそうでもなく、というわけで意識的になんかの約束事に乗ってみることにした。

で。ついでにgitconfigいじってたりああそう言えばと思い出してzshrcいじったりしてたら日が暮れてた。開発環境整備すんのなんであんな楽しいんだろな。やりすぎ良くない。

faultierfaulist  at 00:57  | コメント( 0 )  | トラックバック( 0 )  | この記事をクリップ!

ちょっと見てて面白かったので

なんか金縛りにあって眠れなくなったのでTwitter見てたら面白いやりとりをみかけた。すげー大雑把に要約すると、こんな感じ。

  1. Railsの勉強をしてる人が「なんでHello worldしたいだけなのにDB必要なの!?DB使うにしたってMySQLなんか今必要じゃないしチュートリアルではSQLiteとかにしといてくれたっていいのに!プリプリ!」みたいな呟きをする
  2. 「いやRails使うんなら普通DB要るでしょ。なんでそこに文句言ってるの。」とツッコミが入る
  3. 「とりあえずチュートリアルは一番簡単なやり方書いといてくれた方が親切じゃないですか。」と反論
  4. 「いやだから、Railsを使うのに一番当たり前の構成を作るのにはどうしたらいいかがチュートリアルでしょ。それが必要ないんなら別なの、例えばSinatraとか使えばいいじゃん。フレームワークの特性も分からずに使っておいてチュートリアルがおかしいとか筋違いだろ。」と再度ツッコミ
  5. 「Railsがどんな特性のフレームワークとか知らないから勉強してるのに…最初っからそういうの全部考えてやれって言われても…僕はただRailsを手っ取り早く学ぶ手段があればいいのにって思っただけなのに…」みたいに凹んでしまう

ってな感じ。んで、それと隣接して外野で「最初は知らないことだらけなんだからおまじないだって言っておけばいいじゃん、フレームワークの特性がーとか大鑑巨砲主義がーとかどうでもいいだろ、やってみなきゃ分かんないことだってあんじゃん」「いや不要なとこに不要なもの使おうとしてるから適切なのはこっちだって教えてやっただけだろ、なんでわざわざ面倒なことやろうとしてるのにフレームワークに文句付けてんだよ」みたいな論争が若干生じてたりとか。

なんちゅーかね。この件に関して言えば、「それRailsに文句付けるとこじゃねーよ」って指摘は妥当だとは思う。Railsは「中規模のアプリケーションを、一般的な構成で」作るときに楽になるように設計されてるものなので、それを知らないまま進むと大概後で苦労する(レールに乗るから楽なんであって、レールから外れると荒野だ。今は大分良いけど、昔は黒魔術に手を染めないとレールから外れることすらできなかったんだよ!)。

そんなの知らないから勉強してるんだろ、それは後から考えさせろって?いや、WAFの選択って、「どんな目的に何を使うか」も重要なことなので、何かWAFを学ぶってときにはそういう選択の力も付ける必要はあると思うよ。WAF一般について知りたいんじゃないんだ、Railsについて勉強してるんだって?だったらとりあえずRailsの流儀に沿っておきなよ、何か意味があるからそういう構成になってるんだよ。分からないなら文句付けてないで、何でそうなってるか考えようよ。

少しまとめる。

  • 何かにとりかかろうとして、チュートリアルの時点で「なんでこんなことせにゃならんのだ?」って思うなら、「自分がやろうとしてることに適切じゃないものを使おうとしている」か、「今取り組んでいることを正しく理解できてない」可能性を先に疑った方がいい。大概の場合は「あー俺が間違えてたわー」ってなるか「あーこういうことだったのかー」って後で分かる。まぁ本当に提供側が不親切だったりいい加減だったりすることもままあるけど、それにつっこめるのはある程度使い熟せてからだ。
  • 教える側は「これはおまじないだから、そういうことだって思ってやりなさい」を安易に使うべきじゃない。何にか躓いてるときは、何が間違ってて何が理解できてないのか把握する・させるチャンスなので、そのタイミングで教えてあげればいいのにと思う。あんまり深入りさせてなかなか最初のステップに到達しないのはアレだけど、「おまじないだからとりあえず無視して」って言っちゃうとほんの少し潜ってみる足掛かりすら失なう。
  • でも、分からないことを責めるな、とは思う。「うぇぶかいはつしゃもすなるれいるずをしてみんとてするなり」みたいな人に「お前はゴキブリ退治に第七艦隊を呼ぶのか?」みたいなツッコミ入れたってそもそも分かってなかったからそんなことしてるんでしょと。単に教えてあげればいいじゃない。

どうでもいいけどRailsは「最初の一歩」には丸っきり向いてないと思うんだけど、なんでみんなRailsやりたがるし教えたがるんだろね。不思議。

faultierfaulist  at 03:24  | コメント( 0 )  | トラックバック( 0 )  | この記事をクリップ!

#isucon で学ぶWebアプリの高速化の話

あるいは、お遊びチーム2号は一体何をしていたのかについて

ISUCONという大変白熱した楽しいお祭を開催するにあたって、その前夜祭的な環境試験のためのチューニング祭が社内の有志数名で行われていて、そのときに色々学んだことをおまけとして書いておきます。

ISUCONて何?

下記参照。

要するに、「閲覧者視点での振る舞いさえ満たしてくれれば何をしようと構わんからWebアプリのレスポンスを改善しなさい」というお題で、誰が一番速くできるか競うイベント。

最初にある程度環境が整備されてるサーバ4台と、主催者側が用意した参考実装のアプリとテスト用データが渡される。このアプリってのはごくシンプルな個人ブログだと思ってもらえば良くて、最新記事10件が表示されるインデックスページと、記事全文が見られる詳細ページがあり、記事の投稿とコメントの投稿ができて即時反映される。全てのページには「最近コメントが付いた記事10件」が表示されるサイドバーがあり、ヘッダ画像、スタイルシート、JavaScriptが読み込まれる。テストデータにはそれなりに大量の記事データとコメントデータが入っている。フロントのWebサーバはApache、DBはMySQL、アプリはPerlとRubyとNode.jsのものが用意されている。

「お遊び組」って?

ISUCONは見学席が用意されていたのだけど、参加者以外はぶっちゃけ暇なので、空いたサーバ1チーム分を使って好きに遊んでいいことになっていた。で。そのお遊び用サーバで、事前に社内βで散々いじくり倒した俺とhidedenさんが空気読まずに自分のアプリで参加者達に対抗する、という大人気ないことをしていた。(すいません…)

一応ちゃんと言っておくと、中の人なので裏ルールや罠の存在についてある程度聞いていた上、6時間どころか2日3日費して試行錯誤していて、加えてhidedenさんに圧倒的な差を付けられた時は教えを乞うなどしていて、完全なるチートです。正直あの短時間でここまでやってくるって本当凄いな参加者のみなさん、と感動しっぱなしでした。勝手に熱くなって最後の方はムキになってたのは内緒です。

※ あ、でもでも、参加者の条件と対等じゃない「強くてニューゲーム」だった(会場に着くやいなや会社に行ってソース取って来た)という意味での「チート」で、コンテスト向けの実運用ではありえない実装にするとかはしてないです。なるべく突飛なことをせず普通にWAFを使い普通に配置するように心掛けてます。サーバのセットアップとかは手伝って貰ってるけど、アプリの実装は一人でやってるし、戦略そのものも自分で考えてます。

というわけで以下は俺のアプリで実際にやったこと。お遊び組ベストスコアを出した方とは別のアプリです。ちなみに使用言語はRuby。隣に座ったCTOと向かいに座った部長に「うちPerlの会社だからね?」とニヤニヤされながらも100%趣味全開のチョイス。「言語処理系の性能の違いが戦力の決定的差では無いということを教えてやる!」と赤くて速い人気取りで息巻いてみたものの、実際問題別な意味で全くその通りだった

糞クエリ対策とキャッシュ

殆どのチームがまずはクエリの見直しとDBのチューニングに手を付けてた様子。テーブル構造の見直しからMySQLのオプションやストレージエンジンの変更とかをやってたチームもあったみたいだけど、俺は必要なカラムにインデックスを貼る程度に留めて、データをガンガンキャッシュしてなるべくDBまで到達しないようにする戦略にすることにした。

まぁまずみんな真っ先に気付いてたところとしては、アプリ内に非常に残念なクエリがわざと仕込んであるということ。素晴しく分かりやすいお手本のような糞クエリだった。サイドバーのデータを取得するのが次のようなクエリ。

SELECT a.id, a.title
FROM comment c
LEFT JOIN article a ON c.article = a.id
GROUP BY a.id
ORDER BY MAX(c.created_at) DESC LIMIT 10;

記事が数千件、コメントが十数万件あるので、これは大分辛い。しかもサイドバーは全ページに表示されるので、全てのリクエストでこのクエリが発行されるという鬼畜さ。なので、次のように変更する。

  1. キャッシュからサイドバーに表示する記事IDのリスト取得を試みる
    1. なければ、commentテーブルから記事IDのみのリストを取得する
    2. キャッシュする
  2. 記事IDのリストを元に、キャッシュから記事データ10件のリスト取得を試みる
    1. それでも無かった数件をarticleテーブルからWHERE id INで取得する
    2. キャッシュする

あと、サイドバー・インデックスページ・記事詳細ページでそれぞれ必要なカラムだけを取って使ってたけど、これは逆にID・タイトル・本文・投稿日時全てを取得してIDをキーにキャッシュに突っ込むようにした。こうすることでサイドバーとインデックスと記事詳細でキャッシュを共有できるようになる上、コメントの投稿も記事の参照も新しい記事に偏っているので、サイドバーを読み込む時点では記事データはほぼ全てキャッシュに載っていることが期待できる。

commentテーブルから記事IDのリストを取得してるところはまだ重いと思うけど、当初のクエリよりは遥かに速いし、並行してコメントが投稿されたときの整合性の担保とかするのが地味に面倒だったので、とりあえず後回しにする。

コメントはコメントのIDではなく記事のIDでまとめてキャッシュするようにした。ページングや並べかえ、コメントの削除や編集などは仕様になく、コメントのパーマリンクなどもないので記事ページ以外では表示されないため、1回で全部取れるのがよかろうという判断。それらがあるようだったら、記事と同じようにコメントIDをキーにして「リストの順番や内容が変更されてもIDだけを取ってくるようにして、データ自体はキャッシュに載ってるものは使う」みたいにしたかもしれない。でもあんまりDBやmemcachedへのリクエストが増え過ぎるのはアレなので何らかのまとまりでキャッシュするかなぁ、とか色々考えてたけど、複雑になって余計悪化する、みたいなことにもなりかねないし、微妙に難しい。これもとりあえずこれでいいやってことにして後回し。

ノンブロッキングなフレームワーク

若干工夫してみたのは、フレームワークにはGoliathを選んだあたり。EventMachineベースのRubyのWebアプリケーションフレームワークおよびサーバで、これでI/Oを多重化して、同時接続数が増えてきても重いI/O処理でブロックしないで効率的に処理してくれることを期待する。まぁ、ぶっちゃけこれは実際にはそんなに効果なかった。

当初はPOSTのリクエストがばんばん飛んでくるとか、複数のテーブルに跨ってデータを取ってきて複雑な集約をするとか、画像アップロードみたいな長時間コネクション張り続けるようなリクエストがあるかなとか、そういう状況を想定してたんだけど、実際にはGETの比率のが圧倒的に高いしテーブル構造もシンプルで投稿はテキストのみ、みたいなパターンだったので「並列にI/O処理をする」ことがあまりなかった。

「遅延書き込みをするようにしてPOSTの際は即座にレスポンスを返してしまう」みたいなこともちょっとはやろうとしてたのだけど、「POSTリクエスト完了後1秒以内に表示に反映すること」ってルールにひっかかってテスト通らないことが頻繁にあったりして地味に嫌な感じになったので方針転換した。ここはむしろ同期処理にして「終わったら即書き込み即キャッシュ破棄」するようにして(実際には反映までにかかる時間に大差は無いはずなんだけど、こう振る舞った方がクライアント側からは速く反映されてるように見える)、POSTでは若干の時間をかけてしまってもいい、それよりもGETリクエストが来たときに既に準備が整ってるようにここで再取得再キャッシュまでやってしまう。

同時接続数の方はどうかというと、そもそもベンチマークスクリプトの並列数が最大10とかだったしコネクションも一瞬で切断されるし、じゃあワーカプロセスを10個立ち上げちゃえばいいじゃん、というオチが付いた。CPUもメモリもスッカスカでアプリサーバが遊んでたし、RubyやPythonみたいな言語を使う分には、1プロセス辺りの並列処理の効率化みたいなとこよりもUnicornの割り切りっぷりの方が現状理にかなってると思う。てことで言えばぶっちゃけ最初の素のSinatraで別に問題なかったような…どうしてもEventMachine使いたいならRainbows!Async Sinatraとかでも良かったような…まぁもう書いちゃったしいいか…。

余談だけどGoliath採用に併せて関連ライブラリも選び直すことになったので、MySQLクライアントにはMysql2を、mechachedクライアントにはremcachedを、ついでに趣味でテンプレートエンジンにSlimを使った。Mysql2やSlimは大分良かったので何か機会があれば使っていきたいところ。

Web「アプリ」って何?

とにかくもうDBに複雑なことさせたら負け、それ以前にDBに行ったら負け、と来たら次に来るのは「ていうかアプリに行ったら負け」。この段階ではフロントのWebサーバはアプリサーバ2台のロードバランサとしての仕事しかしていなくて、静的ファイルもアプリ側でファイル読んで返してたし、更新してないページも毎回アプリにリクエストが来ていた。バックエンドのアプリケーションサーバがどんなに速くなってもフロントエンドのWebサーバの処理速度とは文字通り桁が違うので、できればなるべくバックエンドに行って欲しくない。ならばということで、フロントエンドでページ丸ごとキャッシュしてGETリクエストは全部そっちで返してしまうことにする。

VCLの記述力やキャッシュ管理のしやすさ、ESI機能、あと名前のかっこよさなどが魅力的だったので、最初はVarnishを使ってみた。directorでアプリサーバ2台をまとめて、GET以外のリクエストは素通りするようにして、GETのレスポンスはページ単位でキャッシュするようにする。このままだと当然「投稿は1秒以内に反映されること」というルールが満たせないので、HTTPでパージできるように設定して、POSTのリクエストを処理したらアプリ側からVarnishにPURGEリクエストを送るようにした。

そうなると今度はキャッシュの破棄のタイミングが問題になってくる。記事の投稿はまぁいい。記事が投稿されて内容が更新されるのはインデックスページだけなので、1ページ破棄してやればいい。が、問題はコメントの方。コメントが投稿されるとまず記事ページが更新されて、「最近コメントが付いた記事10件」が表示されるサイドバーも更新される。が、このサイドバーは全てのページで表示されている。どこかの記事に1件コメントされる度に全ページのキャッシュが破棄されてたのでは殆どキャッシュの意味を為さない。なのでサイドバーは各ページのレンダリング時には生成せず、ESIでVarnishの段階でincludeさせるようにした。こうすれば、コメントが投稿されたときにパージするのは該当する記事ページとサイドバーだけになる。

そこまでやると最初の数回とPOST直後の数回以外は全部Varnishが返してくれるようになるので、アプリサーバの方は殆ど遊んでいる状態になる。前の段で「リソース余ってるからガンガンプロセス増やしちまおうぜ」って言えたのはこのおかげ。数万リクエスト処理しても数百とか数千くらいしか後ろに到達しなくて、さらにその後ろのDBまで行くのはもっと絞られてくる。この段階で初期状態から10倍くらいパフォーマンスが向上している。

ところがこの辺りで地味に嫌な罠を踏む。VarnishでESIを使うとContent-Lengthヘッダを返さなくなるので、Keep-Aliveで接続してるクライアントがいつレスポンスが終わったのかよくわからなくてタイムアウトするまで一部のクライアントできちんと扱えないのにKeep-Aliveのリクエストを送ってきたときにいつレスポンスが完了したのか判断できず、コネクションが切れるまで待ち続けてしまう。設定でどうにかできそうな気がするけど不慣れなVarnishに四苦八苦してなかなかうまくいかず、前段にもう一段Nginxを立ててリクエスト/レスポンスをいじってみたりするも今度は多段にしたのが祟ってそのオーバーヘッドで大分パフォーマンスが落ちてしまう。

結局Keep-Alive問題は真面目に対応するのをやめていかなる場合でも無効にしてしまおうかーとか考えてるあたりで、hidedenさんのNginx/SSI+SCGI構成にダブルスコアをつけられてしまって、NginxマジはえーほんとパネェつかVarnishってぶっちゃけNginxより遅いのに何で選んだの、みたいな声に負けてVarnishと戯れるのを放棄することにした。いや多分、俺のVarnish力の低さのせいで真の実力を発揮できてなかっただけでそこまでオワコンでは無いと信じたいのだけど、と一応擁護しておく。でもしばらくはNginx一択だけど。

ちなみに、ベンチマークスクリプトはそもそもKeep-Aliveに対応してないその「一部のクライアント」と同じ挙動をしていたわけではないので、この時点ではこの問題は割り切って無視するという選択肢もあった。が、本番当日の講評の際にkazeburoさんが恐しい罠を仕込んでたことを知らされる(っていうか社内では普通に話してたらしいけど聞いてなかった)。なんと、HTTP1.1じゃない持続的接続ができないのにそうできるかのように偽装してKeep-Aliveって付けた嫌がらせリクエストを3%程混ぜており、これに律儀に応えると、ベンチマークスクリプトはコネクション切れるまで待ち続けてしまって致命的に遅くなる、というもの。Nginxが素晴らしいのは周知の事実なので本番でも使ってくるチームが多いことを予想して、「Nginx(や、他の高速Webサーバやキャッシュサーバ)をチューニングせずにただ設置するとハマる罠」を仕掛けたんだとか。そうと知らずにそれを回避することに成功していて怪我の功名だった。本当運営の人達悪魔や…。

Nginxのチューニング

気を取り直してNginxの設定。まずNginxはWebサーバなので、餅は餅屋ということで静的なファイルはアプリサーバからフロントサーバに全部持ってきてNginxに返させてしまう。これでアプリから静的ファイルの配信機能を取っ払うのに成功して、本当に若干だけど無駄な処理を減らせる。リバースプロキシの設定は簡単なのでこれも普通に設定してしまう。それからVarinishのESI同様NginxでもSSIを有効にする。

それからキャッシュ。Nginxのキャッシュの方法はいくつかある。まず直感的なのはファイルキャッシュ。キャッシュファイルの置き場所を決めておいて、upstreamに飛ばすlocationのところでcacheを使うよって指定してあげれば、upstreamからのレスポンスを自動的にそこに貯めてってくれて、二回目以降は勝手にそのファイルから返すようにしてくれる。Varnishのときはキャッシュストレージにメモリキャッシュとファイルキャッシュが指定できて、ファイルの方を指定すると格段に遅くなるので、Nginxもそうなるんじゃないかと思ったけどこれが驚く程高速でびっくりする。むしろVarnishのメモリキャッシュの時より速いくらいだった気がする。ちゃんとは検証してない。ただ、このファイルキャッシュはuriをハッシュしてディレクトリに配置してしまうため、外からはどれが何のキャッシュなのか分からなくて、Varnishに比べるとキャッシュの管理が難しい、と思ってたら、ちゃんとこういうモジュール作ってる人がいた。これならVarnishのときに作ったHTTP越しのパージの仕組みがそのまま使える。

もう一つ、memcachedをまるでアプリサーバのように見立てて、pathをキーにしてmemcachedにあるデータをそのままレスポンスボディにして返してしまうという驚きのモジュールもある。こっちの利点は、アプリ側からもキャッシュが扱い易いということ。普通にアプリからmemcachedに繋いで、Nginxにレスポンスを返すときに同時にmemcachedにも書き込んでおくと、次はそっちから読んでくるようにしてくれる。破棄するのも普通にdeleteすればいい。HTTPリクエストを投げてパージするよりは分かりやすいし、memcachedプロトコルのがHTTPよりは速そうだ。が、問題は、前述のファイルキャッシュより遥かに遅いということ。これはmemcachedが遅いというよりmemcachedに毎回コネクションを張り直すコストがファイルキャッシュからの読み込み(これはおそらくかなり内部で最適化されているはず)のコストよりも高いせいらしい。ほぼ同じ状態でファイルキャッシュをmemcachedに切り換えたら、スコアが半分以下になってしまって絶望的な気分になった。仮にHTTP越しのパージよりもmemcachedのがアプリ側からは扱い易くてコストも低かったとしても、大半はNginxの段階で完結するキャッシュ済みGETリクエストなので、そっちのオーバーヘッドの方がもろに結果に影響する。ので最初はファイルキャッシュを採用した。

けど、hidedenさんの方はmemcachedを使っててそれでも俺のやつよりパフォーマンス出てるのでなんでだろうと聞いてみたら、upstreamとのコネクションを繋ぎっぱなしにしていたからだったらしい。試しにkeepaliveを設定してみたら、アプリ側何もせずに一気に3倍くらいのスコアになってhidedenさんのスコアに肉迫するレベルになった。同じことを当日もやらかした。hidedenさんが毎分11万リクエストというハイスコアを出した一方、前日まで大差はついてなかった俺の方は3万程度しか出てなくて焦ったのだけど、nginx.confを見直してkeepalive付け忘れに気付いて再起動したらちゃんと動いた。

で、結果はというと、ギリギリ100000req/minを越えるくらい。「お前はチートしてそれかよショボいな」と言われないくらいの結果は出せたと思うのでちょっとホッとした…。

この戦略の意味

POSTよりGETが圧倒的に多く、大半のリクエストが新しいデータに集中し、一度書き込まれた投稿はその後はあまり書き換えが起こらない、というのは、「大部分がほとんど書き換わらない動的コンテンツ」ではなくて「一部分が書き換わることがある静的コンテンツ」だと思っちゃえば少し話が簡単になる。

GETリクエストなんか静的なHTMLファイルを自動生成するためのトリガー、くらいに考えて、ただそれがファイル書き出しじゃなくてメモリ上のキャッシュに突っ込む方が扱い易いよねって発想で行けば、どんなミドルウェアが欲しいかとか、アプリは何をするべきなのかとか、どこで一番頑張るんだそうかフロントのWebサーバか、みたいなことでやることが決まる。てか、どっか1チームくらい本当にHTMLファイル書き出してデプロイしちゃうとこ無いかなーとか思いながら見てた。多分それはそれで速い。絶対面倒なのであまりやりたくは無いけど。

もっとぶっちゃけ話をすると、ライブドアブログの閲覧側チューニング戦略が(実現してる方法は違うけど)大体この形なので、ページのキャッシュと更新の局所化は最初からやる予定でいた。アプリエンジニアの性でついついアプリいじりに時間を割きたくなっちゃうとか、ミドルウェアやサーバ管理の知識経験が致命的に不足していたので時間ばっかり食ってしまったとか、ってのが「時間内には終わらなかったけど最終的には出来た」の実情だったりします。アプリエンジニアは常日頃からそういう知識をちゃんと収集しておけ、あとインフラチームと仲良くしておけ、色々捗るぞ、というお話でした。

上手く行かないケースと使い回せるテクニック

ブログ型の単純な表示系のリクエストが多いお題だったからWebサーバの性能が結果に直結してたけど、更新系のリクエストが多くて条件によって表示要素の個数や種類が大きく変わる、みたいな場合だと今度はアプリやDBの方に比重が移ってくる。例えばTwitterのサブセットみたいなのがお題だったらまた全然結果が違ったはず。

データのキャッシングやSSIみたいな仕組みはその場合でも有効だろうけど、「Nginx置いたらパフォーマンス20倍になったwwwwマジうめぇwwwwアプリ関係ないwwww」みたいなシンプルなことにはならないので、そっちの場合はアプリの実装力を鍛えてないと死ぬ。ISUCONに「部門別」とかあったら面白いかもねーとか思ったけど準備する側が死にそうなので軽々しく言うのはやめときます。僕お手伝いって名目なのに普通に遊んでただけでほんとごめんなさい。

反省点とか

DBロクに見てない。上位陣の方々見てるとMySQLバリバリチューニングしてるので、もっとちゃんといじればもう少し速度出るはず。一番効果が高いところを優先したと言えばそうだけど、あんま詳しくないところを放置しただけだったりもするので(アプリの全面書き直しとかマジで要らんかった)、ちゃんと勉強する。

みんなもやってみるといいよ

ISUCON運営チーム謹製のベンチマークツールと各言語の参考アプリは公開されてるので、是非触ってみてくだしあ。ゲーム感覚で楽しいし、各参考実装や意図的に仕込まれてる罠は、新人教育なんかにもうってつけだと思う。ええはい。自分の実力不足をガチで痛感した次第です。いや本当勉強しよう。ちゃんと。

faultierfaulist  at 19:13  | コメント( 0 )  | トラックバック( 0 )  | この記事をクリップ!
livedoor プロフィール
記事検索
月別
カテゴリ別
  • ライブドアブログ