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などでこっそり教えていただけるとありがたいです。