Table of Contents
前回に引き続き Graceful Shutdown について書きます。
というのも、これを読んで知識が少しアップデートされたので、改めて備忘録として書き残しておきます。
Go Advent Calendar 2025の記事です!
なぜ Graceful Shutdown?
理由は 2 つです
- 最近、仕事とプライベートの両方で Graceful Shutdown について考えることがあったためです。学んだ知識をアウトプットしたいと思ったので、今回書くことにしました
- これがとても参考になりました。ですが英語なので、今回理解したことを日本語で書き残しておきたいと思いました
結論
- Go 1.25
- macOS 15.7 で動作確認済
こちらがソースコードです。
このコードは下記のような環境で動作するサーバーを想定しています。
- Linux 上で稼働している
- 前段にはロードバランサーがある
- ロードバランサーのヘルスチェック方法
- ロードバランサーはサーバーへ 10 秒ごとにヘルスチェックリクエストを送る
- サーバーが 503 を 1 度返したら、以降ロードバランサーはそのサーバーへトラフィックを流さない
- サーバーが 503 以外のエラーを何度か返したら、以降ロードバランサーはそのサーバーへトラフィックを流さない
- シャットダウン前、サーバーには SIGTERM が送られてくる。10 秒以内にサーバーが終了しない場合、サーバーには SIGKILL が送られてきて強制終了される
ソースコードを記事にも掲載します。 newHandler 関数が返すハンドラーは こちらです。
1package main
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "net/http"
9 "os"
10 "os/signal"
11 "sync/atomic"
12 "syscall"
13 "time"
14)
15
16func main() {
17 catchedShutdownSignal := atomic.Bool{}
18 handler := newHandler(&catchedShutdownSignal)
19
20 os.Exit(runHandlerWithGracefulShutdown(
21 context.Background(),
22 handler,
23 8080,
24 options{
25 WaitSecondsUntilGracefulShutdownIsStarted: 15,
26 GracefulShutdownTimeoutSeconds: 10,
27 ForcefullyRequestCancellationTimeoutSeconds: 3,
28 },
29 &catchedShutdownSignal,
30 ).Int())
31}
32
33type exitCode int
34
35func (t exitCode) Int() int {
36 return int(t)
37}
38
39const (
40 shutodownGracefully exitCode = 0
41 shutodownForcefully exitCode = 1
42)
43
44type options struct {
45 // シグナル受信後、http.Server.Shutdown メソッドが呼ばれるまでスリープする時間 (秒)
46 WaitSecondsUntilGracefulShutdownIsStarted int
47
48 // http.Server.Shutdown メソッドが呼ばれた後、全ての TCP コネクションをアイドル状態にする処理のタイムアウト時間 (秒)
49 GracefulShutdownTimeoutSeconds int
50
51 // HTTP リクエストのキャンセル発動後、サーバーが強制終了するまでスリープする時間 (秒)
52 // http.Server.Shutdown メソッドが呼ばれた後、GracefulShutdownTimeoutSeconds 秒だけ待ったにもかかわらず
53 // 全ての TCP コネクションをアイドル状態にできなかった場合、HTTP リクエストコンテキストのキャンセルが発動される。
54 // HTTP ハンドラーは HTTP リクエストコンテキストのキャンセルを受信した場合、
55 // ForcefullyRequestCancellationTimeoutSeconds 秒以内に
56 // リソースを解放し、処理を終了させ、レスポンスを返し、コネクションをアイドル状態にしなければならない。
57 ForcefullyRequestCancellationTimeoutSeconds int
58}
59
60// グレースフルシャットダウン付き HTTP サーバーのサンプル実装
61func runHandlerWithGracefulShutdown(
62 ctx context.Context,
63 handler http.Handler,
64 serverPort int,
65 opts options,
66 isSignalCatched *atomic.Bool,
67) exitCode {
68 // HTTP リクエストコンテキストのキャンセルを発動させるために
69 // BaseContext を設定したサーバーを作成する
70 // 本サンプルでは、GracefulShutdownTimeoutSeconds 秒待っても TCP コネクションがアイドル状態にならない場合
71 // cancelCtxBaseRequest() を実行し、HTTP リクエストコンテキストのキャンセルを発動させる
72 ctxBaseRequest, cancelCtxBaseRequest := context.WithCancel(context.Background())
73 defer cancelCtxBaseRequest()
74 server := http.Server{
75 Addr: fmt.Sprintf(":%d", serverPort),
76 Handler: handler,
77 BaseContext: func(l net.Listener) context.Context {
78 return ctxBaseRequest
79 },
80 }
81
82 // シグナルハンドラーの登録
83 // ctxSignal は、シグナルをキャッチしたら ctxSignal.Done() チャンネルがクローズされる
84 ctxSignal, stop := signal.NotifyContext(
85 ctx,
86
87 // キャッチするシグナルの種類を指定する
88
89 // SIGINT は Unix 互換 OS だけなので、OS の違いが吸収できる os.Interrupt を使った方が良い
90 // os.Interrupt はプログラムの中断シグナル。プログラム実行中に Ctrl+C を叩くと、SIGINT シグナルがプログラムへ送られる。
91 os.Interrupt,
92
93 // SIGTERM は Unix 互換 OS におけるプログラムの強制終了シグナル
94 // Cloud 上のプロセス終了時、このシグナルを送るケースがある
95 // 例えば Cloud Run
96 // https://docs.cloud.google.com/run/docs/container-contract#instance-shutdown
97 syscall.SIGTERM,
98 )
99 defer stop()
100
101 // サーバーの起動
102 chServeIsDone := make(chan error)
103 go func() {
104 fmt.Println("server started")
105
106 // リスン状態を開始する
107 // 意図的なリスン状態の終了 (http.Server.Shutdown または http.Server.Close が実行されたことによる終了) においては
108 // ListenAndServe メソッドは ErrServerClosed エラーを返す
109 // そうでない場合においては、そのエラー内容を返す
110 if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
111 fmt.Printf("server finished with error: %+v\n", err)
112 chServeIsDone <- err
113 } else {
114 // ErrServerClosed だった場合は異常終了ではない
115 // なぜならグレースフルシャットダウンによる終了 (http.Server.Shutdown メソッドが実行された) なため
116 fmt.Println("server finished")
117 }
118
119 close(chServeIsDone)
120 }()
121
122 // サーバーの終了、または、シグナルの受信、を待つ
123 select {
124 case err := <-chServeIsDone:
125 // サーバーの終了
126
127 if err != nil {
128 // シグナルを受信していないけどなんらかの理由でサーバーがエラー終了した場合、このパスが実行される
129 fmt.Printf("server listen is finished with error: %+v\n", err)
130 return 1
131 }
132
133 // シグナルを受信していないけどサーバーが正常終了した場合、このパスが実行される
134 // シグナルを受信するまでサーバーが正常終了することはありえないため
135 // 理論上、このパスを通ることは考えられないが
136 // もしこのパスを通るとしたら、意味としては
137 // エラーなくリスン状態を終了したことを意味する
138 fmt.Println("server listen is finished")
139 return 0
140 case <-ctxSignal.Done():
141 // シグナルを受信
142 }
143 // シグナル受信後の処理をここから下に書く
144
145 // シグナルハンドラーを解除するために stop 関数を実行する
146 stop()
147
148 fmt.Printf("catch signal: %+v\n", context.Cause(ctxSignal))
149 if isSignalCatched != nil {
150 isSignalCatched.Store(true)
151 }
152
153 fmt.Printf("http.Server.Shutdown メソッドが呼ばれるまで %d 秒間スリープする\n", opts.WaitSecondsUntilGracefulShutdownIsStarted)
154 time.Sleep(time.Duration(opts.WaitSecondsUntilGracefulShutdownIsStarted) * time.Second)
155
156 // グレースフルシャットダウンの実行が開始される
157 fmt.Println("全ての TCP コネクションをアイドル状態にする処理 (http.Server.Shutdown メソッドを呼ぶだけですが) を開始します")
158 fmt.Printf("%d 秒間待ちます\n", opts.GracefulShutdownTimeoutSeconds)
159 ctxTimeout, cancel := context.WithTimeout(
160 context.Background(),
161 time.Duration(opts.GracefulShutdownTimeoutSeconds) * time.Second,
162 )
163 defer cancel()
164 err := server.Shutdown(ctxTimeout)
165 if err != nil {
166 // Shutdown メソッドがエラーを返した場合
167 // GracefulShutdownTimeoutSeconds 秒以内に全ての TCP コネクションをアイドル状態にできなかったことを意味する
168 // アイドル状態に戻せなかったコネクションが残っているが、もうこれ以上は待てないので
169 // cancelCtxBaseRequest を呼び、コンテキストを介してハンドラー側へキャンセル信号を伝播する
170 // ハンドラー側はキャンセル信号を受信したら即座にリソースを解放し、処理を終了させ、レスポンスを返し、コネクションをアイドル状態にする
171 cancelCtxBaseRequest()
172 fmt.Printf("全ての TCP コネクションをアイドル状態にできませんでした: %+v\n", err)
173 fmt.Println("ハンドラー側へキャンセル信号を送ります")
174 fmt.Printf("%d 秒後にサーバーを強制終了させますので、ハンドラーは時間内に処理を終えてください\n", opts.ForcefullyRequestCancellationTimeoutSeconds)
175 time.Sleep(time.Duration(opts.ForcefullyRequestCancellationTimeoutSeconds) * time.Second)
176 fmt.Println("exit server forcefully")
177 // 実はこれを呼ぶ必要があるらしい...
178 // server.Close を呼ばない場合、TCP コネクションは生き続ける(サーバーは終了していない)
179 server.Close()
180 return shutodownForcefully
181 }
182
183 fmt.Println("exit server gracefully")
184 return shutodownGracefully
185}
上のソースコードにおいては、次の流れで Graceful Shutdown が進みます。
- サーバーが SIGTERM を受信する
- サーバーはヘルスチェック用のエンドポイントで
HTTP Status 503を返すようになる - ロードバランサーはヘルスチェックでサーバーから
HTTP Status 503が返ってきたのでトラフィックを流さなくなる - この状態で 15 秒待つ。サーバー上で稼働中の処理が自然と終了するのを待つために。ほとんどの場合、ここの待ち時間以内で処理が終了する(という想定)
- 10 秒間のタイムアウト付きで http.Server.Shutdown メソッドを実行する
- http.Server.Shutdown がエラーを返さない場合、全てのコネクションがアイドル状態になりサーバーが終了したことを意味する。Graceful Shutdown の成功である
- http.Server.Shutdown がエラーを返した場合、アイドル状態ではないコネクションが残されている。そのためハンドラー側へコンテキストを介したキャンセルを伝搬させ、3 秒待つ。ハンドラーがキャンセルを検知し、処理を終わらせてくれることを期待して。
- 3 秒待った後、http.Server.Closeにより強制的に終了する
学び
実行環境のヘルスチェック方法に合わせる
今回勉強になったことは、サーバーの実行環境によってシャットダウン方法は異なるということです。
特に、サーバーの前段にあるトラフィックを振り分けているコンポーネント(例えばロードバランサーのような)仕組みにおけるヘルスチェック方法を気にする必要がありそうです。
自分がいつも仕事で使っている Cloud Run においては、設定によりますが、ヘルスチェックについては気にする必要はないです。なぜなら
- 新しいリビジョンが立ち上がる
- トラフィックが古いリビジョンから新しいリビジョンへ切り替わる
- 古いリビジョンへ SIGTERM を送る
という流れであるため、サーバーに SIGTERM が送られてきた時点でサーバーにはトラフィックが流れてこなくなっています。 基本的にはヘルスチェックのことを気にする必要はありません (Cloud Run の Liveness Probe のような仕組みがある場合は、それと衝突しないように気をつける必要はあるかも)。
Graceful Shutdown を実装するためのエッセンス
自分が書いたサンプルコードを読み直してみると、下記の基本的な知識を応用していることに気づきます。
サーバーの実行環境が変わったとしても、これらの知識を用いることで環境に応じた Graceful Shutdown を書くことができるような気がします。
シグナルのハンドリング方法
シグナルをキャッチし、コールバック処理を実行する方法として以下の 2 つがあります。
ゴルーチン間の通信方法
サンプルプログラムでは色々なゴルーチン間の通信方法を用いていました。
context.Context を用いている例
以下の例ではキャンセル命令を HTTP ハンドラーへ伝搬させています。 WithCancel 関数を用いてキャンセル機能付きのコンテキストを作成しています。
1 ctxBaseRequest, cancelCtxBaseRequest := context.WithCancel(context.Background())
2 server := http.Server{
3 ...省略...
4 BaseContext: func(l net.Listener) context.Context {
5 return ctxBaseRequest
6 },
7 }
8 ...省略...
9 // しかるべきタイミングでコンテキストをキャンセルし、HTTP ハンドラーへ伝播する
10 cancelCtxBaseRequest()
チャンネルを用いている例
1 chServeIsDone := make(chan error)
2 go func() {
3 ...省略...
4 if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
5 chServeIsDone <- err
6 ...省略...
7 }
8 }
9 ...省略...
10 select {
11 case err := <-chServeIsDone:
アトミックなオブジェクトを用いている例
1func main() {
2 catchedShutdownSignal := atomic.Bool{}
3 handler := newHandler(&catchedShutdownSignal)
4 runHandlerWithGracefulShutdown(
5 ...省略...
6 &catchedShutdownSignal,
7 )
8}
9
10func newHandler(
11 catchedShutdownSignal *atomic.Bool,
12) http.Handler {
13 ...省略...
14 mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
15 if catchedShutdownSignal != nil && catchedShutdownSignal.Load() {
16 w.WriteHeader(http.StatusServiceUnavailable)
17 ...省略...
18 }
19 ...省略...
20 })
21 ...省略...
22}
23
24func runHandlerWithGracefulShutdown(
25 ...省略...
26 isSignalCatched *atomic.Bool,
27) exitCode {
28 ...省略...
29 if isSignalCatched != nil {
30 isSignalCatched.Store(true)
31 }
32 ...省略...
33}
シャットダウン方法
サーバーの性質に応じたシャットダウン方法を用いる必要がありそうです。
HTTP サーバー
HTTP であれば http.Server.Shutdown を用いて Graceful Shutdown を実行します。
- 全てのリスナーをクローズする
- 全てのアイドル状態のコネクションをクローズする
- アイドル状態ではないコネクションがアイドル状態になるまで待ってクローズする
- Shutdown メソッドの引数として与えられるコンテキストに対してタイムアウト時間を設けることで、アイドル状態になるまでの待ち時間に上限を与えることができます
Shutdown メソッドのコンテキストに対してタイムアウト時間を設けている例
1 ctxTimeout, cancel := context.WithTimeout(
2 context.Background(),
3 time.Duration(5) * time.Second,
4 )
5 defer cancel()
6 err := server.Shutdown(ctxTimeout)
gRPC サーバー
gRPC であれば こちら の方法に従います。
感想
普段の業務ではGraceful Shutdownについて調べる機会は少なかったので新鮮でした。
それ故にというか、勉強したことベースで書いている記事であり実務で使っているコードではなかったりするため、間違っている部分があるかもしれません。 もし間違いを見つけたらXなどでこっそり教えていただけるとありがたいです。