GoのHTTPサーバーのグレースフルシャットダウン

1. 概要

デプロイ時または スケールダウン時、プロセスはSIGTERMシグナルを受け取ります。サーバーがすぐに終了すると、処理中のリクエストは途中で中断されます。クライアントはconnection resetエラーを受け取り、注文がデータベースに部分的にしか書き込まれない可能性があります。

このガイドでは、GoのHTTPサーバーを正しく終了する方法をご説明します。サーバーは新しい接続を受け入れなくなり、実行中のリクエストが完了するのを限定された時間内で待機してから、リソースを正しい順序で閉じます。すべての例は標準ライブラリのみを使用し、Go 1.22以降を必要とします。

2. サーバーが突然シャットダウンされるときの問題

最も一般的な書き方から始めましょう。

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // 遅いDBクエリをシミュレート
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

サーバーを実行してcurl localhost:8080/orders/42を呼び出します。リクエストが待機中に、ターミナルでCtrl+Cを押します。クライアント側の結果:

curl: (52) Empty reply from server

GoはSIGINTやSIGTERMのハンドラーを自動的にインストールしません。ランタイムはシグナルを受け取るとプロセスを即座に終了します。deferは実行されず、リクエストも待たれません。

3. http.Server.Shutdownを使用したグレースフルシャットダウン

http.ServerにはShutdown(ctx)メソッドがあります。このメソッドは順に3つのことを行います:

  1. すべてのlistenerを閉じるため、サーバーが新しい接続を受け入れなくなります。
  2. アイドル状態の接続を閉じます。
  3. アクティブな接続がアイドル状態に戻るのを待ってから閉じます。コンテキストがタイムアウトしたら、メソッドはコンテキストエラーを返します。

このメソッドを使用するには、オペレーティングシステムのシグナルを自分でキャッチする必要があります。

