Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Implementing Graceful Shutdown in Go HTTP Servers

2026年10月22日に「Dev.to」が公開したITニュース「Implementing Graceful Shutdown in Go HTTP Servers」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

Go言語のHTTPサーバーを急停止させると、データ破損やリソース残存の原因となる。これを防ぐには、「グレースフルシャットダウン」の実装が不可欠だ。`SIGTERM`シグナルを受け取り、`Server.Shutdown()`で進行中のリクエストを安全に完了させ、`sync.WaitGroup`でバックグラウンド処理も待ってからサーバーを停止する。

ITニュース解説

システムを開発する上で、サーバーを停止するという作業は日常的に発生する。新しい機能を追加したり、不具合を修正したり、サーバー自体を再起動したりと、様々な理由でサーバーの停止が必要になるのだ。しかし、この「サーバーの停止」を安易に行ってしまうと、予期せぬ大きな問題を引き起こす可能性があることを知る必要がある。

例えば、稼働中のサーバーを突然強制的に停止させると、処理中のリクエストが中断されてしまう。もしデータベースへの書き込み処理の途中で停止した場合、データが不完全な状態で残ってしまったり、最悪の場合はデータの整合性が崩れてしまったりするかもしれない。大きなファイルをダウンロードしている顧客がいれば、ダウンロードが途中で途切れてしまい、破損したファイルを受け取ることになる。これらは単なる理論上の問題ではなく、適切な停止手順を踏まずにサーバーのデプロイや再起動を行うたびに、実際に発生する可能性がある深刻な事態なのだ。

なぜこのような問題が起こるのか。それは、サーバープロセスを強制終了させる「SIGKILL」というシグナルが送られた場合、カーネルは一切のクリーンアップ処理を行うことなく、即座にプロセスのメモリやリソースを解放してしまうからだ。例えば、kill -9というコマンドや、Dockerコンテナをタイムアウトなしで停止させる際に送られるのは、このSIGKILLシグナルである。ハンドラがデータベーストランザクションを完了していなかったり、一時ファイルを削除していなかったり、外部サービスへの接続を適切に切断していなかったりすると、部分的なデータ、破損した応答、リソースリーク(使われなくなったリソースが解放されずに残り続けること)などが引き起こされる。また、重要なログをデータベース書き込み後に記録するような設計の場合、データベースへの書き込みは成功しても、そのログが残らないという事態も発生しうる。

このような問題を避けるために利用すべきなのが、「SIGTERM」というシグナルである。これは「優しく終了を促す合図」と考えるとわかりやすい。Kubernetes、systemd、Dockerといった現代のシステム運用基盤は、プロセスを停止する際に、まずこのSIGTERMを送信し、プロセスが自らクリーンアップ処理を行い、きれいに終了するのを一定時間待つ。もしプロセスがその時間内に終了しなかった場合に限り、最終手段としてSIGKILLを送信するのだ。この猶予期間を有効活用し、サーバーが安全に終了するための処理を実行することが「グレースフルシャットダウン」(優雅な停止)である。

Go言語のHTTPサーバーでグレースフルシャットダウンを実装するための基本的なパターンは、「os/signal」パッケージを使ってSIGTERMシグナルを捕捉し、「net/http」パッケージのServer.Shutdown(ctx)メソッドを呼び出すことだ。Server.Shutdown(ctx)は、新規のHTTPリクエストの受け付けを停止し、現在処理中のリクエストが完了するのを待ってくれる機能である。この機能とシグナルハンドリングを組み合わせることで、基本的なグレースフルシャットダウンが実現できる。

具体的なコードの流れは次のようになる。まず、HTTPサーバーを別のゴルーチン(Go言語の軽量な並行処理の仕組み)で起動し、main関数はブロックされないようにする。次に、「os/signal」パッケージを使って、SIGINT(Ctrl+Cなどで送られる割り込みシグナル)やSIGTERMシグナルを捕捉するためのチャネル(ゴルーチン間でデータをやり取りする仕組み)を作成する。サーバーは、このチャネルにシグナルが送られてくるまで待機する。シグナルを受信したら、サーバーはlog.Printfでシグナルを受信したことを記録し、次にcontext.WithTimeoutを使ってタイムアウト付きのコンテキスト(処理のキャンセルやタイムアウトを伝達するための仕組み)を作成する。このコンテキストをsrv.Shutdown()メソッドに渡すことで、サーバーは新規リクエストの受け付けを停止し、既存のリクエストがタイムアウト期間内に完了するのを待機する。もしエラーが発生しなければ、サーバーはきれいに終了したと判断できる。ここで重要なのは、ListenAndServe()が正常に終了した場合に返すhttp.ErrServerClosedというエラーを、致命的なエラーとして扱わないことである。これは正常なシャットダウンを示すエラーなので、適切に区別する必要がある。また、シグナルを捕捉するチャネルは、バッファリングされたチャネル(make(chan os.Signal, 1)のように容量を指定したもの)として作成することが推奨される。これにより、シグナルが送られた瞬間にゴルーチンがメッセージを受け取る準備ができていなくても、シグナルが失われるのを防ぐことができる。

しかし、Server.Shutdown()だけでは不十分な場合がある。Server.Shutdown()はHTTP接続に関連する処理は待機してくれるが、HTTPハンドラ内で起動されたバックグラウンドのゴルーチン(例えば、非同期でデータを処理するタスクなど)までは待ってくれないのだ。ハンドラが処理を開始し、クライアントに「受け付けた」という応答(HTTPステータスコード202など)を返した後も、バックグラウンドのゴルーチンが処理を続けているようなケースでは、サーバーがシャットダウンしても、これらのバックグラウンド処理は中断されてしまう可能性がある。データベースプールがすでに閉じられた後に、バックグラウンドゴルーチンがデータベースアクセスを試みてエラーになる、といった事態も起こりうる。

