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

圧力下でのガベージコレクション

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

これは 512 MiB のコンテナの中で死んでいく Go プログラムだ — 語り手は、そのプログラム自身の GC トレース。ワークロードはわざと退屈にしてある — 260 MiB の生存データを保持したまま、1 MiB ずつゴミを作っては捨てる(churn)。セットアップも同じくらい退屈だ:go1.26.4、静的な linux/arm64 バイナリ、podman 上の alpine に --memory=512m(cgroup v2、6 P)。この記事のすべての数字は、この箱の中での実行から来ている。

gc 6 @0.027s 0%: ... 163->164->164 MB, 166 MB goal, 0 MB stacks, 0 MB globals, 6 P
gc 7 @0.142s 0%: ... 325->325->262 MB, 328 MB goal, 0 MB stacks, 0 MB globals, 6 P
[churn  250] heapAlloc=447MiB heapSys=451MiB sys=457MiB numGC=7
[churn  300] heapAlloc=497MiB heapSys=499MiB sys=505MiB numGC=7

終了コードは 137 — そして numGC を見てほしい。7 で凍りついたままだ。最後のコレクションと kill の間に、ヒープは 262 MiB から 497 MiB まで登ったのに、コレクタは何もしなかった。自分の計算では、何もしなくてよかったからだ:gc 7 の後の生存ヒープが 262 MB なら、次のコレクションはヒープがだいたい倍の約 524 MiB になるまで発動しない(この値はペーサーの式から計算したもの — トレースには一度も出てこないし、このプロセスはそれを見るまで生きられなかった)。最後の統計行の時点で、ランタイムは「まだ 27 MiB くらいの滑走路(ランウェイ=残りの余裕)がある」と信じていた。コンテナの天井は 512 MiB。そしてカーネルは交渉しない。

このシリーズのパート 1では、Go 1.25 がついに GOMAXPROCS にデフォルトで cgroup の CPU quota を読ませるようになった話をした。メモリは、その物語のまだ出荷されていない半分だ — Go 1.26 の時点でも、ランタイムは memory.max を見ない。つまり、同じプロセスを二人の管理者が、一度も会話しないまま動かしている:生存ヒープに比例する GC トリガーと、絶対値であるカーネルの limit だ。

Go はいつ回収するかをどう決めるか
#

GC ガイドがトリガーの式をそのまま載せている:

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

(GC ルート — ゴルーチンのスタックとグローバル変数 — が式に入るのは「Go 1.18 以降のみ」だ。)デフォルトの GOGC=100 でルートセットが小さければ、これは単純化してこうなる:前回の生存セットからヒープが倍を超えたら回収する。固定の予算でもなく、マシンの何割かでもなく — 比率だ。GODEBUG=gctrace=1 を付けて、生存 200 MiB に向かって育てれば、ローカルでその動きを見られる:

gc 1 @0.001s 7%: ... 4->4->4 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 4 @0.005s 4%: ... 36->36->36 MB, 36 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 7 @0.077s 0%: ... 277->277->202 MB, 280 MB goal, 0 MB stacks, 0 MB globals, 12 P

ゴールは 4 → 9 → 18 → 36 → 72 → 142 → 280 MB と歩いていく — プログラムが全部を生かし続けている間の、教科書どおりの倍々だ。(stacksglobals のフィールドが、さっきの式に出てきた GC ルート。1.18 のペーサー再設計から出力されるようになった。ここではほぼ 0。)277->277->202 MB の三つの数字は、トリガー時のヒープサイズ、マーク終了時のサイズ、その後の生存量 — そして最後の数字が、次のゴールの種になる。ラップトップの上では、このスケジュールはただの背景だ。コンテナの中では、壁があることをまったく知らない何かが計算するカウントダウンになる。

カーネルはいつ殺すかをどう決めるか
#

Kubernetes の resources.limits.memory は cgroup v2 の memory.max になり、使用量は memory.current に積み上がる — これは cgroup 全体を数える:匿名メモリ(anonymous memory)、ページキャッシュ、dentry や inode といったカーネル構造、TCP ソケットバッファ。アロケータが頼んだ分だけではない。カーネルの cgroup-v2 ドキュメントは結末をはっきり書いている:「cgroup のメモリ使用量がこの limit に達し、減らせない場合、その cgroup の中で OOM killer が呼び出される」。

