フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン
Day 075 の次回予告どおり「デバッグ」。Day 075 で停止の順(srv.Shutdown→ 応答後の仕事の待ち)はedge.Serveに閉じた。今日のバグはその外側——プロセスの入口(シグナル・os.Exit)と、Serveに入る前後(readiness・要求の ctx・長い接続)——にある。テスト 7 本(うち 2 本は本物のプロセスに SIGTERM を送る)、1 つずつ入れた 5 通り+間違った直し方 5 通り、Drain の長さ 7 段の実測で確かめる。
📚 背景知識(読んでから問題へ)
ここまでと今日の位置
Day 075: e.Serve(ctx, srv, ln, grace) … ctx が終わったら srv.Shutdown → 応答後の仕事を待つ → 期限で切る(ここは正しい)
今日: main ── signal ──> ctx ──> app.Run ──> (readiness・Drain) ──> e.Serve
↑B1 ↑B3 BaseContext ↑B4 ↑B5 長い接続
main ── os.Exit ──> defer(監査の控えの Flush)
↑B2
Day 075 で「止める順」を部品に閉じても、その部品にいつ・どんな ctx を渡すかと、部品が戻った後に何が起きるかは呼ぶ側のコードが決める。今日はそこを見る。
用語
| 用語 | 意味 |
|---|---|
| readiness | 「このプロセスに新しい要求を送ってよいか」を LB(ロードバランサ)やオーケストレータに答える口。今日は GET /readyz |
| Drain(今日の意味) | SIGTERM を受けてから Listener を閉じるまでの間。readiness は 503 を返すが、来た要求は今まで通り処理する。LB が外してくれるのを待つ時間 |
| SSE(Server-Sent Events) | text/event-stream で応答を閉じずに送り続ける形。retry: で再接続の間隔を相手に指示できる。今日は GET /events |
| StateNew | http.ConnState の 1 つ。接続は来たが、まだ最初の要求の頭を読んでいない状態 |
| BaseContext | http.Server のフィールド。全部の要求の ctx の親を返す関数。nil なら context.Background() |
今日使う実物(一次資料。2026-10-16 に手元の Go 1.27.2 のソースで読んだ)
| 実物 | 今日に関わる所 | 場所 |
|---|---|---|
**signal.Notify** | 「signal パッケージは c への送信でブロックしない。呼び出し側が十分な容量を用意せよ。1 つのシグナル値の通知なら容量 1 で足りる」 | os/signal/signal.go 114〜117 行 |
signal.NotifyContext | 内部で make(chan os.Signal, 1)。受けた後は、stop を呼ぶまで 2 回目の Interrupt でも既定の終了をしない | 同 300 行・281〜285 行 |
| vet の sigchanyzer | misuse of unbuffered os.Signal channel as argument to signal.Notify | cmd/vendor/golang.org/x/tools/go/analysis/passes/sigchanyzer/sigchanyzer.go 88 行 |
**os.Exit** | 「プログラムは即座に終わる。defer された関数は走らない」 | os/proc.go 59 行 |
**http.Server.BaseContext** | 全部の要求の ctx の親。nil なら context.Background() | net/http/server.go 3156〜3162 行 |
**Shutdown の文書** | hijack された接続(WebSocket など)は閉じも待ちもしない。長い接続には呼び出し側が別に停止を知らせよ。そのために RegisterOnShutdown がある | 同 3264〜3268 行 |
**RegisterOnShutdown** | 「プロトコル固有の穏やかな停止を始めるべきで、完了を待つべきではない」 | 同 3313〜3317 行 |
**closeIdleConns** | StateNew の接続は、最初の要求の頭を 5 秒以上読んでいなければ idle とみなす(Issue 22682)。それまでは「まだ活動中」 | 同 3326〜3348 行(5 秒の判定は 3335 行) |
Day 075 の edge.Serve | 今日は変更なしで使う(edge/after.go 201 行) | problems/2026-10-15-Day075.md |
📝 問題
状況
Day 075 の edge を入れた書き出し API(S1)に、同僚が次の 3 つを足して本番に出しました。
/events: 書き出しの進み具合を SSE で流す画面(開きっぱなしになる)/readyz: LB のヘルスチェック用- 起動時のキャッシュの温め(300 ms)と、監査の控えをローカルのファイルに書く(
bufio.Writer)
出した翌週、次の 4 件が報告されました。
- **デプロイの直後だけ、書き出しが
503 canceledで失敗する**(その時間に処理中だった分が全部) - **デプロイのたびに、LB のログに
connection refusedが十数件出る** - 起動の直後に止めた Pod は、止まるのに 30 秒かかって SIGKILL される
- 停止が猶予切れで error になった Pod では、その回の監査の控えがファイルに 1 行も無い
なお、/events を足した最初の版では「デプロイのたびに停止が猶予(20 秒)いっぱいまでかかる」問題があり、同僚がそれを直してから出しています(下の BaseContext の行のコメント)。
課題
下の app/app.go(137 行)と cmd/srv/main.go(74 行)にはバグが 5 つあります。報告の 4 件で 4 つ、残りの 1 つは別のバグに隠れていて、まだ症状が出ていません。
- 5 つのバグを見つけ、それぞれ報告のどれの原因か(または隠れているか)を書いてください
- 直してください。
edge/after.go(Day 075)は変えません - 5 つそれぞれを検出するテストを書いてください。シグナルと
os.Exitは本物のプロセスで確かめます
edge/after.go は Day 075 の 201 行をそのまま使います。
mkdir -p d76/edge d76/app d76/cmd/srv && cd d76 && go mod init d76 # 作業ディレクトリとモジュール(外部の依存なし)
awk '/^### `edge\/after.go`/{f=1;next} f&&/^```go/{g=1;next} g&&/^```/{exit} g{print}' \
../2026-10-15-Day075.md > edge/after.go # Day 075 の after.go を取り出す(201 行)
app/app.go(バグ入り、137 行)
// Package app は書き出し API(S1)の組み立て。停止の順は Day 075 の edge.Serve に任せ、
// その前後(readiness を落とす・長い接続に終われと知らせる)だけをここで持つ。
package app
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
"d76/edge"
)
type Config struct {
Logger slog.Handler
Audit io.Writer // 監査の控え(ローカルの送信箱。並行に書かないので App が鍵を持つ)
Drain time.Duration // readiness を落としてから Listener を閉じるまで(> 0)
Grace time.Duration // Listener を閉じてから、要求と応答後の仕事を待つ期限(> 0)
ExportDur time.Duration // /export の書き出し(既定 200 ms)
AuditDur time.Duration // 監査の送信(既定 300 ms)
ReportDur time.Duration // /report の重い集計(既定 1.5 s。ctx を見ない古いライブラリ)
Tick time.Duration // /events の送信間隔(既定 100 ms)
}
type App struct {
cfg Config
log *slog.Logger
edge *edge.Edge
ready atomic.Bool
auditMu sync.Mutex
nextID atomic.Int64
}
func New(cfg Config) (*App, error) {
var errs []error
if cfg.Logger == nil {
errs = append(errs, errors.New("app: Logger is required"))
}
if cfg.Audit == nil {
errs = append(errs, errors.New("app: Audit is required"))
}
if cfg.Drain <= 0 || cfg.Grace <= 0 {
errs = append(errs, fmt.Errorf("app: Drain and Grace must be > 0, got %v / %v", cfg.Drain, cfg.Grace))
}
e, err := edge.New(edge.Config{Logger: cfg.Logger, MaxJobs: 256})
errs = append(errs, err)
if err := errors.Join(errs...); err != nil {
return nil, err
}
def := func(d *time.Duration, v time.Duration) {
if *d <= 0 {
*d = v
}
}
def(&cfg.ExportDur, 200*time.Millisecond)
def(&cfg.AuditDur, 300*time.Millisecond)
def(&cfg.ReportDur, 1500*time.Millisecond)
def(&cfg.Tick, 100*time.Millisecond)
return &App{cfg: cfg, log: slog.New(cfg.Logger), edge: e}, nil
}
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if !a.ready.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok\n")
})
mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(a.cfg.ExportDur): // 書き出し
case <-r.Context().Done(): // 相手が切った(または接続を切られた)
http.Error(w, "canceled", http.StatusServiceUnavailable)
return
}
id := a.nextID.Add(1)
if err := a.edge.Go(r.Context(), 2*time.Second, "audit", func(ctx context.Context) error {
select {
case <-time.After(a.cfg.AuditDur): // 監査サービスへ送る
case <-ctx.Done():
return context.Cause(ctx)
}
a.auditMu.Lock()
defer a.auditMu.Unlock()
_, err := fmt.Fprintf(a.cfg.Audit, "audit export id=%d\n", id)
return err
}); err != nil {
http.Error(w, "try again", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "exported\n")
})
mux.HandleFunc("GET /report", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(a.cfg.ReportDur) // ctx を見ない集計(古いライブラリ)
io.WriteString(w, "report\n")
})
mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
t := time.NewTicker(a.cfg.Tick)
defer t.Stop()
for n := 0; ; n++ {
select {
case <-r.Context().Done(): // 相手が切った(BaseContext のおかげで SIGTERM でも終わる)
return
case <-t.C:
fmt.Fprintf(w, "data: tick %d\n\n", n)
fl.Flush()
}
}
})
return mux
}
// Run は ctx(SIGTERM で終わる)が終わったら止める。
func (a *App) Run(ctx context.Context, ln net.Listener) error {
srv := &http.Server{Handler: a.Handler(), ReadHeaderTimeout: 5 * time.Second,
// /events が止まらず毎回 Grace まで待たされたので、要求の ctx を SIGTERM につないだ
BaseContext: func(net.Listener) context.Context { return ctx },
}
a.ready.Store(true)
return a.edge.Serve(ctx, srv, ln, a.cfg.Grace)
}
cmd/srv/main.go(バグ入り、74 行)
// 書き出し API のプロセス。環境変数: ADDR・AUDIT_FILE・WARM・DRAIN・GRACE。
package main
import (
"bufio"
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"d76/app"
)
func main() {
h := slog.NewTextHandler(os.Stderr, nil)
log := slog.New(h)
sig := make(chan os.Signal)
signal.Notify(sig, syscall.SIGTERM, os.Interrupt) // 温めの間に来ても取れるよう、先に登録しておく
f, err := os.OpenFile(env("AUDIT_FILE", "audit.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Error("open audit", "err", err)
os.Exit(1)
}
defer f.Close()
aud := bufio.NewWriter(f)
defer aud.Flush()
log.Info("warming")
time.Sleep(dur("WARM", 300*time.Millisecond)) // キャッシュの温め
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-sig
cancel()
}()
ln, err := net.Listen("tcp", env("ADDR", "127.0.0.1:8076"))
if err != nil {
log.Error("listen", "err", err)
os.Exit(1)
}
a, err := app.New(app.Config{Logger: h, Audit: aud,
Drain: dur("DRAIN", 5*time.Second), Grace: dur("GRACE", 20*time.Second)})
if err != nil {
log.Error("config", "err", err)
os.Exit(2)
}
log.Info("listening", "addr", ln.Addr().String())
if err := a.Run(ctx, ln); err != nil {
log.Error("stopped with error", "err", err)
os.Exit(1)
}
log.Info("stopped cleanly")
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func dur(k string, def time.Duration) time.Duration {
d, err := time.ParseDuration(os.Getenv(k))
if err != nil {
return def
}
return d
}
予想してから解く(自己評価欄へ)
- P1: LB が
/readyzを 50 ms ごとに見て、2 回続けて失敗したら外す。5 ms ごとに要求が来る。Drain を 0・25・50・75・100・150・200 ms にしたとき、断られる要求は何本か。何 ms 以上で 0 になるか - P2: 繋ぐだけで何も送らない接続(LB の事前接続など)が 1 本あるとき、
srv.Shutdown(期限 10 秒、ReadHeaderTimeoutなし)は何秒で戻るか - P3: バグ入りの版から、報告 1 の原因(B3)だけを直すと、
/eventsを 1 本開いた状態での停止は何秒かかるか(Grace 1.5 秒)
💡 ヒント
ヒント1(方向性)
5 つのうち 2 つは main の中、3 つは Run と /events の中にあります。main の 2 つは「Go のランタイムが何をしないか」——シグナルを待ってくれない、defer を走らせない——を文書で確かめると見つかります。Run の 3 つは「SIGTERM を受けた瞬間に、何を終わらせるべきで、何を終わらせてはいけないか」の取り違えです。
ヒント2(アプローチ)
- B1:
go vet ./...を流す。なぜ容量 0 だと取りこぼすのかをsignal.Notifyの文書で - B2:
os.Exitの文書を読み、bufio.Writerの Flush がどこで呼ばれるつもりだったかを見る - B3:
BaseContextが返す ctx は全部の要求の親。SIGTERM でそれが終わると、処理中の要求のr.Context()も終わる - B4: Listener を閉じた瞬間から、LB が外すまでの間に来た要求はどうなるか。
/readyzはいつ 503 になるか - B5: B3 を「直す」と
/eventsはいつ終わるか。Shutdownの文書の「長い接続には別に知らせよ」と、Day 075 で「待つのには使えない」と分かったRegisterOnShutdownの本来の用途
ヒント3(コード骨格)
func main() { os.Exit(run()) } // B2: os.Exit は main の 1 行だけ。run の defer は必ず走る
func run() int {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) // B1: 温めより前・容量 1
defer stop()
// … 温め・Listen・app.New …
if err := a.Run(ctx, ln); err != nil { return 1 }
return 0
}
func (a *App) Run(ctx context.Context, ln net.Listener) error {
srv := &http.Server{Handler: a.Handler()} // B3: BaseContext に SIGTERM の ctx を入れない
srv.RegisterOnShutdown(a.stopStreams) // B5: 長い接続に「終われ」と知らせる(待たない)
serveCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
go func() { <-ctx.Done(); a.ready.Store(false); /* Drain だけ待つ */; cancel() }() // B4
return a.edge.Serve(serveCtx, srv, ln, a.cfg.Grace)
}
✅ 模範解答
5 つのバグ
| # | 場所 | バグ | 起きること | 報告 | 検出するテスト |
|---|---|---|---|---|---|
| B1 | main | make(chan os.Signal)(容量 0)で Notify し、受け手の goroutine は温めの後に起動 | 温めの最中の SIGTERM は捨てられる(Notify は送れなければ捨てる)。既定の終了も外れているので、SIGKILL まで止まらない | 3 | D5(4 秒待っても終わらず kill) |
| B2 | main | defer aud.Flush() のあとで os.Exit(1) | os.Exit は defer を走らせない。bufio に溜まった監査の控え(最大 4 KiB)が捨てられる | 4 | D6(exit 1・控え 0 行) |
| B3 | Run | BaseContext に SIGTERM の ctx を返す | SIGTERM の瞬間に処理中の全部の要求の r.Context() が終わる。/export は 503 canceled | 1 | D1(20 本中 200 は 0 本)・D2・D3 |
| B4 | Run | readiness を落とさず、SIGTERM ですぐ Serve を止める(Drain は設定にあるのに使っていない) | Listener が閉じてから LB が外すまでの間の要求が connection refused | 2 | D3(11 本 refused) |
| B5 | /events・Run | 長い接続に停止を知らせる手段が無い(r.Context() しか見ていない) | B3 があると隠れる(B3 が /events も終わらせるから)。B3 を直すと、停止のたびに猶予いっぱい待ち、最後は接続を切られて error | (隠れていた。同僚が B3 で「直した」元の問題) | D2(1.5 秒・error) |
隠し合い: B3 は B5 の症状(停止が猶予まで延びる)を「直す」ために入ったバグ。B3 だけを直すと B5 が出る(P3。実測 4 の W1)。さらに B5 で Run が error を返すと、その経路で B2 が控えを消す——B5 → B2 の連鎖で報告 4 が起きる。
直した app/app.go(168 行)
// Package app は書き出し API(S1)の組み立て。停止の順は Day 075 の edge.Serve に任せ、
// その前後(readiness を落とす・長い接続に終われと知らせる)だけをここで持つ。
package app
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
"d76/edge"
)
type Config struct {
Logger slog.Handler
Audit io.Writer // 監査の控え(ローカルの送信箱。並行に書かないので App が鍵を持つ)
Drain time.Duration // readiness を落としてから Listener を閉じるまで(> 0)
Grace time.Duration // Listener を閉じてから、要求と応答後の仕事を待つ期限(> 0)
ExportDur time.Duration // /export の書き出し(既定 200 ms)
AuditDur time.Duration // 監査の送信(既定 300 ms)
ReportDur time.Duration // /report の重い集計(既定 1.5 s。ctx を見ない古いライブラリ)
Tick time.Duration // /events の送信間隔(既定 100 ms)
}
type App struct {
cfg Config
log *slog.Logger
edge *edge.Edge
ready atomic.Bool
auditMu sync.Mutex
nextID atomic.Int64
streamsStop chan struct{} // 閉じたら、長い接続(/events)は締めの1通を送って終わる
stopOnce sync.Once
}
func New(cfg Config) (*App, error) {
var errs []error
if cfg.Logger == nil {
errs = append(errs, errors.New("app: Logger is required"))
}
if cfg.Audit == nil {
errs = append(errs, errors.New("app: Audit is required"))
}
if cfg.Drain <= 0 || cfg.Grace <= 0 {
errs = append(errs, fmt.Errorf("app: Drain and Grace must be > 0, got %v / %v", cfg.Drain, cfg.Grace))
}
e, err := edge.New(edge.Config{Logger: cfg.Logger, MaxJobs: 256})
errs = append(errs, err)
if err := errors.Join(errs...); err != nil {
return nil, err
}
def := func(d *time.Duration, v time.Duration) {
if *d <= 0 {
*d = v
}
}
def(&cfg.ExportDur, 200*time.Millisecond)
def(&cfg.AuditDur, 300*time.Millisecond)
def(&cfg.ReportDur, 1500*time.Millisecond)
def(&cfg.Tick, 100*time.Millisecond)
return &App{cfg: cfg, log: slog.New(cfg.Logger), edge: e, streamsStop: make(chan struct{})}, nil
}
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if !a.ready.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok\n")
})
mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(a.cfg.ExportDur): // 書き出し
case <-r.Context().Done(): // 相手が切った(または接続を切られた)
http.Error(w, "canceled", http.StatusServiceUnavailable)
return
}
id := a.nextID.Add(1)
if err := a.edge.Go(r.Context(), 2*time.Second, "audit", func(ctx context.Context) error {
select {
case <-time.After(a.cfg.AuditDur): // 監査サービスへ送る
case <-ctx.Done():
return context.Cause(ctx)
}
a.auditMu.Lock()
defer a.auditMu.Unlock()
_, err := fmt.Fprintf(a.cfg.Audit, "audit export id=%d\n", id)
return err
}); err != nil {
http.Error(w, "try again", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "exported\n")
})
mux.HandleFunc("GET /report", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(a.cfg.ReportDur) // ctx を見ない集計(古いライブラリ)
io.WriteString(w, "report\n")
})
mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
t := time.NewTicker(a.cfg.Tick)
defer t.Stop()
for n := 0; ; n++ {
select {
case <-r.Context().Done():
return
case <-a.streamsStop: // 停止: 締めの1通(retry で再接続の間隔を指示)を送って終わる
io.WriteString(w, "event: bye\nretry: 1000\ndata: server is shutting down\n\n")
fl.Flush()
return
case <-t.C:
fmt.Fprintf(w, "data: tick %d\n\n", n)
fl.Flush()
}
}
})
return mux
}
func (a *App) stopStreams() { a.stopOnce.Do(func() { close(a.streamsStop) }) }
// Run は ctx(SIGTERM で終わる)が終わったら、次の順で止める。
// 1. readiness を落とし、Drain の間は今まで通り受ける(LB が外すのを待つ)
// 2. edge.Serve: Listener を閉じ、要求を待ち、応答後の仕事を待つ(Day 075)
// その入口で RegisterOnShutdown が長い接続に「終われ」と知らせる(待たない)
//
// 要求の ctx は SIGTERM では終わらない(終わるのは相手が切ったか、Grace を越えて接続を切られたとき)。
func (a *App) Run(ctx context.Context, ln net.Listener) error {
srv := &http.Server{Handler: a.Handler(), ReadHeaderTimeout: 5 * time.Second}
srv.RegisterOnShutdown(a.stopStreams)
a.ready.Store(true)
serveCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
defer cancel()
go func() {
select {
case <-ctx.Done():
case <-serveCtx.Done(): // Listener の故障などで Serve が先に戻った
return
}
a.ready.Store(false)
a.log.Info("draining", "drain", a.cfg.Drain.String())
t := time.NewTimer(a.cfg.Drain)
defer t.Stop()
select {
case <-t.C:
case <-serveCtx.Done():
}
cancel()
}()
return a.edge.Serve(serveCtx, srv, ln, a.cfg.Grace)
}
直した cmd/srv/main.go(76 行)
// 書き出し API のプロセス。環境変数: ADDR・AUDIT_FILE・WARM・DRAIN・GRACE。
package main
import (
"bufio"
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"d76/app"
)
func main() { os.Exit(run()) } // os.Exit は main の 1 行だけ。run の defer は必ず走る
func run() int {
h := slog.NewTextHandler(os.Stderr, nil)
log := slog.New(h)
// 温めより前に登録し、channel は Notify が内部で持つ(容量 1 と同じ。取りこぼさない)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
f, err := os.OpenFile(env("AUDIT_FILE", "audit.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Error("open audit", "err", err)
return 1
}
aud := bufio.NewWriter(f)
defer func() { // 後に登録した方が先に走る: Flush → Close
if err := aud.Flush(); err != nil {
log.Error("flush audit", "err", err)
}
f.Close()
}()
log.Info("warming")
time.Sleep(dur("WARM", 300*time.Millisecond)) // キャッシュの温め(この間の SIGTERM も ctx に残る)
ln, err := net.Listen("tcp", env("ADDR", "127.0.0.1:8076"))
if err != nil {
log.Error("listen", "err", err)
return 1
}
a, err := app.New(app.Config{Logger: h, Audit: aud,
Drain: dur("DRAIN", 5*time.Second), Grace: dur("GRACE", 20*time.Second)})
if err != nil {
log.Error("config", "err", err)
return 2
}
log.Info("listening", "addr", ln.Addr().String())
if err := a.Run(ctx, ln); err != nil {
log.Error("stopped with error", "err", err)
return 1 // defer の Flush は走る
}
log.Info("stopped cleanly")
return 0
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func dur(k string, def time.Duration) time.Duration {
d, err := time.ParseDuration(os.Getenv(k))
if err != nil {
return def
}
return d
}
テスト
| テスト | 何を約束するか | バグ入りで |
|---|---|---|
| D1 | 処理中の書き出し 20 本は SIGTERM の後も 200 で返り、監査も 20 件残る | 落ちる(200 は 0 本・監査 0 件) |
| D2 | /events を開いたままでも停止は 800 ms 未満で終わり、相手は event: bye を受け取り、書き出し 5 本も 200 | 落ちる(bye なし・書き出し 503) |
| D3 | LB の模型(50 ms ごと・2 回失敗で外す)で、止め始めてから外されるまでの要求を 1 本も断らない | 落ちる(refused 11) |
| D4 | 基準線: 何も処理していない所で止める | 通る |
| D5 | 本物のプロセス: 温めの最中の SIGTERM で、温めの後に exit 0 | 落ちる(kill されるまで終わらない) |
| D6 | 本物のプロセス: 猶予切れで exit 1 でも、それまでの監査の控え 5 行が残る | 落ちる(0 行) |
| D7 | 基準線: 本物のプロセスを普通に止める | 通る |
app/app_test.go(255 行。D1〜D4。package app_test で公開 API だけを使う)
package app_test
import (
"bufio"
"bytes"
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"d76/app"
)
// lines は並行に書かれても安全な io.Writer。監査の控えの行数を数える。
type lines struct {
mu sync.Mutex
b bytes.Buffer
}
func (l *lines) Write(p []byte) (int, error) { l.mu.Lock(); defer l.mu.Unlock(); return l.b.Write(p) }
func (l *lines) N() int {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Count(l.b.String(), "\n")
}
type running struct {
url string
stop context.CancelFunc // SIGTERM の代わり
done chan error
audit *lines
logs *lines
cancAt time.Time
}
func start(t *testing.T, cfg app.Config) *running {
t.Helper()
r := &running{audit: &lines{}, logs: &lines{}, done: make(chan error, 1)}
cfg.Logger = slog.NewTextHandler(r.logs, nil)
cfg.Audit = r.audit
a, err := app.New(cfg)
if err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
r.url = "http://" + ln.Addr().String()
ctx, cancel := context.WithCancel(context.Background())
r.stop = func() { r.cancAt = time.Now(); cancel() }
go func() { r.done <- a.Run(ctx, ln) }()
t.Cleanup(cancel)
waitReady(t, r.url)
return r
}
func waitReady(t *testing.T, url string) {
t.Helper()
for i := 0; i < 100; i++ {
// keep-alive の接続を残さない。残すと次の http.Get で Transport が予備の接続を張ることがあり、
// 要求を送らない接続(StateNew)は Shutdown に 5 秒待たれる(実測 3)
if res, err := noKeep.Get(url + "/readyz"); err == nil {
res.Body.Close()
if res.StatusCode == 200 {
return
}
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("server never became ready")
}
func (r *running) wait(t *testing.T, limit time.Duration) (error, time.Duration) {
t.Helper()
select {
case err := <-r.done:
return err, time.Since(r.cancAt)
case <-time.After(limit):
t.Fatalf("Run did not return within %v after stop", limit)
return nil, 0
}
}
var noKeep = &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
func get(url string) (int, string, error) {
res, err := noKeep.Get(url)
if err != nil {
return 0, "", err
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
return res.StatusCode, string(b), err
}
// D1(B3): 処理中の書き出し 20 本は、SIGTERM の後も最後まで返り、監査も 20 件残る。
func TestD1_InFlightSurviveSIGTERM(t *testing.T) {
r := start(t, app.Config{Drain: 100 * time.Millisecond, Grace: 3 * time.Second})
const n = 20
codes := make(chan int, n)
for i := 0; i < n; i++ {
go func() { c, _, _ := get(r.url + "/export"); codes <- c }()
}
time.Sleep(50 * time.Millisecond) // 20 本ともハンドラの中(書き出し 200 ms)
r.stop()
ok := 0
for i := 0; i < n; i++ {
if <-codes == 200 {
ok++
}
}
err, _ := r.wait(t, 5*time.Second)
if ok != n || err != nil || r.audit.N() != n {
t.Fatalf("200=%d/%d Run=%v audit=%d/%d", ok, n, err, r.audit.N(), n)
}
}
// D2(B5): /events を開いたままでも、停止は Drain+少しで終わり、相手は締めの 1 通を受け取る。
func TestD2_StreamsEndOnShutdown(t *testing.T) {
r := start(t, app.Config{Drain: 100 * time.Millisecond, Grace: 1500 * time.Millisecond})
res, err := http.Get(r.url + "/events")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
br := bufio.NewReader(res.Body)
if l, _ := br.ReadString('\n'); !strings.HasPrefix(l, "data: tick") {
t.Fatalf("first line %q", l)
}
codes := make(chan int, 5)
for i := 0; i < 5; i++ {
go func() { c, _, _ := get(r.url + "/export"); codes <- c }()
}
time.Sleep(30 * time.Millisecond)
r.stop()
rest, _ := io.ReadAll(br) // 接続が閉じるまで読む
err, took := r.wait(t, 5*time.Second)
for i := 0; i < 5; i++ {
if c := <-codes; c != 200 {
t.Errorf("export status %d", c)
}
}
t.Logf("Run returned %v after stop", took.Round(time.Millisecond))
if err != nil || took > 800*time.Millisecond {
t.Fatalf("Run=%v took=%v (want nil, < 800ms)", err, took)
}
if !strings.Contains(string(rest), "event: bye") {
t.Fatalf("stream ended without bye: %q", tail(string(rest)))
}
if r.audit.N() != 5 {
t.Fatalf("audit=%d/5", r.audit.N())
}
}
func tail(s string) string {
if len(s) > 60 {
return "…" + s[len(s)-60:]
}
return s
}
// D3(B4): LB が readyz を 50 ms ごとに見て 2 回続けて失敗したら外す、という模型。
// 止め始めてから外されるまでの間に来た要求を 1 本も断らない。
func TestD3_NoRefusedWhileLBCatchesUp(t *testing.T) {
r := start(t, app.Config{Drain: 200 * time.Millisecond, Grace: 2 * time.Second,
ExportDur: 5 * time.Millisecond, AuditDur: 5 * time.Millisecond})
var up atomic.Bool
up.Store(true)
quit := make(chan struct{})
var downAt atomic.Int64
go func() { // ヘルスチェック
fails := 0
tk := time.NewTicker(50 * time.Millisecond)
defer tk.Stop()
for {
select {
case <-quit:
return
case <-tk.C:
}
if c, _, err := get(r.url + "/readyz"); err == nil && c == 200 {
fails = 0
continue
}
if fails++; fails == 2 && up.Load() {
up.Store(false)
downAt.Store(time.Now().UnixNano())
}
}
}()
var sent, refused, non200 atomic.Int64
var wg sync.WaitGroup
go func() { // 5 ms ごとに 1 本、LB が「生きている」と思っている間だけ送る
tk := time.NewTicker(5 * time.Millisecond)
defer tk.Stop()
for {
select {
case <-quit:
return
case <-tk.C:
}
if !up.Load() {
continue
}
sent.Add(1)
wg.Add(1)
go func() {
defer wg.Done()
c, _, err := get(r.url + "/export")
switch {
case err != nil:
refused.Add(1)
case c != 200:
non200.Add(1)
}
}()
}
}()
time.Sleep(300 * time.Millisecond)
r.stop()
err, _ := r.wait(t, 5*time.Second)
for i := 0; i < 100 && up.Load(); i++ { // LB が外すまでは送り続ける(プロセスが先に止まっても)
time.Sleep(10 * time.Millisecond)
}
close(quit)
wg.Wait()
lag := time.Duration(downAt.Load() - r.cancAt.UnixNano())
t.Logf("sent=%d refused=%d non200=%d LB removed %v after SIGTERM", sent.Load(), refused.Load(), non200.Load(), lag.Round(time.Millisecond))
if refused.Load() != 0 || non200.Load() != 0 || err != nil {
t.Fatalf("refused=%d non200=%d Run=%v", refused.Load(), non200.Load(), err)
}
}
// D4(基準線): 何も処理していない所で止めれば、すぐ止まり監査は全部残る。バグ入りでも通る。
func TestD4_IdleStop(t *testing.T) {
r := start(t, app.Config{Drain: 50 * time.Millisecond, Grace: time.Second})
for i := 0; i < 3; i++ {
if c, _, err := get(r.url + "/export"); c != 200 {
t.Fatalf("export %d %v", c, err)
}
}
time.Sleep(400 * time.Millisecond) // 監査 300 ms が終わる
r.stop()
err, _ := r.wait(t, 3*time.Second)
if err != nil || r.audit.N() != 3 {
t.Fatalf("Run=%v audit=%d/3", err, r.audit.N())
}
}
cmd/srv/main_test.go(178 行。D5〜D7。go build したプロセスに SIGTERM を送る)
package main_test
// 本物のプロセスを作って SIGTERM を送る。シグナルと os.Exit はプロセスの外からしか確かめられない。
import (
"bufio"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
var bin string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "d76")
if err != nil {
panic(err)
}
bin = filepath.Join(dir, "srv")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "build: %v\n%s", err, out)
os.Exit(1)
}
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
type proc struct {
cmd *exec.Cmd
lines chan string
audit string
exit chan error
}
func startProc(t *testing.T, env ...string) *proc {
t.Helper()
p := &proc{lines: make(chan string, 100), exit: make(chan error, 1),
audit: filepath.Join(t.TempDir(), "audit.log")}
p.cmd = exec.Command(bin)
p.cmd.Env = append(os.Environ(), append([]string{"ADDR=127.0.0.1:0", "AUDIT_FILE=" + p.audit}, env...)...)
stderr, err := p.cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := p.cmd.Start(); err != nil {
t.Fatal(err)
}
go func() {
sc := bufio.NewScanner(stderr)
for sc.Scan() {
p.lines <- sc.Text()
}
close(p.lines)
p.exit <- p.cmd.Wait()
}()
t.Cleanup(func() { p.cmd.Process.Kill() })
return p
}
// until は msg を含む行が出るまで読み、その行を返す。
func (p *proc) until(t *testing.T, msg string) string {
t.Helper()
deadline := time.After(3 * time.Second)
for {
select {
case l, ok := <-p.lines:
if !ok {
t.Fatalf("process ended before %q", msg)
}
if strings.Contains(l, msg) {
return l
}
case <-deadline:
t.Fatalf("no %q within 3s", msg)
}
}
}
func (p *proc) addr(t *testing.T) string {
l := p.until(t, "msg=listening")
i := strings.Index(l, "addr=")
return "http://" + l[i+len("addr="):]
}
// waitExit は終了コードを返す。limit までに終わらなければ -1(その後 SIGKILL)。
func (p *proc) waitExit(t *testing.T, limit time.Duration) int {
t.Helper()
go func() {
for range p.lines { // 残りのログを捨てる
}
}()
select {
case err := <-p.exit:
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.ExitCode()
}
return 0
case <-time.After(limit):
p.cmd.Process.Kill()
return -1
}
}
func auditLines(t *testing.T, path string) int {
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return strings.Count(string(b), "\n")
}
func httpGet(t *testing.T, url string) {
res, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
}
// D5(B1): 温めの最中に来た SIGTERM を取りこぼさず、温めの後に止まる。
func TestD5_SIGTERMDuringWarmup(t *testing.T) {
p := startProc(t, "WARM=1s", "DRAIN=100ms", "GRACE=1s")
p.until(t, "msg=warming")
time.Sleep(100 * time.Millisecond)
p.cmd.Process.Signal(syscall.SIGTERM)
start := time.Now()
code := p.waitExit(t, 4*time.Second)
if code != 0 {
t.Fatalf("exit=%d after %v (want 0; -1 means it ignored SIGTERM and was killed)", code, time.Since(start).Round(time.Millisecond))
}
}
// D6(B2): 期限切れで exit 1 になっても、それまでの監査の控えは全部ファイルに残る。
func TestD6_ErrorExitKeepsAudit(t *testing.T) {
p := startProc(t, "WARM=0s", "DRAIN=50ms", "GRACE=300ms")
url := p.addr(t)
for i := 0; i < 5; i++ {
httpGet(t, url+"/export")
}
time.Sleep(400 * time.Millisecond) // 監査 5 件が bufio に入る(まだディスクには無い)
go func() {
res, err := http.Get(url + "/report")
if err == nil {
res.Body.Close()
}
}()
time.Sleep(50 * time.Millisecond) // /report(1.5 s)が処理中
p.cmd.Process.Signal(syscall.SIGTERM)
code := p.waitExit(t, 4*time.Second)
if n := auditLines(t, p.audit); code != 1 || n != 5 {
t.Fatalf("exit=%d audit lines=%d (want exit 1, 5 lines)", code, n)
}
}
// D7(基準線): 普通に止めれば exit 0 で監査は全部残る。バグ入りでも通る。
func TestD7_CleanStop(t *testing.T) {
p := startProc(t, "WARM=0s", "DRAIN=50ms", "GRACE=1s")
url := p.addr(t)
for i := 0; i < 3; i++ {
httpGet(t, url+"/export")
}
p.cmd.Process.Signal(syscall.SIGTERM)
code := p.waitExit(t, 4*time.Second)
if n := auditLines(t, p.audit); code != 0 || n != 3 {
t.Fatalf("exit=%d audit lines=%d (want 0, 3)", code, n)
}
}
app/measure_test.go(103 行。実測 1・3。-tags measure のときだけ)
//go:build measure
package app_test
import (
"context"
"net"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"d76/app"
)
// 実測 1: Drain の長さと、LB(50 ms ごと・2 回失敗で外す)が外すまでに断られた要求の数。
func TestMeasureDrainSweep(t *testing.T) {
for _, d := range []time.Duration{1, 25 * time.Millisecond, 50 * time.Millisecond, 75 * time.Millisecond,
100 * time.Millisecond, 150 * time.Millisecond, 200 * time.Millisecond} {
var res []int64
for rep := 0; rep < 5; rep++ {
res = append(res, lbRun(t, d))
}
t.Logf("Drain=%-6v refused=%v", d, res)
}
}
func lbRun(t *testing.T, drain time.Duration) int64 {
r := start(t, app.Config{Drain: drain, Grace: 2 * time.Second,
ExportDur: 5 * time.Millisecond, AuditDur: 5 * time.Millisecond})
var up atomic.Bool
up.Store(true)
quit := make(chan struct{})
go func() {
fails := 0
tk := time.NewTicker(50 * time.Millisecond)
defer tk.Stop()
for {
select {
case <-quit:
return
case <-tk.C:
}
if c, _, err := get(r.url + "/readyz"); err == nil && c == 200 {
fails = 0
} else if fails++; fails == 2 {
up.Store(false)
}
}
}()
var refused atomic.Int64
var wg sync.WaitGroup
go func() {
tk := time.NewTicker(5 * time.Millisecond)
defer tk.Stop()
for {
select {
case <-quit:
return
case <-tk.C:
}
if up.Load() {
wg.Add(1)
go func() {
defer wg.Done()
if c, _, err := get(r.url + "/export"); err != nil || c != 200 {
refused.Add(1)
}
}()
}
}
}()
time.Sleep(120 * time.Millisecond)
r.stop()
r.wait(t, 5*time.Second)
for i := 0; i < 100 && up.Load(); i++ {
time.Sleep(10 * time.Millisecond)
}
close(quit)
wg.Wait()
return refused.Load()
}
// 実測 3: 要求を 1 本も送らない接続(StateNew)があると、Shutdown はいつ戻るか。
func TestMeasureStateNew(t *testing.T) {
for _, rht := range []time.Duration{0, time.Second, 5 * time.Second} {
srv := &http.Server{Handler: http.NotFoundHandler(), ReadHeaderTimeout: rht}
ln, _ := net.Listen("tcp", "127.0.0.1:0")
go srv.Serve(ln)
c, err := net.Dial("tcp", ln.Addr().String()) // 繋ぐだけで何も送らない(LB の事前接続・ブラウザの preconnect)
if err != nil {
t.Fatal(err)
}
time.Sleep(20 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
s := time.Now()
err = srv.Shutdown(ctx)
t.Logf("ReadHeaderTimeout=%-4v Shutdown=%v after %v", rht, err, time.Since(s).Round(10*time.Millisecond))
cancel()
c.Close()
}
}
実行
go vet ./... # バグ入りでは B1 を 1 件指摘する(直した版は指摘なし)
go test -race -count=1 -v ./app/ ./cmd/srv/ # D1〜D7(cmd/srv は TestMain で go build してから流す)
go test -tags measure -count=1 -v -run Measure ./app/ # 実測 1(Drain 7 段)と実測 3(StateNew)
実行結果(Go 1.27.2、darwin/arm64、2026-10-16)
バグ入り:
$ go vet ./...
cmd/srv/main.go:22:2: misuse of unbuffered os.Signal channel as argument to signal.Notify
$ go test -race -count=1 -v ./app/ ./cmd/srv/
app_test.go:121: 200=0/20 Run=<nil> audit=0/20
--- FAIL: TestD1_InFlightSurviveSIGTERM (0.05s)
app_test.go:147: export status 503 (× 5)
app_test.go:150: Run returned 1ms after stop
app_test.go:155: stream ended without bye: "\n"
--- FAIL: TestD2_StreamsEndOnShutdown (0.14s)
app_test.go:235: sent=70 refused=11 non200=1 LB removed 50ms after SIGTERM
app_test.go:237: refused=11 non200=1 Run=<nil>
--- FAIL: TestD3_NoRefusedWhileLBCatchesUp (0.35s)
--- PASS: TestD4_IdleStop (1.01s)
main_test.go:140: exit=-1 after 4s (want 0; -1 means it ignored SIGTERM and was killed)
--- FAIL: TestD5_SIGTERMDuringWarmup (4.23s)
main_test.go:162: exit=1 audit lines=0 (want exit 1, 5 lines)
--- FAIL: TestD6_ErrorExitKeepsAudit (1.79s)
--- PASS: TestD7_CleanStop (0.93s)
直した版(-race -count=5 でも同じ。Run returned は 473〜489 ms、refused は 5 回とも 0):
$ go vet ./... (指摘なし)
$ go test -race -count=1 -v ./app/ ./cmd/srv/
--- PASS: TestD1_InFlightSurviveSIGTERM (0.51s)
app_test.go:150: Run returned 473ms after stop
--- PASS: TestD2_StreamsEndOnShutdown (0.61s)
app_test.go:235: sent=70 refused=0 non200=0 LB removed 51ms after SIGTERM
--- PASS: TestD3_NoRefusedWhileLBCatchesUp (0.50s)
--- PASS: TestD4_IdleStop (1.07s)
ok d76/app
--- PASS: TestD5_SIGTERMDuringWarmup (1.22s)
--- PASS: TestD6_ErrorExitKeepsAudit (1.84s)
--- PASS: TestD7_CleanStop (0.93s)
ok d76/cmd/srv
本物のプロセスに kill -TERM(DRAIN=1s GRACE=5s。/events を 1 本開き、/export を 1 本処理中に送った):
直した版 バグ入り
SIGTERM の 50ms 前の export 200 503(B3)
+0.3s readyz 503(drain 中) 000(もう閉じている。B4)
+0.3s export 200(drain 中も受ける) 000(connection refused)
+1.5s export 000(drain 後は閉じる) 000
/events の最後 event: bye / retry: 1000 (何も来ずに切れる)
監査の控え 2 行 0 行
server のログ draining → shutdown started → stopped cleanly
shutdown started → stopped cleanly ← バグ入りでも「きれいに止まった」と書く
🪜 Step-by-Step 解説
Step 1: B1——「先に登録しておけば安全」は容量 0 だと成り立たない
sig := make(chan os.Signal) // 容量 0
signal.Notify(sig, syscall.SIGTERM, os.Interrupt) // 既定の「終了」はここで外れる
time.Sleep(warm) // この間に SIGTERM が来ると…
go func() { <-sig; cancel() }() // 受け手はまだいない → Notify は捨てる
同僚のコメント「温めの間に来ても取れるよう、先に登録しておく」は、容量 1 なら正しい。signal.Notify は送れないときに待たずに捨てる(文書 114〜117 行)ので、容量 0 だと「その瞬間に受け手が待っている」ときしか届かない。しかも Notify を呼んだ時点で既定の終了も外れているので、プロセスは SIGTERM を無視したのと同じになり、Kubernetes なら猶予の後に SIGKILL(報告 3 の 30 秒)。
直し方は signal.NotifyContext(内部で容量 1、300 行)。make(chan os.Signal, 1) にして受け手を温めの後に置いたままでも D5 は通る(W3)。go vet が見つける唯一のバグ。
Step 2: B2——os.Exit は defer を飛ばす
aud := bufio.NewWriter(f)
defer aud.Flush() // 普通に main を抜けたときだけ走る
...
if err := a.Run(ctx, ln); err != nil {
log.Error(...)
os.Exit(1) // defer は走らない(os/proc.go 59 行)→ 4 KiB までの控えが消える
}
普段は Run が nil を返すので見えない(D7 はバグ入りでも通る)。Run が error を返したときだけ——つまり B5 で猶予切れになったときだけ——控えが消える。error の経路でだけ壊れるので、正常系のテストでは見つからない。
直し方は func main() { os.Exit(run()) }。os.Exit を main の 1 行に閉じ込め、後始末は全部 run の defer に置く。Flush と Close は1 つの defer にまとめて順を固定した。別々の defer にして順を逆に書くと(W4: defer Flush の後に defer f.Close())、Close が先に走って Flush が閉じたファイルに書き、正常に止めても控えが 0 行になった(D6・D7 が落ちる)。
Step 3: B3——SIGTERM で終わらせてよいのは「新しいもの」だけ
BaseContext が返す ctx は、全部の要求の ctx の親です。そこに SIGTERM の ctx を渡すと、
SIGTERM ─→ ctx 終了 ─→ 処理中の全要求の r.Context() 終了 ─→ /export は 503 canceled(D1: 20 本中 0 本しか 200 にならない)
└→ Drain 中に来た新しい要求も、ctx が最初から終わっている(S3 で non200=12)
srv.Shutdown は処理中の要求の ctx を終わらせない(終わらせるのは srv.Close)。だから Day 075 の Serve は「処理中の要求を最後まで待つ」ことができていた。B3 はその前提を外から壊した。
同僚は「/events が止まらない」を直すために B3 を入れた。/events は確かに止まる。だが止まってほしいのは長い接続だけで、書き出しは止まってほしくない。その区別は ctx の親子では付けられない——次の B5 の直し方で付ける。
Step 4: B5——長い接続には「終われ」を別に知らせる
B3 を外すと、/events は相手が切るまで終わらない。srv.Shutdown は全接続が idle になるのを待つので、猶予いっぱい待ってから srv.Close で切る(P3: 1.5 秒・error。本番の猶予 20 秒なら 1 Pod あたり 20 秒、毎回)。
Shutdown の文書(3264〜3268 行)は、長い接続には呼び出し側が別に知らせよと書き、そのための口として RegisterOnShutdown を挙げている。Day 075 では「RegisterOnShutdown で待つと全部断られる」と分かったが、今日の使い方は文書どおり「始めるだけ」:
srv.RegisterOnShutdown(a.stopStreams) // Shutdown の入口で go f() される。close(streamsStop) するだけ
...
case <-a.streamsStop: // /events の中
io.WriteString(w, "event: bye\nretry: 1000\ndata: server is shutting down\n\n")
fl.Flush()
return // ハンドラが戻る → 接続が idle → Shutdown が閉じる
retry: 1000 で、ブラウザの EventSource は 1 秒後に再接続する(記憶: SSE の仕様)。そのころには LB が別の Pod に回している。締めの 1 通の Flush を忘れても(W5)、ハンドラが戻るときに net/http が残りを送るので bye は届いた(予想は「届かない」で外れ)。
**なぜ RegisterOnShutdown で、Drain の始め(SIGTERM の瞬間)ではないか**: Drain の間はまだ Listener が開いていて、LB が外す前のこの Pod に再接続が来うる。切った相手がすぐ同じ Pod に戻ってくるので、Listener を閉じる瞬間に合わせた。
Step 5: B4——閉じる前に「外してくれ」と言って、外されるまで待つ
バグ入り: SIGTERM ─→ Listener を閉じる LB はまだ送ってくる → refused
|←─── LB が気付くまで(50 ms × 2 回)───→|
直した版: SIGTERM ─→ readyz=503、まだ受ける ─(Drain)─→ Listener を閉じる
|←─── LB が外す ───→| ← この間の要求は 200
Drain は設定にも環境変数にもあったのに、Run が使っていなかった。readiness を落とすだけで待たない直し方(S4 と同じ)も、待つだけで readiness を落とさない直し方(W2: LB は外さないので Drain の 200 ms 中ずっと送り、その後 refused 11)も、どちらも足りない。落とす・待つ、の 2 つで 1 組。
Drain の長さは「LB が外すまでの最悪時間」以上にする。実測 1 では、50 ms × 2 回で外す LB に対して 100 ms 以上で 0 本になった。
Step 6: 猶予の配分(main の既定値)
SIGTERM ─ Drain 5s ─┬─ Grace 20s(要求を待つ+応答後の仕事を待つ。Day 075 で共有)─┐
Listener を閉じる Run が戻る → defer の Flush → exit
合計 25s < Kubernetes の既定 terminationGracePeriodSeconds 30s(記憶)
既定値の 5 s・20 s は仮定(今日の LB の設定を持っていない)。Drain の後に Grace が来るので、2 つの和が SIGKILL までの時間を超えないように決める。
📏 実測(予想と外れた所を含む)
測定時のロードアベレージは 5.5〜11.7(15 コア)。時間は参考、件数を根拠にする。
1. Drain の長さと断られた要求(P1)
LB の模型: /readyz を 50 ms ごとに見て 2 回続けて失敗したら外す。要求は 5 ms ごとに 1 本、LB が「生きている」と思っている間だけ送る。各 5 回。
| Drain | 断られた要求(5 回) |
|---|---|
| 0(1 ns) | 17 17 17 17 17 |
| 25 ms | 12 12 12 12 12 |
| 50 ms | 7 7 7 7 7 |
| 75 ms | 2 1 2 2 2 |
| 100 ms | 0 0 0 0 0 |
| 150 ms | 0 0 0 0 0 |
| 200 ms | 0 0 0 0 0 |
25 ms 延ばすごとにおよそ 5 本(25 ms ÷ 5 ms)減り、間隔 × 回数 = 100 ms で 0 になる。Drain が 0 でも 17 本で済むのは、Listener を閉じた後の /readyz も失敗するので LB が 2 回目の確認(最悪 100 ms)で外すから。D3 の 11 本は Drain 200 ms の設定のまま B4 を入れたもので、止める前の送信の位相で 11〜20 本の幅が出た(W1 で 18 本)。
2. 1 つずつ入れた 5 通り・間違った直し方 5 通り
直した版にバグを 1 つだけ戻した。
| 版 | 落ちたテスト | vet | 主な出力 |
|---|---|---|---|
| S1 B1 だけ | D5 | 指摘あり | exit=-1(4 秒待っても終わらない) |
| S2 B2 だけ | D6 | なし | exit=1・控え 0 行 |
| S3 B3 だけ | D1・D2・D3 | なし | 200=0/20、bye なし、Drain 中の要求が 503(non200=12) |
| S4 B4 だけ | D3 | なし | refused=11 |
| S5 B5 だけ | D2 | なし | 1.603 s・http shutdown: context deadline exceeded |
どのバグも、1 つだけ入れれば少なくとも 1 本が落ちる。vet が見つけるのは B1 だけ。
| 間違った直し方 | 落ちたテスト | 分かったこと |
|---|---|---|
| W1 バグ入りから B3 だけ直す | D2・D3・D5・D6 | D2 は「bye なしで 2 ms で終わる」から「1.502 s かかって error」に変わる=B5 が出た(P3) |
| W2 Drain は待つが readyz を落とさない | D3 | LB は外さない。Drain 200 ms の後に閉じて refused 11、LB が外したのは 250 ms 後 |
| W3 容量 1 にして受け手は温めの後のまま | なし | 容量 1 で十分(文書どおり)。受け手が遅れても 1 通は channel に残る |
| W4 defer を 2 つに分けて Close → Flush の順に | D6・D7 | 正常に止めても控えが 0 行。B2 より悪い |
| W5 締めの 1 通を Flush しない | なし | ハンドラが戻るときに net/http が送るので bye は届く(予想外れ) |
3. 要求を送らない接続 1 本で Shutdown はいつ戻るか(P2)
ReadHeaderTimeout | Shutdown が戻るまで |
|---|---|
| なし | 6.35 s |
| 1 s | 1.09 s |
| 5 s | 5.33 s |
closeIdleConns は StateNew の接続を「最初の要求の頭を 5 秒以上読んでいない」ときだけ idle とみなす(3335 行)。秒単位の比較とポーリング間隔(最大 500 ms)が重なって 5〜6.5 秒。ReadHeaderTimeout があれば、その時間で接続自体が切られる。
これは最初のテストで踏んだ。D2 を -race で流すと 6 回中 5 回、バグ入りの版が 1.5 秒(猶予いっぱい)かかった。原因は、テストの http.Get(/readyz) が残した keep-alive 接続を http.Get(/events) が拾う間に、Transport が予備の接続をもう 1 本張っていたこと。その接続は要求を送らないので StateNew のまま、Shutdown に待たれていた(ConnState を全部出して確認)。テストの /readyz を keep-alive なしにして直した。本番でも、LB や前段のプロキシが事前に張る接続は同じ形になる(導出)。
4. 報告の数字を本番に直すと(導出)
| 報告 | 今日の実測 | 本番の仮定 | 導出 |
|---|---|---|---|
| 1(503 canceled) | 処理中 20 本が全部 503 | 1 Pod 50 rps・書き出し 200 ms → 処理中 約 10 本 | 1 Pod の停止ごとに約 10 本、Pod 10 個のローリングで約 100 本/デプロイ |
| 2(refused) | Drain なしで 17 本(5 ms 間隔・LB 100 ms) | LB の確認 5 s × 2 回、1 Pod 50 rps | 最悪 10 s × 50 = 約 500 本/Pod |
| 4(控え 0 行) | 5 行中 5 行消えた | 1 行 約 20 B、bufio 4,096 B | 1 回で最大 約 200 行 |
⚖️ 迷った他案と採らなかった理由
| 論点 | 採った案 | 迷った他案と採らなかった理由 | 前提が変わったら |
|---|---|---|---|
| シグナルの受け方 | **NotifyContext を温めより前** | 容量 1 の channel +自前の goroutine: 同じく正しい(W3)が、ctx に変える数行を毎回書く。温めの後に Notify: 温めの間の SIGTERM は既定の終了でプロセスが即死し、控えの Flush も走らない | 2 回目の SIGTERM(または Ctrl-C)で即終了させたいなら、<-ctx.Done() の直後に stop() を呼んで既定の動作に戻す(文書 281〜285 行)。今日は猶予を守る方を取り、stop は run の終わり |
os.Exit の置き場所 | **main の 1 行(os.Exit(run()))** | **log.Fatal の前に手で Flush: 経路ごとに忘れる。runtime.Goexit: main で呼ぶと他の goroutine が終わるまで待ち、最後はデッドロックの panic になる(記憶**) | — |
| 長い接続の止め方 | **RegisterOnShutdown で知らせ、締めの 1 通を送って自分で終わる** | **BaseContext を SIGTERM につなぐ: B3。/events だけ別の http.Server: ポートが増え、LB とヘルスチェックも 2 組になる。srv.Close に任せる**(猶予切れで切られる): 毎回猶予いっぱい待ち、相手には retry が届かない | WebSocket(hijack)なら Shutdown は接続を数えもしない(3264 行)。そのときは自分で数えて待つ(Day 075 の Edge.Go と同じ形) |
/events をいつ止めるか | Listener を閉じる瞬間(RegisterOnShutdown) | SIGTERM の瞬間(Drain の始め): Drain 中は Listener が開いているので、retry: 1000 で戻ってきた相手がまた同じ Pod に繋がりうる | Drain が retry より十分長く、LB が先に外すと分かっているなら、早く止めてもよい |
| readiness と Drain | readiness を落とす+Drain だけ待つ | readiness だけ落とす(S4 と同じ)/待つだけ(W2): どちらも refused が出る。Drain の間は新しい要求に 503: LB が外す前の要求をわざわざ失敗させる | Kubernetes では、Pod の削除で EndpointSlice から外れるのは readiness と別の経路で、遅れは主に kube-proxy・外部 LB への伝わり(記憶)。readiness を落とすことより「SIGTERM の後もしばらく受け続ける」こと(preStop の sleep と同じ役)が本体 |
| Drain の長さ | 環境変数で渡す(既定 5 s は仮定) | 固定値: LB の確認間隔は環境ごとに違う。実測 1 のとおり「確認間隔 × 回数」以上が要る | LB の設定を Terraform などから読めるなら、そこから計算する |
| テストのやり方(B1・B2) | **本物のプロセス(go build → SIGTERM → 終了コードとファイル)** | **main を関数に分けて同じプロセスでシグナルを送る**: syscall.Kill(os.Getpid(), SIGTERM) はテストのプロセス自体を止めうる。os.Exit はテストごと終わる | — |
| SSE の締め | **event: bye + retry: 1000** | 何も送らず切る: ブラウザの再接続間隔は既定(ブラウザ次第、記憶)になり、画面に「切れた」と出るかはクライアント次第 | — |
🧭 設計思想・なぜこう書くのか
- 終わらせるものを名指しする: ctx の cancel は「その下にあるもの全部」を止める。
BaseContextは「全部の要求」の親なので、SIGTERM をそこにつなぐのは「全部止めろ」と同じ。Go の停止では、新しいものを断る(Listener を閉じる)・**長いものに知らせる(RegisterOnShutdown)・残りを待つ(Shutdown)・期限で切る(Close)** を別々の操作として持っている。今日のバグ B3 は 4 つ目を 1 つ目の時点でやってしまった - ランタイムがしないことは、文書に 1 行で書いてある:
Notifyは待たない、os.Exitは defer を走らせない、Shutdownは hijack を待たない、RegisterOnShutdownは待たない。どれも 1 行。Go は暗黙の後始末をしない(atexitも finalizer の保証も無い)代わりに、しないことを明記している - **後始末は 1 か所の defer に、終了は 1 か所の
os.Exitに**:func main() { os.Exit(run()) }は Go のコードでよく見る形(記憶: 標準ライブラリの cmd 配下にも多い)。後始末の順が 1 つの関数の中で読める - error の経路でだけ壊れるものは、error を起こすテストでしか見つからない: B2 は正常系(D7)では見えない。D6 はわざと猶予を切らせて error の経路を通す
🌐 他言語との比較
| 言語・FW | SIGTERM の受け方 | 終了時の後始末 | 今日の Go との違い |
|---|---|---|---|
| Go(今日) | NotifyContext → ctx。受けた後の順は自分で書く | defer。os.Exit は飛ばす | 何もしない代わりに、しないことが文書にある |
| Java | Runtime.addShutdownHook(Thread)。SIGTERM・System.exit の両方で走る(記憶) | shutdown hook は System.exit でも走る。Runtime.halt は走らない(記憶) | Go の os.Exit は Java の halt に近い。exit に当たるものは無い |
| Spring Boot | server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase。readiness は ReadinessState.REFUSING_TRAFFIC に自動で切り替わる(記憶) | フレームワークが段階を持つ | Drain に当たる待ちは Kubernetes の preStop に任せる案内(記憶) |
| Node.js | process.on('SIGTERM', …)。登録すると既定の終了が外れる(Go の Notify と同じ) | process.exit() は beforeExit を走らせない(記憶) | イベントループなので「取りこぼし」は起きにくいが、process.exit で書き込み途中のストリームを失う点は B2 と同じ |
| Python | signal.signal(SIGTERM, handler) は主スレッドでだけ走る(記憶) | atexit は os._exit で走らない(記憶) | sys.exit は例外なので finally が走る。Go の os.Exit は os._exit 側 |
(Go 以外の行は今回ソースも実行も確認していない)
🏭 実務での使いどころ
- PR レビューで見る 5 点:
signal.Notifyの channel の容量(vet が見る)/os.Exit・log.Fatalがmain以外にあるか、defer の後にあるか(grep -n 'os.Exit\|log.Fatal')/BaseContext・ConnContextに SIGTERM の ctx を返していないか//readyzが SIGTERM で 503 になるか、Listener を閉じる前に待つか/text/event-stream・WebSocket のハンドラが停止を知る口を持つか - 報告の読み方: 「デプロイの時だけ」の失敗は、時刻を SIGTERM と Listener を閉じた時刻で分ける。SIGTERM 直後の 503 は B3 型、閉じた後の refused は B4 型、猶予いっぱいの停止は B5 型か StateNew(実測 3)
- **
ReadHeaderTimeoutは停止にも効く**: 要求を送らない接続は、ReadHeaderTimeoutが無いと停止を 5〜6.5 秒延ばす(実測 3)。slowloris 対策として付けるもの(Day 061)が、停止の速さにも効く
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
signal.Notify を先に呼んでおけば、後で受け取れる | 登録と受信を分けて考える | 容量 0 だと、その瞬間に受け手がいなければ捨てる。容量 1 か NotifyContext |
os.Exit(1) の前の defer は走る | 他の言語の exit は後始末を走らせるものが多い | 走らない(os/proc.go 59 行)。log.Fatal も中で os.Exit(1) |
| SIGTERM で要求の ctx を終わらせれば、ハンドラが早く終わって止まりが速くなる | 「ctx を伝える」のが Go の作法なので | 処理中の要求まで全部失敗する。Shutdown が要求の ctx を終わらせないのは、それを待つため |
| readiness を 503 にすれば、すぐ要求が来なくなる | ヘルスチェックの結果がすぐ伝わると考える | LB が外すまで「確認間隔 × 回数」かかる。その間は受け続ける(実測 1) |
RegisterOnShutdown は使えない(Day 075 で全部断ったから) | 前回の結論を広げすぎる | 「待つ」のに使えないだけ。長い接続に「終われ」と知らせるのが本来の用途 |
Shutdown が遅いのは処理中の要求のせい | 遅さの原因を要求の長さだけで考える | 要求を送らない接続(StateNew)も 5 秒待たれる(実測 3) |
defer f.Close() と defer w.Flush() は順不同 | どちらも「最後に走る」ので | 後に登録した方が先に走る。Close が先だと Flush は失敗する(W4) |
🚧 扱わなかった範囲と理由
| 範囲 | 理由 |
|---|---|
| 本物の Kubernetes・LB での Drain の長さ | 手元にクラスタが無い。LB は 50 ms × 2 の模型。Kubernetes の既定値と EndpointSlice の動きは記憶 |
| 2 回目の SIGTERM で即終了させる | 迷った他案に書いた。猶予を守る方を取った |
| WebSocket(hijack)の停止 | Shutdown は数えない。数えるのは Day 075 の Edge.Go と同じ形になるので、今日は SSE だけ |
HTTP/2 の GOAWAY と /events | HTTP/1.1 でだけ確かめた |
| SIGKILL の後の控えの消失 | Flush の前に殺されたら消える。ディスクに直接書く(O_SYNC や毎行 Flush)か、送信箱にするかは、控えの重みで決める。今日は範囲外 |
仕事の中の Go が停止中に断られる(Day 075 の予告にあった) | 今日の 5 つに入れると、Day 075 の edge を変えることになる。edge を変えずに直せるバグに絞った |
| 他言語の行 | 記憶。確認していない |
| 時間の比較 | ロードアベレージ 5.5〜11.7。件数と終了コード・行数を根拠にした |
➡️ 次のステップ
- 発展:
Runの段階(Drain → 長い接続に知らせる → 要求を待つ → 仕事を待つ → 期限で切る)を、各段の開始時刻と残り件数を 1 行ずつログに出す形にし、報告の「デプロイの時だけ」を時刻で切り分けられるようにする - 次回予告: 同テーマで「設計・アーキテクチャ」——Day 063〜076 のミドルウェア(積む順・ルートごとのポリシー・error の出口・OTel・停止)を 1 枚の表にまとめ、10/25 にテーマが進む前に「このテーマで決めたこと/決めなかったこと」を整理する