背景
前回の記事で書いた、早稲田大 石川研の方々の白黒写真に着色するプログラムをWebで動かすサービスを公開したところ、予想以上にバズって驚いている。まさかITMediaに、加えてねとらぼの方にも掲載される日が来るとは夢にも思わなかった。ちょっとバズったら嬉しいな〜という下心が無かったといえばそんなことはなく、それでTweetするボタンをつけてみたりしたが、まさかここまでとは。という心境だ。
おかげさまで、公開からの5日間(6/5〜6/9)で3万人近いユニークユーザに訪問していただき、13万を超えるページビュー(PV)を記録した。ありがたい限りだ。
一方、急なアクセスの増大に対して、サービスを安定させて動かすためにいくつか改良を行った。私個人が作ったこれまでのサービスはだいたい閑古鳥が鳴いているし、業務でもだいたいコンセプト実証のためのコードを書いているので、これまでトラフィックやパフォーマンスの問題に直面することはなかった。今回アクセスが増えるのを横目に見ながら、できるところから急いで修正を加えるのは怖いながらも面白かったし、なかなかできない経験ができて知見が得られた。本記事ではそれらについて記録を残しておく。
前提
上記だけ見ると、大量といっても現代であれば落ちるレベルではないというように感じられるかもしれない。実際に、ピーク日(6/8)でも約3.4万PV ≒ 24PV/分程度である。しかし、このサービスでは1リクエストに対する処理が著しく重い。現在、長辺が800pxになるように与えられた画像を縮小して処理しているが、それでも1回の処理で5〜10秒程度かかるし、メモリも2GB程消費する。実際に処理を行うプロセスが3〜4個立つとメモリ不足でプロセスが落ちるという状況だった。無限の金さえあればIaaSに強いインスタンスを無限に立てて並列化するところだが、ドラム式洗濯機を買うことで精一杯の僕には現実的ではない。
なお、公開時の実装は、画像がPOSTされると、
- uuidを発行する。
/img/{uuid}/orig.jpg
(長辺800pxに縮小された元画像)、/img/{uuid}/glayscale.jpg
(グレースケールにしたもの)を生成する。- Dockerコンテナを生成し、その中でカラー化プログラムを起動し、
/img/{uuid}/colorized.jpg
(結果)を生成する。 - 3が終わった時にレスポンスを返し、
/result/{uuid}
にリダイレクトする。このビューは各画像へのパスを含むimgタグを含んでいるので画像が表示される。
という単純な処理だった。各画像はディスクに直接ファイルとして書き出され、静的に送信される。サーバはNode.jsで書いた。サーバは以前の記事に書いたホスト(CPU: Intel Core i3-4130T)上で、8GBのメモリを割り当てたKVM仮想マシン内で動かしている。
ボタン連打できないようにした
最初に(うすうす想像してたものの)あ、やっぱダメだこれと気づいたのは知人から「502 Bad Gateway 出てるで」との報告を受けたときだった。サーバを確認すると、プロセス5つほど同時に走り、メモリ不足で落ちていた。何度か再起動をしたものの、すぐに落ちるという状況になっていた。
落ちる際、リクエストが複数個同時に送られていることパターンがあることがログから観測できた。一連のorig.jpg
のハッシュ値を見ると同じ値だったため、送信ボタンを連打している人がいるのではないかと感じた。おそらく、リクエストが重なって遅くなっている時に、待てずにリクエストを再送信する人がいたのだと思う。送信されるたびに処理のプロセスが起動するので大変なことになってしまう。
そこで、とりあえず応急処置として、送信ボタンを押されたら無効の状態にし「処理中」と表示するようなJSを書いて、エラーではなく処理中であるだけということを分かるようにした。結果として、多少メモリ不足で落ちること頻度が減った気がしたが、計測はしていない。いずれにしても、TOPページを再読込すればもう一度リクエストを投げられるし、そもそもユーザ数が増えつつあって、1人の複数回送信を防ぐだけでは不十分な状況になるのに時間はかからなかった。
このようにボタンを無効化した。どうでもいいが、送信ボタンの右端がカラフルになっているのは個人的に気に入っている。
ジョブキューを導入した
やはり、リクエストが送られるたびに何も考えずにプロセスを立てる脳筋プレイはやめようということで、ジョブキューを実装した。キューイングして結果を遅延させて返すようなWebアプリケーションを書いたことは無かったため少し二の足を踏んだが、意外とあっさり終わった。
Node.jsのいい感じのジョブキューが無いか探したら、Kueが見つかった。記法もシンプルだし、日本語のブログ記事もいくつかあるし、starもかなり集めていたのでこれを使った。
「前提」のところで書いた手順のうち、2.が終わった時点、すなわちカラー化プログラムに渡すファイルができた時点で、カラー化の処理をキューに入れると同時に、ユーザにはビューを返してしまうようにした。結果の画像(colorized.jpg
)はこの時点では存在せず、当然読み込めない。そこで、結果画像が読み込めなかった場合、スピナーを表示した上で定期的にポーリングして読み込み直すようにした。
結果の画像は静的に送信しているため、処理が終わるまではファイルが存在せず404が返ってきて、終わったらファイルが返ってくるというシンプルな判定ができたので良かった。
2.での、画像の縮小やグレースケール化といった前処理の部分もキューにしたほうが良いのだろうとは思うが、キューイングの実装は初めてだし小さく進めて行こうと思ったことと、カラー化の重さに比べたら前処理の重さは些細なことであること、さらに、入力に変な画像や壊れた画像が与えられたときのエラーを遅延させて返すのはややめんどくさそうだなーと思ったので、一旦行わないことにした。
結果、かなりサービスは安定して、大量のリクエストがあってもキューが伸びるだけでアプリケーション自体が落ちることはなくなった。メモリ消費量の問題から、同時に実行できるジョブ数は2個に制限した。このため、安定したとはいえ、キューが伸びてくるとかなり待たされる状況になってしまった。ちょうどこの頃Twitterでの#siggraph2016_colorizationについての言及も増えてきて、待機中のジョブが100個を超え、10分ほど待たされることもあった。
ジョブの待ち時間を表示するようにした
10分もスピナーが回り続けているだけだと、落ちていると思われそうだし、実際に#siggraph2016_colorizationにも落ちてる?とか重いといったことが書かれていたので、混みすぎていて10分ほどかかる場合がある旨と、ページを閉じても同じURLに後から戻れば処理が終わった画像は表示される旨のメッセージを突貫で貼った。
それでもやはり進捗が見えないのは不安なので、実際の待ち人数と待ち時間を表示するようにした。待ち時間は、経験的に10ジョブ待ちで1分程という感じだったので、単純にこれから逆算した。
自分で試してみても、定期的に待ち人数が更新されていくと、落ちていないのが分かるし、減っていく待ち人数の数字を見るのも少し面白さがあるし良いなと思った。加えて、この変更を行った直後、キューの長さがあまり長くならなくなった。一方でアクセス数は減っていなかったので、おそらく、実際に処理されているか不安になった人がもう一度リクエストを投げて、重複したジョブを作ることをしなくなったのだと思う。これは予想していなかったが幸運だった。
実装としては、UUIDを受け取って、当該UUIDのジョブの待ち人数と時間を返すAPIを作って、/resultから定期的にポーリングするようにした。このAPIは、Kueが持つJSON APIで待機中のジョブ一覧を取得して、その中から与えられたUUIDのジョブのインデックスを返すようにした。ジョブ一覧はジョブ作成時刻昇順でソートされているので、インデックスがそのまま待ち人数の数になる。APIが叩かれるたびに毎回ジョブ一覧を取得するのはコスト高そうだったので、5秒ほどアプリケーション側でキャッシュした。
ところで、Kue側のAPIは
GET /jobs/:state/:from..:to/:order?
となっており、何番目から何番目までを取得するかを必ず入力しなければならない。:from
は0で良いとして、:to
はどうしようと悩んだのだが、試しに-1を入れたら全部取得できた。ドキュメントには書かれていないので若干怖い。
おわりに
今回、処理が重いWebアプリケーションで、大量のアクセスをどう捌くかの知見が得られたし、実際にジョブキューの実装まで行えたので良い経験になった。特に、進捗を表示することで負荷が減ったのは面白くて、ユーザエクスペリエンス(UX)大事だなーと思った。時間が掛かる処理で進捗をユーザに見せるというのはUXの基本だと分かってはいたものの、改めて重要さを感じた。やっぱり実装めんどくさいんだけど。
負荷を減らすためにはもっと色々やれることがあると思う。特に、元のカラー化プログラムは実行するたびに毎回600MB強のモデルをロードし、1枚の画像を処理し、終了する。このプログラム自体をサーバ化して、一旦ロードしたモデルを使いまわしてその後与えられるファイルを処理するようにすれば、処理時間もメモリ使用量もかなり改善すると思っている。元のプログラムはLua+Torchで書かれているが、Torchで動くWebサーバフレームワークもあるようなので、これを使えばすぐにできそうな気もする(Luaの経験が無いので後回しにした)。ということで、今後やっていきたい。