😽

Pod終了時のWebSocket接続を安全に切るためのサイドカー実装事例

2024/12/22に公開

開発チームの鈴木(@sZma5a)です!
この記事はAI Shift Advent Calendar 2024の22日目になります。
本記事では、WebSocketを利用するアプリケーションをKubernetes上で安全にシャットダウンするために実施したサイドカーの活用事例について紹介します。

背景

弊社では公衆回線とVoicebotを接続するために、一般的なCPaaSサービスであるTwilioを利用しています。Twilioでは、Voicebotとの間で音声パケットの送受信にWebSocketを用いていますが、Podのシャットダウン時に接続中のWebSocketセッションを持っていると、通話が突然切断されてしまう問題があり、設定や作業に制約が発生しておりました。

通常であれば、アプリケーション側でグレースフルシャットダウンを実装すべきではありますが、ビジネス上の制約や既存コードベースの都合上、メインアプリケーション側に直接手を入れることが難しい状況でした。

このような背景から、今回はサイドカーコンテナを用いて安全にPodを終了する仕組みを構築しました。
本記事では、このアプローチについてご紹介します。

仕組みの概要


今回の要件を満たすため、下記機能を持つサイドカーを実装します。

1. コネクションの制御

シャットダウン開始時、新規WebSocketセッションを受け付けない

2. コネクションの監視

既に接続中のWebSocketコネクションがすべて切断されるまで待機する

3. 終了処理の続行

コネクション数がゼロになったタイミングでメインコンテナを停止する

実装

実装するサイドカーは以下の2つのコンポーネントから構成します。

プロキシ(常時起動)

メインコンテナへのヘルスチェック(Readiness Probe)をプロキシし、終了処理開始時にReadinessを落とすことで、新規リクエストをServiceから外します。

CLIツール(preStopでの実行)

メインコンテナを直接修正できないため、WebSocket接続数をアプリ側で取得することができません。そこで、Pod内ネットワーク名前空間を共有することを利用し、/proc/net/tcpから特定ポートへのTCP接続状況を取得することで、接続中のコネクション数を把握します。

また、終了処理の開始タイミング(CLIツール側のpreStopフックが呼ばれた時点)をプロキシコンポーネントへ伝える必要があります。今回はこの情報共有を行うために名前付きパイプを利用しました。

プロキシ側にエンドポイントを生やしてCLIツールからHTTP経由で通知する、といった実装でも実現は可能だと思われますが、今回はシンプルな通信機構として名前付きパイプを採用しています。

通常時の状態

通常時プロキシはヘルスチェックをメインコンテナにプロキシします。Readiness Probeは正常値のためWebSocketは問題なく接続・継続可能です。

終了処理開始時

Podが削除要求などを受けて終了処理が始まると、プロキシはReadiness Probeを失敗させます。これによりServiceから新たなコネクションが張られなくなります。
一方でCLIツールは既存のコネクション数を監視し、すべて切れるのを待機します。

全コネクション終了時

全コネクションが終了した段階でCLIツールはメインコンテナのプロセスをキルします。これにより、すべての通話終了後に安全にPodを終了させます。

Kubernetesマニフェスト例

以下はDeployment例です。readinessProbelifecycleフックを用いています。

main-appコンテナ側でpreStopフックを設定しない場合、PodのTerminate開始直後にSIGTERMが送られてしまうため、あらかじめ最大通話秒数に相当する時間を待機させる必要があります。しかし、本実装では、コネクション数がゼロになったタイミングでサイドカーからmain-appを停止させるため、仮に通話が10秒で終了した場合、そのタイミングでコンテナの終了処理が続行されます。

また、サイドカー側からmain-appのプロセスをキルするためにshareProcessNamespaceを有効化しています。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-deployment
spec:
  ...
  template:
    spec:
      shareProcessNamespace: true
      terminationGracePeriodSeconds: 600
      containers:
        - name: main-app
          ...
          lifecycle:
            preStop:
              exec:
                command:
                - /bin/sh
                - -c
                - sleep 600
          ...
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8081
              scheme: HTTP
          ...
        - name: sidecar
          ...
          lifecycle:
            preStop:
              exec:
                command:
                - /bin/sh
                - -c
                - /app/bin stopper --target-port 8080

コネクション監視の仕組み(CLIツール)

/proc/net/tcpからESTABLISHED状態のTCP接続数をカウントすることで、現在の通話数を取得します。以下はGoでのサンプルコードです。

portNumber := 8080
targetPort := strings.ToUpper(fmt.Sprintf("%04X", portNumber))

file, err := os.Open("/proc/net/tcp")
if err != nil {
    return err
}
defer file.Close()

scanner := bufio.NewScanner(file)
// 最初の行はヘッダ行のためスキップ
scanner.Scan()

connectionsCount := 0
for scanner.Scan() {
    line := scanner.Text()
    fields := strings.Fields(line)
    if len(fields) < 10 {
        continue
    }

    state := fields[3]
    parts := strings.Split(fields[1], ":")
    if len(parts) != 2 {
        return errors.New("invalid address format")
    }

    portHex := parts[1]
    port := strings.ToUpper(portHex)

    // state "01" はESTABLISHEDを示す
    if state == "01" && port == targetPort {
        connectionsCount++
    }
}

if err := scanner.Err(); err != nil {
    return err
}

fmt.Println("connectionsCount:", connectionsCount)

このカウント結果を定期的に確認し、全てのコネクションがなくなった段階で終了処理を続行させます。

まとめ

今回紹介したサイドカーアプローチは、メインアプリケーションの修正が困難な環境でも、WebSocketベースの通話を確実にクリーンアップしてからPodを終了させることを可能にしました。これにより、ユーザーへの影響(通話の瞬断)を最小限に抑え、Kubernetes上でのリアルタイム通信サービス運用の安定性を向上できます。
リアルタイム性が求められるサービス運用の一つの解法として、参考になれば幸いです。

最後に

AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion