Edited at

大規模Node.jsを支えるロードバランスとオートスケールの独自実装(FRPもあるよ)

More than 3 years have passed since last update.

というテーマで東京Node学園祭2015でセッションさせて頂くことになったので、先に整理/メモ的ななにかを。


(追記)以下資料で発表しました。

大規模Node.jsを支える ロードバランスとオートスケールの独自実装

http://www.slideshare.net/kidach1/nodejs-54841327



作ったもの

・スマホゲーム(マルチプレイアクション)

【公式】メザマシフェスティバル(メザフェス) | 株式会社アカツキ

https://mezamashi-festival.aktsk.jp

・2D横スクロール

・マルチプレイ

 ・4人同時対戦

 ・座標同期型

 ・全国マッチング


システム概要

Client:

 Cocos2d-x (c++)

Server:

 API Server:Rails

 Websocket Server:Node.js


詳しくは

スマホアプリにおけるマルチプレイアクションゲーム開発の実例紹介

http://www.slideshare.net/aktsk/ss-52126411/1

CEDEC2015での弊社CTOによるトークです。


今回は全体的な話は割愛

以下、本題のNode.jsアプリケーションを載せるインフラの話に絞ります。


インフラ規模/要件

・同時一万接続を想定

 → 常時数十〜百数十台規模のインスタンスが稼働

・柔軟なオートスケール

 → やるからには安心安全、負荷に応じて柔軟に自律的に伸縮してくれるようなインフラにしたい


アーキテクチャ

Node_jsを支えるインフラ_key.png

A. 各Cluster/各Nodeの状態を毎秒監視

B. Node毎の重み付けを毎秒更新

C. Clusterの状態に応じてオートスケール

D. LB間でのプロセス監視&自動FailOver

①② LobbyNode取得

③ Lobby接続

④ マッチングとroom_idの決定

⑤ room_id返却

⑥⑦ start(REST API)(GameNode取得)

⑧ GameNodeとroomを指定の上 GameServer接続

⑨ finish(REST API)

※ GameCluster・・・ゲームのアクションパートにおけるリアルタイム通信を担当するサーバー群

※ LobbyCluster・・・ロビーでのユーザーマッチングを担当するサーバー群


この構成になった経緯

ざっくり以下のような変遷を辿りました。


最初の壁

スケールを考えるにあたって大事(というよりあって当然)なのはロードバランスですが、どうもWebsocket/Socket.ioはELBと相性が悪そうだということが分かりました。

・WebsocketとELBの相性×

 ・Websocketは一度接続するとサーバー/クライアント間のセッションを張りっぱなしにするため、コネクション確立時以外LBを挟むメリットがない。むしろコネクション数が肥大化し、ボトルネックになり得る。

・Socket.ioとELBの相性××

 ・ELBのTCP Listenerを使った場合、Socket.ioのコネクション確立に必要なStickySessionがサポートされておらず、そもそもhandshakingが確立できない。

さてどうするか

※ ここで、そもそもNodeやsocket.ioを捨てる選択肢も有り得ましたが、最近のNode・JavaScript界隈の目覚ましい変化やnpmの資産は魅力的でした。

ということで過去事例を調査。


過去事例1

EC2とAPPサーバーとの間にプロキシを挟んでip_hashでバランスしstickynessを担保するケース

 Load-balancing Websockets on EC2 — Medium

 https://medium.com/@Philmod/load-balancing-websockets-on-ec2-1da94584a5e9

Node_jsを支えるインフラ_key.png


これは・・

・ELBとProxyの二重運用辛そう

・proxy配下のサーバー群(upstream)の動的管理が出来ない=実質オートスケールを捨てることになる

  ※ 正確にはNGINX PLUSを購入すれば動的upstreamは可能だが、APIを見た感じ結局ラッパーを書く必要がありそう

ということで引き続き調査。


過去事例2

ELBはセッション確立時のみ、もしくはELBなしでロードバランスするケース

WebSocket on AWS (ロードバランサとSocket接続を使用したイベント通知サーバの負荷分散)

http://www.slideshare.net/AmazonWebServicesJapan/socket-15753751

TV連動サービスのリアルタイム通知を支える技術

http://www.slideshare.net/tsuyoshitorii5/public-43549341

TV連動サービスのリアルタイム通知を支える技術.png

なるほど、こんな構成が。

※ その後たまたまスライドの作成者様と直接お話をさせて頂く機会があり、色々アドバイスを頂くことが出来ました。ありがとうございました!

ただどちらもAutoScalingは想定していない事例だったため、基本構成を参考にしつつオートスケールまで達成できるように独自実装していく方向で決定しました。

以下詳細です。


LoadBalance

Node_jsを支えるインフラ_key.png

・EC2のtagをもとに、各クラスタ(lobby/game server)の各ノードごとのコネクション数を毎秒取得

・RedisのSortedSetで保管/更新

・APIServerからリアルタイムで最も接続数の少ないノードを読み出し、クライアントにendpointを返却


実装のポイント

・各インスタンス起動時、クラスタに応じたtagを振り分ける。

 ・prod-realtime-lobbyなど

 ・CloudFromation/kumogataで(後述)。

・インスタンスはコア数が少ないものを大量に横に並べる方針

 ・SingleThreadで動くNodeとは相性が良い

 ・コア数を増やしてClusterModuleを利用する方法もあるが、実装が複雑化するので避ける

・CPU使用率やLoadAverageといった指標ではなく、socket.ioプロセス(アプリケーションレイヤー)でのコネクション数を見てバランス

 →サーバー自体はhealtyなのにプロセスは死んでいた、等を排除。

  ※ websocket/socket.ioを用いる場合、httpと比べて「OSレイヤーの指標とアプリケーションのプロセスの生死」が直結していない印象だった(正確には、その部分に対する勘所がなかった)ため。

・4人同時対戦なので、GameServerはマッチした4人を同一ノードに送り込む仕組み。

 → ノード間の協調が不要、実装がシンプルに。

・LobbyServerは全国マッチングを実現するために全ノード間でユーザーのセッション情報の協調が必要。

 → socketio/socket.io-redisを利用。


Autoscaling

Node_jsを支えるインフラ_key.png

・Game/Lobby各Clusterの状態を常時監視

・設定した閾値に応じてサーバーを自動で追加/縮小

・追加(スケールアウト)は最短で3分に一度、縮小は1時間に一度のスパンに制限(任意の値を設定可能)

・ゼロダウンタイム

 ・サーバープロセスが立ち上がり接続が確認できるまでLB側で有効なノードとして認識しない

 ・縮小時は、ノードへの接続がない状態でしかトリガーされない


実装のポイント

・Clusterの状態変化をシームレスにスケーリングイベントに繋げるため、FRPのパラダイムを利用

 ・Reactive-Extensions/RxJS

 ・Redisに保管され刻々と更新されていくインスタンス毎のコネクション数をもとに、オートスケールの発火を管理する

実装イメージ

export default class Autoscale {

・・・
// オートスケールの実行判断/HotObservableの生成と制御
checkAndApply() {
let published = this._mainStream().publish();
this._checkByHostNumStream(published);
this._checkBySpecifiedTimeStream(published);
this._checkByConnNumAverageStream(published);
published.connect();
}

// Redisから各インスタンスのコネクション情報を取得、整形
_mainStream() {
return Rx.Observable.fromPromise(this.redis.zrange([key, 0, -1, 'withscores']))
.flatMap((ipAndConnNumStr) => {
return this.oddElemFrom(ipAndConnNumStr)
.map((connNumStr) => {
return parseInt(connNumStr); })
.filter((connNum) => {
return connNum !== config.get('connNumForRedHost'); })
.toArray();
}
}

// 実行判断ストリームその1(有効なインスタンス数から判断)
_checkByHostNumStream(parentStream) {
parentStream.subscribe((connNumsOfGreenHost) => {
return Rx.Observable.fromArray(connNumsOfGreenHost)
.count()
.filter((countOfGreenHosts) => {
return countOfGreenHosts <= config.get('minimumGreenHostsNum'); })
.subscribe(() => {
this._scaleOut();
}, (e) => {
log.autoscale.error(e);
});
});
}

// 実行判断ストリームその2(任意の指定時刻から判断)
_checkBySpecifiedTimeStream(parentStream) {

・・・
}

 ・強力なオペレータ群で一本のストリームを宣言的に操作でき、コードの見通しが上がる

 ・複雑な状態管理が抽象化、隠蔽される

 ・非同期処理もスマートに

 実装のポイントは

 ・Observable生成時に既存資産(Promise)の世界とFRPの世界を繋ぐ

 ・Hot/Coldを使い分けて無駄の無いストリームを構築

 等ですが、長くなるので詳細は割愛。どこかで別記事にする予定です。(追記)しました

・スケーリングのツールはCloudFormationだが、後述の課題を吸収するためRubyラッパーであるkumogataを用いる。


CloudFormation/kumogata

・socket.ioプロセスベースでコネクション数を監視してスケーリングをコントロールしたい

・コネクション数の閾値に応じて柔軟に増減台数を決定したい

→ 監視と増減台数決定部分はNode(前述のFRPの箇所)で、台数に応じたスケーリングはCloudFormationで実現したい

→ CloudFormation単体(JSON)では力不足

・kumogataを使う

 winebarrel/kumogata

 https://github.com/winebarrel/kumogata

 ・CloudFormationをRubyで。

 ・任意のインスタンス数を指定できるようロジックを記述して上述の問題を吸収。


kumogataで抽象化した結果

例)50台同時起動

$ INSTANCE_NUM=50 kumogata create cloudformation/kumogata.rb prod-realtime

→ StackNameprod-realtimeで、LobbyServer / GameServer 25台ずつのクラスタが生成される


可用性

Node_jsを支えるインフラ_key.png


Lobby & GameCluster

・Multi-AZ

・擬似FailOver

