この記事はZOZOアドベントカレンダー2025 Series6 20日目の記事です。
1. はじめに
現在、私は26卒内定者アルバイトとしてバックエンド(Go)の開発に携わっています。
実際に、ZOZOTOWNのようなECサービスの開発現場に入り、個人開発では気づけなかった学びを日々得ています。例えば、Kubernetes Podの停止時の挙動や、APIにおける認可処理などです。
今回はその学びの1つであるグレースフルシャットダウンについて共有しようと思います!
起動中のアプリケーションでのPodの停止や再起動などの行為一つとっても、そこには「今まさにリクエストを送ってくれているユーザー」への配慮が必要です。もし操作の途中でサーバーを強制終了させてしまったら…想像するだけで冷や汗が出ます。
この記事では、「Go言語のアプリケーションコードとして、どうやって行儀よく(Gracefulに)終了するか」に焦点を当てます 。
2. グレースフルシャットダウンとは?
簡単に言うと、「処理中のタスクをできる限り完了させてから、安全にシステムを停止すること」です。
これを行うメリットの1つはユーザーへの悪影響を最小限にすることです。
例えば、ユーザーが「注文確定」ボタンを押して、サーバー側でリクエストを処理している最中にサーバーが強制終了したらどうなるでしょうか?
リクエストがエラーになって、ユーザーはフォームの再入力を強いられたり、エラーになっている間に商品の在庫がなくなってしまうといった事態が発生する恐れがあります。
グレースフルシャットダウンを実装することで、こうした重要な処理を途中で切ることなく、安全に完了させてからサーバーを閉じることができます。
3.実装
以下は、公式ドキュメント[1][2]を参考に実装した検証用コードです。
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// 1. サーバーの設定
srv := &http.Server{
Addr: ":8080",
}
idleConnsClosed := make(chan struct{})
// ハンドラの設定(10秒かかる重い処理をシミュレート)
http.HandleFunc("/heavy", func(w http.ResponseWriter, r *http.Request) {
log.Println("⏳ 重い処理を開始しました (所要時間: 10秒)...")
// 処理のシミュレーション
time.Sleep(10 * time.Second)
fmt.Fprintln(w, "✅ 処理が完了しました!")
log.Println("✨ 重い処理が完了しました")
})
// 2. シグナルの待機とシャットダウン処理をサブゴルーチンで実行
// SIGINT (Ctrl+C) や SIGTERM (killコマンド) を受け取るチャネル
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
// シグナルが来るまでここでブロック(待機)
<-quit
log.Println("\n🛑 シャットダウンシグナルを受信しました。終了処理を開始します...")
// 最大10秒間、処理中のリクエストが終わるのを待ちます
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
// srv.Shutdownがエラーを返した場合、致命的なエラーとしてログに出力
log.Fatalf("❌ シャットダウン中にエラーが発生しました: %v", err)
}
log.Println("👋 サーバーを正常に停止しました")
close(idleConnsClosed)
}()
// 3. サーバーをメインゴルーチンで起動
// ListenAndServeはブロックするため、サーバー停止(Shutdown)がなければここでプログラムは停止します。
log.Println("🚀 サーバーを起動しました (http://localhost:8080)")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// http.ErrServerClosed は Shutdown() が呼ばれた際に発生するため、これはエラーとして扱わない
log.Fatalf("サーバー起動エラー: %v", err)
}
// ListenAndServe終了後、チャネルが閉じられるのを待ちます
// この行は、Shutdown()が完了するまでプログラムをブロックします。
<-idleConnsClosed
Printf("main関数が終了します")
}
コードの流れ
1. サーバー起動準備とシグナル待機ゴルーチンの設定:
-
http.Serverを設定し、同時にOSの停止シグナル(SIGINT, SIGTERM)を待ち受けるサブゴルーチンを起動します。 -
このサブゴルーチンは、シグナルを受信するまでブロックします。
-
idleConnsClosed := make(chan struct{})が宣言され、シャットダウン完了通知の役割を担います。
2. ハンドラの設定:
-
/heavyエンドポイントを設定します。この処理は10秒間かかります。
3. サーバー起動:
- メインゴルーチンで
srv.ListenAndServe()を呼び出し、サーバーをポート:8080で起動します。メインゴルーチンはここでブロックされます。
4. グレースフルシャットダウン:
-
OSから停止シグナルを受信するとサブゴルーチンの
<-quitがブロック解除されます。 -
Shutdownは、新しいリクエストの受け付けを停止し、既存の処理中のリクエストが完了するのを最大10秒間待ちます。 -
srv.Shutdown()が開始すると、メインゴルーチンのsrv.ListenAndServe()はhttp.ErrServerClosedを返してブロックを解除し、次の行に進みます。 -
サブゴルーチン内では、
srv.Shutdown()が完了した直後にclose(idleConnsClosed)が実行されます。close()はチャネルを閉じ、メインゴルーチンへの通知を行います。
5. 終了:
-
メインゴルーチンは
srv.ListenAndServe()の終了後、<-idleConnsClosedで待機します。 -
サブゴルーチンから送られた
close(idleConnsClosed)によって、この待機が解除されます。 -
log.Println("main関数が終了します")が実行され、プログラムプロセス全体が終了します。
参考文献 [1] net/http - Go Packages [2] os/signal - Go Packages
4.検証
解説:
-
左の画面(サーバー)では停止シグナルを受信後も、処理中のリクエストが完了するまで終了を待機しています。
-
右の画面(クライアント)には、エラーではなく正常に「処理が完了しました(200 OK)」が返ってきています。
-
処理が終わったのを見届けてから、サーバーが終了しています。
5.最後に
サービスの要件を満たすだけでなく、そのサービスの向こう側にいるユーザーの体験まで想像し、配慮することが、バックエンドエンジニアとしての重要な役割であるという気付きを得ることができました。まだ未熟者ですが、自分から積極的に技術イベントに参加して、知見を増やしZOZOのプロダクトをより良いものにしていきたいと思いました!
