地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Linux】Socket MigrationとWebSocketを使ったデプロイ戦略

この記事はLinux Advent Calendar 2024の23日目の記事です。


WebSocketを使っているアプリケーションを安全にデプロイする方法について考えてみる思考実験的な記事です

目次

WebSocketとは

WebSocket は、クライアント(通常はWebブラウザ)とサーバー間で双方向かつリアルタイム通信を実現するための通信プロトコルです。これにより、Webアプリケーションが効率的かつインタラクティブな通信を行うことが可能になります。以下はイメージしやすくするためのPythonでのWebSocketサーバのコードです。

import asyncio
import websockets

async def echo(websocket, path):
    print("Client connected!")
    try:
        async for message in websocket:
            print(f"Received: {message}")
            await websocket.send(f"Echo: {message}")  # クライアントに送信
    except websockets.ConnectionClosed:
        print("Client disconnected!")

# サーバーを開始
start_server = websockets.serve(echo, "localhost", 8765)

print("WebSocket server started at ws://localhost:8765")

asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()

WebSocketの仕組み

初回接続

WebSocketはHTTPを利用して最初の接続(ハンドシェイク)を確立します。クライアントがUpgradeヘッダーを送信し、サーバーが対応を確認するとプロトコルがWebSocketに切り替わります。以下はHTTPリクエスト例です。

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

サーバーレスポンス例

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
データ交換

ハンドシェイク後は、両者が継続的にフレーム(データの塊)を交換。データは軽量なバイナリフォーマットまたはテキストフォーマットで送受信される。

接続の維持

WebSocketは長時間接続を維持します。時折、ピンやポンと呼ばれる「生存確認メッセージ」を送信して接続状態を確認することがあります。

WebSocketの利点

  • 効率性: データの送信が頻繁なリアルタイムアプリケーションで、従来のHTTPポーリングよりも効率的。
  • 低レイテンシ: リクエストやレスポンスの待機時間が少ない。
  • シンプルなAPI: Webブラウザで使える標準APIが提供されているため、実装が容易。

デプロイの課題

一般的なWebサーバであれば通信はクライアントからリクエストが来ない限りサーバ側は処理が走りません。graceful shutdownのような仕組みではクライアントからのリクエストを止めておいて現在有効な処理中のリクエストが停止次第でアプリケーションを入れ替えることでダウンタイムやエラーなしでデプロイができます。

一方でWebSocketはコネクションは維持したままクライアントサーバ間で双方向通信が行われます。仮にアプリケーションがステートレスであればコネクションを一旦クローズしてしまってWebSocketのoncloseイベントを監視し、接続が切れたことを検出しクライアントから再接続するように実装をしておくことでアプリケーションのデプロイをすることができます。

let socket;
let reconnectInterval = 1000; // 再接続の初期間隔(ミリ秒)

function connect() {
    socket = new WebSocket("wss://example.com/socket");

    socket.onopen = () => {
        console.log("WebSocket connected");
        reconnectInterval = 1000; // 再接続間隔をリセット
    };

    socket.onclose = () => {
        console.log("WebSocket disconnected. Reconnecting...");
        setTimeout(connect, reconnectInterval);
        reconnectInterval = Math.min(reconnectInterval * 2, 30000); // 最大30秒までバックオフ
    };

    socket.onmessage = (event) => {
        console.log("Message received:", event.data);
    };

    socket.onerror = (error) => {
        console.error("WebSocket error:", error);
        socket.close(); // 切断して再接続
    };
}

connect();

このような実装でクライアントが再接続をしてくれるのでエラーなしでバイナリのアップグレードが可能になります。

クライアントに再接続ロジックを実装できなかったら?

この部分が本題です。クライアント側に再接続のロジックが実装できない場合はどうなるでしょうか?デプロイのたびに通信中のコネクションが切れることになります。それをどうにかならないかを考えてみます。

SO_REUSEPORT

Linuxが提供するSO_REUSEPORTというソケットオプションは、効率的で高性能なネットワーク通信を実現するための鍵となる機能です。このオプションを利用することで、複数のプロセスやスレッドが同一のポート番号を共有し、並列処理を実現できます。

通常、ネットワークアプリケーションでは、1つのポート番号に1つのプロセスしかバインドすることができません。この制約は、多くの接続を処理する必要があるシステムにとってパフォーマンスのボトルネックとなることがあります。しかし、SO_REUSEPORTを有効にすると、複数のプロセスやスレッドが同じポート番号を使用してバインドできるようになります。これにより、カーネルが受信するリクエストを複数のソケットに自動的に分配するため、負荷が均等に分散されます。この分配はラウンドロビン方式で行われ、プロセスごとにリクエストが順次処理されるため、効率的な負荷分散が可能です。

例えば、Webサーバーやプロキシサーバーのような高スループットが求められるアプリケーションでは、この機能を活用することで、1つのポートを複数のプロセスで共有しながら、並行してクライアントからの接続を処理できます。NGINXやHAProxyといった高性能なサーバーソフトウェアも、このSO_REUSEPORTを利用して負荷分散を実現しています。過去にブログも書いてあるので見てみてください。

ryuichi1208.hateblo.jp

これを使えば複数プロセスで一つのポートをbind(2)できて解決できそうですがそうはいきません。コネクション自体は単一のプロセスで処理されます。カーネルが新しい接続を受け入れる際、どのプロセスがその接続を処理するかを決定します。(SYNが来た時点で決定している)。そこで登場するのがSocket Migrationです。net.ipv4.tcp_migrate_req は、Linux カーネルが TCP 接続の移行(TCP Connection Migration)をサポートする際に利用される sysctl パラメータの一つです。この設定は、確立済みの TCP 接続を別のプロセスやシステムに移行できるようにするためのものです。通常、TCP 接続は特定のプロセスやソケットに固定されていますが、tcp_migrate_req を有効にすることで、この固定性を柔軟に変更できます。詳しくは以下のブログがとてもとても詳しいので読むことをお勧めします。

kuniyu.jp

これを使うことで以下のようなデプロイフローが可能になります。

1. クライアントとサーバでWebSocketの通信を開始
2. サーバ側はプロセスAで3000番ポートで通信をしている
3. サーバ側に新しいバイナリをデプロイしてプロセスBを立ち上げ3000番ポートをbind
4. 以降の通信はプロセスA or プロセスBが行う
5. プロセスAに何らかのシグナルを送ってread(2)などのシステムコールを実行しないようにする
6. 以降はプロセスBのみが通信を行う

これで安全にプロセスAからプロセスBに安全にデプロイができました。

まとめ

net.ipv4.tcp_migrate_req は、TCP接続を柔軟に移行する機能を提供し、高可用性や負荷分散の改善に寄与します。この設定を活用することで、リアルタイム性が求められるアプリケーションや、スケーラブルなシステムの設計が可能となります。ただし、セキュリティやクライアント側の対応を十分に考慮しながら、慎重に導入することが重要です。