Dedicated Server アーキテクチャ設計の実践:1コンテナで48人同時接続、コストまで実測
権威型のゲームサーバーをバックエンドから切り離し、マッチメイキングのキューとルーム割り当てを作り直しました。1コンテナで48人を同時に支える構成と、同時接続プレイヤー1人あたりの実コストを、実測値とともにまとめています。リージョン選定とスケーリングの判断基準、そしてリレー方式で十分なケースについても触れます。
プレイテストの夜:40人、1プロセス、1回のクラッシュ
昨年2月の金曜21時、40人規模のクローズドなプレイテストを実施しました。このゲームは8人対戦を前提に作られており、その夜は5つのマッチが同時に走っていました。23時40分、プレイヤーの3分の1が一斉に切断されました。手元に残ったのは1行だけです。OutOfMemoryException、そしてその下に並んだ5つの異なるmatchId。
原因はバグではなく、アーキテクチャでした。5つのマッチが1つのプロセス、1つのUnityヘッドレスビルドの中に同居していたのです。片方のマッチのリークが、残る4つを道連れにしました。あの夜に学んだのは、ルーム単位の分離は最適化ではなく正しさの要件だということです。プロセスごとにメモリ上限を設ける案も試しましたが、結局死ぬのは共有プロセスのままでした。
そこから3か月かけて、ゲームサーバーのアーキテクチャをゼロから作り直しました。以下の数値と、採用しなかった選択肢はすべてその期間の実測です。対象は8人対戦の競技系シューターのプロトタイプ、エンジンはUnity 2022 LTS、トランスポートはUDPの上に自作した信頼性チャネルです。
権威型サーバーが交渉の余地を持たない理由
最初のバージョンでは、移動をクライアントが計算し、その結果をサーバーに報告していました。2回目のプレイテストでは、40人のうち3人がパケットを書き換えて移動速度を2倍にしました。壊れていたのは検知コードではなく、アーキテクチャそのものです。クライアントに座標を書かせた時点で、チート対策は検証のいたちごっこになり、その勝負には勝てません。サーバー側で速度上限を課す方法も試しましたが、新しい移動アビリティを足すたびに上限を調整し直す羽目になりました。
権威型サーバーでは、クライアントが送るのは入力だけです。私たちのInputCommand構造体は、tick番号、移動ベクトル、視点角、ボタンのビットで合計14バイト。シミュレーションは30 Hz固定tickでサーバーが回し、状態をブロードキャストします。クライアント側の予測は残りますが、権威が移ることは決してありません。
代償は現実的です。サーバーは全プレイヤーの物理演算を自分で回すことになります。実測では、8人セッションが3.4 GHzのコア1本の約38%を消費しました。キャラクターコントローラー、hit-scan、可視性フィルタを含めた値です。この1つの数字が、その後のスケーリングとコスト計算すべての入力になりました。しかも38%はピークではなく平均で、混戦時には同じセッションで55%まで上がりました。
ゲームサーバーとゲームバックエンドは別物である
ゲームサーバーは状態を持ち、寿命が短く、落ちても死ぬのはマッチ1つだけです。一方のゲームバックエンド、つまりアカウント、インベントリ、進行度、フレンドリストはステートレスで寿命が長く、落ちれば全員に影響します。この2つを同じプロセスに同居させたことで、最初のバージョンでは2週間得をし、その後2か月を失いました。私が使っている基準はこうです。マッチより長く生きるデータなら、それはゲームサーバーの仕事ではありません。
線引きはこうしました。マッチ中、ゲームサーバーは永続的なデータベースに一切書き込みません。マッチが終わった時点で、MatchResultのボディを1つだけバックエンドにPOSTします。中身はプレイヤーごとのスコア、時間、ダメージ、使用アイテムです。8台のサーバーが同じ瞬間にインベントリテーブルへ書き込む代わりに、毎分数百件の小さなリクエストになり、キューに載せられます。マッチ途中で落ちたプレイヤーのために30秒ごとの中間記録も送っており、クラッシュが誰かの進行度を消すことはありません。
認証もバックエンドの担当です。ログイン時にプレイヤーは有効期限120秒のsessionTicketを受け取り、ルームのアドレスと一緒にゲームサーバーへ渡します。ゲームサーバーは1回の呼び出しでそれをバックエンドに検証させます。ゲームサーバーはデータベースの認証情報を一切持ちません。乗っ取られたゲームサーバーからインベントリには到達できず、壊せるのはそのマッチ1つだけです。
マッチメイキングのキューと部屋割り当ての仕組み
マッチメイキングはMatchQueueという単一のサービスです。キューに入るプレイヤーは、スキルレーティング、リージョン、パーティIDとともに記録されます。マッチャーは2秒ごとにキューを走査し、同じリージョンの±100レーティング帯でプレイヤーをまとめ、5秒ごとに帯を50ずつ広げます。45秒で帯は上限に達し、品質より待ち時間が優先されます。
8人がそろうと、処理はRoomAllocatorに移ります。代替案も試しました。マッチごとにコンテナを新規に立ち上げるやり方です。Unityのヘッドレスビルドはシーンを読み込んで待ち受けに入るまで6〜9秒かかり、その時間はすでにできあがったグループを解散させるのに十分でした。代わりに、リージョンごとに空きセッションを4つウォームな状態で待機させています。割り当ては300 ms以内に終わります。
ウォームプールが尽きた場合、新しいセッションはバックグラウンドで起動し、プレイヤーが待たされるのは最悪でもその8秒です。プールサイズを固定してはいけません。私たちの環境では21時から24時の同時接続数が日中の6倍になるため、プールも時間帯プロファイルに応じて4から12まで動きます。固定プールでは、夜にキューが伸びるか、昼に遊んでいるマシンの代金を払うかのどちらかになります。
コンテナあたりのセッション管理とスケーリング
ゲームサーバーのプロセスは、ちょうど1つのマッチを回し、マッチが終われば終了します。実測ではセッションあたり180 MBのRSSと、平均0.42 vCPUでした。2 vCPU / 4 GBのノードには余裕をもって4セッション、詰め込めば6セッションが載ります。6セッション×8人で、ノードあたり48人同時接続です。それ以上詰める実験もしましたが、8セッション目でtickが33 msを超え始め、移動の挙動が目に見えて崩れました。
スケーリングをCPU使用率に紐づけてはいけません。正しい指標は空きセッション数です。freeSessionsが8を下回れば新しいノードを要求し、24を超えれば1台をドレイン対象にします。CPU基準だった最初のバージョンは、マッチが終わって負荷が下がるたびに、まだプレイヤーが乗っているノードを落とそうとしていました。この指標はリージョンごとに分けて持ってください。そうしないと、フランクフルトの余剰がイスタンブールの逼迫を覆い隠します。
停止は必ずドレイン経由で行います。対象ノードは新しい割り当てを受け取らなくなり、その上のマッチは自然に終わるのを待ちます。私たちのマッチは最長20分なので、最悪のドレイン時間も20分です。同じ理由から、スポットインスタンスやプリエンプティブなマシンはウォームプールにしか使いません。稼働中のマッチを抱えるノードを、安さで選ぶべきではないからです。
ログとクラッシュ収集もこの層の仕事です。各プロセスは標準出力に1行1つのJSONを書き、どの行にもsessionId、matchId、buildId、tick番号が入ります。クラッシュ時にはプロセスごと消えるため、コレクターはプロセスから独立して動き、コンテナが死ぬ前にPlayer.logとクラッシュダンプを外へ書き出さなければなりません。これを整えるまで、3件のクラッシュは原因が分からずじまいでした。ログ量はセッションあたり毎分およそ400行で、サンプリングなしに保管できる規模です。
リージョンとコスト、リレーで足りる範囲
リージョンの選択はプレイヤーの好みではなく、pingで決めます。ブルサから測った往復時間は、イスタンブールが18〜22 ms、フランクフルトが48〜55 ms、北バージニアが128〜140 msでした。30 Hzのtickでは1tickが33 msなので、フランクフルトは遊べますが、競技系シューターとして北バージニアは無理です。クライアントはログイン時に3つのリージョンへUDPのプローブを1発ずつ投げ、上位2つでキューに入ります。パーティのメンバーが別々の都市にいる場合は共通で最良のリージョンが選ばれ、決め手になるのはパーティ内で最も悪いpingです。
コストはマシン単位ではなく、同時接続プレイヤー単位で計算してください。2 vCPU / 4 GBのノードは1時間あたり約0.09ドルで48人を収容するので、満席なら1プレイヤー時あたり0.0019ドルです。ただし満席にはなりません。平均稼働率は35%で、ウォームプールとリージョンごとの最低キャパシティを足すと、実際の数字は0.006ドルまで上がります。同時接続500人、1日4時間のピークプロファイルなら、月あたりおよそ270ドルです。帯域の請求は総額の8%に収まり、プレイヤーあたりの上りは20 kbps程度でした。
3人のチームにとって、この金額は本当の出費に比べれば小さなものです。割り当てサービス、ドレインのロジック、ログ経路、クラッシュ収集で、フルタイム1か月ほどの作業になりました。競技性のない4〜6人の協力プレイを作っていて、同時接続が数百人にとどまるなら、リレー越しのリッスンサーバーで十分です。リレーはNATを解決し、権威はホストに残り、支払う代償はホスト有利という一点だけです。
私が使っている閾値は単純です。ランキング、順位、経済に触れるものが1つでもあるなら、専用サーバーと権威型の構成が必要で、そうでなければリレーから始めてください。後からの移行を安く済ませる唯一の方法は、最初からゲームロジックをサーバー側で動かせる形に切り出しておくことです。私たちのGameSessionクラスはどのMonoBehaviourにも依存しておらず、同じコードがリッスンサーバーでもヘッドレスビルドでも動きます。この分離を初日にやっておけば、判断を先送りするコストはほぼゼロになります。