Open1

コードリーディングその1 - Graceful Shutdown付きTCPプロキシサーバの実装

ta.toshiota.toshio

Graceful Shutdown付きTCPプロキシサーバの実装

https://www.lifull.blog/entry/2025/05/08/170000

この記事の中にある「Graceful Shutdown付きTCPプロキシサーバの実装」の理解


🔹 1. 接続制御と終了処理の準備

shutdown := make(chan struct{}, 1)
semaphore := make(chan struct{}, a.MaxConnections)
wg := sync.WaitGroup{}

ここでは以下を準備しています:

  • shutdown: 終了指示用チャンネル。これを閉じると全体の終了が始まる。
  • semaphore: 同時接続数の制限をするためのチャンネル。バッファサイズは最大接続数。
  • wg: 各クライアント処理の終了を待ち合わせるための仕組み。

🔹 2. 接続受付ループの開始

go func() {
    for {
        local, err := listener.(*net.TCPListener).AcceptTCP()
        if err != nil {
            select {
            case <-shutdown:
                return
            default:
                continue
            }
        }
        ...
    }
}()
  • 新しいTCP接続を受け付け(例:クライアントが curl localhost:8080)。
  • エラーが起きたら shutdown をチェック。そうでなければ次へ。

🔹 3. 接続処理用のGoroutineを起動

semaphore <- struct{}{}
wg.Add(1)
go func() {
    defer func() {
        <-semaphore
        wg.Done()
    }()
  • セマフォを使って接続数を制限。
  • 接続処理が終わったら semaphore を戻し、wg.Done() で終了を通知。

🔹 4. ターゲット(転送先)への接続を確立

remoteAddress := target.Host
if target.Port() == "" {
    switch target.Scheme {
    case "http":
        remoteAddress = net.JoinHostPort(target.Hostname(), "80")
    case "https":
        remoteAddress = net.JoinHostPort(target.Hostname(), "443")
    }
}
remote, err := net.DialTimeout("tcp", remoteAddress, 10*time.Second)
  • target にポートがない場合、プロトコルに応じてデフォルトポートを補完。
  • remote には、ターゲットサーバとのTCP接続が確立される。

🔹 5. 双方向転送の実行

c := make(chan struct{}, 2)
f := func(c chan struct{}, dst io.Writer, src io.Reader) {
    _, _ = io.Copy(dst, src)
    c <- struct{}{}
}
go f(c, remote, local)
go f(c, local, remote)
  • クライアント → ターゲット、ターゲット → クライアントの2方向を io.Copy で並行実行。
  • いずれかが完了すると c に通知。

例:

クライアント: GET /hello\n
↓
リモートサーバ: HTTP/1.1 200 OK\nHello!
↓
クライアントへ返送

🔹 6. 終了 or 転送完了の検出

select {
case <-c:
case <-shutdown:
    local.CloseWrite()
}
  • どちらかの転送が終了 or シャットダウンが来たら select が進む。
  • シャットダウンなら local.CloseWrite() によって書き込み側を閉じ、片方向終了を伝える。

🔹 7. シャットダウン信号の待機と処理

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM)
<-quit
time.Sleep(time.Duration(a.Lameduck) * time.Second)
  • プロセスに SIGTERM(例:killdocker stop)が来るまで待機。
  • 来たら Lameduck(猶予)期間だけ待つ。ここで新しい接続は停止すべきだが、既存接続は継続する。

🔹 8. サーバ停止処理

close(shutdown)
listener.Close()
wg.Wait()
  • shutdown を閉じて、すべてのGoroutineに終了を促す。
  • listener.Close() により Accept() が失敗してループが抜ける。
  • wg.Wait() によって、すべての接続処理が終わるのを待つ。

✅ まとめ図解

┌────────────┐      ┌────────────┐
│ クライアント │<──→│ プロキシ   │<──→ ターゲット
└────────────┘      └────────────┘
        │                 │
   SIGTERM         io.Copyで双方向中継
        ▼                 │
   Lameduckタイマー → listener.Close()
        ▼
    既存処理が終わるまで wg.Wait()
        ▼
      プロセス終了