 ・各クラスタ最低構成台数を持っておき、この閾値を下回るとAutoscaleが発火

  ・AutoscaleでFOの機能をざっくり吸収

  ・状態を持たないdisposableな設計のため実現しやすかった


LoadBalancer & AutoscalingServer

・Multi-AZ

・FailOver

 ・Lobbyクラスタ用LB、Gameクラスタ用LB それぞれがお互いをSocket.ioプロセスベースで監視し合う

 ・障害発生時に一方が片方の機能を吸収する形で自動でFailOver

 ・インスタンスが復旧した時点で自動でFailBack


監視

・CloudWatch

・Slack連携

 ・破壊的なイベント発生時やサーバーの状態を定期的にSlackで通知

Slack.png

Slack.png


負荷試験

・同時1万Connectionをシミュレートしたい

・攻撃サーバー1台ではマシンパワー不足

newsapps/beeswithmachineguns

https://github.com/newsapps/beeswithmachineguns

・インスタンス複数台立ち上げ&攻撃

 ・良さそう。が、

 ・jmeter内包 = httpのみ。今回は使えず


結局自分で作る

socket.io-clientベース

実装イメージ

function lobbyEvent(payload, lobbySocket, chara_id) {

commonEvent(lobbySocket);

lobbySocket.on('ALL_CONNECTED', (data) => {
payload.room_id = data.room_id;
co(function*() {
let gameHost = yield getHost('Game', 9100);
let gameSocket = io.connect(gameHost, {'forceNew': true});
setTimeout(() => {
gameEvent(payload, gameSocket, data.room_id, chara_id);
lobbySocket.disconnect();
}, Math.floor( Math.random() * 2000 ))
})
});
}

function gameEvent(payload, gameSocket, room_id, chara_id) {
commonEvent(gameSocket);
payload.character_id = chara_id;
gameSocket.emit('CONNECT', payload);

・・・

cloudformationで攻撃サーバー複数台同時起動

$ kumogata create cloudformation/bees-kumogata.rb bees

ansibleで複数台同時攻撃

$ ansible-playbook -i ./hosts/develop/ec2.py bees.yml —private-key=<key_path>


複数台同時起動の図

Node_jsを支えるインフラ_key.png


まとめ/実装してみて

・Websocket/Socket.ioとELBの相性悪い問題は消滅

・LBが直接ユーザーからのコネクションを受けることがないため、単一障害点になるリスクが低い

 ・実運用において、いまのところ

  ・直接的な障害無し

  ・緊急対応的な手動オペレーション無し(事前に必要とわかっているデプロイ/メンテ等を除く)

・本質的な部分(※)の実装は薄く、モジュール化可能

 ※Clusterの状態(コネクション数やCPU使用率など)を常時監視し、設定した閾値に応じて任意の操作をトリガー

・今回のws/socket.ioのケースに限らず、http以外のプロトコルでAutoscalingさせたい時全般で使えるかもしれない

・FRPはインフラ制御とも相性が良い

続きは学園祭で!


2015/10/21追記

FRPの部分、続きを書きました。

Node.jsのオートスケールをFRPで管理する - Qiita