Go の net/http でグレースフルシャットダウンを実装する - os.Signal と context.Context の連携

HTTP サーバーを本番環境で運用するとき、プロセスを停止する場面は必ず訪れる。デプロイ、スケールイン、障害対応——理由はさまざまだが、いずれの場合も「処理中のリクエストを正しく完了させてから終了する」ことが求められる。これがグレースフルシャットダウンだ。

Go の標準ライブラリには http.Server.Shutdown メソッドが用意されており、外部ライブラリなしでグレースフルシャットダウンを実現できる。

なぜグレースフルシャットダウンが必要なのか

サーバープロセスを Ctrl+Ckill で強制終了すると、処理途中のリクエストは中断される。クライアントはレスポンスを受け取れず、データベースへの書き込みが中途半端な状態で止まる可能性もある。

強制終了

プロセスが即座に終了し、処理中のリクエストは破棄される。クライアントは接続が切断されたエラーを受け取る。

グレースフルシャットダウン

新規リクエストの受付を停止し、処理中のリクエストが完了するのを待ってからプロセスを終了する。クライアントは正常なレスポンスを受け取れる。

ファイルのアップロード中、決済処理の途中、バッチ処理の実行中——こうした場面で強制終了されると、復旧が困難な不整合状態に陥ることがある。グレースフルシャットダウンはこのリスクを大幅に減らしてくれる。

os.Signal でシャットダウンシグナルを受け取る

Unix 系 OS では、プロセスにシグナルを送ることで動作を制御する。Ctrl+CSIGINTkill コマンドのデフォルトは SIGTERM だ。Go の os/signal パッケージを使えば、これらのシグナルをチャネル経由で受信できる。

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

signal.Notify は指定したシグナルを quit チャネルに転送する。バッファサイズを 1 にしておくのが定石で、シグナルの送信側がブロックされるのを防ぐ。

チャネルからシグナルを受信するまで処理をブロックするには、単純に <-quit と書けばよい。この行に到達すると、ゴルーチンは SIGINT か SIGTERM が届くまで待機する。

http.Server.Shutdown の仕組み

http.ServerShutdown メソッドは、以下の手順でサーバーを安全に停止する。

リスナーを閉じて新規接続の受付を停止する

アイドル状態の接続を閉じる

アクティブな接続が完了するのを待つ

すべての接続が閉じたら return する

Shutdowncontext.Context を引数に取る。このコンテキストにタイムアウトを設定することで、「最大何秒待つか」を制御できる。タイムアウトを超えると Shutdown はエラーを返し、残っている接続は強制的に切断される。

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

if err := srv.Shutdown(ctx); err != nil {
    log.Fatalf("サーバーの停止に失敗: %v", err)
}

タイムアウトの長さは、アプリケーションの特性に合わせて決める。一般的なWeb API なら 5〜30 秒程度が妥当だろう。長すぎるとデプロイが遅延し、短すぎると処理中のリクエストが打ち切られる。

完全な実装例

ここまでの要素をまとめた、実用的なグレースフルシャットダウンの実装を示す。

package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // 重い処理をシミュレート
        time.Sleep(5 * time.Second)
        w.Write([]byte("完了"))
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // サーバーを別ゴルーチンで起動
    go func() {
        log.Println("サーバー起動: :8080")
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("ListenAndServe: %v", err)
        }
    }()

    // シグナル待機
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    sig := <-quit
    log.Printf("シグナル受信: %v", sig)

    // グレースフルシャットダウン
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("シャットダウン失敗: %v", err)
    }
    log.Println("サーバー停止完了")
}

いくつかのポイントを押さえておこう。

srv.ListenAndServe() は別ゴルーチンで実行する必要がある。メインゴルーチンはシグナル待機に使うためだ。ListenAndServehttp.ErrServerClosed を返した場合、それは Shutdown による正常停止を意味するのでエラーとして扱わない。

Shutdown が呼ばれると、ListenAndServe は即座に http.ErrServerClosed を返す。しかし、処理中のリクエストはバックグラウンドで引き続き実行されており、Shutdown 自体はそれらが完了するまでブロックし続ける。この二段階の動作を理解しておくことが重要だ。

動作確認の方法

実装が正しく動いているかを確認するには、以下の手順で試すとよい。

まずサーバーを起動し、別のターミナルから curl でリクエストを送る。ハンドラに 5 秒の Sleep を入れてあるので、リクエスト中にサーバーを Ctrl+C で停止しても、レスポンスが正常に返ってくることを確認できる。

# ターミナル 1: サーバー起動
go run main.go

# ターミナル 2: リクエスト送信
curl http://localhost:8080/

# ターミナル 1: Ctrl+C を押す
# → ターミナル 2 のレスポンスが返ってからサーバーが停止する

強制終了の場合と比較してみると違いがよくわかる。kill -9 でプロセスを殺すと、curl は curl: (52) Empty reply from server のようなエラーを返す。

シャットダウン時にクリーンアップ処理を入れる

実際のアプリケーションでは、HTTP サーバーの停止だけでなく、データベース接続のクローズやバックグラウンドワーカーの停止も必要になる。Shutdown の後にこれらの処理を追加すればよい。

// グレースフルシャットダウン
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
    log.Fatalf("シャットダウン失敗: %v", err)
}

// クリーンアップ処理
db.Close()
log.Println("DB 接続を閉じました")

workerCancel()
log.Println("バックグラウンドワーカーを停止しました")

log.Println("すべてのクリーンアップ完了")

クリーンアップの順序も重要になる。HTTP サーバーを先に止めてからデータベース接続を閉じないと、処理中のリクエストが DB にアクセスできなくなってしまう。依存関係の逆順で停止するのが原則だ。

HTTP サーバーをシャットダウンする

バックグラウンドワーカーを停止する

データベース接続を閉じる

ListenAndServe と Shutdown の分離がもたらす設計上の利点

Go の http.ServerListenAndServeShutdown を別メソッドとして提供しているのは、単なる機能分離ではない。これにより、サーバーのライフサイクルを呼び出し側が完全に制御できるようになっている。

たとえば、複数の HTTP サーバーを 1 つのプロセスで動かす場合(公開 API とメトリクスエンドポイントを別ポートで提供するなど)、それぞれのサーバーに対して独立にシャットダウンを実行できる。errgroup と組み合わせれば、複数サーバーの並行起動と一括停止を簡潔に書ける。

Go の標準ライブラリが提供する Shutdown メソッドは内部で SetKeepAlivesEnabled(false) を呼び出し、Keep-Alive 接続の新規作成を抑止している。

既存の Keep-Alive 接続はアイドルになった時点で閉じられ、アクティブなリクエストが完了するまで待機する仕組み。

このように、Go の標準ライブラリだけでグレースフルシャットダウンを実現できる。外部ライブラリに頼る必要はなく、os/signalcontexthttp.Server.Shutdown の 3 つを組み合わせるだけで、本番環境に耐えうる安全な停止処理が書ける。