3.1. signal.NotifyContextでシグナルをキャッチする

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErr := make(chan error, 1)
	go func() {
		log.Println("listening on", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // 2回目のCtrl+Cでプロセスが即座に終了します

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

コードの主要なポイント:

  • signal.NotifyContextはcontextを返します。このcontextはプロセスがSIGINT(Ctrl+C)またはSIGTERM(DockerとKubernetesが送信するシグナル)を受け取ったときにキャンセルされます。
  • ListenAndServeは独立したgoroutineで実行されます。Shutdownを呼び出した後、すぐにhttp.ErrServerClosedを返します。これは正常なエラーなのでフィルター処理します。
  • チャネルserverErrはバッファ1を持っています。ポートがすでに使用されている場合など、サーバー起動時のエラーをキャッチするのに役立ちます。これにより、mainは決して来ないシグナルを待ってハングしません。
  • stop()はシグナルを受け取った直後に呼び出されます。この関数はデフォルトの動作を復元するため、もう一度Ctrl+Cを押したらプロセスは即座に終了します。このアプローチはシャットダウンがハングした場合に非常に便利です。
  • shutdownCtxはcontext.Background()をベースとして使用します。ctxではなく使用する理由は、ctxがすでにキャンセルされているからです。それを使用すると、Shutdownは即座にエラーを返します。
  • 10秒経ってもまだリクエストが残っている場合、srv.Close()はすべての接続を強制的に閉じます。

セクション2の実験を繰り返します。curlを呼び出してからCtrl+Cを押します。サーバーのログ:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

クライアント側は完全なレスポンスを受け取ります:

order 42

サーバーはリクエストの残り約2秒待ってから終了します。

注意: よくあるミスは2つのgoroutineの役割を入れ替えることです。誰かがmainの中でListenAndServeを呼び出し、別のgoroutineでShutdownを呼び出します。ListenAndServeがShutdownの開始時にすぐに返すため、mainは終了しますがShutdownがまだリクエストを待機しています。プロセスが終了し、リクエストは仍然カットされます。ルールはmainがShutdownが返されるのを待つ必要があります。

4. Shutdownが自分で処理しないもの

4.1. ハイジャックされた接続:WebSocket、HTTP/1.1 upgrade

Shutdownはハイジャックされた接続(例えばWebSocket)を追跡しません。サーバーもそれらを待ちません。これらの接続を閉じるには、RegisterOnShutdownを使ってコールバックを登録します:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// main内:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

ここでのwebsocketConnはあなたが使うWebSocketライブラリの接続型を表します。コールバックは独立したgoroutineで実行され、Shutdownが開始されるとすぐに実行されます。Shutdownはこのコールバックが終了するのを待ちません。待つ必要がある場合は、sync.WaitGroupを使って自分で同期してください。

4.2. 長時間実行されるハンドラー:ストリーミング、ロングポーリング

ハンドラーが無限にデータをストリーミングする(例えばServer-Sent Events)場合、アイドル状態に戻りません。その場合、Shutdownは常にタイムアウトするまで待ちます。BaseContextを使うことで、サーバーがシャットダウンを開始するときにすべてのリクエストcontextがキャンセルされるようにできます:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // サーバーがシャットダウンしているかクライアントが接続を切断している
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

各リクエストcontextはappCtxから作成されます。Shutdownが実行されるとき、cancelAppが呼び出され、すべてのr.Context()がキャンセルされます。ストリーミングハンドラーがDone()を見て返ります。その後、Shutdownは早く完了します。

注意: このアプローチはすべてのハンドラーに適用すべきではありません。リクエストのcontextをキャンセルすると、注文をDBに書き込んでいるその書き込みコマンド自体がキャンセルされます。「リクエストが完了するのを待つ」という目的は達成されません。このようにBaseContextを使用するのは、すべてのハンドラーが安全に中途で停止できる場合のみです。

4.3. バックグラウンドgoroutineと補助リソース

ShutdownはHTTP接続のみを管理します。メール送信ワーカー、Kafkaコンシューマー、DB接続プールはあなたの責任です。閉じる順序は初期化の順序の逆である必要があります:

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}

cancelWorkers() // バックグラウンドgoroutineに停止するよう通知する
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

HTTPサーバーが最初に終了される(ハンドラーはまだDBが必要だから)、DBは最後に閉じられます。プロセス全体が共通のshutdownCtxを使用するため、合計時間は10秒の予算を超えません。

5. Kubernetesで実行

ポッドが削除されるとき、Kubernetesは並行して2つのことを行います:コンテナーにSIGTERMを送信し、サービスのエンドポイントリストからポッドを削除します。エンドポイントを削除することは、kube-proxyとingressに伝播するまで数秒かかります。サーバーがSIGTERMを受け取ったときにすぐにlistenerを閉じると、まだポッドにルーティングされているリクエストが拒否されます。

修正方法は、readiness probeが最初にエラーを報告してから数秒待機し、その後Shutdownを呼び出すことです:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// シグナルを受け取った後:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // ロードバランサーがポッドを削除するのを待つ
srv.Shutdown(shutdownCtx)

また、terminationGracePeriodSecondsを待機時間の合計とシャットダウンタイムアウト以上に設定する必要があります。この例では5 + 10 = 15秒です:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

Grace時間が終わったら、KubernetesがSIGKILLを送信します。このシグナルはキャッチできず、あなたのシャットダウンロジックは無視されます。

注意: シェルスクリプト経由でバイナリを実行する場合(例えばCMD ./start.sh)、シェルはPID 1となり、GoプロセスにSIGTERMを転送しないことができます。execフォームを使用してくださいCMD ["/app/server"]、またはスクリプトの最後でexec /app/serverを呼び出してください。

6. シャットダウン動作のテスト

Shutdownが実行中のリクエストが完了するまで待つことを確認するテストを書くことができます。このテストはtestingパッケージのみが必要です:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // リクエストがハンドラーに入ったことを確認
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

テストの主要なポイント:

  • net.Listenをポート0で実行することで、オペレーティングシステムが空きポートを選択します。これにより、テストを並行して実行でき、競合がありません。
  • チャネルstartedはリクエストが実際にハンドラーに入ったときにのみShutdownが呼び出されることを保証します。
  • テストは3つのことをチェックします:Shutdownがnilを返す、クライアントが完全なボディを受け取る、新しい接続が拒否される。
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

実行時間は約0.5秒で、これはハンドラーのスリープ時間と同じです。これは示していますShutdownがリクエストを待ったということです。

7. よくあるミス

  • Shutdownにキャンセルされたcontextを使用する。 signal.NotifyContextのctxを直接渡すと、Shutdownは即座にcontext canceledを返します。
  • http.ErrServerClosedをエラーと見なす。 このエラーでlog.Fatalを呼び出すと、シャットダウンが完了する前にプロセスが終了します。
  • タイムアウトを設定しない。 期限のないcontext.Background()を使用すると、ハングしたハンドラーはプロセスを永遠に保持し、KubernetesがSIGKILLを送信するまで続きます。
  • ReadHeaderTimeoutを忘れる。 遅いクライアントはアクティブな状態で接続を保持でき、シャットダウンは常にタイムアウトするまで実行されます。
  • シャットダウン開始後にos.Exitまたはlog.Fatalを使用する。 どちらもすべてのdeferをスキップするため、DBとファイルは閉じられません。

8. 結論

Goのグレースフルシャットダウンには3つのコンポーネントが必要です:SIGTERMをキャッチするためのsignal.NotifyContext、timeoutを持つcontextを使用したsrv.Shutdown、そしてmainがShutdownが返されるのを待つことです。その基盤から、Shutdownが自分で処理しない部分を追加します。それはWebSocketのRegisterOnShutdown、ストリーミングハンドラーのBaseContext、workerとDBを初期化時の順序の逆で閉じることです。Kubernetesで実行する場合は、readinessがエラーを報告してから数秒待ってからlistenerを閉じ、同時にterminationGracePeriodSecondsを十分に大きく設定します。最後に、セクション6のようなテストを保持してください。誰かが誤ってシャットダウン流れを壊した場合に早期に検出するために。

golanghttpgraceful-shutdownkubernetesconcurrency