はじめに
Go の net/http パッケージは http.Server.Shutdown() により グレースフルシャットダウンを実現できます。運用環境ではプロセスが終了シグナル(例: SIGTERM)を受け取ることがあり、その際に 新規リクエストの受付停止 → 処理中リクエストの完了待ち → リソース解放の順で安全に終了できるようにしておく必要があります。
たとえば Kubernetes のようなコンテナオーケストレーター環境では、更新やスケール変更のタイミングでサーバーが SIGTERM を受け取ります。こうした状況では、限られた猶予時間の中で graceful shutdown を正しく行えるかどうかが安定運用に直結します。
Shutdown() が実際にどの接続を待ち、どの接続を待たないのかを知らなければ、意図しない挙動に悩まされることがあります。
そのため本記事では net/http の内部実装を読み解き、接続の管理・状態遷移・Shutdown の待機条件を整理します。
- 対象ソース: Go 1.26.0
前提知識:接続の管理
Shutdown の挙動を理解するには、まず net/http が通常時にどのように接続を管理しているかを知る必要があります。
接続の状態と遷移
net/http は接続を次の 5 つの状態で管理します。
// net/http/server.go
type ConnState int
const (
StateNew ConnState = iota // 新規接続、リクエスト受信前
StateActive // リクエスト処理中
StateIdle // リクエスト間のアイドル状態(Keep-Alive)
StateHijacked // Hijack() により奪われた接続
StateClosed // 接続が閉じられた
)
これらの状態がどう遷移するかを図で示します。
| 状態 | 何をしている状態か |
|---|---|
| StateNew | TCP接続直後。まだリクエスト未受信 |
| StateActive | リクエスト処理中(handler実行中) |
| StateIdle | Keep-Aliveで次のリクエスト待機中 |
| StateHijacked | http.Serverの管理外(終端状態) |
| StateClosed | 接続終了済み(終端状態) |
通常のリクエスト処理の流れ
では実際のコードで、接続がこれらの状態をどう遷移していくかを追ってみましょう。
Serve() — 接続の受付
Serve() はリスナーから TCP 接続を受け付けるループです。新しい接続が来ると conn オブジェクトを作り、状態を StateNew にセットしてから goroutine で処理を開始します。
// net/http/server.go(一部抜粋)
func (srv *Server) Serve(l net.Listener) error {
// ...
for {
rw, err := l.Accept()
if err != nil {
// ...
return err
}
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // ← StateNew に設定
go c.serve(connCtx) // ← goroutine で処理開始
}
}
conn.serve() — リクエストの処理
各 goroutine 内では、リクエストの読み取り → ハンドラの実行 → 次のリクエスト待ち、というループが回ります。
// conn.serve() 内(概念的な抜粋)
for {
w, err := c.readRequest(ctx)
// リクエストのデータを読み取った時点で StateActive に
c.setState(c.rwc, StateActive, runHooks)
// ハンドラを実行
serverHandler{c.server}.ServeHTTP(w, w.req)
w.finishRequest()
// Keep-Alive なら StateIdle にして次のリクエストを待つ
c.setState(c.rwc, StateIdle, runHooks)
// 次のリクエストデータを待機...
}
// goroutine 終了時に defer で StateClosed に遷移
まとめると、1つの接続は StateNew → StateActive → StateIdle → StateActive → ... というサイクルを繰り返し、最終的に StateClosed で終了します。
activeConn — Server が接続を追跡するマップ
ここで重要なのは、http.Server が内部に activeConn というマップを持っていることです。
// net/http/server.go(関連フィールドの抜粋)
type Server struct {
// 公開フィールド(省略)
// 非公開フィールド
inShutdown atomic.Bool // Shutdown 中かどうか
mu sync.Mutex
listeners map[*net.Listener]struct{} // 管理中のリスナー
activeConn map[*conn]struct{} // 追跡中の接続
onShutdown []func() // Shutdown 時のコールバック
listenerGroup sync.WaitGroup // Serve goroutine の終了待機用
}
状態遷移のたびに呼ばれる setState が、このマップへの追加・削除を制御しています。
// net/http/server.go(概念的な抜粋)
func (c *conn) setState(nc net.Conn, state ConnState, runHook bool) {
srv := c.server
switch state {
case StateNew:
srv.trackConn(c, true) // activeConn に追加
case StateHijacked, StateClosed:
srv.trackConn(c, false) // activeConn から削除
}
// ConnState フックがあれば呼び出す
if hook := srv.ConnState; hook != nil {
hook(nc, state)
}
}
注目すべきは、StateActive や StateIdle への遷移では activeConn は変化しない という点です。接続は StateNew の時点で追加され、StateHijacked または StateClosed になるまで残り続けます。
| 状態遷移先 | activeConn |
|---|---|
| StateNew | 追加 |
| StateActive | 変化なし |
| StateIdle | 変化なし |
| StateHijacked | 削除 |
| StateClosed | 削除 |
Shutdown の内部動作
通常時の接続管理を踏まえた上で、Shutdown() がこの仕組みにどう介入するかを見ていきます。
Shutdown が呼ばれるまで
典型的な使い方では、SIGTERM をシグナルハンドラで受け取り Shutdown() を呼び出します。
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done() // SIGTERM を受信
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
以下のシーケンス図は、SIGTERM の受信から Shutdown 完了までの流れを示しています。
以下が Shutdown() の実装です。①〜⑤の番号を付けています。
// net/http/server.go(概念的な抜粋)
func (srv *Server) Shutdown(ctx context.Context) error {
// ① シャットダウンフラグを立てる
srv.inShutdown.Store(true)
srv.mu.Lock()
// ② リスナーを閉じる(新規接続の受付を停止)
lnerr := srv.closeListenersLocked()
// ③ OnShutdown コールバックを非同期で実行
for _, f := range srv.onShutdown {
go f()
}
srv.mu.Unlock()
// ④ リスナーの goroutine 終了を待機
srv.listenerGroup.Wait()
// ⑤ アクティブ接続がすべて閉じるまでポーリング
pollIntervalBase := time.Millisecond
nextPollInterval := func() time.Duration {
// 10% のジッターを加算
interval := pollIntervalBase + time.Duration(rand.IntN(int(pollIntervalBase/10)))
pollIntervalBase *= 2
if pollIntervalBase > 500*time.Millisecond {
pollIntervalBase = 500 * time.Millisecond
}
return interval
}
timer := time.NewTimer(nextPollInterval())
defer timer.Stop()
for {
if srv.closeIdleConns() {
return lnerr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
timer.Reset(nextPollInterval())
}
}
}
各ステップを詳しく見ていきます。
① inShutdown フラグ
inShutdown は atomic.Bool で、Shutdown 中であることを示すフラグです。
func (s *Server) shuttingDown() bool {
return s.inShutdown.Load()
}
このフラグは 2 箇所で接続の動作に影響します:
-
Serve()のループ: リスナーが閉じられた後、Accept()でエラーを受け取るとshuttingDown()をチェックし、trueならErrServerClosedを返す -
conn.serve()のリクエストループ: リクエスト読み取り後にshuttingDown()をチェックし、trueなら即座にリターンする(StateClosedに遷移)
② リスナーの閉鎖
closeListenersLocked() は登録されたすべてのリスナーを閉じます。これにより Accept() がエラーを返し、新規接続の受付が停止します。
③ OnShutdown コールバック
RegisterOnShutdown() で登録されたコールバックが goroutine で実行されます。http2.ConfigureServer はこの仕組みを利用して、HTTP/2 接続に GOAWAY フレームを送信します。
func (srv *Server) RegisterOnShutdown(f func()) {
srv.onShutdown = append(srv.onShutdown, f)
}
④ リスナー goroutine の終了待機
listenerGroup は sync.WaitGroup で、すべての Serve() goroutine が Accept ループから抜けるのを待ちます。これにより、ポーリング開始時点で新規接続が追加される可能性がないことが保証されます。
⑤ アイドル接続の閉鎖とポーリング
Shutdown の核心部分です。closeIdleConns() は activeConn 内のアイドル接続を閉じ、すべての接続が消えた(= quiescent になった)場合に true を返します。
// net/http/server.go(概念的な抜粋)
func (s *Server) closeIdleConns() bool {
s.mu.Lock()
defer s.mu.Unlock()
quiescent := true
for c := range s.activeConn {
st, unixSec := c.getState()
// 5秒以上 StateNew のままの接続はアイドルとみなす(Issue 22682)
if st == StateNew && unixSec < time.Now().Unix()-5 {
st = StateIdle
}
if st != StateIdle || unixSec == 0 {
// unixSec == 0 は状態がまだ設定されていない非常に新しい接続
quiescent = false
continue
}
c.rwc.Close()
delete(s.activeConn, c)
}
return quiescent
}
ポーリング間隔: 初回 1ms から倍増し最大 500ms。各回に 10% のジッターが加算されます。
処理中の接続はどうやって終了に向かうのか: conn.serve() のリクエストループでは、ハンドラ実行後に doKeepAlives() を呼び出します。Shutdown 中はこれが false を返すため、Keep-Alive による接続の再利用が行われず、接続は StateClosed に遷移します。
// conn.serve() 内(概念的な抜粋)
c.setState(c.rwc, StateIdle, runHooks)
if !w.conn.server.doKeepAlives() {
// Shutdown 中: Keep-Alive を無効にして接続を終了
return
}
Shutdown が待機する条件・しない条件
| 接続の状態 | Shutdown の挙動 | 理由 |
|---|---|---|
| StateActive (処理中) | 待機する | ハンドラの完了を待つ。closeIdleConns() は何もしない |
| StateIdle (Keep-Alive待ち) | 即閉じる |
closeIdleConns() が接続を閉じて activeConn から削除 |
| StateNew (5秒未満) | 待機する | まだリクエストが来る可能性がある |
| StateNew (5秒超) | 閉じる | アイドルとみなして閉じる(Issue 22682) |
| StateHijacked | 待機しない |
activeConn から既に削除済み |
| Shutdown 後の新規接続 | 受付しない | リスナーが閉じられているため |
Hijack された接続と Shutdown
http.Hijacker インターフェースを通じて接続を奪うと、その接続は http.Server の管理対象外になります。
hj, ok := w.(http.Hijacker)
if ok {
conn, buf, err := hj.Hijack()
// conn は http.Server から切り離される
// 以降、接続のライフサイクルは呼び出し側の責任
}
Hijack 時の内部動作:
-
Hijack()が呼ばれる - 接続の状態が
StateHijackedに変わる -
trackConn(c, false)によりactiveConnから削除される -
Shutdown()はこの接続の存在を認識しなくなる
Hijack を利用するライブラリ(WebSocket、h2c など)を使う場合、Shutdown() だけでは処理中リクエストの完了を保証できません。 別途、自前でリクエストの追跡と完了待機を実装する必要があります。RegisterOnShutdown() を使って、これらの接続に対する graceful shutdown の通知を行うことが推奨されています。
実装上の注意点
1. Shutdown の context にタイムアウトを設定する
Shutdown() は処理中リクエストが完了するまで無期限に待機する可能性があります。デプロイ環境の停止猶予時間(例: Kubernetes の terminationGracePeriodSeconds はデフォルト 30 秒)を考慮し、適切なタイムアウトを設定してください。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown timed out: %v", err)
}
2. Shutdown 後のリソース解放
Shutdown() が正常に返った後は、activeConn が空であることが保証されます。この時点で DB プールなどのリソースを安全に解放できます。
<-ctx.Done() // SIGTERM 待機
server.Shutdown(shutdownCtx) // 処理中リクエストの完了を待機
pool.Close() // 安全にリソース解放
3. ConnState フックでの監視
http.Server.ConnState フックを設定すると、接続の状態遷移を監視できます。デバッグや可観測性の向上に有用です。
server := &http.Server{
ConnState: func(conn net.Conn, state http.ConnState) {
log.Printf("conn %s: %s", conn.RemoteAddr(), state)
},
}
動作確認
ここまで説明した Shutdown の挙動を、実際に動かして確認してみましょう。以下のプログラムは ConnState フックで状態遷移をログ出力しつつ、スローハンドラで処理中の Shutdown 待機を観察できます。
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
log.Println("handler: start")
time.Sleep(10 * time.Second)
log.Println("handler: done")
fmt.Fprintln(w, "ok")
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ConnState: func(conn net.Conn, state http.ConnState) {
log.Printf("[ConnState] %s → %s", conn.RemoteAddr(), state)
},
}
// SIGTERM / SIGINT でシャットダウン
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("ListenAndServe: %v", err)
}
log.Println("server: Serve returned")
}()
log.Println("server: listening on :8080")
<-ctx.Done()
log.Println("shutdown: signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: error: %v", err)
}
log.Println("shutdown: complete")
}
実行手順
3つのターミナルを使います。
# ターミナル1: サーバ起動
go run main.go
# ターミナル2: リクエスト送信(10秒かかるハンドラ)
curl localhost:8080/slow
# ターミナル3: リクエスト処理中に SIGTERM を送信
kill -TERM $(lsof -ti:8080)
期待されるログ出力
server: listening on :8080
[ConnState] 127.0.0.1:xxxxx → new ← StateNew(activeConn に追加)
[ConnState] 127.0.0.1:xxxxx → active ← StateActive(handler 実行開始)
handler: start
shutdown: signal received ← SIGTERM 受信
server: Serve returned ← ② リスナー閉鎖 → Serve 終了
handler: done ← handler 完了(Shutdown が待機していた)
[ConnState] 127.0.0.1:xxxxx → idle ← StateIdle
[ConnState] 127.0.0.1:xxxxx → closed ← StateClosed(activeConn から削除)
shutdown: complete ← closeIdleConns() → activeConn 空 → 完了
shutdown: signal received の後、handler: done まで Shutdown が待機していることが確認できます。また [ConnState] のログで、記事で説明した new → active → idle → closed の状態遷移が実際に起きていることが分かります。
まとめ
| 項目 | 内容 |
|---|---|
| 接続管理 |
activeConn マップで接続を追跡。StateNew で追加、StateHijacked / StateClosed で削除 |
| Shutdown の流れ | ① フラグ設定 → ② リスナー閉鎖 → ③ OnShutdown → ④ Serve 終了待機 → ⑤ ポーリング |
| 待機の仕組み |
closeIdleConns() を指数バックオフ(1ms〜500ms + ジッター)でポーリング |
| 待機しないケース |
Hijack() された接続は activeConn から除外されるため待機対象外 |
| 実装上の注意 | タイムアウト設定、Hijack 利用ライブラリの考慮、リソース解放の順序 |
http.Server.Shutdown() は activeConn に登録された接続のみを待機対象とします。この仕組みを理解することで、グレースフルシャットダウンが期待通りに動作しないケースを予見し、適切な対策を講じることができます。