インフラ・DevOps
Kubernetes 1.36におけるkubeletのメモリリークの修正
Fixing a kubelet memory leak in Kubernetes 1.36 (heyoncall.com)
要約
この記事は、Kubernetes 1.36におけるkubeletのメモリリーク問題の解決について解説しています。筆者は、自身の小規模なKubernetesクラスターでメモリ不足アラートが発生したことをきっかけに調査を開始。Goのpprofツールを用いて問題の根本原因を特定し、contextのライフサイクルに関するGoのバグを発見しました。最終的に、Kubernetesプロジェクトにパッチを提出し、問題の修正に貢献した経緯を詳しく説明しています。
全文翻訳
Kubernetes 1.36におけるkubeletのメモリリークの修正 2026年6月30日
Kubernetes kubeletのstartPodSyncごとにcontextをリークさせていた小さなGoのライフサイクルバグをどのようにして突き止めたか。
著者 マイク・ロビンズ
数週間前、私は小さなKubernetesクラスターからアラートを受け取り始めました。それは単一ノードのKubernetesテストクラスターで、本番ワークロードは何も実行していませんでした。私は最近、このクラスターをv1.36にアップグレードし、DigitalOceanのマネージドDOKSサービスでホストしていました。私の倹約が予期せぬ恩恵をもたらしました。この小さな(ええと、安価な!)2 GiB RAMノードは非常に高いメモリプレッシャーにさらされ、もしメモリが豊富であったなら発見に時間がかかったであろうKubernetes 1.36のより深い問題をすぐに明らかにしたのです。
アラートを調査した結果、Podが再起動されていることが判明しましたが、`kubectl top pods`では異常に大きなPodは表示されませんでした。ノードで実行されているアプリケーションはメモリの増加を経験しておらず、メモリ制限には遠く及んでいませんでした。Kubernetesの表層を剥がして、ノード自体のrootシェルを開き、短いhtopとMの後、kubeletプロセス自体が肥大化しており、成長していることをすぐに発見しました!ノードで`systemctl restart kubelet`を素早く実行すると、クラスターは再び正常になりましたが、根本的なリークはまだ存在しており、リークの原因を特定しない限りすぐに再発するでしょう。
kubeletのヒープをダンプする
KubernetesはGoで書かれており、kubeletはKubernetesの動作の核となるコンポーネントです。すべてのノードで実行され、そのノードのコンテナを目的のクラスター状態と同期させる責任があります。Goのpprofパッケージを使用すると、実行中のプロセスからヒープメモリプロファイルをキャプチャでき、それをファイルに保存しました。
`kubectl get --raw "/api/v1/nodes/${NODE}/proxy/debug/pprof/heap?debug=0" > "kubelet_pprof_heap.pb.gz"`
その後、`go tool pprof -top`を使用して、合計サイズとオブジェクトカウントの両方で何が起こっているかを確認できます。
オブジェクトカウント別:
```
go tool pprof -top -sample_index=inuse_objects kubelet_pprof_heap.pb.gz
flat flat% sum% cum cum%
642456 45.52% 45.52% 918672 65.09% context.(*cancelCtx).propagateCancel
380137 26.93% 72.45% 380195 26.94% context.withCancel (inline)
276216 19.57% 92.02% 276216 19.57% context.(*cancelCtx).Done
10923 0.77% 92.80% 10923 0.77% container/list.(*List).insertValue (inline)
10923 0.77% 93.57% 10923 0.77% container/list.New (inline)
10923 0.77% 94.34% 10923 0.77% golang.org/x/net/http2.(*clientConnReadLoop).handleResponse
10923 0.77% 95.12% 10923 0.77% google.golang.org/protobuf/internal/impl.consumeStringValueValidateUTF8
10923 0.77% 95.89% 10923 0.77% k8s.io/api/core/v1.(*VolumeMount).Unmarshal
10923 0.77% 96.67% 10923 0.77% os.(*File).readdir
4681 0.33% 97.00% 16833 1.19% k8s.io/apimachinery/pkg/watch.(*StreamWatcher).receive
19 0.0013% 97.00% 21865 1.55% k8s.io/utils/internal/third_party/forked/golang/golang-lru.(*Cache).Add
0 0% 97.00% 10923 0.77% container/list.(*List).PushFront (inline)
0 0% 97.00% 380195 26.94% context.WithCancel
0 0% 97.00% 918614 65.08% context.WithDeadline (inline)
0 0% 97.00% 918614 65.08% context.WithDeadlineCause
0 0% 97.00% 918614 65.08% context.WithTimeout ...
0 0% 97.00% 918206 65.06% k8s.io/apimachinery/pkg/util/wait.PollUntilContextTimeout ...
0 0% 97.00% 918215 65.06% k8s.io/kubernetes/pkg/kubelet.(*Kubelet).SyncPod ...
0 0% 97.00% 22742 1.61% k8s.io/kubernetes/pkg/kubelet.(*Kubelet).syncLoopIteration
0 0% 97.00% 1309333 92.77% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).UpdatePod.func1
0 0% 97.00% 1309333 92.77% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop
0 0% 97.00% 929138 65.83% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop.func1 (inline)
0 0% 97.00% 380195 26.94% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).startPodSync ...
0 0% 97.00% 907283 64.28% k8s.io/kubernetes/pkg/kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount
```
ヒープメモリ総使用量別:
```
go tool pprof -top -sample_index=inuse_space kubelet_pprof_heap.pb.gz
flat flat% sum% cum cum%
86.33MB 51.28% 51.28% 115.83MB 68.80% context.(*cancelCtx).propagateCancel
29.50MB 17.52% 68.80% 29.50MB 17.52% context.(*cancelCtx).Done
29MB 17.23% 86.03% 30.54MB 18.14% context.withCancel (inline)
1.66MB 0.99% 87.02% 1.66MB 0.99% google.golang.org/grpc/mem.(*sizedBufferPool).Get
1.50MB 0.89% 87.91% 2.50MB 1.49% github.com/google/cadvisor/container/libcontainer.newContainerStats
1MB 0.59% 88.50% 1MB 0.59% reflect.unsafe_New
1MB 0.59% 89.10% 1MB 0.59% github.com/google/cadvisor/container/libcontainer.diskStatsCopy
1MB 0.59% 89.69% 1MB 0.59% k8s.io/apimachinery/pkg/util/sets.Set[go.shape.string].Insert (inline)
1MB 0.59% 90.28% 1MB 0.59% internal/bytealg.MakeNoZero ...
0 0% 91.48% 30.54MB 18.14% context.WithCancel
0 0% 91.48% 114.29MB 67.89% context.WithDeadline (inline)
0 0% 91.48% 114.29MB 67.89% context.WithDeadlineCause
0 0% 91.48% 114.29MB 67.89% context.WithTimeout ...
0 0% 91.48% 103.52MB 61.49% k8s.io/apimachinery/pkg/util/wait.PollUntilContextTimeout ...
0 0% 91.48% 104.04MB 61.80% k8s.io/kubernetes/pkg/kubelet.(*Kubelet).SyncPod ...
0 0% 91.48% 135.08MB 80.24% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).UpdatePod.func1
0 0% 91.48% 135.08MB 80.24% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop
0 0% 91.48% 104.54MB 62.10% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop.func1 (inline) ...
0 0% 91.48% 103.02MB 61.19% k8s.io/kubernetes/pkg/kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount
```
kubeletの実装をこれまで覗いたことがなかったにもかかわらず、ほとんど100万個のcontextがメモリ使用量の大部分を占めているのを見て、すぐに驚きました!それはおかしいです。
Codexが回帰を発見
馴染みのない新しいコードベースに飛び込み、質問できることは、最新世代のAIコーディングツールの強力な超能力の一つです。私は`kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount`の行に疑念を抱いていましたが(おそらくexecベースのreadinessおよびlivenessプローブが問題を起こしているのだろうか?)、Codexはすぐに正しい問題へと私を導きました。それはKubernetes 1.36で2026年2月19日に導入された変更で、以下のコードが
```
// initialize a context for the worker if one does not exist
if status.ctx == nil || status.ctx.Err() == context.Canceled {
status.ctx, status.cancelFn = context.WithCancel(context.Background())
}
ctx = status.ctx
```
以下のコードに置き換えられました。
`ctx, status.cancelFn = context.WithCancel(parentCtx)`
これは、各Podのコアとなる調整ループである`startPodSync`が実行されるたびに実行されます。それ自体は、新しい行は無害に見えるかもしれません。新しいキャンセル可能なcontextを作成し、キャンセル関数を保存します。問題は2回目のパスで起こることです。
もし`status.cancelFn`がすでに前のキャンセル関数を指している場合、この代入はそれを上書きします。古いキャンセル関数が呼び出されなかった場合(そして典型的な成功ケースでは呼び出されません)、古い子contextは親に接続されたままになります。Goのcontextドキュメントは、`CancelFunc`を呼び出すと親の参照から子が削除され、それを呼び出さないと親がキャンセルされるまで子がリークすることを明示的に述べています。これはすべてのPodの調整ループごとに呼び出されるため、数日間でほぼ100万個のリークしたcontextにまで成長し、より多忙なクラスターではさらに多くなっていたでしょう!
報告とパッチ
私はこれまでKubernetesプロジェクトにコミットしたことはありませんでしたが、新参者としての私の経験では、彼らは問題を迅速にトリアージし、パッチのマージをサポートしてくれる素晴らしいプロセスを実行していました。さらに複雑なことに、私の最初のパッチ試行はローカルテストには合格しましたが、Kubernetes CI環境で実行されるE2E統合テストで失敗しました。これらのテストは、readinessおよびlivenessプローブを処理するプロバーワーカーもcontextを正しく使用していなかったという別の問題を明らかにしました!メモリリークを解決するという目的のため、チームは、差し迫った問題を元に戻すことに単純化し、より広範なcontextの修正は後回しにするよう私を導きました。私は、より深いクリーンアップを試みる次の勇敢な人のために、コードにいくつかの「注意」コメントを残しました。
`// Be careful not to leak contexts (see #139823). // Be careful t`