1. 再現する障害 — requests/limitsは正しいのに、Podが突然重くなる

requestsとlimitsをマニフェスト通りに設定しているのに、ある日突然、特定のPodだけ応答が遅くなる。再起動はしていない。kubectl get eventsを見てもOOMKilledは記録されていない。ノードのメモリにも余裕がある。それでもレイテンシだけが悪化し続ける——こういう障害に遭遇したことはないだろうか。
K3s / Kubernetesの運用でよくある思い込みは、「メモリ周りの異常はOOMKillとして現れる」というものだ。limitsを超えたら強制終了され、Podイベントに刻まれる。だからOOMKilledが出ていなければメモリは無関係だと、多くのエンジニアが判断してしまう。
だが実際には、killされずに、ただ重くなるというメモリ起因の障害が存在する。その正体は、cgroup v2のmemory.highという、limitsよりも手前に置かれたスロットリングの壁だ。
2. 最初に疑った場所 — メモリリーク、GC設定、ノードのメモリ圧迫。どれもハズレ

このような症状に遭遇した場合、インフラエンジニアがまず疑うのは次のような定番ポイントだろう。
- アプリケーション側のメモリリーク: ヒープダンプを取得して確認するが、使用量はrequestsの範囲内で安定している
- GC(ガベージコレクション)のチューニング不足: GCログを確認しても、極端なフルGCの連発は見られない
- ノードのメモリ圧迫(Node Pressure Eviction):
kubectl describe nodeを確認しても、MemoryPressureのConditionは立っていない
一つずつ潰していっても原因にたどり着けない。こうなると、調査は「アプリケーション層」から「カーネル・cgroup層」へと視点を移す必要がある。こうした間欠的で原因が掴みにくい障害は、自己運用のクラスタでは調査に半日以上かかることも珍しくない。Kuboのようなマネージド環境であれば、こうした切り分けの初動を運用側のノウハウで短縮できる場面もある。
3. 真因 — cgroup v2のmemory.highによる早期スロットリング

結論から言うと、犯人はmemoryThrottlingFactorという設定によって有効化される、cgroup v2のmemory.highだった。
KubernetesにはMemory QoSという仕組みがあり、Podのrequests/limitsをcgroup v2のメモリ制御インターフェースにマッピングする(KEP-2570: Memory QoS)。この仕組みを有効化すると、limitsとは別に、もう一段階手前の「スロットリング境界」がカーネルレベルで設定される。これがmemory.highだ。
今回のケースでは、このMemory QoSのmemoryThrottlingFactorが、古いドキュメントの推奨値や過去バージョンの設定を引き継いだ形で明示的に設定されていたことが根本原因だった。そのため、アプリケーションのメモリ使用量がlimitsに達するよりずっと手前の段階で、静かにスロットリングが発動していた。
4. cgroup v2のメモリ制御4段階 — memory.max/min/low/highの役割分担

ここで、cgroup v2のメモリ制御を正しく理解しておきたい。Linuxカーネルの公式ドキュメントでは、メモリコントローラは次の4つのインターフェースファイルで構成されると定義されている(Control Group v2(Linux kernel 公式ドキュメント))。
- memory.min: メモリ使用がこの境界内であれば、いかなる条件下でもそのcgroupのメモリは回収されないハード保護
- memory.low: memory.minよりも緩いベストエフォート保護。回収可能な他のメモリがない場合のみ対象になる
- memory.high: 超過すると、プロセスがスロットリングされ、ダイレクトメモリ回収の圧力下に置かれる。重要なのは、この時点ではOOM killerは呼び出されないという点だ
- memory.max: ハードリミット。この制限に達してメモリを削減できない場合に、初めてOOM killerが呼び出される
つまり、Podの監視対象は「limits = memory.max」だけでは不十分で、memory.highという別のレイヤーが存在し、しかもそれはkillを伴わないという事実が、今回の障害を見えにくくしていた。
Red Hatのエンジニアも、cgroup v2がもたらす改善点として「OOM圧迫時のノード安定性向上」を挙げている(cgroups v2 goes GA in OpenShift 4.13(Red Hat公式ブログ))。memory.highによる早期のリクレイム圧は、ノード全体を巻き込むOOM Killよりも"穏便な"挙動として設計されているが、その穏便さが裏目に出て障害調査を難しくするケースがある。
5. なぜOOMKilledが出ないのか — memory.highとmemory.maxの挙動の違い

memory.maxを超過した場合、カーネルはOOM killerを呼び出し、対象プロセスを強制終了する。これがKubernetesのPodイベントに記録される、おなじみの「OOMKilled」だ。
一方、memory.highを超過した場合はまったく異なる挙動になる。カーネルはプロセスをkillせず、代わりに**ダイレクトメモリ回収(direct reclaim)**を発動し、ページキャッシュの追い出しなどでメモリ使用量を抑え込もうとする。この処理自体に時間がかかるため、アプリケーションのレイテンシが悪化する。しかしプロセス自体は生き続けているため、Podイベントには何も記録されない。
この非対称性が、「OOMKilledが出ていない=メモリは無関係」という調査者の直感を裏切る。memory.eventsファイルを確認すると、highカウンタが増加し続けていることが分かるはずだが、Podのイベントログだけを見ている限り、この情報には決してたどり着けない。
6. 切り分け手順 — cgroupファイルでmemory.high/memory.current/memory.eventsを確認する

