TECHNOLOGY 2014.01.01

Web Developerでもできる Node.jsのサービス構築のポイント

  • javascript
  • System
  • Tips

こんにちは。萌えきゅん記事を世界にお届け、システムエンジニアの眞下です。
以前から興味があったので日頃からちょこちょこNode.jsをいじっているのですが、最近では技術自体だけでなく"Node.jsを使用したサービスの構築"についても興味を持つようになりました。 と言うのも、何か調べようとネットで検索した時に、サンプルのソースはすぐ見つかっても実際に稼働中のサービスはなかなか見つからないことが多かったり、稼動中のサービスがあっても、その構築にはWeb制作のエンジニアだけではなく専門のサーバエンジニアが携わっているケースが多いように思えます。 そのため、実際にNode.jsを使用したサービスを構築するためには、何かWeb制作の技術だけでは完結するのが難しい要因でもあるのだろうかと軽い疑問を感じていました。
 

そこで、そんな疑問もやってみれば分かるだろうという訳で、季節柄のせいかたまたま共有サーバ内の堅牢なデザインフォルダに転がっていた弊社の2014年の年賀状デザインを拝借し、実際にNode.jsを使用したサービスを自分で構築してみることにしました。 今回は、Socket.ioを利用して複数人でリアルタイムに遊べる簡単なパズルゲームを作ってみましたので、Node.jsを使用して実際にサービスを構築する際に注意した方が良さそうな点を、ゲーム完成までの流れに沿って紹介したいと思います。
  • 小規模なサービスでも負荷テストは必須!
  • メモリ使用量に配慮したプログラミングを心掛ける!

ちなみに、フロント部分は最近社内で流行っているTypeScript+CreateJSという組み合わせで実装してみました。

 

■ ゲームサンプル(※ビジュアルは弊社の今年の年賀状です)
http://nenga2014.invogue.jp/

■ ゲームのルール
・ 各ピースを当てはまる形の場所にドラッグ&ドロップで配置するだけです。
・ 最大で5人まで同時にゲームに参加できます。
・ 5分間操作が無い場合は自動でログアウトされます。

■ 全てのピースが揃ったら・・・
特に何もありません!
全てのピースが揃ったらページの下のほうにある「Clash」ボタンを押してください。
また一から何の生産性も無い作業を開始できます。
誰かがコツコツ積み上げたものをワンクリックで台無しにするのにも利用できますね。

小規模なサービスでも負荷テストは必須!

これはもちろんサーバ自体のスペックや負荷分散の仕組みの導入如何によっても変わってきますが、今回の実装で僕が一番ハマったのが何といってもNode.jsを利用することによるサーバ負荷でした。
今回のパズルゲーム自体は各ユーザのマウス座標とパズルの各ピースの座標をSocket.ioでやりとりするだけの単純な仕組みだったため、フロントエンドとバックエンドの連携自体は比較的スムーズに実装できたのですが、いざ諸先輩方に動作を確認してもらうと、時間が経つにつれて徐々に通信に遅延が発生しはじめ、最終的には画面がフリーズしたような状態になってしまいました。
これはイカンとさっそくログ等から原因を探ってみると、ゲームに参加しているユーザ数の増加に伴ってトラフィックとサーバのメモリ使用量がぐんぐん増えており、それが結果的にパフォーマンスの低下を招いているようでした。
トラフィックの増加は当たり前だとしても、"Node.jsはクライアントからの大量同時接続を処理できる"という自身への刷り込みもあってか、(スペックが低いとは言え)100%に近いメモリを使用することは予想しておらず驚きました。

そこで、以下のような条件で簡単な負荷テストを実施し、このパズルゲームが耐えうる大まかなトラフィックやユーザ数を把握するとともに、メモリ使用量が増える原因を探ってみることにしました。

  1. (低い)サーバスペックを考慮し、最大同時接続ユーザ数を決める(今回は安全圏の5人)
  2. 各ユーザが同時に操作を行っていると想定
  3. クライアントからサーバへの通信(Socket.ioクライアントからのemit)は100ミリ秒に1回発生
  4. 3を1万回繰り返す
  5. メモリ使用量に異常が無ければ1~4を複数回繰り返す

