システム肥大化時に直面するWebSocketの悪夢
チャットやリアルタイム通知機能を開発し始めた当初、私はよくGoの gorilla/websocket ライブラリを使った「自作」ソリューションを選んでいました。数人のユーザーでローカル環境で動かす分には非常にスムーズです。しかし、同時オンラインユーザー数が1万人に達すると、本当のトラブルが見え始めます。
純粋なWebSocketの最大の課題は状態(State)にあります。各接続はクライアントとサーバー間の継続的なパイプラインです。負荷分散のために3〜4台のサーバーを稼働させている場合、サーバーAはユーザーがサーバーBに接続していることをどうやって知り、メッセージを送信すればよいのでしょうか?同期のためにRedisを使用したPub/Subレイヤーを独自に構築する必要があります。さらに、自動再接続(reconnection)の処理、チャンネル管理、JWTによるセキュリティ対策なども非常に時間がかかります。機能開発に集中する代わりに、WebSocketインフラの保守に忙殺されることになります。
リアルタイムシステムにおける3つの一般的なアプローチ
システムをスケールさせる必要がある際に、エンジニアが検討する主な選択肢は以下の通りです:
- 自作 (Go + Redis): コードを100%制御できますが、水平スケーリング(horizontal scaling)時に発生するエラーの処理に多大な労力がかかります。
- クラウドサービス (Firebase, Pusher): 導入は非常に高速です。しかし、ユーザーが急増すると月末の請求額が数千ドルに達する可能性があり、ベンダーロックインの懸念も伴います。
- Centrifugo: 独立したサーバーとして動作する特化型ソリューションです。接続管理からPub/Sub、スケーラビリティまで、すべてを引き受けます。バックエンド(Go, Python, Node.js)はAPIを呼び出すだけで済みます。
Centrifugoは完璧な妥協点です。Goによる極めて高いパフォーマンスを提供しながら、有料のクラウドサービスのように簡単に統合できます。
なぜCentrifugoが本番環境で最適な選択肢なのか?
Goで書かれたCentrifugoは、並行処理(concurrency)能力を最大限に活用します。最大のメリットは、リアルタイムロジックをアプリケーションサーバーから完全に分離できる点です。バックエンド(Go, Python, Node.js)はAPIを呼び出すだけで済みます。
データモデルは次のように変化します:
Client <–> Centrifugo <– (API/GRPC) –> Go Backend
このモデルにより、Goのバックエンドは「ステートレス(stateless)」になります。バックエンドは認証とメッセージのプッシュのみを担当します。数百万の接続を維持するためのRAMやCPUの負荷は、すべてCentrifugoが肩代わりしてくれます。
実践的な導入ガイド
1. DockerでCentrifugoを起動する
最も素早く開始するには、Dockerを使用します。セキュリティ用のシークレットキーを設定するための config.json ファイルを用意するだけです。
# 設定ファイルをクイック作成
echo '{"token_hmac_secret_key": "my-secret", "api_key": "my-api-key"}' > config.json
# ワンライナーでCentrifugoを実行
docker run -p 8000:8000 -v `pwd`/config.json:/centrifugo/config.json centrifugo/centrifugo centrifugo
起動後、http://localhost:8000 で管理ダッシュボードにすぐにアクセスできます。
2. バックエンド側:権限付与とメッセージ送信
クライアントがCentrifugoに接続するには認証トークン(JWT)が必要です。Goのバックエンドがこのトークンの生成を担当します。開発中、私はよく toolcraft.app を使って返されるJSON構造を素早く確認し、デバッグをスムーズに進めています。
func GenerateCentrifugoToken(userID string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte("my-secret"))
}
サーバーからクライアントに通知を送信するには、gocent ライブラリを介してシンプルなAPIを呼び出すだけです:
c := gocent.New(gocent.Config{
Addr: "http://localhost:8000/api",
Key: "my-api-key",
})
// "notifications" チャンネルにメッセージを送信
ctx := context.Background()
_, err := c.Publish(ctx, "notifications", []byte(`{"message": "皆さん、こんにちは!"}`))
3. フロントエンド側:リアルタイムメッセージの受信
クライアント側では、centrifuge-js ライブラリが非常に軽量で使いやすいです。ネットワーク切断時の自動再接続などの複雑な処理を自動的に行ってくれます。
const centrifuge = new Centrifuge('ws://localhost:8000/connection/websocket', {
token: 'BACKEND_FROM_JWT'
});
const sub = centrifuge.newSubscription('notifications');
sub.on('publication', (ctx) => {
console.log('新しいメッセージ:', ctx.data.message);
});
sub.subscribe();
centrifuge.connect();
運用における「血の滲むような」実体験からのアドバイス
システムを大規模に運用する場合、以下の3つの重要なポイントに注意してください:
常にRedis Engineを使用する
デフォルトでは、Centrifugoはデータをメモリ(Memory)に保存します。ロードバランサーの背後で複数のCentrifugoノードを実行するには、ブリッジとしてRedisを使用することが必須です。そうしないと、あるノードに送信されたメッセージが、別のノードに接続しているユーザーに届かなくなります。
プロキシ認証 (Proxy Authentication)
JWTだけでは柔軟性が足りない場合は、プロキシ認証を試してみてください。ユーザーが接続するたびに、CentrifugoはGoのバックエンドに「このユーザーは有効か?」と問い合わせます。これにより、独自のビジネスロジックに従ってアクセス権限を非常に厳密に制御できます。
メトリクスの厳密な監視
/metrics エンドポイントを有効にし、Grafanaと統合しましょう。特に num_clients(接続数)と messages_sent に注目してください。接続数が10万を超えたあたりでCPU使用率が上昇し始めたら、新しいCentrifugoノードを追加してスケールアウトするタイミングです。
本番環境に耐えうるWebSocketシステムを独自に構築するのは、難しくコストのかかる課題です。CentrifugoとGoを組み合わせることで、強固なインフラを構築し、ユーザー向けの機能開発に完全に集中できるようになります。