この問題を解決するためには、「sync.WaitGroup」という仕組みを使う。これは、複数のゴルーチンの完了を待機するために設計されたGo言語の機能である。具体的な使い方は、バックグラウンドで非同期処理を開始する前にwg.Add(1)を呼び出し、処理が完了したらdefer wg.Done()を呼び出す。そして、シャットダウン処理の後半でwg.Wait()を呼び出すことで、すべてのバックグラウンドゴルーチンが完了するまで待機させることができる。ここで特に注意すべきは、wg.Add(1)の呼び出しタイミングである。これをゴルーチン内部で呼び出すと、稀にShutdown()がwg.Wait()を呼び出す前にWaitGroupのカウンターがゼロだと判断してしまい、まだ実行中のゴルーチンがあるにもかかわらずシャットダウン処理が進んでしまうという競合状態(レースコンディション)が発生する可能性がある。そのため、wg.Add(1)は必ずゴルーチンを起動する前に呼び出す必要がある。

このsync.WaitGroupを用いたシャットダウン処理は、2段階のアプローチが推奨される。まず、srv.Shutdown()を呼び出して新規のHTTPリクエストを停止し、既存のHTTP接続が完了するのを待つ(フェーズ1)。次に、wg.Wait()を使ってバックグラウンドの非同期処理が完了するのを待つ(フェーズ2)。この2段階アプローチにより、それぞれの処理に対して異なるタイムアウトを設定できるというメリットもある。

プロダクション環境でグレースフルシャットダウンを実装する際には、いくつかの注意点がある。一つはヘルスチェックとの兼ね合いである。ロードバランサーがサーバーの稼働状況を/healthエンドポイントで確認している場合、シャットダウンが始まったら、ロードバランサーに503(Service Unavailable)などのエラーを返すように設定する必要がある。そうしないと、サーバーがシャットダウン準備中であるにもかかわらず、ロードバランサーがトラフィックを送り続けてしまうためだ。KubernetesのreadinessProbeを使えば、サーバーがシャットダウンを開始したら自動的にサービスエンドポイントのリストからポッドを削除してくれる。

また、複数のポートでサーバーを公開している場合(例えば、ユーザー向けのAPIを8080ポートで、監視用のメトリクスを9090ポートで提供している場合)、シャットダウンの順序も重要になる。一般的には、ユーザー向けの公開APIサーバーを先にシャットダウンし、その後に監視用のサーバーなどをシャットダウンするのが良いだろう。この場合も、単一のsync.WaitGroupや親となるコンテキストを共有することで、複数のサーバーのシャットダウン処理を一元的に管理できる。

さらに、Kubernetesなどのコンテナオーケストレーション環境で運用する場合、アプリケーションのシャットダウンタイムアウトとプラットフォームのシャットダウン猶予期間の整合性も重要である。例えば、KubernetesのterminationGracePeriodSeconds(停止猶予期間)が30秒に設定されている場合、アプリケーションのServer.Shutdown()に渡すコンテキストのタイムアウトは、この値よりも少し短く設定すべきである。もし同じタイムアウトを設定してしまうと、アプリケーションがシャットダウン処理を完了する前に、KubernetesがSIGKILLを送信してしまう可能性があるからだ。一般的には、プラットフォームの停止猶予期間から5秒程度引いた値をアプリケーションのシャットダウンタイムアウトとして設定するのが安全なルールとされている。

実装したグレースフルシャットダウンが正しく機能するかどうかをテストすることも非常に重要だ。サーバーを起動し、処理に時間のかかるリクエスト(例えば、curl localhost:8080/slow &のようなバックグラウンドで実行される遅いリクエスト)を送信する。その後、サーバープロセスにSIGTERMシグナルを送信する(kill -TERM $(pgrep your-binary)など)。この状態で、以下の点を確認するのだ。まず、新しい接続がすぐに拒否されること(接続拒否エラー)。次に、送信した遅いリクエストが中断されずに正常に完了すること。最後に、プロセスが正常終了コード(終了コード0)で終了すること。この簡単なテストで、最もよくあるミス、つまりサーバーをゴルーチンで起動し忘れて、シグナルリスナーに到達する前にmain関数がブロックされてしまうようなケースを検知できる。

まとめると、Go言語におけるグレースフルシャットダウンの実装は、たった50行にも満たないコードで実現できるが、その順序と設定が極めて重要である。SIGTERMシグナルをバッファリングされたチャネルで捕捉し、タイムアウト付きのコンテキストを使ってsrv.Shutdown()を呼び出し、そして非同期で実行されるバックグラウンド処理はsync.WaitGroupで追跡するというのが基本的なパターンである。wg.Add()の呼び出しタイミングを間違えると、システムが高負荷になった時にのみ現れるような競合状態が発生する可能性がある。また、Kubernetesなどのプラットフォームとのタイムアウト設定を適切に調整しなければ、せっかくのグレースフルシャットダウンが意味をなさず、結局SIGKILLされてしまうという事態にもなりかねない。このパターンは、ハンドラの数が一つであっても五十個であっても、シグナルハンドリングの基本的な仕組みは変わらない。変更されるのは、どのゴルーチンをWaitGroupに登録し、それらの処理が完了するのをどれくらいの時間待つかという部分だけである。適切なグレースフルシャットダウンは、システムの安定性と信頼性を高める上で不可欠な要素である。

関連コンテンツ

関連IT用語