この内容で動作をテストしてみたところ、やはりクライアントからサーバへ通信が発生するたびにサーバのメモリ使用量が増え続け、クライアントとサーバの接続が切れてもメモリ使用量が減らない状態だということが分かりました。
また、メモリ使用量に対してCPU使用率は然程上がっておらず、ガベージコレクションがうまく動作していない可能性も出て来ました。

サービスの大小や想定ユーザ数・データ量に係わらず、予め負荷テストをしておくことは大事ですね。
また、負荷テストを行うことで、そのサービスが現状耐えうるユーザ数やトラフィックを把握しておくことも重要です。

メモリ使用量に配慮したプログラミングを心掛ける!

さて、色々な調査の結果、どうやらメモリの使用量が急激に増えていることとそのままメモリ使用量が減らないことがパフォーマンスの低下に繋がっているらしいということまでは当たりがついたので、では実際に何が原因なのかとサーバ側のソースを見直してみることにしました。
メモリ使用量が増え続けるメジャーな原因といえばメモリリークなので、それを疑いつつメモリ使用量を減らすべく下記の改善を実施しました。
  • 不要になったオブジェクトの破棄を徹底
  • クライアントと通信するデータのダイエット
  • ガベージコレクション

不要になったオブジェクトの破棄を徹底

まず、不要になったオブジェクトはdelet演算子を使用して明示的に破棄するように徹底しました。
オブジェクトの破棄はエンジニアとしては基本的な事ではありますが、Node.jsはJavaScriptで記述することもあり、僕は普段クライアントサイドのJavaScriptを記述する時の癖がサーバサイドのスクリプトでも出がちだったため注意が必要だと思いました。

socket.on('hoge', function (mousePoint) {
    var point = {
        x: 0,
        y: 0
    }

    // ~何か処理~

    // 破棄
    delete point;
});

クライアントと通信するデータのダイエット

次に、クライアント・サーバ間でやりとりするデータから無駄なものを省く作業をしました。
修正前はマウス座標の通信にユーザIDやその他クライアントサイドのスクリプトで利用している情報も含むユーザオブジェクトを利用していたのですが、修正後はシンプルに各ユーザーのマウス座標のみを通信するようにしました。
また、一度に多くの処理を行っていた関数をいくつかに分けることで、クライアントとの一度の通信でやりとりするデータ量を減らしつつ、その際に発生するサーバサイドでの処理をなるべく軽くシンプルになるようにしました。

ガベージコレクション

最後に、一度増えたメモリ使用量がいつまでも減らないことへの対処として、GC実行関数を明示的にスクリプト中に記述するようにしました。
Node.jsのスクリプト中にGC実行関数呼び出しを明記しない場合はV8エンジンのタイミングで自動的にGCが行われますが、(Node.jsやV8のバージョンの問題などもありそうですが)今回はV8のGCのタイミングではメモリ使用量が100%に近づいてしまうようでしたので、スクリプト中の特定の関数の処理完了時、及び、定期的に以下のような記述を追加してGCを行うタイミングをスクリプト中で制御するようにしました。

// GC実行
if ( global.gc ) global.gc();

※スクリプト中でGC実行関数を有効にするには、アプリケーション実行時に下記のように引数を指定する必要があります。

node --expose_gc /path/to/hoge.js

Nodeの永続化にforeverを利用している場合は次のようになります。

forever start -c 'node --expose_gc' /path/to/hoge.js

最後にもう一度負荷テスト

さて、上述したような修正を行い、改めて先ほどと同じ条件で負荷テストを行いました。
ヒヤヒヤ。
ポチっ。
、、、
スー、スー、スー
ヤッターb(゚∀゚)b
無事、通信の遅延などもなく快適にパズルゲームが動作し、各ユーザの画面上のパズルの動きも同期しました。

という訳で、
今回は、軽い気持ちから作ったパズルゲームを通して、実際のサービスにNode.jsを使用する場合に注意が必要そうな点をご紹介しました。