Node.js & WebSocket & herokuで実装する簡単「いいね!カウンター」
こんにちは、羽山です。
今回は弊社で冬期インターンを迎えた際に用意したちょいツールをご紹介します。
弊社のインターンカリキュラムは全部で5日間でそのうちの3日間はチームに分かれて独自のサービスを企画します。
そして最終日にはそのアイデアを発表するプレゼンの場が与えられます。
プレゼンの審査は弊社役員が行うのですが、傍聴する社員や他のインターンチームにも参加している感を出したいというのが今回のツールのスタートでした。
候補に挙がったのは某有名テレビ番組の「へ~」ボタン。これを「いいね!」ボタンに変更してプレゼン中に共感した部分で「いいね!」ボタンを連打するというツールです。
需要があるかどうか分かりませんが、今回開発したツールはGitHubで公開しています。herokuにデプロイすれば簡単に使えるのでLTやイベント等など需要があればぜひご利用ください。
https://github.com/forrep/like-counter
こんなの作りました
まずはいいね!カウンターの本体です。
見たままですが、いいね!ボタンを押したら「みんなのいいね!」と「Myいいね!」の数値がインクリメントされます。
プレゼンターはプレゼンの残り時間とみんなが押した「いいね!」の数を見られるようになっていました。
ターゲットとなっている環境がある程度限られていたのでCSS3のアニメーションを無駄に使ってみました。
要件
あらためて要件を整理します。
- スマホ(社内ネットワーク未接続)での利用を想定する
- 画面上に「自分がいいね!を押した回数」「みんながいいね!を押した合計の回数」「いいね!ボタン」が配置されている
- いいね!を押すと、「自分がいいね!を押した回数」と「みんながいいね!を押した合計の回数」がそれぞれインクリメントされる
- いいね!を別の人が押すと、「みんながいいね!を押した合計の回数」がインクリメントされる
- いいね!に対して、ほぼリアルタイムで全員の画面が更新される
- 表示用に「みんなのいいね!」のみが大きく表示される画面を用意する
- 同時接続人数は100人程度で全員が「いいね!」を連打するシチュエーションを想定する
- 開発およびテストにかける予算(時間)は少なめ、特に負荷テストは行いにくいため一発本番での利用となる
- サーバーの予算は出ない
機能的な要件は問題ないとしても、100人同時接続でいいね!連打はかなりの負荷が想定されます。
さらに社内ネットワークに接続されていないスマホがターゲットのため社内にある余ったサーバも使えないという厳しい条件でした。
そこで予算をかけずに要件をクリアするために次のような構成を考えました。
- heroku(無料枠) & Node.js ( & PostgreSQL)
- WebSocket対応のスマホのみサポート
- PostgreSQLはOLTPに利用しない ※定期的にメモリ内のデータバックアップとアプリ開始時の復元に利用
herokuではRedisも利用可能で今回の用途には本来ならばその方が適切です。しかし「いいね!」のカウントアップ処理にRedisを利用するとボトルネックがNode.jsで動作するウェブサーバではなくRedis側になる可能性がありました。また、herokuの無料枠で利用可能なRedisでどこまでパフォーマンスが出るかが分からないうえに、本番環境の負荷を想定してのテストが難しいことからボトルネックになる可能性を1つでも減らすという点を重視しました。
Node.jsについて
Node.jsは非同期I/Oのイベント駆動が特徴で、待ちが発生する処理は非同期で実行されてコールバックで結果を受け取るというのが基本です。
ユーザーがjavascriptで書いたコードは基本的にシングルスレッドで動作するためシンプルな構造となり、貧弱な環境でもスループットを稼ぎやすいという特徴があります。
メインスレッドは処理が存在していてかつスワップなどメモリでの待ちが発生しないなら100%CPUを使い続けるという動きをします。
開発者としてもロックや排他処理を考慮する必要がさほどないため扱いやすい言語といえます。
ただし、そのために犠牲にしている部分もあります。
- シングルスレッドのためマルチコアの性能を生かしにくい
- イベント処理前提なのでコールバック地獄になりやすい
- javascript内でCPU的に重い処理があるとアプリケーション全体が止まる
(1)はシングルスレッドであるという前提からの制限なので納得するしかないのと必要に応じてクラスタモジュールを使えば改善できます。
(2)については他の言語に比べて見通しの悪いコードになりがちですが、Promiseなどでコーディング方法を工夫すればある程度回避できます。
しかし(3)は場合によって致命的になります。Node.jsは1つの処理が完了したらイベントループが次の処理を取ってきて実行します。この動作は前述の通りシングルスレッドで行われるのでイベントループに積まれた処理の中で一つでも重いものがあったら、それが完了するまでアプリケーション全体が応答しなくなります。
すべてのコードを見渡せる規模のアプリならさほど問題はありませんが、コードの品質がまちまちな複数人での開発をNode.jsで行う場合は少し覚悟した方がいいかもしれません。
いいね!カウンターの仕組み
いいね!カウンターはWebSocketを利用したI/Oが主体で処理自体はカウントアップなど簡単なものしかありません。そのためNode.jsでパフォーマンスを発揮しやすい要件です。
サーバーとブラウザ間はWebSocketでやりとりしています。いいね!ボタンを押す動作だけならブラウザからサーバーへのデータ送信なのでAJAXで十分ですが、他の人が押したいいね!をすべての接続端末に反映するためにはサーバーからのデータ送信が不可欠でした。
主にブラウザとサーバーは次のような動作をします。
<接続開始>
- [ブラウザ] 画面を開くとWebSocketの接続を開始
- [ブラウザ] 現在のいいね!数をWebSocketで要求
- [サーバ] 「みんなのいいね!」「Myいいね!」の数を返す
- [ブラウザ] 画面上の「みんなのいいね!」「Myいいね!」をそれぞれ更新
<いいね!>
- [ブラウザ] WebSocketでいいね!リクエスト
- [サーバ] いいね!数をインクリメントして「みんなのいいね!」「Myいいね!」の数を返す
- [ブラウザ] 画面上の「みんなのいいね!」「Myいいね!」をそれぞれ更新
- [サーバ] 「みんなのいいね!」を接続されているWebSocketすべてにブロードキャスト
- [その他ブラウザ] 画面上の「みんなのいいね!」を更新
パフォーマンス・懸案事項
事前準備の甲斐もあり、本番では特に問題なく正常に動作しました。
負荷軽減に特に効果的だったのは下記の対策だと分析しています。
1. Node.jsという選択
LAMP構成のように接続の数だけプロセスやスレッドを生成する環境では同時接続数が多くなると早々に破綻します。一方でNode.jsでは前述の通り非同期I/Oのイベント駆動のため、接続自体ではさほどリソースを消費しません。
つまり接続数が多くて1つ1つの処理が軽量ならNode.js向きで、接続数が少なめで1つ1つの処理が重い場合は従来のLAMPのような構成が適合します。
2. シングルインスタンス&オンメモリ処理
普段は「スケール」「冗長化」なんて単語を連発してますが、今回はあえて真逆を行く構成を採用しました。
24時間/365日稼働というシステムではなく、インターンのプレゼン発表の瞬間だけ問題なく動作することが目的だったため不要な冗長性は切り捨てました。
スケールについては可能なら実現したかったのですが、heroku無料枠しか使えない時点で絵に描いた餅だったため、あきらめてシングルインスタンスという選択をしました。
実はこのシングルインスタンスというのはパフォーマンスに大きく影響するところで、作り自体をシングルインスタンスに最適化してしまうとアプリケーションサーバのオンメモリですべての処理を行えるためある種のドーピングのような効果を出すことができます。
3. ブロードキャストの制限
例えば100人が同時にいいね!ボタンを10連打すると、いいね!ボタンがトータルで1000回押されたことになります。そして1000回それぞれがブロードキャストをすると
1000回×100人 = 100,000回
のWebSocketでのI/Oが発生することになります。この部分が一番高負荷になりやすいポイントだと考えてブロードキャスト要求をある程度溜めてから送信する仕様としました。ブロードキャストの要求が入ると送信まで1.5秒間待機します。送信までの間にブロードキャスト要求が来た場合はその中で一番最後のデータのみを送信します。
今回のアプリでブロードキャストされるのは「みんなのいいね!」の数値です。このデータは基本的にインクリメントされるだけなので途中のデータが欠損しても全く問題ありません。
これによってブロードキャストは最短でも1.5秒に1度しか発生しなくなり負荷軽減に繋がります。
ただしそれだけでは画面上の「みんなのいいね!」 のカウントアップが1.5秒に1度しかされず、イマイチ盛り上がりに欠けてしまいます。そこでブラウザ側のカウントアップ処理を同じく1.5秒かけて徐々に行うことで擬似的になめらかなカウントアップを実現しました。
ただしそれだけでは画面上の「みんなのいいね!」 のカウントアップが1.5秒に1度しかされず、イマイチ盛り上がりに欠けてしまいます。そこでブラウザ側のカウントアップ処理を同じく1.5秒かけて徐々に行うことで擬似的になめらかなカウントアップを実現しました。
4. herokuの各種タイムアウト
herokuには各種のタイムアウトが設定されていて、よく問題になるのが
H12 - Request timeout
とH15 - Idle connection
です。H12 - Request timeout
はリクエストからレスポンスが30秒以上かかると発生します。対策としては「30秒以内にレスポンスを戻す」につきます。もう一つのH15 - Idle connection
はWebSocketを利用する場合に引っかかることが多いタイムアウトで、無通信が55秒を越えると強制的にコネクションを切断されます。定期的に無駄なデータを送信するか、もしくは切断されたら自動的に再接続するなどの対策が必要です。ローカルではうまく動いていたのにherokuにデプロイすると動かないという場合はheroku logs
でそれらのエラーが出ていないか確認するとよさそうです。
今回はいいね!カウンターという、ちょいツールを紹介させていただきました。
普段はいろいろかっちりとした案件が多い中、こういったゆるいツールの制作は癒やされますね。