「減らせない場合」に注目 — カーネルはまず回収できるものを回収し(誰かが死ぬ前に、クリーンなページキャッシュが絞られる)、それからやっと殺す。送られるのは SIGKILL。だから終了コードは 137:128 + シグナル 9 だ。警告なし、ハンドラもなし……君の defer が目にすることは、永遠にない。

衝突の実録
#

冒頭のトレースに戻ろう。今度はメカニズムが見えている。コンテナの中では、ゴールは 4 → 9 → 20 → 40 → 84 → 166 → 328 MB と歩いた — 倍々の形は同じで、値が少し違うのは、育っていく生存セットに churn のゴミが相乗りしているからだ。そして gc 7。実行開始から 0.142 秒、生存 262 MB で終わった。これを式に入れると、次のターゲットは約 524 MiB — 512 MiB の天井のに落ちる。その瞬間から、このプロセスは自分の GC には救えず、カーネルに殺されることが確定した。さらに 235 MiB のゴミを処理し続け、sys は 505 MiB まで登り、OOM killer が勘定を締めた。

これが何でないかを正確にしておく価値がある:リークではない。スパイクでもないし、断片化(フラグメンテーション)でもない。ランタイムが次のコレクションをコンテナ limit の向こう側に予定したのは、limit があることを誰も教えなかったからだ。

GOMEMLIMIT:自分で開けなければならない安全弁
#

今度は同じバイナリ、同じコンテナに、環境変数を一つだけ足す — GOMEMLIMIT=410MiB、512 MiB の 80% だ。(正直に一つ注記:この実行はハーネスの churn モードを 2,000 回のイテレーションで回している。ちゃんと終了して終了コードを見せられるようにするためだ。OOMKill の実行は grow モードだった — 1 回あたりのアロケーションのパターンは同じで、実行の長さが違う。)

gc 7 @0.074s 0%: ... 293->293->262 MB, 296 MB goal, ... 6 P
gc 8 @0.270s 0%: ... 388->388->262 MB, 392 MB goal, ... 6 P
...
gc 22 @3.133s 0%: ... 388->388->262 MB, 392 MB goal, ... 6 P

これは「limit に近づくと GC がより攻撃的になる」という俗説(よくある誤解)バージョンではない。メカニズムはもっと単純で直接的だ:limit はゴールを下げる。GOGC の比例計算は相変わらず約 524 MiB を欲しがる。メモリ limit は自分の天井を約 392 MB と計算し(410 MiB からランタイムの非ヒープメモリを引いた値)、小さい方の数字が勝つ — gc 8 以降のすべてのコレクションが 388 MB で発動する。メトロノームみたいに。7 回の代わりに 22 回のコレクション、sys は 401 MiB に張り付いたまま、2,000 MiB のゴミを処理し(保護なしの実行が生き延びた長さのおよそ 6 倍)、そして SURVIVED、終了コード 0。

GOMEMLIMITGo 1.19 で出荷された。GOGC を置き換えるのではなく、組み合わさる — リリースノートは「GOGC=off でも尊重される」と指摘している。GC ガイドはさらに踏み込む:GOGC=off とメモリ limit の組み合わせは「リソース経済の最大化を表す」— 私に言わせれば、これでヒープバラスト・ハックは引退だ。

