メインコンテンツへスキップ
  1. Posts/

コンテナ・デバッグの実践ワークフロー

·913 文字·5 分·
Go Debugging Kubernetes Observability
アミット・デイブ
著者
アミット・デイブ
ソフトウェアエンジニアで、しっかりしたシステムを作るのが得意です。難しい問題をわかりやすくすることも得意です。好きなことは、分散システムクリーンアーキテクチャ、そしてオープンソースプロジェクトです。仕事以外では、山を歩くことや日本語の勉強、そしてstderrなどから学ぶことを楽しんでいます。
目次
Go in Containers - この記事は連載の一部です
パート 4: この記事

君はもう理論を読んだ。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 podLast 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.22e18math.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

(行は抜粋。)ランタイムの取り分を読んでみよう。マークワーカー(gcBgMarkWorkergcDrain — 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 桁のミリ秒は、カーネルがポーズを引き伸ばしたということだから、まず先にスロットリングのカウンターを確認しろ。

オンコール・チェックリスト
#

  1. kill を確認する: exit 137 だけじゃなく、エンジンの状態(oom=true / Reason: OOMKilled)を見る。
  2. 起動時のログ行を読む: GOMAXPROCS と limit を比べる。ズレていたら → opt-out 探し。cgroup の発掘作業じゃない。
  3. sentinel を確認する: go_gc_gomemlimit_bytes = 9.22e18 なら未設定 → パート 2 の話。設定しろ(limit の 80〜90% を出発点にして、そこから非ランタイムのギャップを測る)。
  4. throttling のカウンターが上がっているなら → パート 3 の話。GC のつまみを触る前に、quota / GOMAXPROCS / アロケーション速度を直せ。
  5. リークのトリアージ: ランタイム側は sys_bytes の推移、犯人探しは 2 回の inuse_space スナップショット、churn なら alloc_space、大量に止まったゴルーチン軍団なら goroutine profile。そして、ランタイムには page cache が見えないことを忘れるな。
  6. 詰まった、シェルもない、打つ手なし: 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. コンテナ・デバッグの実践ワークフロー

Go in Containers - この記事は連載の一部です
パート 4: この記事

関連記事

GC ポーズとレイテンシ:時間は実際どこに隠れているのか
·664 文字·4 分
Go Kubernetes Containers Garbage-Collection Latency
圧力下でのガベージコレクション
·851 文字·4 分
Go Kubernetes Containers Garbage-Collection Memory
GOMAXPROCS の謎を解く
·870 文字·5 分
Go Kubernetes Containers Scheduler Performance
AIエージェントのタスク管理、GitHub Issuesでやってみた
·678 文字·4 分
Ai Go Automation Github Devops
配管地獄を止める:`bs` を作っている話
·80 文字·1 分
Go Devops Build-Systems Bs Engineering