君はもう理論を読んだ。GOMAXPROCS の丸めロジックも、GOMEMLIMIT という安全弁も、ポーズが実際どこに隠れているかも知っている。でも今、君の目の前にあるのは本番のアラートだ — Pod X が 4 時間ごとに Exit Code 137 で再起動している。
証拠は 3 つの層に分かれている — Go ランタイム、コンテナエンジン、そして Linux カーネル — デバッグとは、この 3 つを結びつける作業だ。この記事はそのワークフローで、シリーズの他の記事と同じく、中のコマンドは全部実際に実行した:go1.26.4、FROM scratch イメージの中の静的バイナリ、podman、cgroup v2。Kubernetes とこの podman 環境で違うところは、その都度書く。
ステップ0:シェルなしコンテナから信号を取り出す #
最初にぶつかる壁がこれで、大抵のガイドはこの壁がないふりをする。本番用の Go イメージは scratch か distroless だ — シェルもない、coreutils もない。定番の最初の一手は、始める前から失敗する:
$ podman exec dbgdemo cat /sys/fs/cgroup/cpu.max
Error: crun: executable file `cat` not found in $PATH: No such file
or directory: OCI runtime attempted to invoke a command that was not found
exit: 127
cat もない、sh もない、何もない。抜け道は 3 つあって、最初の 2 つはインシデントが起きる前に仕込んでおくべきだ:
1. 起動時にバイナリ自身に白状させる。 ログを 1 行仕込むだけで、再起動のたびにランタイムの「世界の見え方」が記録される:
log.Printf("GOMAXPROCS=%d NumCPU=%d", runtime.GOMAXPROCS(0), runtime.NumCPU())
この箱で --cpus=1 を付けて起動すると:
2026/07/05 21:49:35 GOMAXPROCS=2 NumCPU=6
この 1 行だけで、exec アクセスがゼロのまま、パート 1 の「下限ルール」(quota 1 → max(⌈1⌉, 2) = 2)が確認できる。もしこの行が 1 CPU limit の下で GOMAXPROCS=32 と言っていたら、それは cgroup のバグじゃない — 1.25 より前のツールチェーンか、パート 1 で紹介した「サイレントな opt-out」のどれかだ(誰かの init コードに書かれた runtime.GOMAXPROCS(runtime.NumCPU())、ベースイメージの環境変数、とか)。カーネルじゃなくて、自分の依存関係のツリーを grep しろ。
2. SIGQUIT — 最初から入っているデバッガー。 Go ランタイムは SIGQUIT を受け取ると、全ゴルーチンのスタックをダンプしてから終了する(runtime のドキュメント、GOTRACEBACK)。シェルは要らない — 外からシグナルを送るだけでいい:
$ podman kill --signal=QUIT dbgdemo
$ podman logs dbgdemo
SIGQUIT: quit
PC=0x9e144 m=0 sigcode=0
goroutine 9 gp=0x6b89eb964000 m=0 mp=0x9b7f60 [running]:
runtime.memclrNoHeapPointers()
/opt/homebrew/Cellar/go/1.26.4/libexec/src/runtime/memclr_arm64.s:180 ...
runtime.mallocgc(0x100000, 0x46d860, 0x1)
runtime.makeslice(0x46d860?, 0x100000?, 0x100000?)
main.churn(...)
全ゴルーチン、全スタックのフルダンプが、コンテナのログに出てくる(podman logs に注目 — kill 自体は何も表示しない)— ファイルが 1 個しか入っていない scratch イメージからだ。しかも見てほしい、何を捕まえたか:churn しているゴルーチンが [running] のまま、makeslice の真っ最中、0x100000 バイトの新しいバッファをゼロで埋めているところだ。シグナル 1 発で、アロケーションのホットループが行番号つきで録画された。ただしプロセスは殺される(exit 2)ので、健康な Pod に対しては最後の手段だし、詰まった(wedged)Pod に対しては無料の検死解剖になる。Kubernetes では:kubectl exec はシェルなしだと使えないし、kubectl そのものに SIGQUIT を送る方法はない — Pod の削除で送られるのは SIGTERM、その後の SIGKILL だけだ。じゃあ何が使えるか —
3. Ephemeral debug container。 kubectl debug -it <pod> --image=busybox:1.36 --target=<container> は、動いている Pod の PID namespace に道具箱コンテナをくっつける(Kubernetes のドキュメント — 1.25 から GA。--target で process namespace を共有するから、中から Go のプロセスが見える)。これで、シェルなしコンテナの隣にシェル付きコンテナができて、その中から cat /sys/fs/cgroup/cpu.max が普通に動く。この箱にはクラスターがないから、正直に言うと、ここだけはドキュメントからの引用で、俺が実際に実行したコマンドじゃない — この後に出てくる podman の同等コマンドは、俺が自分で確認したものだ。
ステップ1:まずひと目 — 137 が本当に証明すること #
Exit code 137 は 128 + 9、つまり SIGKILL だ。SIGKILL を送るのは OOM killer だけじゃない — kubelet の eviction でも、liveness probe が失敗して再起動がエスカレートしたときでも、docker stop が grace timeout を超えたときでも同じシグナルが飛ぶ。137 はヒントに過ぎない。本当の答えは、エンジンが記録しているコンテナの状態にある:
$ podman inspect dbgdemo --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}'
exited exit=137 oom=true
この oom=true(Kubernetes なら kubectl describe pod → Last State: Terminated, Reason: OOMKilled)が本当の判定だ — そしてこれは正真正銘の OOMKill:GOMEMLIMIT なしで --memory=512m の下で 1 MiB のアロケーションを churn し続けたデモアプリが、1 秒もたたずに死んだ。パート 2 で見た失敗モードそのままだ。
再起動のリズムも、もう一つのタダで手に入る信号だ:安定したトラフィックの下で数時間おきに再起動するなら、リークか、limit へ向かうゆっくりした成長の匂いがする。トラフィックのスパイクに合わせて再起動するなら、容量不足の匂いがする。どちらも診断のやり方は同じだ — このまま読み進めてほしい。
ステップ2:ランタイムの「世界の見え方」を確認する #
確認するのは 2 つの数字、2 つのチェックで、どちらも exec なしでできる:
- GOMAXPROCS — さっきの起動時ログの行を、limit と突き合わせる。ズレていたら opt-out 探しだ(全リストはパート 1 に)。
- GOMEMLIMIT — ここで、タダで手に入るダッシュボードの技を一つ。デフォルトの Prometheus Go collector(client_golang)が、もうこれをエクスポートしている:
go_gc_gomemlimit_bytes 9.223372036854776e+18
これはこのデモから実際にスクレイプした値だ。9.22e18 は math.MaxInt64 — つまり**「未設定」を意味する番人(sentinel)の値**。ダッシュボードにこの数字が出ているなら GOMEMLIMIT は設定されていない、その先どうなるかはパート 2 で書いた通りだ。ちゃんと設定された Pod なら実際のバイト値が出る。この sentinel にアラートを張れ — これほど安上がりな設定ミス検知器はほかにない。
ステップ3:pprof — みんなが必ずやる 2 つの間違い #
pprof は、狙って外に出す:import _ "net/http/pprof" して、localhost だけにバインドした別ポートで待ち受ける — サービス本体のリスナーに乗せるのは絶対にダメだ。このエンドポイントはヒープの中身を漏らすし、DoS の的にもなる。アクセスするときは kubectl port-forward(か ephemeral debug container の中から)を使う。
import _ "net/http/pprof"
go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
間違い1:heap profile を allocation profile として読んでしまう。 この churn デモ — ホットループの中で 1 MiB のバッファを確保し続けるゴルーチン — を、デフォルトの heap view で見るとこうなる:
$ go tool pprof -top http://localhost:6060/debug/pprof/heap
Type: inuse_space
flat flat% sum% cum cum%
224.36MB 100% 100% 224.36MB 100% main.churn
この出力が片付けてくれることが 2 つある。heap profile がメモリの持ち主として名指しするのは自分のコードの呼び出し箇所 — main.churn、名前も晒されて — であって runtime.mallocgc じゃない(mallocgc は表にすら出てこない)。もしどこかのガイドが「heap profile で mallocgc の増加を見ろ」と言っていたら、それは CPU profile と混同している。そして inuse_space が見せてくれるのは今この瞬間に保持されているもの — このデモがわざと抱え続けている 218 MB と、sweeper がまだ返していない数 MiB の churn したてのゴミだ。churn した何テラバイトもの短命なゴミは、ここには映らない。それが見えるのは allocation view の方だ:
$ go tool pprof -sample_index=alloc_space -top http://localhost:6060/debug/pprof/allocs
Type: alloc_space
18.63TB 100% 100% 18.63TB 100% main.churn
生涯で確保した 18.63 テラバイト(このスナップショットは churn 開始から約 2 分後のもの)対、今使っている 224 MB — この比率こそが「churn している」という状態の見た目だ。リーク探しは inuse_space を使って、時間をおいた 2 回のスナップショットを比べる。GC 圧力探しは alloc_space を使う。同じエンドポイントの仲間だけど、聞いている質問が違う。
ゴルーチンリークは 3 つ目の質問だ — スタックは heap profile の外側にまるごと住んでいる。go_goroutines が際限なく増え続けているなら、/debug/pprof/goroutine?debug=1 を取って、同じソースコードの行で何千個ものゴルーチンが止まっていないか見てみろ。
それと RSS とヒープの差について:pprof のヒープは小さいのに cgroup の使用量が巨大なとき、いきなり「cgo リークだ」と決めつけるな。パート 2 では、300 MB のファイル書き込みが memory.current を 315 MB 動かした一方で、Go ランタイムには何も見えていなかった — page cache、tmpfs、syscall.Mmap、ゴルーチンのスタック、これ全部が heap profile に映らない容疑者だ。cgo もそのリストの中の一つであって、リストの全部じゃない。
間違い2:CPU profile の中でスロットリングを探してしまう。 Green Tea(go1.26.4)の下でこの churn デモを取った CPU profile:
$ go tool pprof -top -seconds 5 http://localhost:6060/debug/pprof/profile
Duration: 5s, Total samples = 4.99s (99.80%)
flat flat% sum% cum cum%
3.64s 72.95% 72.95% 3.64s 72.95% runtime.memclrNoHeapPointers
0.04s 0.8% 88.58% 0.13s 2.61% runtime.scanObject
0.02s 0.4% 90.38% 4.23s 84.77% main.churn
0.01s 0.2% 92.99% 4.21s 84.37% runtime.mallocgc
0 0% 93.39% 0.55s 11.02% runtime.gcBgMarkWorker
(行は抜粋。)ランタイムの取り分を読んでみよう。マークワーカー(gcBgMarkWorker → gcDrain — GOMAXPROCS=2 の 25% では専用ワーカーがもらえないから、gcDrainMarkWorkerFractional として走っている)が約 11%。Green Tea の scanObject は 2.6% — 1 MiB のバイトスライスはポインタを持たないから安い。まさにパート 3 で言った通りだ。そして本当の請求書は cum 84% の mallocgc で、そのほとんどが新しいバッファをゼロで埋める memclrNoHeapPointers だ。これはコレクタとアロケータが君の quota を使い込んでいるということ — アロケーションの圧力で、パート 2 の領域だ。でも、この profile がどうしても見せてくれないのがスロットリングだ:pprof がサンプリングするのはCPU の上で動いている時間だけで、カーネルに凍らされている時間は CPU の外の出来事だから見えない。スロットリングの判定は、カーネル自身の台帳から来る。次はそれだ —
ステップ4:カーネルの自白 #
コンテナの cgroup の中から(k8s なら ephemeral container 経由、podman なら直接):
$ cat /sys/fs/cgroup/cpu.stat # during a --cpus=0.5 run of the churn demo
nr_periods 123
nr_throttled 122
throttled_usec 2430155
123 期間中 122 期間でスロットルされている — 12 秒のウィンドウの中で、2.4 秒分が没収された。パート 3 で見た 84ms のポーズのテールを作ったのと同じ飢餓のメカニズムを、カーネル側の机から見た姿だ。Kubernetes なら、同じ台帳を cAdvisor の container_cpu_cfs_throttled_seconds_total として継続的に取れる — このレートがゼロじゃないタイミングでポーズのヒストグラムが跳ねているなら、その相関こそが診断そのものだ。
ステップ5:寝てる間に全部捕まえてくれるダッシュボード #
全部デフォルトの client_golang collector(実際にスクレイプして確認済み)と cAdvisor だけで揃う — カスタムの計装は要らない:
| メトリクス | 教えてくれること | 見るべきサイン |
|---|---|---|
go_sched_gomaxprocs_threads |
ランタイムが実際に選んだ値 | CPU limit から期待される値と≠(パート 1) |
go_gc_gomemlimit_bytes |
設定されている GOMEMLIMIT | 9.22e18 = 未設定の sentinel |
go_memstats_sys_bytes |
ランタイム全体のメモリ — GOMEMLIMIT が支配するもの | コンテナの limit に向かって近づいていないか |
go_goroutines |
並行度 | 際限なく増え続ける = リーク。goroutine profile を取れ |
go_gc_duration_seconds |
STW ポーズのサマリー | P99 が数十ミリ秒なら、GC じゃなくてスロットリング(パート 3) |
container_cpu_cfs_throttled_seconds_total |
カーネルによるスロットリング | 0 より大きいレートが続いていないか |
この表の「俗説バージョン」に、訂正を 2 つ。GOMEMLIMIT を比べる相手は go_memstats_sys_bytes であって、heap_alloc ではない — この limit が支配するのはランタイム全体のメモリだ(パート 2 で測った、その 2 つの間の約 12 MiB のギャップは、まさに俗説アラートが消してしまうマージンだ)。それと、P99 の GC「時間」が 50ms だったとして、それは GC の問題じゃない — 健全なポーズはマイクロ秒単位だ(パート 3 で測定済み)。2 桁のミリ秒は、カーネルがポーズを引き伸ばしたということだから、まず先にスロットリングのカウンターを確認しろ。
オンコール・チェックリスト #
- kill を確認する: exit 137 だけじゃなく、エンジンの状態(
oom=true/Reason: OOMKilled)を見る。 - 起動時のログ行を読む: GOMAXPROCS と limit を比べる。ズレていたら → opt-out 探し。cgroup の発掘作業じゃない。
- sentinel を確認する:
go_gc_gomemlimit_bytes= 9.22e18 なら未設定 → パート 2 の話。設定しろ(limit の 80〜90% を出発点にして、そこから非ランタイムのギャップを測る)。 - throttling のカウンターが上がっているなら → パート 3 の話。GC のつまみを触る前に、quota / GOMAXPROCS / アロケーション速度を直せ。
- リークのトリアージ: ランタイム側は
sys_bytesの推移、犯人探しは 2 回のinuse_spaceスナップショット、churn ならalloc_space、大量に止まったゴルーチン軍団なら goroutine profile。そして、ランタイムには page cache が見えないことを忘れるな。 - 詰まった、シェルもない、打つ手なし: SIGQUIT。検死解剖はタダだ。
意見としてのまとめ #
- インシデントの前に計装しておけ。 起動時のログ行、localhost の pprof ポート、デフォルトの Prometheus collector — 合わせてたった 5 行くらいのコストで、この先の深夜 3 時が「探検」じゃなくて「ただの調べ物」に変わる。
- 137 はヒント。
OOMKilledが判定だ。 常にエンジンが記録した状態を読め。 - Heap ≠ allocations。 リークには
inuse_space、churn にはalloc_space— そして一番上の行にいるのは自分のコードであってmallocgcじゃない。 - pprof にスロットリングは見えない。 サンプリングするのは CPU の上の時間だけだ。カーネルの
cpu.statは嘘をつけない台帳 — 122/123 期間、上のログの通り。 - 9.22e18 という sentinel にアラートを張れ。 存在する中で一番安上がりな GOMEMLIMIT の監査だ。
- SIGQUIT は、どんなイメージにも最初から入っているデバッガーだ、
FROM scratchでも同じ。インストールするものは何もない、読むものは全部ある。
コンテナの中の Go をデバッグするというのは、結局は相関を取る作業だ:ポーズのスパイク × throttle のカウンター = quota の問題。OOMKill × 未設定の sentinel = 予算の問題。cgroup の使用量は大きいのにヒープは小さい = ランタイムの外を見ろ、というサイン。ランタイムも、エンジンも、カーネルも、それぞれ嘘のない帳簿をつけている — ワークフローとは、その 3 冊全部を読むことに過ぎない。
これでシリーズは終わりだ。コンテナの中の Go バイナリというのは、3 つのシステムが 1 つのふりをしているだけのものであって、4 本の記事は全部、その継ぎ目についての話だった:CPU の会計(パート 1)、メモリの会計(パート 2)、その両方が生むレイテンシの請求書(パート 3)、そして今回、本番でその継ぎ目を読むための道具箱。教訓はいつも同じだ — デフォルトは賢くなったけど、全部が見えるわけじゃない。ランタイムが仮定していることと、カーネルが強制することの間のギャップ、そこに君が深夜に呼び出される理由が住んでいる。
「コンテナの中の Go」シリーズ: イントロ — サイレント・クライシス · 1. GOMAXPROCS の謎を解く · 2. 圧力下でのガベージコレクション · 3. GC ポーズとレイテンシ · 4. コンテナ・デバッグの実践ワークフロー