この記事は クラスター Advent Calendar 2022 の12/4の分です。
昨日は warabi さんの「ScriptableItemでTypeScriptを活用してみた」でした。TypeScriptでアイテム開発がはかどりますね。
クラスター株式会社でソフトウェアエンジニアをやってる neguse と申します。最近はもっぱらMarvel Snapというゲームを遊んでいます。
さて、clusterというサービスを構成するサーバは複数あり、それぞれ技術やコードベースが似通っているところもあれば異なるところもあります。
今回は room server と呼んでいるリアルタイム通信を司るサーバに焦点をあてて、開発に利用している技術や、普段どういうことを考えて開発しているかを紹介します。
clusterのサーバ全体の話や、room serverの歴史については以下の記事も参照ください。
cluster全体のサーバ構成: メタバースプラットフォーム clusterのserverについて(2021応用編) | thara.dev
room serverの歴史: clusterのリアルタイム通信サーバーの漸進的な進化|cluster - メタバースプラットフォーム|note
room serverの紹介
room serverはclusterの3D空間内の同期を司るものです。
room serverには以下のような機能があります。
- 空間のメタデータの同期
- 会場の設定やメンバーリスト
- アバターの同期
- 位置、姿勢、音声
- アイテムの同期
- 位置、所有権
- stateの同期
- Key-Value形式で任意の変数を記録できる
クライアントとroom server間は常時接続で同期データをやりとりしており、クライアントから送信された同期対象のデータはroom serverを介して他のクライアントに送信(broadcast)されます。
room serverはroom(空間の単位)ごとに1つのプロセスとして起動されます。誰か1人でもroomに存在している間は起動し続け、roomから全員が抜けるとプロセスが終了されます。
イベントなどでは同じ空間にたくさんのroomが存在することがあり、この場合はプロセス間(サーバ間)通信で同期を行うようになっています。この仕組みにより大規模イベントのスケールアウトを実現しています。
room serverの構成技術
room serverを構成する技術には以下のようなものがあります。
- インフラ
- AWS EC2
- 最近Graviton(ARM)インスタンスに移行しました
- 開発言語
- Go
- clusterのサーバはおおむねGoで実装されています
- リアルタイム通信のプロトコル
- MQTT
- 実装も確認した上で、パフォーマンスに影響するような仕様は極力使わずシンプルなメッセージ配送ライブラリとして使っています
- メッセージのシリアライズ・デシリアライズ
- Protocol Buffers
- 後述しますが、かなり活用しています
- ログ
- 構造化ログ
- https://github.com/uber-go/zap を利用しています
- 監視
- Prometheus metricsを出力し、Grafanaからモニタリング・アラートするようにしています
- Feature Flagによるrelease管理
- clusterでのFeature Flagの活用については cluster はどのように新機能を並行開発しているのか
を参照ください。この記事はUnity Clientのものですが、serverにも同様の仕組みが存在します。
- clusterでのFeature Flagの活用については cluster はどのように新機能を並行開発しているのか
room serverの技術的な特徴
高いリアルタイム性
room serverが扱っている同期対象のデータは、一般的なWEBサービスのAPIに比べるとリアルタイム性の要求水準が高いです。たとえば空間内で複数人でジャンケンやダンス、おしゃべりをしたりといったユースケースを考えたときに、誰か1人の同期が0.5秒でも遅れてしまうと支障があります。そのため、安定して高速に処理を行う必要があります。
room serverでは、高いリアルタイム性が必要な箇所ではプロセス内で処理が完結するように、room serverプロセス内のin-memoryにデータを持つことが多いです。一般的なWEBサービスではデータ永続化のためにRDBMSやいわゆるNoSQLなどのデータベースサーバを利用して、WEBサーバプロセス自体はステートレスにすることが多いです。room serverはこの点でWEBサーバとはやや指向が異なります。(room serverはどちらかというとゲーム開発文脈におけるDedicated Serverに近いです。)
ここで仮に外部プロセスとしてのデータベースサーバを採用した時、どのぐらいリアルタイム性に影響があるか考えてみましょう。Latency Numbers Everyone Should Know (リンク先PDF) という色々な処理にかかるレイテンシをまとめた記事がありますが、これによると「Round trip within same datacenter」は0.5msとなっています。単純な計算ではシーケンシャルな1000回の操作(Round trip)を行うと500ms(0.5秒)かかることになります。Redisのpipelineのような仕組みやデータ構造の工夫で効率化できる余地はあるものの、これではroom serverが要求するリアルタイム性に影響が出そうです。
さてプロセス内のin-memoryにデータを格納する時、どのようなことに気をつける必要があるでしょうか。複数のGoroutineから同じin-memoryの変数を参照・更新してしまうとrace conditionとなってしまうため、適宜MutexやChannelを用いて排他を行う必要があります。Go標準ライブラリのsync.Mutexは再入できない仕様となっており、あるMutexをLock()したあとUnlock()せずに再度Lock()してしまうとデッドロックとなります。room serverのコードでは、基本的に大文字から始まるexportedな(package外から呼ばれうる)メソッドでMutexのLock()/Unlock()を行い、小文字から始まるメソッドではロックされていることを前提にそのまま処理するようにしています。このようなルールを設けることで、ロックの範囲がわかりやすくなります。
type Example struct {
value int
mu sync.Mutex // Example型全体の排他
}
func (e *Example) updateValue(newValue int) {
// すでにロックされている前提でそのまま処理する
e.value = newValue
}
func (e *Example) UpdateValue(newValue int) {
// 大文字から始まるexportedなメソッドではロックする
e.mu.Lock()
defer e.mu.Unlock()
e.updateValue(newValue)
}
またリアルタイム性を高めるため、処理の計算量(オーダー)にも気を使います。たとえばsliceを毎回linear searchすると計算量が増えてしまうためmapにしたり、全ユーザ共通で使うデータは全体で1回だけ処理することでユーザ数に比例せず定数オーダーで処理できるようにしたり、のような話です。ただし早期の最適化は今後の機能拡張の妨げになることもあるため、日々性能とメンテナンス性のバランスに苦慮しながら開発を行っています。
互換性の維持
room serverやクライアントの機能拡張を行う際、MQTT経由で通信されるメッセージのフォーマットはある程度互換性を維持する必要があります。これはclusterがクライアントを過去2バージョンまでサポートしていることや、room serverのhot deployができないこと(room serverはstatefulなため)などによって、クライアント, room serverともに複数のバージョンを組み合わせても動作することが求められるためです。
前述の通りメッセージのシリアライズにはProtocol Buffersを採用しており、この機能を使って互換性を保ったままフォーマットの更新を実現しています。
Protocol Buffersの公式ドキュメントに更新の手順があるのですが、ここで例を挙げて紹介します。
https://developers.google.com/protocol-buffers/docs/proto3#updating
たとえば、あるserverからクライアントへの送信メッセージ型にfieldを追加することを考えます。
serverはfieldに値を設定し、クライアントはfieldの値を参照します。
message Message {
int original_value = 1; // もともとあったfield
int new_value = 2; // 今回新しく追加したfield
}
このとき、serverとクライアントのバージョンの組み合わせは以下の4つのパターンがあります。
server version | 送信field | client version | 参照field |
---|---|---|---|
1 | original_value | 1 | original_value |
1 | original_value | 2 | original_value, new_value |
2 | original_value, new_value | 1 | original_value |
2 | original_value, new_value | 2 | original_value, new_value |
ここで server version=1, client version=2
の行に着目します。このケースでは送信側のserverではoriginal_valueだけが設定されますが、受信側のclientではoriginal_valueとnew_valueを参照し、new_valueを参照した結果はデフォルト値 (int型の場合0)となります。そのため、「client version=2はnew_valueとして0が参照できた時に、それがserver version=1で未設定だったがゆえの0なのか、server version=2で0を設定していたがゆえの0なのかの区別がつかない」ことに注意する必要があります。未設定を0として扱えるかどうかはケースバイケースなのですが、もし仕様上難しいということであれば、値が有効であるかどうかのbool値を追加したり、messageをネストすることができます。
message Message {
int original_value = 1; // もともとあったfield
bool new_value_enabled = 2; // new_valueに値が入っているならtrue
int new_value = 3; // new_value_enabled がtrueの時のみ値を参照してよい
}
message Message {
int original_value = 1; // もともとあったfield
message InnerMessage {
int new_value = 1;
}
InnerMessage new_value = 2; // messageの場合未設定ならnullとなる(言語による)
}
別の例として、既存のfieldの型や意味を変えたいというケースも考えてみましょう。Protocol Buffersの型は互換性があるものと無いものがありますが、意味が変わるようなケース(たとえば数値が秒単位だったものをミリ秒単位に変更したい)では互換性が無いものとして、以下の手順をとることが多いです。
- 変更後の型をもつ新しいfieldを追加
- 一時的に2つのfieldに値が設定されるようになる
- 新しいfieldのみ参照するように受け取り側のコードを変更
- 新しいfieldを参照する実装が行き渡り古いfieldを参照する処理が使われなくなったら、古いfieldをdeprecatedに指定
- 最終的に古いfieldを削除し、tag numberが再利用されないようreservedにする
この一連の手順が完了するまで複数回のリリースをはさむ点は少し面倒ですが、互換性が保たれているためリリースに伴うダウンタイムが無い、トラブル時の切り戻しが容易になるなどのうれしいメリットがあります。
まとめ
以上、ざっくりとですがroom serverの構成技術や、リアルタイム性と互換性を重視している話をしてみました。
room server開発の雰囲気を少しでもお伝えできたら幸いです。
明日は @kouta_vr さんの「なんか」です。どんな記事なのか楽しみですね。