実際の切り分けは、Pod単位ではなくノード上のcgroupファイルを直接確認することから始まる。
- 対象Podの稼働ノードを特定する:
kubectl get pod <pod-name> -o wideでノード名を確認 - cgroupパスを特定する: 該当ノードにアクセスし、コンテナIDからcgroup v2のパス(
/sys/fs/cgroup/kubepods.slice/...配下)を特定する - 現在のmemory.high設定値を確認する: 値が
max(無制限)になっていなければ、何らかの設定でスロットリング境界が入っている - memory.currentで実使用量を確認する: memory.highの値に実使用量が張り付いていないかをチェック
- memory.eventsでhighカウンタを確認する:
highの値が増加し続けていれば、まさにスロットリングが発生している証拠
このステップを踏めば、「OOMKilledは出ていないが、確実にメモリ制御が関与している」ことを客観的な数値で証明できる。cgroup v2ベースでのリソース分離の考え方そのものについては、OpenShift/Kubernetesの公式解説も参考になる。
7. 恒久対策 — memoryThrottlingFactorの見直しとrequests/limits設計

対策の方向性は大きく2つある。
1つ目はmemoryThrottlingFactorそのものを見直すこと。 CNCFのブログでは、この値が積極的すぎる設定になっていると、典型的なJavaアプリケーションのようにメモリ使用率が常時高めに推移するワークロードで、頻繁なスロットリングを引き起こす実例が報告されている(How Katalyst guarantees memory QoS for colocated applications(CNCF blog))。値を1.0に近づけることで、スロットリング境界をmemory.maxに近づけ、早期発動を抑制できる。
2つ目はrequests/limitsの設計そのものを見直すこと。 memory.highはrequestsとlimitsの間のどこかに設定される性質上、両者の差が大きいワークロードほど早期スロットリングの影響を受けやすい。差を意図的に縮めることも、根本的な対策になり得る。
ここで一点、正確に書いておきたい。Memory QoS自体はKubernetes 1.37でBetaに昇格し、フィーチャーゲートはデフォルトで有効になった(Kubernetes v1.37: Memory QoS Graduates to Beta(公式ブログ))。だが「v1.37にアップグレードしただけで自動的にスロットリングが始まる」わけではない。公式ブログは「ベータ機能の有効化だけで既存ワークロードのレイテンシが黙って変わってはならない」という方針を明言しており、memoryThrottlingFactorのデフォルト値はnullに変更されている。明示的にこの値を設定しない限り、kubeletはmemory.highを書き込まない。
今回のような障害が起きるのは、memoryThrottlingFactorを明示的に設定している場合だ。実際、公式ドキュメント(Pod Quality of Service Classes)のKubeletConfiguration設定例にはmemoryThrottlingFactor: 0.9という値が載っている。こうしたサンプルや古いバージョンのドキュメント、一部のマネージドKubernetesサービスの推奨設定をそのまま適用している、過去バージョンの設定を引き継いでいる、といったケースが典型的だ(経緯の詳細: Kubernetes v1.36: Tiered Memory Protection(公式ブログ)、Kubernetes 1.37 Memory QoS解説)。なお計算式や係数はバージョンで記述が異なるため、本記事では断定せず、導入・変更時は手元のクラスタのkubeletログとcgroupファイルで実測確認することを勧める。
自己運用のK3sクラスタでこうした設定を一つひとつ追いかけ、バージョンアップのたびに挙動変化を把握し続けるのは軽くない運用負荷だ。KuboのようなマネージドK3sであれば、こうしたデフォルト挙動の変化を運用側で把握し、設定の妥当性を可視化した状態で提供できる。
まとめ

「OOMKilledが出ていない=メモリは無関係」という前提は、cgroup v2のmemory.highの存在によって必ずしも正しくない。requests/limitsを正しく設定していても、memoryThrottlingFactorが明示的に設定されていれば、limitsに達するよりずっと手前でスロットリングが発生し得る。
今回整理した切り分け手順——cgroupパスの特定、memory.high/memory.current/memory.eventsの確認——を押さえておけば、次に同様の「原因不明の重さ」に出会ったときも、アプリケーション層だけでなくカーネル・cgroup層まで視野を広げて調査できるはずだ。
こうしたKubernetesのバージョンアップごとの挙動変化や、cgroup v2レベルの細かな設定差分まで自前で追い続けるのは、インフラチームにとって決して軽くない負担だ。Kubo On-PremiseやKuboのようなマネージドK3s環境を活用し、こうした調査・運用の負担そのものを見直すことも、選択肢の一つとして検討する価値がある。気になる方はお問い合わせから相談してみてほしい。