2017年頃から開発&運営しているGranblue Searchで得られた知見とか実装の話です。

最近の利用者状況

2020年の夏ごろが一番利用者が多く、毎日ピーク時(21~24時)に約1万人くらいでした。

最近はグラブル自体がだいぶ落ち着いたこともありピーク時で約6000人くらいです。

(現在サーマーミッションなどが開催されていることもあり約8000人くらいになっています。)

現在のサーバー構成

現在の実装

現在は上の図のようになっています。

なぜこのような構成になったのかについて書いていきます。


nginx

最初期からフロントのホスティングはnginxで行っていました。

SSL化する際に、WebSocketサーバー(Node.js)に暗号化処理を持たせるとめちゃくちゃ重かったので、そこの部分もnginxに持たせるようになりました。

利用者が増えるにつれてWeb Socketサーバーへの負荷がかなり厳しいことになったので、WebSocketサーバーを各CPUで起動してnginxで振り分ける形にしました。

デーモン化や各CPUでの起動にはpm2を使っています。

それでも厳しかったのでサーバーを更に増やして現在に至ります。

nginx.conf(WebSocketの負荷分散部分)
upstream gbs3_nodes {
	least_conn;
	server localhost:10300;
	server localhost:10301;
	server localhost:10302;
	server localhost:10303;
	server (サブサーバーのIP):10300 weight=3;
	server (サブサーバーのIP):10301 weight=3;
}

server {
  listen 10310 ssl;
	server_name gbs.eriri.net;
	location / {
		proxy_pass http://gbs3_nodes;
	}

	# socket.ioの設定
	location /socket.io/ {
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_pass http://gbs3_nodes;
		proxy_http_version 1.1;
		proxy_set_header Upgrade $http_upgrade;
		proxy_set_header Connection 'upgrade';
	}

	# SSL
	ssl_certificate_key (.keyのパス);
	ssl_certificate (.crtのパス);

}

最初期は全ての救援ツイートをクライアントに送信していました。

ですが送信処理が一番重いことがここで判明したので、送信するデータ量を削減するために

  1. クライアントが必要としている種類の救援ツイートのみを送信
  2. 敵番号のみを送信して、クライアントは敵リストから情報(名前やレベル)を参照する

といった実装を行いました。

敵リストは手動で管理しています。(SQLiteでデータを管理してそれをJSONファイルに書き出して全サーバー共通で使用しています)


WebSocketサーバー(Node.js)

WebSocketを直接扱うのは骨が折れるのでsocket.ioを使用しています。

最初期はここでツイート取得なども行っていました。

ですが、各CPUで起動する都合で分離することになりました。

pm2の起動config
module.exports = {
	apps: [{
		name: "gbs3",
		script: "./index.js",
		exec_mode: "fork",
		instances: 4,
		// 変数定義とかもできる
		env: {
			SERVER_MODE: "main",
			REDIS_HOST: "localhost"
		}
	}]
}
pm2の変数参照
// インスタンスの番号
// 今回の場合だと0~3
process.env.NODE_APP_INSTANCE

// configで定義したやつもここに入ってる
process.env.REDIS_HOST

そこでツイート取得サーバーとのデータの受け渡しにRedisのpub/subを使うことになりました。

Redisのpub/subであれば外部サーバーからの接続も容易なのでかなりいい感じです。

Redisを使う際にはufwなどでポートの戸締りをしておかないと不正なリクエストが馬鹿ほど飛んできたりするので注意が必要です。(1敗)


ツイート取得サーバー(Node.js)

ツイートの取得とパースを行うサーバーです。

最初期はstatuses/filterを用いてツイートを取得していたのですが、

  1. 接続が不安定
  2. Twitter APIの調子が悪いとツイートが流れてこない
  3. ちょっと遅い(ツイートから5~10秒くらいかかる)

などの理由から現在はsearch/tweetsを数秒おきに叩いています。

レート制限はapp認証だと450/15分なので単純計算で2秒おきに叩いても大丈夫なことになります。

実装としてはsearch/tweetsのレスポンスヘッダ内のx-rate-limit-remainingx-rate-limit-resetを用いて次のリクエストまでの時間を計算しています。

計算部分
const limit = res.headers["x-rate-limit-remaining"] - 0;
const limit_reset = res.headers["x-rate-limit-reset"] * 1000;
const now = Date.now();

// 次の取得時間を決める
let interval = (limit_reset - now) / Math.max(1, limit - 5); // 5回分余裕を持たせる
interval = Math.max(interval, 2000); // 最低でも2秒はあける
使用しているクエリ
{
	q: `"Battle ID" OR "参戦ID"`, // これで英語と日本語両方の全救援依頼が取得できる
	result_type: "recent", // 時系列順
	include_entities: true, // 画像などを含める
	count: 100, // 取得する最大個数
	since_id: since_id // 前回の最終ツイートのid(初回なら0)
}

Twitter APIの詳細

/2/tweets/search/recentもありますがプロジェクト単位で50万ツイート/月の制限があるので今回の用途では使用できませんでした。

これは知見なのですがTwitter APIは調子が悪いときに何もレスポンスを返さないでタイムアウトすることがあります。

なので通常のエラー処理に加えてタイムアウトなどのエラー処理もちゃんと書いておいたほうがいいです。

タイムアウトが頻発しているのにTwitter API Statusで何も表示されていない場合はおそらく地域特有の調子の悪さなので、ツイート取得部分のみ別リージョンのサーバーに移すなどすれば改善することもあります。

Granblue Searchでは一時期GCPのアメリカリージョンに置いていたこともあります。

Twitter APIを信用してはいけません。


救援ツイート確認&保持サーバー

一番新しく追加した部分です。

元々はリアルタイム性を重視していたのでサイトにアクセスする以前の救援ツイートはいらないだろうと考えていました。

ですが、同じマルチバトルから複数回ツイートを行っている場合などがありました。

  • 途中参加を避けたい
  • 逆に終わりかけのマルチに参加したい

などの需要があることからツイートを一定時間保持しておいて同じ参戦IDと敵であれば一番最初にツイートされた時間を添付するといった実装を追加しました。

これにより1回目のツイートのみを表示したり、逆に5分以上前からあるツイートのみを表示といったことができるようになりました。

また、保持されているツイートから最新のものを数個返すAPIなどもここに実装しました。


クライアント

v1とv2はjQueryを使ってフルスクラッチしていました。(2017年はまだまだjQuery現役だった気がする。)

ですが度重なる機能追加の影響でかなり煩雑なコードとなってしまいメンテナンスなんてできたものじゃなかったです。

ある程度はv1とv2でソースコードを共通化することで耐えていましたがまぁ無理でした。

そこでv3という形で新しく作り直しました。(v1とv2はバグ対応のみという形で残しています。)

作り直す際にReactとVue.jsで迷ったのですが、Vue.jsのほうが書きやすそうだったので採用しました。(正直あまりこだわっていない)

メンテナンス性はかなり向上しました。

ただどのみちCSS(SCSS)はべた書きだし、全然コンポーネントとして切り分けてないしで誉められたものではありません。

最近お手伝いしている「うまっちんぐ!(大会機能のフロントエンド全般を担当)」というサービスでReactとTypeScriptに触れたのですが、これがかなり良くてGranblue SearchでもTypeScript導入すれば良かったなと少し後悔しています。


おわりに

リリースから4年も経っていることに少し驚きました。

今でもかなりの人に使ってもらえてて嬉しい限りです。

最近あまり機能追加とか出来ていませんが保守は続けていくので今後ともよろしくお願いします。