http.Server.Shutdown は、最後のリクエストが終わってから最大で約500ms待ってから返ってきます。手元で5回計測したところ、ハンドラ完了から Shutdown が返るまでの間隔は 11ms・470ms・479ms・510ms・526ms でした。ドキュメントには書かれていないこの遅れがどこから来るのか、そして Shutdown が「待たないもの」が何なのかを、Go 1.26.4 で動かしながら整理します。
graceful shutdown 自体は signal.NotifyContext と Shutdown を組み合わせれば10行ほどで書けるのですが、そのまま本番に出すと「処理中のリクエストが消える」「WebSocket が切れない」「Kubernetes で 502 が出る」の3つを踏みやすいと感じています。書き方の正解だけでなく、なぜそう書くのかを実測ベースで残しておきたい、というのがこの記事の動機です。
Shutdown が「やること」と「やらないこと」
まず公式ドキュメントの func (*Server) Shutdown の説明を読み直します。要点は次の一文に詰まっています。
Shutdown works by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down.
つまり順番は「リスナーを閉じる → アイドル接続を閉じる → アクティブな接続がアイドルに戻るのを待つ」です。処理中のハンドラを中断することはしません。逆に言うと、ハンドラが5分かかるなら5分待ちます。上限を決めるのは呼び出し側が渡す context.Context だけです。
Close との違いを表で押さえる
似た名前の Server.Close と混同しやすいので、ドキュメントの記述をもとに比較します。
Shutdown(ctx) | Close() | |
|---|---|---|
| リスナー | 閉じる | 閉じる |
| アイドル接続 | 閉じる | 閉じる |
| 処理中(Active)の接続 | アイドルに戻るまで待つ | 即座に閉じる |
| Hijack された接続 | 閉じない・待たない | 閉じない(存在も知らない) |
| 待ち時間の上限 | ctx のデッドライン | なし(即時) |
| 戻り値 | ctx 超過なら ctx.Err() | リスナー close のエラー |
どちらを呼んでも、その時点で ListenAndServe は http.ErrServerClosed を返して即座に抜けます。ドキュメントにわざわざ “Make sure the program doesn’t exit and waits instead for Shutdown to return” と書かれているのは、ここでプログラムが終了してしまう事故が多いからだと思います。
ドキュメントに書かれていない 500ms の正体
冒頭の遅れの正体は net/http/server.go にありました。Shutdown はアクティブな接続の完了を通知で受け取るのではなく、ポーリングで確認しています。
// net/http/server.go (Go 1.26.4) より抜粋
const shutdownPollIntervalMax = 500 * time.Millisecond
func (s *Server) Shutdown(ctx context.Context) error {
s.inShutdown.Store(true)
// ... リスナーを閉じ、RegisterOnShutdown の関数を go f() で起動 ...
pollIntervalBase := time.Millisecond
nextPollInterval := func() time.Duration {
// Add 10% jitter.
interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10)))
// Double and clamp for next time.
pollIntervalBase *= 2
if pollIntervalBase > shutdownPollIntervalMax {
pollIntervalBase = shutdownPollIntervalMax
}
return interval
}
timer := time.NewTimer(nextPollInterval())
defer timer.Stop()
for {
if s.closeIdleConns() {
return lnerr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
timer.Reset(nextPollInterval())
}
}
}
1ms から始めて倍々に伸ばし、上限 500ms に 10% のジッターを足した間隔で「全部アイドルになったか」を見に行く実装です。最初のチェックはループ先頭で即座に走るので、処理中の接続がひとつも無ければ Shutdown は 0ms で返ります(手元でも Shutdown returned after 0s でした)。一方、数秒かかるハンドラを待っている間にポーリング間隔は 500ms に張り付くので、ハンドラが終わった瞬間から最大 550ms ほど遅れて返る。5回の計測で 11ms が1回、470〜526ms が4回だったのはこの仕組みそのものです。
実害はほぼ無い数字ですが、「Shutdown のタイムアウトは処理時間 + 0.5秒 の余裕を見る」と覚えておくと、ぎりぎりの猶予設定でタイムアウト判定される事故を避けられます。
まず NG 例—kill した瞬間にレスポンスが消える書き方
先に踏みがちな2パターンを見せます。どちらも「一見 graceful に見える」のが厄介なところです。
NG その1: シグナルの ctx をそのまま Shutdown に渡す
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
// NG: この ctx はシグナルを受けた時点でキャンセル済み
err := srv.Shutdown(ctx)
log.Printf("Shutdown returned err=%v", err)
ctx はシグナルが来た瞬間に Done が閉じています。その ctx を Shutdown に渡すと、ポーリングループの case <-ctx.Done() が即座に選ばれて context.Canceled で返ります。2秒かかるハンドラを叩いた直後に kill -TERM した結果がこれです。
$ curl -s -o /dev/null -w "http=%{http_code} exit=%{exitcode} total=%{time_total}s\n" localhost:8080/slow &
$ kill -TERM $(pgrep -f bad)
http=000 exit=52 total=0.406509s
# サーバー側ログ
2026/08/18 20:04:11 [ 0.038s] handler start
2026/08/18 20:04:12 [ 0.442s] signal received
2026/08/18 20:04:12 [ 0.442s] Shutdown returned err=context canceled
curl の exit 52 は “Empty reply from server”。Shutdown が 0秒で返り、main が終了してプロセスごと落ちたので、処理中だったリクエストはレスポンスを受け取れませんでした。ログには handler done が出ていません。graceful shutdown のコードを書いたのに graceful ではない状態です。
NG その2: log.Fatal(srv.ListenAndServe())
go func() {
// NG: Shutdown を呼ぶと ErrServerClosed が返り、ここで os.Exit(1) する
log.Fatal(srv.ListenAndServe())
}()
チュートリアルでよく見る形ですが、Shutdown を呼んだ瞬間に ListenAndServe が ErrServerClosed を返し、log.Fatal が os.Exit(1) を呼びます。Shutdown の待機が終わる前にプロセスが消えるうえ、defer も一切実行されません。ErrServerClosed を errors.Is で除外するか、エラーを channel で main に戻して select で扱う必要があります。
基本形—signal.NotifyContext と 2 つの context
上の2つを潰した形がこれです。ポイントは「シグナル検知用の ctx」と「Shutdown の猶予用の ctx」を別物として持つことに尽きます。
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// 1. シグナルを context に変換する(SIGINT=Ctrl+C, SIGTERM=kubectl delete / docker stop)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("done\n"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// 2. サーバーは別 goroutine で動かし、エラーは channel で main に戻す
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
// 3. 起動失敗かシグナルか、先に来た方で抜ける
select {
case err := <-errCh:
log.Fatalf("listen: %v", err) // ポート衝突など。ErrServerClosed はここには来ない
case <-ctx.Done():
log.Printf("signal: %v", context.Cause(ctx))
}
stop() // 4. これ以降の SIGINT/SIGTERM はデフォルト動作(即終了)に戻す
// 5. Shutdown の猶予は Background から切った別の context で決める
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("shutdown: %v", err) // 猶予内に終わらなかった
os.Exit(1)
}
}
同じ手順(2秒ハンドラを叩いた 0.6秒後に kill -TERM)で動かすと、今度は最後まで届きます。
$ curl -s -o /dev/null -w "curl1: %{http_code} %{time_total}s\n" localhost:8080/slow &
$ kill -TERM $(pgrep -f ok)
$ curl -s -o /dev/null --max-time 2 localhost:8080/slow; echo "curl2 exit=$?"
curl2 exit=7 # 新規接続は Connection refused(リスナーが閉じている)
curl1: 200 2.002946s # 処理中だった方は 200 で返ってきた
# サーバー側ログ
2026/08/18 20:03:18 [ 0.135s] handler start
2026/08/18 20:03:19 [ 0.741s] signal: terminated signal received
2026/08/18 20:03:20 [ 2.137s] handler done (ctx.Err=<nil>)
2026/08/18 20:03:20 [ 2.360s] Shutdown returned after 1.619s err=<nil>
Go 1.26 で context.Cause がシグナル名を返すようになった
ログの signal: terminated signal received は context.Cause(ctx) の出力です。Go 1.26 のリリースノート(os/signal の項)にあるとおり、NotifyContext が内部で context.WithCancelCause を使うようになり、どのシグナルで止まったかを Cause から取れます。実装は s.String() + " signal received" なので、SIGTERM なら terminated signal received、Ctrl+C なら interrupt signal received になります。1.25 以前では ctx.Err() も Cause も context canceled しか返さず、シグナルの種類を残すには自前で signal.Notify の channel を読むしかありませんでした。停止理由を Cause で伝搬させる設計は、Go contextでキャンセルを設計する—Causeで「誰が止めたか」を残すでまとめた話がそのまま使えます。
stop() を「シグナル受信直後」にもう一度呼ぶ理由
defer stop() だけで済ませている例が多いのですが、上のコードではシグナルを受けた直後にも stop() を呼んでいます。NotifyContext のドキュメントに “Future interrupts received will not trigger the default (exit) behavior until the returned stop function is called” とあるとおり、stop を呼ぶまでは 2回目の Ctrl+C も context のキャンセル(既に済んでいる)に吸われて何も起きません。graceful shutdown が10秒待っている最中に「もういいから止まってくれ」と Ctrl+C を連打しても効かない、というのはローカル開発で地味にストレスになります。stop() を先に呼んでおけば、2回目の Ctrl+C はデフォルト動作の即終了に戻ります。
実測してわかった、ハンドラ側の見え方
ここが個人的に一番の学びでした。上のログをもう一度見ると、handler done (ctx.Err=<nil>) となっています。Shutdown が始まっても、処理中ハンドラの r.Context() はキャンセルされません。
r.Context() は Shutdown では切れない
r.Context() は接続が閉じるか ServeHTTP が返ったときにキャンセルされる、と Request.Context のドキュメントにあります。Shutdown はアクティブな接続を閉じないので、その接続上のリクエスト context も生きたままです。したがって「Shutdown が始まったら長い処理を打ち切って 503 を返したい」「バッチ的な重い API はキューに戻したい」のような制御は、r.Context() を見ているだけでは実現できません。
ハンドラに「終了が始まった」を伝えたいなら、http.Server.BaseContext に自前のキャンセル可能な context を渡します。BaseContext は「このサーバーで受ける全リクエストの親 context を返す関数」で、r.Context() はここから派生します。
baseCtx, cancelBase := context.WithCancelCause(context.Background())
srv := &http.Server{
Addr: ":8080",
Handler: mux,
BaseContext: func(net.Listener) context.Context { return baseCtx },
}
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(5 * time.Second):
w.Write([]byte("done\n"))
case <-r.Context().Done(): // BaseContext のキャンセルもここに届く
http.Error(w, "shutting down", http.StatusServiceUnavailable)
}
})
// ... シグナル受信後 ...
cancelBase(context.Cause(ctx)) // 先にハンドラへ通知
srv.Shutdown(shutdownCtx) // その後で待つ
$ curl -s -w "\ncurl: http=%{http_code} total=%{time_total}s\n" localhost:8080/slow &
$ kill -TERM $(pgrep -f base)
shutting down
curl: http=503 total=0.601413s
# サーバー側ログ
2026/08/18 20:04:48 [ 0.113s] handler start
2026/08/18 20:04:49 [ 0.713s] signal: terminated signal received
2026/08/18 20:04:49 [ 0.713s] handler saw cancel: terminated signal received
2026/08/18 20:04:49 [ 0.715s] Shutdown returned after 1ms err=<nil>
5秒待つはずのハンドラが 0.6秒で 503 を返し、Shutdown は 1ms で終わっています。context.Cause がハンドラまで terminated signal received のまま届いているのも確認できます。
「全部 503 で返す」か「最後まで処理する」かは API の性質で決める
ただし BaseContext を切るのは万能ではありません。決済確定のような「途中で止めるほうが被害が大きい」処理は、Shutdown に最後まで待たせるほうが正しいはずです。使い分けの目安を表にしておきます。
| ハンドラの性質 | Shutdown 開始時の扱い | 実装 |
|---|---|---|
| べき等で再試行できる(検索・一覧取得) | 打ち切って 503 + Retry-After | BaseContext のキャンセルを見る |
| 副作用があり途中で止めたくない(決済・書き込み) | 最後まで処理する | r.Context() だけを見る(BaseContext は切らない) |
| 長時間ストリーミング(SSE・ダウンロード) | 猶予内に切る | BaseContext のキャンセルでループを抜ける |
1つのサーバーに両方が混在するなら、BaseContext を切るタイミングを「猶予の残り数秒」まで遅らせる、あるいはミドルウェアで種類ごとに context を差し替える設計になります。
Shutdown が待たないもの—WebSocket と goroutine
サジェストに「go graceful shutdown goroutines」「golang graceful shutdown websocket」と出るのは、Shutdown の守備範囲外にあるものが多いからだと思います。ドキュメントには次のように明記されています。
Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets. The caller of Shutdown should separately notify such long-lived connections of shutdown and wait for them to close, if desired.
Hijack した接続は Shutdown が「存在を知らない」
http.NewResponseController(w).Hijack() で接続を奪ってから Shutdown を呼ぶとどうなるか、試したログがこれです。
srv.RegisterOnShutdown(func() {
mu.Lock()
defer mu.Unlock()
log.Printf("OnShutdown: closing %d hijacked conns", len(conns))
for c := range conns {
c.Close() // 実際の WebSocket なら Close フレームを送ってから閉じる
}
})
2026/08/18 20:04:30 [ 0.117s] hijacked; holding connection
2026/08/18 20:04:31 [ 0.669s] signal: interrupt signal received
2026/08/18 20:04:31 [ 0.669s] Shutdown returned after 0s err=<nil>
2026/08/18 20:04:31 [ 0.669s] OnShutdown: closing 1 hijacked conns
2026/08/18 20:04:31 [ 0.669s] hijacked conn closed
接続を1本握ったままなのに Shutdown は 0秒で返っています。Hijack した接続はサーバーの接続追跡から外れる(StateHijacked)ので、アイドル待ちの対象になりません。ログの順序にも注目で、Shutdown returned の後に OnShutdown が出ています。RegisterOnShutdown で登録した関数は go f() で起動されるだけで、Shutdown はその完了を待ちません。ドキュメントも “should start protocol-specific graceful shutdown, but should not wait for shutdown to complete” と書いています。WebSocket を全部閉じ終わるまで待ちたいなら、sync.WaitGroup なりで自前で待つ必要があります。gorilla/websocket や coder/websocket を使う場合も、接続の一覧を自分で持ち、Close フレームを送って待つところは自分で書くことになります。
バックグラウンド goroutine は errgroup でまとめて待つ
HTTP サーバー以外にキュー消費や定期ジョブの goroutine を持っているサービスでは、それらの終了も自分で待たないとプロセス終了で途中の処理が消えます。golang.org/x/sync/errgroup の WithContext と組み合わせると、シグナル・サーバーエラー・ワーカーのエラーのどれが起きても全体を畳む形にできます。
g, gctx := errgroup.WithContext(ctx) // ctx は NotifyContext の戻り値
g.Go(func() error {
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
})
g.Go(func() error {
<-gctx.Done() // シグナル or どこかのエラー
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
})
g.Go(func() error {
return worker.Run(gctx) // gctx を見て自分で抜けるワーカー
})
if err := g.Wait(); err != nil {
log.Printf("exit with error: %v", err)
os.Exit(1)
}
ワーカー側が gctx.Done() を見ていなければ g.Wait() は永遠に返らないので、ワーカーの終了検知は別途タイムアウトを付けるか、goroutine が本当に抜けたかを Goのgoroutineリーク対策で書いた方法で確認しておくと安心です。errgroup 自体の使い分けはGoのerrgroupで並行処理のエラーを集約するにまとめています。
Gin・Echo・gRPC・MCP サーバーの場合
フレームワークを挟んでいても、最終的に *http.Server に辿り着くものは同じ手順です。
| フレームワーク | graceful shutdown の入口 | 備考 |
|---|---|---|
| Gin | &http.Server{Handler: router} を自分で作り Shutdown | 公式 examples の “graceful-shutdown” がこの形。router.Run() は使わない |
| Echo v4 | e.Shutdown(ctx) | 内部の *http.Server の Shutdown を呼ぶラッパー |
| gRPC (google.golang.org/grpc) | server.GracefulStop() | ctx を取らないので、タイムアウトは time.AfterFunc で Stop() を呼ぶ形で自前実装 |
| MCP (Streamable HTTP) | *http.Server の Shutdown | go-sdk の StreamableHTTPHandler はただの http.Handler |
gRPC の GracefulStop だけ ctx を受け取らないので注意です。無限に待つのを避けるには「GracefulStop を goroutine で呼び、猶予が過ぎたら Stop を呼ぶ」二段構えにするのが定番になっています。MCP サーバーについては、GoでMCPサーバーを自作する—go-sdk v1.7でClaude Codeに繋ぐまでで Streamable HTTP を http.ListenAndServe で公開しましたが、あれも *http.Server に載せ替えれば今回の手順がそのまま効きます。
Kubernetes で SIGTERM を受けるときの時間配分
ローカルの kill -TERM では見えない問題が、Kubernetes だと表に出ます。Shutdown は真っ先にリスナーを閉じるので、その瞬間から新規接続は Connection refused になる(上の実測でも curl2 が exit 7 でした)。ところが Kubernetes は Pod を Service のエンドポイントから外す処理と、コンテナへの SIGTERM 送信を並行して進めます。公式ドキュメント “Pod Lifecycle” の “Termination of Pods” にあるとおり、両者に順序保証はありません。結果として、SIGTERM を受けて即 Shutdown すると、まだ Pod 宛にルーティングされている数百ms〜数秒のあいだ、新規リクエストが 502/503 になります。
terminationGracePeriodSeconds 30秒をどう割るか
猶予はデフォルト 30秒(terminationGracePeriodSeconds)で、超えると SIGKILL です。この 30秒を次のように割るのが、いくつかの記事と自分の運用感覚が一致するところでした。
- 最初の数秒は Shutdown を呼ばずに待つ。 readiness probe を落とすか、
preStopフックでsleep 5するかして、エンドポイントからの除外が行き渡るのを待つ - 残りの 20秒前後を Shutdown の猶予にする。 ここに前述の「ハンドラの最大処理時間 + 0.5秒のポーリング遅れ」が収まるように
WithTimeoutを決める - 最後の数秒は空けておく。 Shutdown が返ったあとの DB 接続 close やログのフラッシュ、それに SIGKILL までのマージン
preStop で待つ方式はアプリのコードを変えずに済むのが利点で、Go 側は「シグナルを受けたら Shutdown」の基本形のままでよい。一方 readiness を落とす方式は、Go 側で /readyz が 503 を返すよう状態を切り替えてから数秒待つ処理を書く必要がありますが、preStop が使えない環境(一部のマネージド実行基盤)でも動きます。
docker stop と ECS でも同じ話
docker stop は SIGTERM のあと 10秒(--time / stop_grace_period)で SIGKILL、ECS の stopTimeout はデフォルト 30秒です。数字が違うだけで「猶予 – 数秒 = Shutdown のタイムアウト」という組み立ては変わりません。ちなみに Dockerfile の CMD をシェル形式(CMD ./server)で書くと PID 1 が sh になり、SIGTERM が Go のプロセスに届かず、猶予いっぱい待ってから SIGKILL される、というのは何度見ても踏む人が出る罠なので、CMD ["./server"] の exec 形式にしておくのが前提です。コンテナの CPU 制限まわりはGOMAXPROCSをコンテナのCPU制限に合わせるで触れています。
まとめ
Shutdownは「リスナー close → アイドル接続 close → アクティブ接続がアイドルに戻るのを待つ」。処理中ハンドラは中断しないし、r.Context()もキャンセルしない- 待機はポーリング(1ms から倍々、上限 500ms + 10% ジッター)。最後のハンドラ完了から返るまで最大 550ms ほど遅れるので、タイムアウトは処理時間 + 0.5秒以上の余裕を見る
- シグナル検知の ctx と Shutdown の猶予の ctx は別物にする。同じ ctx を渡すと即
context canceledで返り、処理中リクエストは Empty reply になる log.Fatal(srv.ListenAndServe())は Shutdown 中にos.Exit(1)する。ErrServerClosedは除外して channel か errgroup で main に戻す- Go 1.26 から
context.Causeでterminated signal receivedのようにシグナル名が取れる。シグナル受信直後にstop()を呼ぶと 2回目の Ctrl+C で即終了できる - ハンドラに終了を伝えるなら
BaseContext。Hijack した接続(WebSocket)はShutdownの対象外で、RegisterOnShutdownの関数も完了を待ってもらえない - Kubernetes では SIGTERM 即 Shutdown だと 502 が出る。preStop か readiness で数秒稼いでから Shutdown、猶予 30秒の内訳を先に決めておく