Day 076 — 2026-10-16 | ミドルウェア設計パターン: 止め方のバグ 5 つ——SIGTERM を取りこぼす・os.Exit で控えが消える・BaseContext で処理中が全滅・readiness を落とさず閉じる・長い接続が猶予を食う(1 つ直すと隠れていた 1 つが出る)

2026-10-16 🟡 上級者 / Phase 3 デバッグ・コードレビュー ミドルウェア設計パターン
フェーズ: 上級者 (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
StateNewhttp.ConnState の 1 つ。接続は来たが、まだ最初の要求の頭を読んでいない状態
BaseContexthttp.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 の sigchanyzermisuse of unbuffered os.Signal channel as argument to signal.Notifycmd/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 件が報告されました。

  1. **デプロイの直後だけ、書き出しが 503 canceled で失敗する**(その時間に処理中だった分が全部)
  2. **デプロイのたびに、LB のログに connection refused が十数件出る**
  3. 起動の直後に止めた Pod は、止まるのに 30 秒かかって SIGKILL される
  4. 停止が猶予切れで error になった Pod では、その回の監査の控えがファイルに 1 行も無い

なお、/events を足した最初の版では「デプロイのたびに停止が猶予(20 秒)いっぱいまでかかる」問題があり、同僚がそれを直してから出しています(下の BaseContext の行のコメント)。

課題

下の app/app.go(137 行)と cmd/srv/main.go(74 行)にはバグが 5 つあります。報告の 4 件で 4 つ、残りの 1 つは別のバグに隠れていて、まだ症状が出ていません。

  1. 5 つのバグを見つけ、それぞれ報告のどれの原因か(または隠れているか)を書いてください
  2. 直してください。edge/after.go(Day 075)は変えません
  3. 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 つのバグ

#場所バグ起きること報告検出するテスト
B1mainmake(chan os.Signal)(容量 0)で Notify し、受け手の goroutine は温めの後に起動温めの最中の SIGTERM は捨てられる(Notify は送れなければ捨てる)。既定の終了も外れているので、SIGKILL まで止まらない3D5(4 秒待っても終わらず kill)
B2maindefer aud.Flush() のあとで os.Exit(1)os.Exit は defer を走らせない。bufio に溜まった監査の控え(最大 4 KiB)が捨てられる4D6(exit 1・控え 0 行)
B3RunBaseContext に SIGTERM の ctx を返すSIGTERM の瞬間に処理中の全部の要求の r.Context() が終わる。/export は 503 canceled1D1(20 本中 200 は 0 本)・D2・D3
B4Runreadiness を落とさず、SIGTERM ですぐ Serve を止める(Drain は設定にあるのに使っていない)Listener が閉じてから LB が外すまでの間の要求が connection refused2D3(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)
D3LB の模型(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 ms12 12 12 12 12
50 ms7 7 7 7 7
75 ms2 1 2 2 2
100 ms0 0 0 0 0
150 ms0 0 0 0 0
200 ms0 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・D6D2 は「bye なしで 2 ms で終わる」から「1.502 s かかって error」に変わる=B5 が出た(P3)
W2 Drain は待つが readyz を落とさないD3LB は外さない。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)

ReadHeaderTimeoutShutdown が戻るまで
なし6.35 s
1 s1.09 s
5 s5.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 本が全部 5031 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 B1 回で最大 約 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 と Drainreadiness を落とす+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 の経路を通す

🌐 他言語との比較

言語・FWSIGTERM の受け方終了時の後始末今日の Go との違い
Go(今日)NotifyContext → ctx。受けた後の順は自分で書くdefer。os.Exit は飛ばす何もしない代わりに、しないことが文書にある
JavaRuntime.addShutdownHook(Thread)。SIGTERM・System.exit の両方で走る(記憶)shutdown hook は System.exit でも走る。Runtime.halt は走らない(記憶)Go の os.Exit は Java の halt に近い。exit に当たるものは無い
Spring Bootserver.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase。readiness は ReadinessState.REFUSING_TRAFFIC に自動で切り替わる(記憶)フレームワークが段階を持つDrain に当たる待ちは Kubernetes の preStop に任せる案内(記憶)
Node.jsprocess.on('SIGTERM', …)。登録すると既定の終了が外れる(Go の Notify と同じ)process.exit() は beforeExit を走らせない(記憶)イベントループなので「取りこぼし」は起きにくいが、process.exit で書き込み途中のストリームを失う点は B2 と同じ
Pythonsignal.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 と /eventsHTTP/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 にテーマが進む前に「このテーマで決めたこと/決めなかったこと」を整理する

🎯 自己評価(解いた後に記入)

見つけたバグ(5 つのうちいくつ、隠れていた B5 に気付いたか)

P1・P2・P3 の予想と答え合わせ

気づき・メモ