パート 1 の後だと、ここが痛い。Go 1.25 は GOMAXPROCS をデフォルトで cgroup 対応にした。しかしメモリ側の同等品は、Go 1.26 の時点で存在しない:cgroup limit から GOMEMLIMIT を導出する提案(golang/go#75164)はまだオープンのままで、1.25 と 1.26 のリリースノートはどちらもこれについて沈黙している。コミュニティのつなぎは automemlimit で、デフォルトで GOMEMLIMIT を cgroup limit の 90% に設定する。提案が着地するまで、CPU は自分で自分を設定し、メモリは君の仕事だ。

設計としての「ソフト」
#

ドキュメントは GOMEMLIMIT をソフト limit と呼ぶ。そしてこの一語が、まったく違う二つの仕事をしている — どちらも実演できる。

顔その一:limit は暴れるのではなく、譲る。同じ 410 MiB の limit の下で 450 MiB を生存させてみる — 生存セットだけで天井を超えている — と、ランタイムの応答はオーバーシュートだ(sys は limit を超えた 465 MiB に落ち着く)。同時に、ほぼ連続でコレクションを回す:トレース 0.36 秒の間に 247 回の GC、途切れなく、ゴールは生存セットにぴったり張り付いたまま。これで生き延びられる理由は、ドキュメントに書かれた上限だ:GC の CPU は「おおよそ 50%、2 * GOMAXPROCS CPU 秒のウィンドウで」制限される。だから最悪の場合でも「プログラムは最大 2 倍遅くなる」だけで、純粋なコレクション地獄に落ちていくことはない。この実行はずっと前進し続けて、終了コード 0 で終わった。劣化はしたが、生きている。

顔その二:これは盾ではない。同じセットアップに 560 MiB — コンテナそのものより大きい — を生存させろと頼むと、GOMEMLIMIT は意味のあることを何一つ変えない:84 回の無駄なコレクションの末、ヒープ約 507 MB で殺されて、結局終了コード 137。箱より大きい生存セットはプロビジョニングの問題で、プロビジョニングを直してくれる環境変数は存在しない。シートベルトであって、大きい車ではない。

GOMEMLIMIT が数えるもの — そして見えないもの
#

SetMemoryLimit のドキュメントが会計のルールを定義している:「Go ランタイムがマップし、管理し、解放していないすべてのメモリ」— 実質的に MemStats.Sys − HeapReleased だ。これにはゴルーチンのスタックが含まれる。コンテナの中で runtime/metrics から確認できる。2,000 個のゴルーチンをチャネルの上で待たせると:

/memory/classes/heap/objects:bytes                          65.3 MiB
/memory/classes/heap/stacks:bytes                           16.2 MiB
/memory/classes/total:bytes                                 92.9 MiB

これは、デフォルトサイズのスタック 2,000 本(1 本あたり約 8.3 KiB)が、limit の管理する合計の中に座っているということだ — だから、スタックを GOMEMLIMIT のに予算として積む必要はない。ドキュメントが除外しているのは、ランタイムが決して触らないもの:バイナリ自身のマッピング、cgo 経由の C コードのメモリ、syscall.Mmap、そしてプロセスのために OS カーネルが持っているメモリだ。

一方、cgroup は Go に API のないものまで数える。コンテナの中から 300 MB のファイルを書いてみる:

before dd, memory.current: 946176
after 300MB file write, memory.current: 315764736
anon 53248
file 314572800

カウントされたメモリは 0.9 MB から 315.8 MB へ — ほぼ全部がページキャッシュだ — その間、Go ランタイムから見た世界は一ミリも動いていない。救いは、クリーンなページキャッシュは回収可能なことだ:カーネルは OOM killer を呼ぶ前にそれを絞る(さっきの「減らせない場合」の話だ)。だから、キャッシュだけで殺されることは滅多にない。危険なのはダーティページと tmpfs — 同じようにカウントされて、しかも捨てられない。

予算の話:80% ルールを、正直に
#

コンテナ limit の下に、GOMEMLIMIT はどれだけのヘッドルーム(余裕)を必要とするのか?世間には三つの答えがあって、しかも同じ数字ではない:

  • GC ガイドは「Go ランタイムが把握していないメモリ源を考慮して、追加の 5〜10% のヘッドルームを残せ」と言う — そして、まさにコンテナのケースのために limit を推奨している。
  • automemlimit はデフォルトとして 90% を出荷している。
  • 経験則は 80% と言う — 救出の実行が使った値だ(512 MiB の箱に 410 MiB)。一次情報源は存在しない。法則ではなく、保守的な開始点として扱うこと。

この記事の旧バージョンは、メモリの 5〜10% が「アロケータのオーバーヘッド」に消えると主張していた。測ってみると、それは成立しなかった:約 453 MiB のヒープで、sys − heapAlloc は約 12 MiB — 5〜10 ではなく 2〜3% だ(ワークロード依存。/memory/classes/* で確認を)。本当の予算の問いは、アロケータの遊び(スラック)ではまったくない — 君の非ランタイムメモリだ:I/O が生むページキャッシュ、cgo、mmap。ヘッドルームはそのためにある。そして測定できるのだから、測れ。

二つのつまみは補完関係のままだ:

レバー 制御するもの 使いどころ
GOGC GC がどれくらいの頻度で走るか(比例で) CPU とメモリのトレードオフ
GOMEMLIMIT ゴールが越えられない天井 memory.max 近くの危険地帯

コードの中ではなく環境変数として設定する。リビルドなしでチューニングできるように:

env:
  - name: GOMEMLIMIT
    value: "410MiB"   # 512MiB コンテナ limit の 80% — 開始点
  - name: GOGC
    value: "100"

そしてワークフロー。雰囲気から測定へのアップグレード版だ:

  1. ベースラインGOGC=100、limit なし、開発用コンテナで。
  2. 測定:実負荷の下で。ランタイム側は /memory/classes/*、cgroup 側は memory.current。その差が、君の見えないメモリだ。
  3. 予算:ピーク時のランタイムメモリ + その差 + マージン = コンテナ limit。
  4. 保護:GOMEMLIMIT を limit の 80〜90% に。ステップ 2 の結果で調整する。
  5. 最適化:GC の CPU が高すぎるなら、GOGC を上げて、GOMEMLIMIT には最後の砦(とりで)をやらせる。

自分で再現する
#

ハーネスは 150 行未満だ — パッケージレベルの [][]byte を N MiB まで育て、1 MiB のチャンクを churn し、途中経過の MemStats を出力する:

GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o alloc-linux .
podman run --rm --memory=512m -v "$PWD":/app -e GODEBUG=gctrace=1 \
  docker.io/library/alpine:latest /app/alloc-linux -mode=grow -retain=260
# grow = 殺されるまで実行。救出には -e GOMEMLIMIT=410MiB と -mode=churn を追加
# (生き延びれば終了コード 0)

結果を静かに偽ってくれる罠が一つ:保持セットは必ずパッケージレベルの変数に置くこと。ローカルなスライスに置くと、生存解析(liveness analysis)のせいで、最後に使われた後は GC が回収できてしまう — そして君の「救出」実行は合格する。limit が働いたからではなく、生存セットが静かに消えたからだ。

意見としてのまとめ
#

コンテナで動かしているすべての Go サービスに、今日、環境変数として GOMEMLIMIT を設定しろ。コンテナ limit の 80〜90% から始めて、最終的な数字は比率ではなく測定に選ばせる。正しいヘッドルームとは、君の非ランタイムメモリそのもの — それ以外の何ものでもないからだ。ランタイムが代わりにやってくれるのを待つな:CPU は 1.25 で cgroup 対応のデフォルトを手に入れた。メモリの提案(#75164)はまだオープンだ。そして、希望はペーサーではない。

そして、二つの故障モードを混同しないこと。必要な直し方が違うからだ。numGC が凍りついたままヒープが kill ラインまで登ったなら、それはスケジューリングの隙間 — GOMEMLIMIT が完全に解決する。GC が絶え間なく発動して、それでも 137 をもらったなら、生存セットが箱に収まっていない。直すのはメモリかアーキテクチャで、チューニングではない……シートベルトであって、大きい車ではない。

ただし、これには請求書が来る。救出は 7 回のコレクションを 22 回と交換したし、オーバーシュートの実行は 0.5 秒足らずで 247 回も回収した — GC の頻度は、どこか別の場所で支払うレイテンシだ。そして Go 1.26 の新しいデフォルトコレクタ(Green Tea)は、その計算も変えてくる。それが次回だ。


次回、「コンテナの中の Go」シリーズ:GC ポーズとレイテンシ — あの追加のコレクション全部が、どこで君にコストを払わせるのか。そしてポーズは実際、どこに隠れているのか。


「コンテナの中の Go」シリーズ: イントロ — サイレント・クライシス · 1. GOMAXPROCS の謎を解く · 2. 圧力下でのガベージコレクション · 3. GC ポーズとレイテンシ · 4. コンテナ・デバッグの実践ワークフロー

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

関連記事

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
そのプライマリキーがボトルネックだ(スケーリングじゃ直らない)
·701 文字·4 分
Postgresql Performance B-Tree Uuid Database-Design
Goでモジュラーモノリスを設計する:構成、境界、実践パターン
·529 文字·3 分
Go Architecture Golang Modular-Monolith Architecture