はじめに
Kubernetes のメモリ使用量について質問や相談を受けることが多く、そのたびに同じ説明を繰り返してきました。必要な情報は Kubernetes とカーネルの公式ドキュメントに分かれて書かれていますが、それらをつないで整理したものが見当たらなかったので、この記事を書きました。
「メモリ使用量」に唯一の定義はない
「このコンテナのメモリ使用量は何 MiB ですか」という問いには、唯一の正解がありません。CPU 使用率やディスク使用量のように 1 つの定義で決まる値ではなく、何を測りたいのか、どこから測るのかによって値が変わります。
理由は、Linux におけるメモリが「誰のものか」を一意に決められないところにあります。
-
ページキャッシュは誰の使用量か。ファイルを読むとカーネルがその内容をメモリに保持します。これはアプリケーションが
mallocで要求したものではなく、カーネルが性能のために勝手に確保したものです。しかもメモリが足りなくなればカーネルが黙って破棄します。これを「アプリケーションが使っているメモリ」に数えるべきかどうかは、目的によって答えが変わります - 共有されているメモリは誰の使用量か。共有ライブラリのページや tmpfs、共有メモリは、複数のプロセスが同じ物理ページを参照します。プロセスごとに単純に足し合わせると、同じ 1 ページが何重にも計上され、合計が物理メモリを超えることすらあります
-
「解放した」メモリはいつ減るのか。アプリケーションが
freeしてもアロケータや言語ランタイムが OS へ返さなければ、OS から見た使用量は減りません。逆に OS へ返せば減ります。どの層から見るかで値が違います - 回収できるメモリと、できないメモリがある。同じ「使用中」でも、メモリが逼迫したときにカーネルが取り上げられるもの (ページキャッシュ) と、取り上げられないもの (匿名ページ) があります。空きを見積もるなら、この区別を無視できません
つまり、メモリ使用量は測定方法と目的が決まってはじめて定まる値です。「プロセスが物理メモリ上に持っているページの量」「そのプロセスだけが使っているメモリの量」「メモリが逼迫したときに解放できないメモリの量」は、どれも妥当な定義ですが、それぞれ違う数字になります。
Kubernetes は独自の定義で動いている
その上で問題になるのが、Kubernetes のコンポーネントは管理のために独自の定義を使っているという点です。自分が普段「メモリ使用量」と呼んでいる値と同じものだと思って読むと、値の意味を取り違えます。
kubectl top pod や container_memory_working_set_bytes で見ているコンテナのメモリ使用量は、アプリケーションが確保しているメモリ量 (RSS) ではありません。Kubernetes がコンテナの使用量として扱うのは Working Set という値で、RSS のほかにページキャッシュの一部やカーネルが使うメモリも含んでいます。
container_memory_working_set_bytes # kubectl top pod で見ている値
≒ container_memory_rss # アプリケーションが確保しているメモリ (匿名ページ)
+ active なページキャッシュ # inactive なものだけが除かれる
+ tmpfs や共有メモリ (shmem) # active / inactive を問わず全量含まれる
+ カーネルメモリなど
そのため Working Set は、アプリケーションの実際の消費量より大きくなります。そして厄介なのが、OOM Kill や Eviction が起きる原因は、その見えている値とは別の値が上限に達したことだという点です。Limit 超過による OOM Kill (本記事では memcg OOM と呼びます) でカーネルが上限と比較するのはコンテナの memory.current で、Working Set とは別の値です。しかも memory.current が上限に達しただけでは OOM にならず、回収を試みても上限に収まらなかったときに OOM Kill されます。Eviction の原因になるのはノード全体の Working Set から計算した空きなので、コンテナ単位の値とは対象が異なります。
これらを同じものとして扱うと、次のような一見矛盾して見える状況の説明がつかなくなります。
- Working Set は Memory Limit に達していないのに
OOMKilledになった -
node_memory_MemAvailable_bytesから計算したノードのメモリ使用率は低いのに Pod が Evict された - アプリケーションはメモリリークしていないのに Working Set が増え続ける
- アプリケーション側で取得した RSS と、
container_memory_rssの値が合わない
これらはどれもメトリクスの不具合ではなく、それぞれのコンポーネントが自分の目的に合った定義で値を出している結果です。逆に言えば、どの値がどの定義なのかを押さえておけば、矛盾に見えていたものが説明できるようになります。
この記事では、次の 3 つを順に整理します。
- Kubernetes のメモリ使用量と cgroup v2: 「メモリ使用量」として現れる値がそれぞれ何を測っているのか、cgroup v2 のどの値に対応するのか、そして Eviction の原因になるのはどの値なのか
-
OOM と Eviction の調査方法: OOM Kill には 2 つの経路があり、
OOMKilledや Evict が起きたときにどちらなのかをどう切り分けるのか - 対処方法: 原因が分かったあとに何をするのか。OOM と Eviction をまとめて減らすのにもっとも効くのは Memory Request の適正化なので、確認用の PromQL を含めてそこを中心に扱います
「なぜ OOM Kill や Eviction が起きるのか」だけ知りたい場合は、次の TL;DR に表でまとめてあります。
本記事での OOM Kill の呼び分け
メモリ起因で Pod のプロセスが kill される経路は 2 つあり、原因も影響範囲も違います。両者を区別せずに「OOM」と呼ぶと切り分けができないため、本記事では次の 2 つの名前で書き分け、以降はこの呼称を使います。
| 本記事での呼称 | 起点となる原因 | kill される対象 |
|---|---|---|
| memcg OOM | コンテナ・Pod・/kubepods.slice のいずれかが memory.max (= Memory Limit) を超えたこと |
上限に達した cgroup 内のプロセスだけ |
| global OOM | ノード全体のメモリ枯渇。個々のコンテナの Limit とは無関係 | ノード上の全プロセスが対象 |
ごく簡単に言えば、memcg OOM は「自分の Limit」が原因、global OOM は「ノードの空き」が原因です。Limit に余裕があるのに OOMKilled になった、という状況の多くは後者です。
この 2 つは Kubernetes の公式用語ではありません。 Kubernetes が公開しているのは OOMKilled という終了理由だけで、そこに経路の区別はありません (理由はなぜ OOMKilled では経路を区別できないのかで説明します)。
どちらもカーネルが使っている語なので、本記事はそれをそのまま採っています。 カーネルのコメントでも 2 つが並べて使われています (mm/oom_kill.c の pagefault_out_of_memory())。
/*
* The pagefault handler calls here because some allocation has failed. We have
* to take care of the memcg OOM here because this is the only safe context without
* any locks held but let the oom killer triggered from the allocation context care
* about the global OOM.
*/
判定の実体は struct oom_control の memcg フィールドが NULL かどうかで、値が入っていればその cgroup 内から犠牲を選び、NULL ならノード全体から選びます (oom.h)。is_memcg_oom() もこのフィールドを見るだけの関数です。
/* Memory cgroup in which oom is invoked, or NULL for global oom */
struct mem_cgroup *memcg;
memcg は memory cgroup (memory control group) の略で、上のコードにある struct mem_cgroup を短縮した呼び方です。ソース中の変数名や関数名 (is_memcg_oom()、try_charge_memcg()) もこの綴りを使っており、カーネルのドキュメントにも "Memory cgroup (memcg)" という併記があります (Documentation/mm/hmm.rst)。
なお global OOM という語が出てくるのはソースのコメントと OOM ログだけで、カーネルのドキュメントには記載がありません。memcg OOM のほうは Documentation/admin-guide/cgroup-v1/memory.rst に "memcg OOM killer" という表記があります。
カーネルログでの現れ方は次のとおりです。
| 本記事での呼称 | カーネルログでの現れ方 |
|---|---|
| memcg OOM |
constraint=CONSTRAINT_MEMCG、oom_memcg=<cgroup パス>、Memory cgroup out of memory: Killed process ...
|
| global OOM |
constraint=CONSTRAINT_NONE、,global_oom、Out of memory: Killed process ...
|
Killed process の行の接頭辞がそのまま経路を表しています (oom_kill.c)。
oom_kill_process(oc, !is_memcg_oom(oc) ? "Out of memory" :
"Memory cgroup out of memory");
なお Eviction は OOM Kill とは別の仕組みです。カーネルではなく kubelet が Pod を退去させるもので、Evicted として観測されます。こちらも後述します。
対象コンポーネントとバージョン
本記事は cgroup v2 を前提としています。cgroup v1 ではメトリクスと memory.stat のフィールドの対応が異なります。
本記事の記載内容は、次のバージョンを対象にしています。
| コンポーネント | バージョン | リポジトリ | 本記事での役割 |
|---|---|---|---|
| Linux kernel | v6.12 | https://github.com/torvalds/linux | cgroup v2 のメモリ制御と OOM Killer の本体。カーネルログの OOM メッセージの書式 |
| Kubernetes (kubelet) | v1.37.0 | https://github.com/kubernetes/kubernetes | Eviction の判定、oom_score_adj の設定、SystemOOM Event、container_oom_events_total のカウント |
| Kubernetes (kube-scheduler) | v1.37.0 | https://github.com/kubernetes/kubernetes |
kube_pod_resource_request / kube_pod_resource_limit の公開 (/metrics/resources) |
| cAdvisor | v0.57.0 | https://github.com/google/cadvisor | kubelet に組み込まれ、container_* メトリクスを公開。Working Set の計算 |
| containerd | v2.3.5 | https://github.com/containerd/containerd | コンテナの終了理由を OOMKilled と判定する処理 |
| node-exporter | v1.12.1 | https://github.com/prometheus/node_exporter |
node_memory_* (/proc/meminfo)、node_vmstat_oom_kill (/proc/vmstat) の公開 |
| kube-state-metrics | v2.20.0 | https://github.com/kubernetes/kube-state-metrics |
kube_node_status_capacity、kube_pod_container_status_* の公開 |
| Metrics Server | v0.8.1 | https://github.com/kubernetes-sigs/metrics-server |
kubectl top が参照する値の取得元 |
| Vertical Pod Autoscaler | 1.7.1 | https://github.com/kubernetes/autoscaler | Request / Limit の推奨値算出と自動適用 |
| kind | v0.33.0 | https://github.com/kubernetes-sigs/kind | v1.37 の挙動をローカルで確かめる際に使用 |
コンテナランタイムは containerd を前提にしています。CRI-O など他のランタイムでも、cgroup v2 の値の意味は同じですが、OOMKilled の判定処理や cgroup のパスの命名は異なります。
また本記事では、クラスタに Prometheus / node-exporter / kube-state-metrics が導入され、PromQL を実行できる状態を想定しています。これらは Kubernetes 本体には含まれないため、導入していない場合は kubectl top や、ノード上での memory.stat の直接参照に読み替えてください。
TL;DR
OOM Kill と Eviction が起きる原因は、経路によって違います。 どの値がどうなったときに起きるのかは、次の 4 通りです。
| 事象 | 原因になる値 | 起きる条件 |
|---|---|---|
| memcg OOM (Limit 超過による OOM Kill) | コンテナ・Pod・/kubepods.slice いずれかの memory.current (container_memory_usage_bytes) |
同じ階層の memory.max に達し、かつ回収しても収まらないとき (コンテナの memory.max は Memory Limit) |
| global OOM (ノードのメモリ枯渇による OOM Kill) | ノード全体のメモリが枯渇したかどうか (カーネル視点) | しきい値はありません。kill 対象は oom_score (QoS クラスと Memory Request) で選ばれます |
| Eviction の発生 | ノードの memory.available (= MemTotal − ノードの Working Set) |
Capacity に対する evictionHard / evictionSoft のしきい値 (kubelet のデフォルトは memory.available<100Mi) |
| Eviction の対象 Pod の選定 | Pod の Working Set と Memory Request の差 | 実使用量が Request を超えている Pod が優先されます |
そのため、冒頭に挙げた一見矛盾して見える事象は次のように説明できます。
-
Working Set が Limit に達していないのに
OOMKilledになった: memcg OOM で Limit と比較されるのは Working Set ではなくmemory.currentです (しかも上限に達しただけでは起きず、回収しても収まらなかったときに kill されます)。また、自分のコンテナが Limit を超えていなくても、ノードのメモリ枯渇 (global OOM) に巻き込まれて kill されることがあります。詳細はWorking Set が Limit に達していないのに OOMKilled になりますをご覧ください -
MemAvailableから計算したノードのメモリ使用率は低いのに Pod が Evict された:MemAvailableはページキャッシュの一部を空きに数えますが、kubelet は Working Set ベースで active なページキャッシュを全量「使用中」として扱います。詳細はMemAvailable から計算した使用率は低いのに Pod が Evict されますをご覧ください - アプリケーションはメモリリークしていないのに Working Set が増え続ける: Working Set には active なページキャッシュが含まれます。ページキャッシュはメモリが逼迫するまで回収されないため、ファイル I/O やログ出力の多いワークロードでは増え続けているように見えます。詳細はMemory Working Set が増え続けますをご覧ください
-
アプリケーション側で取得した RSS と
container_memory_rssの値が合わない: 前者はプロセス単位の RSS (ファイルのマッピングを含む) で、後者はコンテナの cgroup に計上された匿名ページです。詳細はアプリケーション側で取得できる RSS と container_memory_rss の値が違いますをご覧ください
これらの値と、kubectl top やダッシュボードで目にするメトリクスの対応は次のとおりです。同じ「メモリ使用量」でもメトリクスごとに測っているものが違い、cgroup v2 の別のフィールドに対応しています。
| 対象 | メトリクス | 出力元コンポーネント | cgroup v2 / /proc での実体 |
ページキャッシュの扱い | 主な用途 |
|---|---|---|---|---|---|
| コンテナ | container_memory_working_set_bytes |
cAdvisor (kubelet 組み込み) | memory.current − inactive_file |
active なものは使用中として含む |
kubectl top pod、使用量の可視化 |
| コンテナ | container_memory_rss |
cAdvisor (kubelet 組み込み) |
memory.stat の anon
|
含まない (匿名ページのみ) | アプリが確保している量の把握 |
| コンテナ | container_memory_usage_bytes |
cAdvisor (kubelet 組み込み) | memory.current |
すべて含む | memcg OOM の判定対象 |
| ノード | container_memory_working_set_bytes{id="/"} |
cAdvisor (kubelet 組み込み) | root cgroup の memory.stat の anon + file − inactive_file (後述) |
active なものは使用中として含む |
Eviction の判定、kubectl top node
|
| ノード | node_memory_MemAvailable_bytes |
node-exporter |
/proc/meminfo の MemAvailable
|
回収可能なものは空きとして扱う | ノードのメモリ使用率の可視化 |
| ノード | kube_node_status_capacity{resource="memory"} |
kube-state-metrics | (Node オブジェクトの status.capacity) |
(使用量ではなく容量) | 使用率に換算するときの分母 |
同じノードを見ていても、node_memory_MemAvailable_bytes から計算した使用率と、kubelet が Eviction の判定に使う使用率は一致しません。ファイル I/O やログ出力の多いワークロードでは、この差が数 GiB 規模になることがあります。
出力元が違うということは、メトリクスに付くラベルも違うということです。cAdvisor 由来のメトリクスにはノードを示すラベルが付かないため、ノード単位の使用率を計算するときは後述のように kubelet_node_name でラベルを揃える必要があります。
最後に、切り分けと対処の結論です。
-
どちらの OOM 経路かは、Node の Event に
SystemOOMがあるかで判断します。 あれば global OOM で確定ですが、無いことは memcg OOM の根拠になりません (OOMKilledもメトリクスも経路を区別しません)。詳細はどちらの経路かを切り分けるをご覧ください -
対処としてもっとも効くのは、Memory Request を実使用量に合わせることです。 配置先の決定・Eviction 対象の選定・
oom_score_adjの 3 つに同時に効きます。確認用の PromQL は対処方法にまとめています
Kubernetes のメモリ使用量と cgroup v2
メトリクスに現れる値が cgroup v2 のどの値に対応しているのか、そして OOM Kill や Eviction の原因になるのはどの値なのかを整理します。
cgroup v2 でのメモリの内訳
コンテナのメモリ使用量は、コンテナごとの cgroup で管理されています。cgroup v2 では memory.current が現在の使用量、memory.stat がその内訳になります。
# コンテナの cgroup での値の例 (ノード上での確認例)
$ cat /sys/fs/cgroup/<コンテナの cgroup>/memory.current
5368709120
$ cat /sys/fs/cgroup/<コンテナの cgroup>/memory.stat
anon 2147483648 # 匿名ページ (ヒープ、スタックなど、ファイルに対応しないメモリ)
file 3087007744 # ページキャッシュ (tmpfs や共有メモリも含む)
shmem 268435456 # file のうち tmpfs や共有メモリの分
...
inactive_anon 134217728 # anon LRU のうち、しばらくアクセスされていないもの
active_anon 2013265920 # anon LRU のうち、最近アクセスされたもの
inactive_file 1073741824 # file LRU のうち、しばらくアクセスされていないもの
active_file 1744830464 # file LRU のうち、最近アクセスされたもの
unevictable 268435456 # 回収できないページ (ここでは tmpfs の分)
...
この例では次の関係が成り立っています。inactive_file + active_file が file と一致せず、file - shmem と一致するのがポイントで、理由は次節で説明します。
anon + file + カーネルメモリ = 5368709120 (memory.current)
inactive_file + active_file = 2818572288 = file - shmem
inactive_anon + active_anon = 2147483648 = anon (shmem は入らない)
unevictable = 268435456 = shmem (Swap なしの tmpfs のため)
memory.stat の主なフィールドは次のとおりです。
anon (匿名ページ)
- ファイルに対応しないメモリで、アプリケーションが
mallocなどで確保した領域が該当します - ページキャッシュと違い、カーネルがプロセスの都合と関係なく回収できません。Swap があれば書き出して空きを作れますが、Swap を使用しないノードではそれもできないため、逼迫時にカーネルが回収できないメモリになります。カーネル側では、Swap が使えないときに
scan_balance = SCAN_FILEとして anon LRU を走査対象から外しています (mm/vmscan.c のget_scan_count()) - 一方で、プロセス自身が
munmapやmadvise(MADV_DONTNEED)などで OS へ返却すればこの値は減ります。freeで解放しても、アロケータや言語ランタイムが OS へ返さない限り減らない点に注意してください
file (ページキャッシュ)
- ファイルの読み書きの際に、カーネルが内容をメモリ上に保持したものです。アプリケーションが明示的に確保したものではありません
- メモリが不足するとカーネルが自動的に破棄 (回収) するため、基本的には回収可能なメモリです
- ディスク上のファイルのキャッシュは
active_file(最近アクセスされたもの) とinactive_file(しばらくアクセスされていないもの) に分かれます。回収はinactive_fileから優先的に行われます -
active_file + inactive_fileはfileと一致しません。 これらは LRU リストの集計値で、後述のとおりshmemは file LRU ではなく anon LRU に載るためです。shmemに加えて、mlockされたページなど LRU から外れたものも差として現れます -
ディスク上のファイルのキャッシュだけでなく、
shmem(tmpfs や共有メモリ) も含まれます。Linux ではこれらもページキャッシュとして扱われるためです。shmemは書き出す先がファイルではなく Swap のため、Swap を使用しないノードでは回収できません。内訳はmemory.statのshmemで確認できます
shmem は file に計上されますが、file LRU には載りません。 カーネルは swap-backed なページを anon 側に分類しており、tmpfs のページもこれに該当します (mm_inline.h の folio_is_file_lru() は "0 if @folio is a normal anonymous folio, a tmpfs folio or otherwise ram or swap backed folio" と定義されています)。
一方で計上先のカウンターは別で、tmpfs のページは NR_FILE_PAGES (= file) と NR_SHMEM (= shmem) の両方に加算されます (mm/shmem.c)。
__lruvec_stat_mod_folio(folio, NR_FILE_PAGES, nr);
__lruvec_stat_mod_folio(folio, NR_SHMEM, nr);
さらに Kubernetes の emptyDir (medium: Memory) の場合、kubelet は tmpfs を noswap オプションで mount します (empty_dir.go の generateTmpfsMountOptions())。カーネルは noswap な tmpfs の inode を unevictable にするため (mm/shmem.c の shmem_get_inode())、このページは anon LRU ではなく unevictable LRU に載ります。
if (sbinfo->noswap)
mapping_set_unevictable(inode->i_mapping);
つまり shmem の計上先は次のようになります。
| mount | LRU |
memory.stat 上の計上先 |
|---|---|---|
emptyDir の medium: Memory (noswap) |
unevictable | unevictable |
| それ以外の tmpfs / 共有メモリ | anon LRU |
inactive_anon / active_anon
|
いずれの場合も inactive_file / active_file には入りません。 そのため次の 2 点が後の話に効いてきます。
-
inactive_file + active_fileはfileではなくfile - shmemと一致します - Working Set から除かれるのは
inactive_fileだけなので、shmemは全量が Working Set に残ります
ログを大量に出力する、大きなファイルを読み書きするといったワークロードでは、アプリケーション側が意図していなくてもページキャッシュが数 GiB まで積み上がります。
memory.current には、anon と file に加えてカーネルが使うメモリ (slab、ソケットバッファなど) も含まれます。そのため anon と file を足しただけでは memory.current になりません。
cgroup v2 のメモリ制御と Pod の設定の対応
cgroup v2 のメモリ制御インターフェース
cgroup v2 では、メモリの使用量に対して 4 段階の制御を設定できます。memory.min と memory.low は「ここまでは回収しない」という保護、memory.high と memory.max は「ここまでしか使わせない」という制限です。
| ファイル | 種別 | 挙動 |
|---|---|---|
memory.min |
保護 (強) | この値までのメモリは回収されません。回収できず上限に収まらない場合は OOM Kill になります |
memory.low |
保護 (弱) | この値まではベストエフォートで回収を避けます。ほかに回収できるメモリがなければ回収されます |
memory.high |
制限 (弱) | 超えると強い回収圧力がかかり、その cgroup のプロセスがスロットリングされます。OOM Kill は起きません |
memory.max |
制限 (強) | 超えると回収が行われ、それでも収まらない場合は cgroup の OOM Killer が動きます |
このほか、状態を参照するファイルとして次のものがあります。
-
memory.current: 現在の使用量 (container_memory_usage_bytesの実体) -
memory.stat: 使用量の内訳 -
memory.events:low、high、max、oom、oom_kill、oom_group_killの発生回数 -
memory.swap.max: Swap の上限 -
memory.pressure: メモリ確保待ちの PSI
各ファイルの厳密な定義はカーネルの Memory Interface Files、Kubernetes から見た cgroup v2 の扱いは About cgroup v2 をご覧ください。
Pod の設定がどう反映されるか
Pod の設定は次のように cgroup v2 へ反映されます。
| Pod の設定 | 反映先 (cgroup v2) |
|---|---|
resources.limits.memory |
memory.max |
resources.requests.memory |
反映されません (v1.37 の kubelet デフォルト設定の場合。設定を変えれば反映できます → KEP-2570) |
resources.limits.cpu |
cpu.max |
resources.requests.cpu |
cpu.weight |
cgroup v2 はディレクトリツリーで構成されます。その最上位がroot cgroup (/sys/fs/cgroup) で、どの子 cgroup にも入っていないプロセスはここに属するため、Pod だけでなく kubelet や containerd などノード上の全プロセスを含みます。Kubernetes が作るのはその下の階層で、「ノード上の全 Pod → Pod → コンテナ」と分かれています。
# systemd cgroup driver の場合 (kubeadm のデフォルト。cgroupfs driver では後述のとおり名前が変わります)
/sys/fs/cgroup ← root cgroup (id="/")
│ ノード上の全プロセスを含む
│ Eviction の判定に使う値
│
├── system.slice/ kubelet、containerd、sshd など
│ ├── kubelet.service (Pod ではないプロセス)
│ └── containerd.service
│
└── kubepods.slice/ ← ノード上の全 Pod
│ (id="/kubepods.slice")
├── kubepods-pod<UID>.slice/ ← Pod (Guaranteed)
│ ├── cri-containerd-<ID>.scope ← コンテナ
│ └── cri-containerd-<pause の ID>.scope ← pause コンテナ
│
├── kubepods-burstable.slice/ (QoS ごとの中間 slice)
│ └── kubepods-burstable-pod<UID>.slice/ ← Pod (Burstable)
│ └── cri-containerd-<ID>.scope ← コンテナ
│
└── kubepods-besteffort.slice/
└── kubepods-besteffort-pod<UID>.slice/ ← Pod (BestEffort)
└── cri-containerd-<ID>.scope ← コンテナ
メトリクスで絞り込むときは、root cgroup が id="/"、ノード上の全 Pod が id="/kubepods.slice"、Pod が container="", image="", pod!=""、コンテナが container!="", image!="" に対応します。kubelet が Eviction の判定に使うのは、いちばん上の root cgroup の Working Set です (後述)。コンテナや Pod の Limit 超過による memcg OOM とは、見ている階層が違います。
メモリの上限は、コンテナだけでなく上位の階層にも設定されます。
| 階層 | cgroup | メモリの上限 |
|---|---|---|
| ノード全体 (root cgroup) | / |
設定されません (root には memory.max が存在しません → 後述) |
| ノード上の全 Pod | /kubepods.slice |
Capacity - kube-reserved - system-reserved |
| Pod | QoS クラスごとに異なります (下記) | Pod の実効 Limit (下記)。全コンテナに Limit がある場合のみ設定されます |
| コンテナ | Pod の cgroup 配下の cri-containerd-<ID>.scope
|
そのコンテナの limits.memory
|
Pod の実効 Limit は、通常コンテナの limits.memory の単純な合計ではありません。init コンテナがある場合はその最大値も考慮され、spec.overhead が設定されていればそれも加算されます。算出方法は Resource sharing within containers と Pod Overhead をご覧ください。
そのため、コンテナ単位では Limit に達していなくても、Pod 全体や /kubepods.slice の上限に達すれば OOM Kill が起こります。
上記のパスは、kubelet が systemd cgroup driver を使っている場合のものです。cgroupfs driver の場合は /kubepods/burstable/pod<UID>/<コンテナ ID> のように .slice や .scope が付かず、コンテナ側も cri-containerd- の接頭辞が付きません。
kubelet 自身のデフォルトは cgroupfs ですが、kubeadm は値が空のときに systemd を設定します (defaults.go、kubelet.go)。どちらが使われているかは次のクエリで確認できます。
$ kubectl get --raw "/api/v1/nodes/<ノード名>/proxy/configz" | jq '.kubeletconfig.cgroupDriver'
"systemd"
cAdvisor のメトリクスの id ラベルも cgroup driver によって変わるため、PromQL で絞り込む際は、次のクエリで自分のクラスタでの値を確認してください。
group by (id) (container_memory_working_set_bytes{id=~"/kubepods|/kubepods.slice"})
Pod の cgroup のパスは QoS クラスによって変わります。上のツリー図のとおり、Burstable と BestEffort は QoS ごとの中間 slice を挟みます。
<UID> の部分は systemd のエスケープにより、Pod の UID のハイフンがアンダースコアに置き換わります (例: 6983351e-4ede-... → pod6983351e_4ede_...)。ただし static Pod (mirror Pod) の UID はハイフンを含まないハッシュなので、pod1ff70b81885169da4e9e32c9530b77c9.slice のような形になります。
Pod の QoS クラスと UID は Pod のリソースから確認できるため、この 2 つが分かれば該当する cgroup のパスを組み立てられます。
$ kubectl get pod -n <Namespace> <Pod 名> -o jsonpath='{.status.qosClass}{"\n"}{.metadata.uid}{"\n"}'
Burstable
6983351e-4ede-4611-8bab-b986614af18d
# 一覧で確認する場合
$ kubectl get pods -n <Namespace> -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass'
コンテナのメトリクス
kubelet に組み込まれた cAdvisor が、上記の cgroup の値を Prometheus のメトリクスとして公開しています。公開先は kubelet の /metrics/cadvisor エンドポイントで、独立した cAdvisor の Pod や DaemonSet を動かす必要はありません。
| メトリクス | 出力元コンポーネント | cgroup v2 での実体 | 説明 |
|---|---|---|---|
container_memory_usage_bytes |
cAdvisor (kubelet 組み込み) | memory.current |
匿名ページ、ページキャッシュ、カーネルメモリをすべて含んだ使用量。memcg OOM の判定対象となる値 |
container_memory_working_set_bytes |
cAdvisor (kubelet 組み込み) | memory.current - inactive_file |
「すぐには回収できない」と見なされるメモリ量。Kubernetes が使用量として扱う値 |
container_memory_rss |
cAdvisor (kubelet 組み込み) |
memory.stat の anon
|
匿名ページの量。本来の RSS と違い、mmap したファイルのページは含みません (後述) |
container_memory_cache |
cAdvisor (kubelet 組み込み) |
memory.stat の file
|
ページキャッシュの量。shmem (tmpfs や共有メモリ) も含みます |
container_memory_total_active_file_bytes |
cAdvisor (kubelet 組み込み) |
memory.stat の active_file
|
ページキャッシュのうち active なもの |
container_memory_total_inactive_file_bytes |
cAdvisor (kubelet 組み込み) |
memory.stat の inactive_file
|
ページキャッシュのうち inactive なもの |
container_memory_mapped_file |
cAdvisor (kubelet 組み込み) |
memory.stat の file_mapped
|
mmap されたファイルの量 |
container_pressure_memory_waiting_seconds_total |
cAdvisor (kubelet 組み込み) |
memory.pressure の some の total
|
一部のプロセスがメモリ待ちだった時間 (後述) |
container_pressure_memory_stalled_seconds_total |
cAdvisor (kubelet 組み込み) |
memory.pressure の full の total
|
すべてのプロセスがメモリ待ちで停止していた時間 (後述) |
container_oom_events_total |
kubelet (v1.37。名前は container_ だが cAdvisor ではない) |
(cgroup の値ではありません) | カーネルログの oom-kill: 行を解析してカウントした値。コンテナが終了すると cgroup が消えて系列自体が失われます (再作成の有無を問いません) |
kubelet には /metrics、/metrics/cadvisor、/metrics/resource などのエンドポイントがあり (server.go)、コンテナやノードの使用量に関わるのは後ろの 2 つです。/metrics/cadvisor が上記の container_* を公開するもので、/metrics/resource は CPU・メモリ・Swap の使用量に絞って公開します (resource_metrics.go)。後者には container_memory_working_set_bytes のほかに Pod 単位・ノード単位の系列 (pod_memory_working_set_bytes、node_memory_working_set_bytes) があります (resource_metrics.go の podMemoryUsageDesc / nodeMemoryUsageDesc)。Pod 単位で使用量を見たい場面での使い分けはMemory Request を実際の使用量に合わせるで説明します。
ここで重要なのは Working Set の定義です。
container_memory_working_set_bytes
= container_memory_usage_bytes - container_memory_total_inactive_file_bytes
つまりWorking Set から除かれるのは inactive なページキャッシュだけで、active なページキャッシュは使用中として含まれます。実装は cAdvisor の handler.go を参照してください。
workingSet := ret.Memory.Usage
if v, ok := s.MemoryStats.Stats[inactiveFileKeyName]; ok {
ret.Memory.TotalInactiveFile = v
if workingSet < v {
workingSet = 0
} else {
workingSet -= v
}
}
ret.Memory.WorkingSet = workingSet
内訳で表すと、おおよそ次の要素の合計になります。RSS (container_memory_rss) は cgroup v2 では memory.stat の anon そのもの、つまり匿名ページの量なので、Working Set に含まれています。
container_memory_working_set_bytes
≒ container_memory_rss (匿名ページ。shmem は含まない)
+ shmem (tmpfs や共有メモリ。全量が残る)
+ container_memory_total_active_file_bytes (active なページキャッシュ)
+ 回収できないページ・カーネルメモリなど (unevictable、slab、ソケットバッファ等)
shmem を別の項として書いているのは、container_memory_rss (= anon = NR_ANON_MAPPED) にも active_file にも shmem が含まれないためです。前述のとおり shmem は file LRU に載らないので active_file / inactive_file には現れず、Working Set から引かれるのは inactive_file だけなので、結果として全量が Working Set に残ります。shmem に対応する container_* メトリクスは公開されていないため、この項はノード上の memory.stat でしか確認できません。
≒ としているのは、shmem に加えて memory.current に含まれるカーネルメモリやロックされたページに対応するメトリクスも公開されていないためです。厳密な内訳はノード上で memory.stat を直接参照してください。
container_memory_rss の RSS は Resident Set Size (常駐セットサイズ) の略ですが、その本来の意味とは範囲が異なります。本来の RSS は「プロセスが物理メモリ上に持っているページの量」を指し、mmap したファイルのページも含みます。
しかし cgroup v2 の memory.stat に rss というフィールドはなく、cAdvisor は anon を container_memory_rss として公開しています。anon はカーネルのドキュメントで "Amount of memory used in anonymous mappings such as brk(), sbrk(), and mmap(MAP_ANONYMOUS)" と定義されており、ファイルのマッピングは含みません。そのため ps の RSS 列や /proc/[pid]/status の VmRSS とは値が一致しません。
container_memory_working_set_bytes が大きいからといって、アプリケーションがそれだけのメモリを必要としているとは限りません。ページキャッシュが積み上がっているだけの場合もあります。アプリケーションが実際に確保しているメモリ量を知りたい場合は、container_memory_rss と見比べてください。
ノードのメトリクス
ノード全体のメモリ使用量を表すメトリクスには、出力元の異なる 2 つの系統があります。
cAdvisor 由来 (kubelet と同じ見方)
cAdvisor はコンテナだけでなく、root cgroup (id="/"。前述のとおりノード上の全プロセスを含む最上位の cgroup) や Pod 全体の cgroup のメモリ使用量も公開しています。Pod 全体の cgroup の id は cgroup driver によって変わるため、下記では =~ で両方の値を指定しています。
-
container_memory_working_set_bytes{id="/"}ノード全体の Working Set (Pod + それ以外のプロセス)。ただし root cgroup にはmemory.currentが存在しないため、実体はコンテナの場合と違います (後述) -
container_memory_working_set_bytes{id=~"/kubepods|/kubepods.slice"}ノード上の Pod 全体の Working Set -
container_memory_total_active_file_bytes{id="/"}ノード全体の active なページキャッシュ
kubectl top node の MEMORY(bytes) と、後述する kubelet の Eviction 判定は、どちらもこの Working Set の値を使っています。
node-exporter 由来 (/proc/meminfo の値そのまま)
node-exporter は /proc/meminfo の内容をそのままメトリクスにしています。
-
node_memory_MemTotal_bytesノードの物理メモリの総量 -
node_memory_MemAvailable_bytesカーネルが見積もった「新しくメモリを要求したときに割り当てられる量」 -
node_memory_Cached_bytesページキャッシュの量。カーネルのドキュメントに "In-memory cache for files read from the disk (the pagecache) as well as tmpfs & shmem" とあるとおり tmpfs や共有メモリも含みます。その内訳はnode_memory_Shmem_bytesで確認できます
node_memory_MemAvailable_bytes は、回収可能なページキャッシュを空きとして数えます。ノードのメモリ使用率を 1 - MemAvailable / MemTotal として計算する方法は、node-exporter 公式の mixin にも NodeMemoryHighUtilization アラートとして含まれています (alerts.libsonnet)。expr はセレクタとしきい値が変数になっているので、それを除くと 100 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100) > <しきい値> という式です。しかし後述するとおり、この値は kubelet が Eviction の判定に使う使用率とは一致しません。
kubelet が Eviction の判定に使う値
kubelet は 10 秒ごとにノードのメモリの空き (memory.available) を確認し、しきい値を下回ると Pod の Eviction を開始します。この memory.available は、/proc/meminfo の MemAvailable ではなく、root cgroup の Working Set から計算されています。
memory.available = node_memory_MemTotal_bytes - container_memory_working_set_bytes{id="/"}
実装は Kubernetes の helper.go と helpers_others.go を参照してください。10 秒という間隔は evictionMonitoringPeriod で定義されています。
Eviction の判定 (と kubectl top node) に使う root cgroup の Working Set は、コンテナの Working Set とは実体が違います。 cgroup v2 では memory.current に CFTYPE_NOT_ON_ROOT が付いており、root cgroup には memory.current が存在しません (mm/memcontrol.c の memory_files[])。
{
.name = "current",
.flags = CFTYPE_NOT_ON_ROOT,
.read_u64 = memory_current_read,
},
一方 memory.stat には同じフラグが付いていないため、root でも読めます。そこで cAdvisor が使っている cgroup ライブラリは、memory.current の読み取りが ENOENT になったとき、root に限り memory.stat の anon + file を usage として代用します (opencontainers/cgroups の rootStatsFromMeminfo())。
// sum `anon` + `file` to report the same value as `usage_in_bytes` in v1.
stats.MemoryStats.Usage.Usage = stats.MemoryStats.Stats["anon"] + stats.MemoryStats.Stats["file"]
つまり id="/" の系列では次のようになります。
container_memory_usage_bytes{id="/"} = anon + file
container_memory_working_set_bytes{id="/"} = anon + file - inactive_file
memory.current と違い、slab・pagetables・kernel_stack・percpu・sock といったカーネルメモリが一切含まれません。 ノードの Working Set が /proc/meminfo 由来の使用量より小さく出やすいのは、主にこれが理由です。コンテナや Pod の cgroup では memory.current がそのまま読めるため、この代用は発生しません。
Working Set には active なページキャッシュが含まれるため、kubelet は active なページキャッシュを「使用中のメモリ」として扱います。
しきい値のデフォルト値は defaults_linux.go の DefaultEvictionHard のとおり Hard Eviction の memory.available<100Mi だけで、Soft Eviction はデフォルトでは設定されていません。ただしこの値は上書きできます。たとえば本記事の確認に使った v1.37 のクラスタでは memory.available<5% になっていました。自分のクラスタの設定は次のクエリで確認してください。
$ kubectl get --raw "/api/v1/nodes/<ノード名>/proxy/configz" | jq '.kubeletconfig | {evictionHard, evictionSoft}'
{
"evictionHard": {
"memory.available": "5%",
"nodefs.available": "5%",
"pid.available": "5%"
},
"evictionSoft": null
}
分母となるメモリ量は Capacity (node.status.capacity.memory、kube_node_status_capacity{resource="memory"}) であり、Allocatable ではありません。これらは node_memory_MemTotal_bytes と同じ値です。kubelet はマシンのメモリ容量として cAdvisor の値を使い、cAdvisor はこれを /proc/meminfo の MemTotal から読み取ります (raw/handler.go)。memory.available<5% のようなしきい値も、この Capacity に対する割合として評価されます。
Allocatable は Capacity からシステム予約分と Hard Eviction のしきい値を引いた値で、スケジューラが Pod の Request を割り当てる際の上限として使われます。Eviction が起きるかどうかの判定には使われません。詳細は Reserve Compute Resources for System Daemons をご覧ください。
node-exporter と kubelet で使用率が一致しない理由
ここまでの内容をまとめると、同じノードについて次の 2 つの見方が存在することになります。
| 見方 | 計算 | 値の出力元コンポーネント | active なページキャッシュ |
|---|---|---|---|
| node-exporter 由来のノードメモリ使用率 | 1 - MemAvailable / MemTotal |
node-exporter (/proc/meminfo) |
回収可能と見積もった分を空きに含める |
| kubelet の Eviction 判定 | ノードの Working Set / MemTotal |
cAdvisor (kubelet 組み込み。root cgroup の anon + file - inactive_file) |
全量を使用中として扱う |
MemAvailable は「新たに割り当てられるメモリ量」のカーネルによる推定値で、ページキャッシュの一部と回収可能な slab を空きに加算します。ページキャッシュを全量そのまま空きに数えるわけではありません。実装は mm/show_mem.c の si_mem_available() です。
/*
* Estimate the amount of memory available for userspace allocations,
* without causing swapping or OOM.
*/
available = global_zone_page_state(NR_FREE_PAGES) - totalreserve_pages;
/*
* Not all the page cache can be freed, otherwise the system will
* start swapping or thrashing. Assume at least half of the page
* cache, or the low watermark worth of cache, needs to stay.
*/
pagecache = global_node_page_state(NR_ACTIVE_FILE) +
global_node_page_state(NR_INACTIVE_FILE);
pagecache -= min(pagecache / 2, wmark_low);
available += pagecache;
/*
* Part of the reclaimable slab and other kernel memory consists of
* items that are in use, and cannot be freed. Cap this estimate at the
* low watermark.
*/
reclaimable = global_node_page_state_pages(NR_SLAB_RECLAIMABLE_B) +
global_node_page_state(NR_KERNEL_MISC_RECLAIMABLE);
reclaimable -= min(reclaimable / 2, wmark_low);
available += reclaimable;
読み取れる点が 3 つあります。
- 差し引かれるのは
min(半分, watermark_low)なので、ページキャッシュと回収可能 slab の少なくとも半分は空きに数えられます - 空きページ側からは
watermark_lowではなくtotalreserve_pagesが差し引かれます - ページキャッシュの項は file LRU (
NR_ACTIVE_FILE + NR_INACTIVE_FILE) なので、anon LRU に載るshmemは最初から空きに数えられません。tmpfs を多用するノードではMemAvailableも小さく出ます
重要なのは、この 2 つの値は必ず乖離するが、どちらが大きくなるかは環境によって変わるという点です。乖離を生む要因が 4 つあり、それぞれ逆の向きに働くためです。
| 要因 | どちらの使用率が高く出るか |
|---|---|
Working Set は active_file を全量「使用中」として数える |
kubelet の使用率が高く出る |
Working Set は inactive_file を全量「空き」として除く |
node-exporter の使用率が高く出る |
MemAvailable はページキャッシュの半分程度しか「空き」に数えない (shmem は最初から数えない) |
node-exporter の使用率が高く出る |
root の usage は anon + file で算出されるため、slab や pagetables などのカーネルメモリを一切含まない |
node-exporter の使用率が高く出る |
4 つのうち 3 つは「node-exporter のほうが高く出る」方向に働き、kubelet 側を高くする要因は active_file だけです。 そのため active_file が数 GiB まで積み上がって残り 3 つを上回ったときにだけ kubelet の使用率が高くなり、それ以外では node-exporter の使用率のほうが高く出ます。
kubelet 側が高く出るのは、active_file が積み上がりやすいワークロードが動いているノードです。次のようなものが該当します。
- 大きなファイルを読み書きするバッチ処理や ETL
- ログを大量に出力するサービス (stdout はコンテナランタイムがノード上のファイルへ書くため、ページキャッシュになります)
- ページキャッシュ前提で設計されたミドルウェア (Kafka、Elasticsearch など)
- ノード上のログファイルを読み続けるログ収集エージェント
基準になるのはファイルアクセスの多さで、言語やランタイムの種類ではありません。たとえば JVM のヒープは匿名ページなので、active_file ではなく RSS を押し上げます。
このようなノードでは、MemAvailable から計算した使用率が 33 % に見えているのに kubelet 側では 53 % に達している、という状態が起こりえます。MemAvailable 由来の使用率を見て「メモリには余裕がある」と判断していても、kubelet はしきい値に近づいており、Eviction が発生します。
一方、ページキャッシュがあまり積み上がっていないノードでは次のようになります。
MemTotal 3.820 GiB
MemAvailable 2.692 GiB → 1 - 2.692/3.820 = 29.6 % (node-exporter 側)
usage(/) 2.557 GiB (= root の anon + file)
inactive_file(/) 1.622 GiB
active_file(/) 0.227 GiB
Working Set(/) 0.935 GiB → 0.935/3.820 = 24.5 % (kubelet 側)
active_file が 0.23 GiB しかない一方で inactive_file が 1.6 GiB あるため、Working Set がそれを全量「空き」として除いた結果、kubelet 側が 5 ポイントほど低く出ています。kubelet 側が必ず高く出るわけではありません。
いずれにせよ、片方の値だけを見て「余裕がある / ない」を判断できないのが結論です。active_file は乖離の原因を確認するための補助指標で、その値がそのまま差分になるわけではありません。ノードのメモリ逼迫を調べる際は、次の PromQL で kubelet 視点の使用率も確認してください。
# kubelet 視点のノードメモリ使用率 (Eviction 判定に使われる値)
max by (node) (
container_memory_working_set_bytes{id="/"}
* on(instance) group_left(node) kubelet_node_name
)
/ max by (node) (kube_node_status_capacity{resource="memory"})
# 乖離の主な原因となる active なページキャッシュの量
max by (node) (
container_memory_total_active_file_bytes{id="/"}
* on(instance) group_left(node) kubelet_node_name
)
ラベルを揃える処理が入っているのは、cAdvisor の container_* メトリクスにはノードを示すラベルが 1 つも付いていないためです。実際に kubelet の /metrics/cadvisor を直接見ると、root cgroup の系列はこうなっています。
container_memory_working_set_bytes{container="",id="/",image="",name="",namespace="",pod=""} 8.92522496e+08
ノードが分かるのは Prometheus がスクレイプ時に付ける instance ラベルだけで、kubernetes_io_hostname のようなラベルは Prometheus の relabel 設定で付けているものです。relabel の設定次第で名前が変わる (そもそも無いこともある) ため、label_replace でそれを参照するのは環境依存になります。
そこで上記のクエリでは、kubelet が /metrics で公開している kubelet_node_name を使っています。
kubelet_node_name{node="memqos-control-plane"} 1
これは値が常に 1 の Gauge で、node ラベルにノード名がそのまま入っています。値そのものには意味がなく、伝えたい情報をラベルで表す info メトリクスのパターンです。kubelet 側の Help もそうなっています。
// NodeName is a Gauge that tracks the node's name. The count is always 1.
NodeName = metrics.NewGaugeVec(
&metrics.GaugeOpts{
Subsystem: KubeletSubsystem,
Name: NodeNameKey,
Help: "The node's name. The count is always 1.",
なお OpenMetrics では Info が独立した metric type として規定されており、サフィックスは _info が必須、サンプルの値は常に 1 です (OpenMetrics spec - Suffixes)。
Info metrics are used to expose textual information which SHOULD NOT change during process lifetime.
kubelet_node_name は _info サフィックスを持たず Gauge として公開されているので、厳密には OpenMetrics の Info 型ではありません。ただし「値は常に 1、情報はラベルに載せる」という使い方は同じなので、この記事では info メトリクスのパターンとして扱います。同種の例として kube-state-metrics の kube_node_info や node-exporter の node_uname_info があります。両方のエンドポイントは同じターゲットからスクレイプされるので instance が一致し、* on(instance) group_left(node) で node ラベルを持ち込めます。しかも付いた node ラベルは kube-state-metrics の node ラベルと同じ名前なので、そのまま結合できます。
relabel で独自のノードラベルを付けている環境なら、次のように label_replace を使っても構いません (kubernetes_io_hostname の部分は自分の環境の値に読み替えてください)。
max by (node) (
label_replace(
container_memory_working_set_bytes{id="/"},
"node", "$1", "kubernetes_io_hostname", "(.*)"
)
)
Eviction の対象は特定のネームスペースの Pod に限りません。ノードのメモリが逼迫すると、そのノード上のすべてのネームスペースの Pod が対象になります。どの Pod が選ばれるかは、(1) 使用量が Memory Request を超えているか、(2) Pod Priority、(3) Request をどれだけ超えているか、の順で決まります。詳細は Pod selection for kubelet eviction をご覧ください。
ただし critical pod は選定の対象外で、そもそも退去させられません (eviction_manager.go の evictPod())。critical pod は Kubernetes の公式ドキュメントにある用語で、PriorityClass に system-cluster-critical か system-node-critical を付けた Pod を指します (Guaranteed Scheduling For Critical Add-On Pods に "To mark a Pod as critical, set priorityClassName for that Pod to system-cluster-critical or system-node-critical." とあります)。
if kubelettypes.IsCriticalPod(pod) {
logger.Error(nil, "Eviction manager: cannot evict a critical pod", "pod", klog.KObj(pod))
return false
}
kubelet の判定はこの定義より広く、IsCriticalPod() は static Pod と mirror Pod も真にします。残りの条件は pod.Spec.Priority が SystemCriticalPriority (= 2 × 1000000000) 以上であることで、ユーザーが定義できる Priority の上限は HighestUserDefinablePriority (= 1000000000) なので、PriorityClass で該当するのは上記の 2 つです。この 2 つを付けた Pod は Evict されない代わりに、global OOM では保護されないことがあります。詳細はどのプロセスが kill されるかをご覧ください。
OOM と Eviction の調査方法
実際に OOMKilled や Evict が起きたときに、どの値をどの順で確認すればよいかをまとめます。
OOM Kill の 2 つの経路
コンテナのプロセスが OOM Kill される経路は 2 つあります。どちらもカーネルの OOM Killer による SIGKILL ですが、発生する条件と巻き込まれる範囲が異なります。
| 経路 | 発生条件 | kill される対象 | Node の Event |
|---|---|---|---|
| memcg OOM | コンテナ・Pod・/kubepods.slice いずれかの memory.max 超過 |
上限に達した cgroup 内のプロセス | 記録されません |
| global OOM | ノード全体のメモリ枯渇 | ノード上の全プロセスから oom_score で選出 |
kubelet が検知できた場合は SystemOOM が記録されます |
自分のコンテナが Limit を超えていなくても、同じノード上の別の Pod が原因で OOM Kill されることがあります。これが global OOM です。
Node の Event はあくまで kubelet が記録するものです。SystemOOM があれば global OOM と確定できますが、Event はデフォルトで 1 時間しか保持されないため、時間が経つと見つからなくなります。つまり SystemOOM が見つからないからといって、global OOM ではなかったとは言えません。
cgroup の上限超過による OOM Kill (memcg OOM)
resources.limits.memory は、コンテナの cgroup の memory.max として設定されます。ある cgroup の memory.current が memory.max に達したときの挙動は次のとおりです。上限は前述のとおりコンテナだけでなく Pod や /kubepods.slice にも設定されており、どの階層で達しても同じ流れになります。
- カーネルがその cgroup 内のメモリの回収を試みます。ページキャッシュは基本的にここで回収されます
- 回収しても
memory.maxに収まらない場合、cgroup の OOM Killer がその cgroup 内のプロセスを SIGKILL で終了させます - kill されたのがコンテナのメインプロセスであれば、
lastState.terminated.reasonがOOMKilledとなり、restartPolicy に従ってコンテナが再作成されます。子プロセスだけが kill された場合は、コンテナはそのまま動き続けるため、OOMKilledや再起動は観測されません
実装は mm/memcontrol.c の try_charge_memcg() です。retry: ラベルへ戻る goto ループになっており、次の順で処理が進みます。
| 手順 | 該当箇所 | 処理 |
|---|---|---|
| 1. charge を試みる | L2177-L2178 |
page_counter_try_charge() が成功すれば、そのまま割り当てて終了します |
| 2. 回収する | L2211-L2216 | 失敗したら try_to_free_mem_cgroup_pages() で回収し、mem_cgroup_margin() が要求分を満たせば 1 へ戻ります |
| 3. 再試行を数える | L2244-L2245 | 回収しても足りなければ nr_retries を 1 つ減らして 1 へ戻ります。初期値は MAX_RECLAIM_RETRIES (= 16。mm/internal.h) です |
| 4. OOM Killer を呼ぶ | L2259-L2264 | 再試行を使い切ってはじめて mem_cgroup_oom() を呼びます |
回収を挟んで最大 16 回再試行したあとにしか OOM に至りません。 2 の判定はコードでは次のようになっています。
nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages,
gfp_mask, reclaim_options, NULL);
psi_memstall_leave(&pflags);
if (mem_cgroup_margin(mem_over_limit) >= nr_pages)
goto retry;
カーネルのドキュメントの memory.max の記述も同じです。
Memory usage hard limit. ... If a cgroup's memory usage reaches this limit and can't be reduced, the OOM killer is invoked in the cgroup.
memory.current が memory.max に達しただけでは OOM は起きません。 ページキャッシュで上限に張り付いているのは正常な状態です。同じ理由で、Working Set が memory.max に達したことも OOM の条件ではありません。Working Set から引かれているのは inactive_file だけで、その中には回収可能なものがまだ残っています。
-
active_file: 圧力がかかれば inactive へ降格されて回収されます -
slab_reclaimable(dentry / inode キャッシュ): memcg の回収でもshrink_lruvec()に続いてshrink_slab()が呼ばれます (mm/vmscan.c のshrink_node_memcgs())
つまり「どちらかの値が上限に達したら OOM」というしきい値は存在せず、実際の条件は charge が失敗し、かつ回収で余地を作れなかったときです。回収できないのは主に匿名ページ (Swap なしの場合) と shmem なので、切り分けではここを見ます。
その上で、memcg OOM でカーネルが memory.max と比較するのは memory.current 全体であり、特定のメトリクス 1 つで決まるわけではありません。memory.current には匿名ページのほかに、カーネルメモリ、shmem (tmpfs や共有メモリ)、回収できない、あるいは直ちには回収できないページキャッシュも含まれます。
container_memory_rss は、このうち匿名ページが主因かどうかを切り分けるための指標です。RSS が Limit に迫っていれば匿名ページが主因と判断できますが、RSS が Limit に達していないことは OOM が起きない根拠にはなりません。RSS と Working Set の差が大きいときは、container_memory_usage_bytes (memory.current の値) と、ノード上の memory.stat の anon、file、shmem、slab などの内訳を併せて確認してください。
ノードのメモリ枯渇による OOM Kill (global OOM)
ノード全体のメモリが枯渇すると、カーネルの OOM Killer がノード上の全プロセスを評価し、oom_score が最も高いものを 1 つ選んで SIGKILL で終了させます。個々のコンテナが Limit を超えているかどうかは関係ありません。
ただし、ノードのメモリ逼迫に対してまず動くのは kubelet の Eviction です。 kubelet はノードを保護するために Pod を退去させ、その選定では「使用量が Memory Request を超えている Pod」が最優先になります (helpers.go の rankMemoryPressure())。
// rankMemoryPressure orders the input pods for eviction in response to memory pressure.
// It ranks by whether or not the pod's usage exceeds its requests, then by priority, and
// finally by memory usage above requests.
func rankMemoryPressure(pods []*v1.Pod, stats statsFunc) {
orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods)
}
limits も requests も設定していない Pod は必ず「Request 超過」側に入るため真っ先に選ばれます。つまりこの種の Pod がノードを逼迫させた場合、Eviction が間に合えば OOMKilled ではなく Evicted として観測されます。
global OOM が先に起きるのは、Eviction による回収がメモリの増加に追いつかないときです。追いつかない要因は複数あります。
| 要因 | 値 | 効果 |
|---|---|---|
| 評価の間隔 | 10 秒 (evictionMonitoringPeriod) |
検知が最大 10 秒遅れる |
| 1 回の評価で退去させる Pod 数 | 1 つだけ (eviction_manager.go の "we kill at most a single pod during each eviction interval") | 回収速度が「10 秒あたり 1 Pod」に律速される |
| 後片付けの待ち | 最大 30 秒 (podCleanupTimeout) |
次の評価までさらに遅れることがある |
| 既定のしきい値 | memory.available<100Mi |
大きなノードでは極端に薄い |
memory.available の算出方法 |
root cgroup の anon + file から計算 |
カーネルメモリを含まないため空きを過大評価する |
Kubernetes の公式ドキュメントにも次のように記載されています (Node out of memory behavior)。
If the node experiences an out of memory (OOM) event prior to the kubelet being able to reclaim memory, the node depends on the oom_killer to respond.
起動直後に一気にメモリを確保するアプリケーションや、急激なリクエスト増に応じてメモリを確保するアプリケーションが同一ノードに同居している場合に起こりやすくなります。
Eviction はノードを保護する仕組みで、個々の Pod を OOM から守るものではありません。 limits を設定していないコンテナにはそもそも memory.max による歯止めがないため、Eviction が最後の防衛線になっているだけです。
しかも rankMemoryPressure() は使用量の大きさより Pod Priority を先に見ます。原因の Pod が高 Priority なら、使用量の小さい低 Priority の Pod が先に退去させられ、逼迫が解消しないまま global OOM に至ることがあります。Eviction を頼りにせず、limits と requests を設定するのが本来の対策です。
どのプロセスが kill されるか
カーネルは各プロセスの oom_score が最も高いものを kill します。kubelet はコンテナの QoS クラスに応じて oom_score_adj (oom_score への加点) を設定するため、QoS クラスと Memory Request の設定が、そのまま kill されやすさになります。QoS クラスは kubectl get pod <Pod 名> -o jsonpath='{.status.qosClass}' で確認でき、決まり方は Pod Quality of Service Classes をご覧ください。
| 条件 | oom_score_adj |
kill されやすさ |
|---|---|---|
system-node-critical (QoS クラスより先に評価されます) |
-997 |
最も kill されにくい |
| Guaranteed | -997 |
最も kill されにくい |
| Burstable |
1000 - 1000 × Memory Request / ノードのメモリ容量 (3〜999 に丸められます) |
Request が小さいほど kill されやすい |
| BestEffort | 1000 |
最も kill されやすい |
実装は Kubernetes の policy.go の GetContainerOOMScoreAdjust() です。QoS クラスの判定より先に system-node-critical が評価されます。
if types.IsNodeCriticalPod(pod) {
// Only node critical pod should be the last to get killed.
return guaranteedOOMScoreAdj
}
Burstable の丸め込みは、下限が 1000 + guaranteedOOMScoreAdj (= 3)、上限が besteffortOOMScoreAdj - 1 (= 999) として実装されています。Request を実使用量より小さく設定している Pod は、Eviction でも global OOM でも狙われやすくなります。
PriorityClass の効き方は Eviction と global OOM で違います。 Priority を上げれば両方から守られる、と考えると読み違えます。
| PriorityClass | Eviction | global OOM (oom_score_adj) |
|---|---|---|
ユーザー定義 (〜1000000000) |
値が大きいほど後回し | QoS クラスと Request で決まる |
system-cluster-critical |
対象外 (退去させられません) | QoS クラスと Request で決まる (保護されません) |
system-node-critical |
対象外 (退去させられません) | -997 固定 |
| static / mirror Pod | 対象外 (退去させられません) | QoS クラスと Request で決まる |
Eviction の列が「対象外」の 3 行が、前述の critical pod (IsCriticalPod() が真になる Pod) にあたります。
oom_score_adj で保護されるのは IsNodeCriticalPod() が真になる system-node-critical だけです。system-cluster-critical の Pod が BestEffort だと oom_score_adj は 1000 になり、global OOM では最優先で kill されます。「Priority を上げたのに OOMKilled になった」はこの経路です。
global OOM でも、Eviction のように Pod が削除されることはありません。その後の挙動は restartPolicy によって変わります。
-
AlwaysまたはOnFailure: 同じノードでコンテナが再作成されます。Pod は同じノードに留まるため、原因が解消しなければ再び OOM Kill が起こります -
Never: コンテナは再作成されず、Pod はFailedのまま残ります。Job などコントローラーが後継の Pod を作る場合、その Pod は別のノードに配置されることがあります
OOMKilled が繰り返されるのに Limit に余裕がある場合は、この経路を疑ってください。
OOM Kill によってメモリが解放されるため、kubelet が次にメモリの空きを評価する時点では逼迫が解消していることがあります。その場合、ノードには MemoryPressure の Condition や node.kubernetes.io/memory-pressure:NoSchedule の Taint は設定されず、新しい Pod のスケジュールも止まりません。逆に逼迫が続いていれば、後続の評価で Condition と Taint が設定され、Eviction も発生します。OOM Kill が起きたかどうかではなく、評価時点の memory.available で決まる点に注意してください。
どちらの経路かを切り分ける
Kubernetes の情報で切り分ける
まず kubectl で確認できる情報から切り分けます。
| 確認先 | 分かること |
|---|---|
kubectl describe pod の Last State、または kubectl get pod -o yaml の lastState.terminated
|
OOM Kill が起きたこと (reason: OOMKilled)。経路は区別できません
|
kubectl describe node <ノード名> の Events |
SystemOOM が記録されていれば global OOM です。記録がない場合は「memcg OOM である」ことにはならず、判定できないだけです |
Pod 側の Event に OOM 専用のものはありません。再起動に伴う BackOff などが出るだけです。
確認は次の手順で行います。SystemOOM は Node に紐づく Event で、Node は namespaced なオブジェクトではないため、Event 自体は default ネームスペースに記録される点に注意してください。
# 1. OOMKilled になった Pod が、どのノードに配置されているかを確認する
$ kubectl get pod -n <Namespace> <Pod 名> -o wide
NAME READY STATUS RESTARTS AGE IP NODE
myapp-... 1/1 Running 5 (2m ago) 1h 10.0.0.1 node-1
# 2. そのノードの Event に SystemOOM があるかを確認する
$ kubectl describe node node-1
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning SystemOOM 3m kubelet System OOM encountered, victim process: java, pid: 12345
# Event だけを一覧したい場合
$ kubectl get events -n default --field-selector reason=SystemOOM,involvedObject.name=node-1
SystemOOM があれば、そのノードで global OOM が起きたことは確定です。ただし kill されたのが対象のコンテナとは限らないため、SystemOOM の時刻とコンテナの終了時刻 (lastState.terminated.finishedAt) が対応しているかも確認してください。
SystemOOM が見つからないことは、memcg OOM である根拠にはなりません。 Event の TTL 切れ、kubelet の再起動、Event の取得漏れでも同じ状態になります。
Event の保持期間は kube-apiserver の --event-ttl で決まり、デフォルト値は 1 時間です (options.go)。つまり発生から 1 時間以上経った SystemOOM は kubectl では確認できません。Event を外部のログ基盤へ転送している場合はそちらを確認してください。
判定できない場合は、次を見て推測します。
- 発生時刻に
container_memory_usage_bytesが Limit に迫っていたか (迫っていれば memcg OOM の可能性が高い) - 発生時刻にkubelet 視点のノードメモリ使用率が高かったか (高ければ global OOM の可能性が高い)
なぜ OOMKilled では経路を区別できないのか
コンテナランタイム (containerd) は、タスクの終了コードが 137 だった場合に cgroup の memory.events の oom_kill を確認し、1 以上であれば終了理由を OOMKilled に更新します (events.go)。確認処理は同じファイルの oomMetricsEventOccurred() です。
switch v := taskMetricsAny.(type) {
case *cg1.Metrics:
return v.GetMemoryOomControl().GetOomKill() > 0, nil
case *cg2.Metrics:
return v.GetMemoryEvents().GetOomKill() > 0, nil
memory.events の oom_kill の定義は "The number of processes belonging to this cgroup killed by any kind of OOM killer" です。つまり global OOM でカーネルに選ばれて kill された場合もカウントされるため、どちらの経路でも OOMKilled になります。
なぜ SystemOOM は global OOM のときだけ記録されるのか
SystemOOM は kubelet が Node オブジェクトに記録する Event です。kubelet はカーネルログの OOM メッセージを解析し、OOM が起きた cgroup が root (/) と判定された場合にだけこれを記録します (oom_watcher_linux.go)。
for event := range outStream {
// Count every OOM kill per container to back the
// container_oom_events_total metric.
recordOOMKill(event.ContainerName)
if event.VictimContainerName == recordEventContainerName {
...
ow.recorder.WithLogger(logger).Eventf(ref, v1.EventTypeWarning, systemOOMEvent, "%s", eventMsg)
}
}
これがちょうど global OOM と一致するのは、カーネルのログ形式とパーサーの組み合わせによります。カーネルは memcg の上限超過による OOM では oom_memcg=<cgroup パス> を出力しますが、global OOM では代わりに ,global_oom を出力します (mm/memcontrol.c の mem_cgroup_print_oom_context())。
if (memcg) {
pr_cont(",oom_memcg=");
pr_cont_cgroup_path(memcg->css.cgroup);
} else
pr_cont(",global_oom");
一方でパーサー側は oom_memcg= を含む形式にしか一致しません (oomparser.go)。
containerRegexp = regexp.MustCompile(`oom-kill:constraint=(.*),nodemask=(.*),cpuset=(.*),mems_allowed=(.*),oom_memcg=(.*),task_memcg=(.*),task=(.*),pid=(.*),uid=(.*)`)
そのため global OOM のメッセージでは cgroup を抽出できず、OomInstance の初期値である / がそのまま残ります (oomparser.go の StreamOoms)。
oomCurrentInstance := &OomInstance{
ContainerName: "/",
VictimContainerName: "/",
TimeOfDeath: msg.Timestamp,
}
つまり SystemOOM の有無は「経路を直接判定した結果」ではなく「カーネルログを解析できたかどうかの結果」です。とはいえ実用上は、記録があれば global OOM と断定してよい一方で、記録がないことは何の根拠にもならない、と理解しておけば十分です。
メトリクスでの見え方
メトリクスは経路をラベルで区別しません。カーネルの __oom_kill_process() が、経路を問わず同じイベントを記録するためです。
| メトリクス | 出力元コンポーネント | memcg OOM | global OOM |
|---|---|---|---|
node_vmstat_oom_kill |
node-exporter (/proc/vmstat の oom_kill) |
増えます | 増えます |
container_oom_events_total |
kubelet (カーネルログの解析結果) | kill されたプロセスが属するコンテナの系列が増えます | コンテナの系列ではなく id="/" の系列が増えます |
cgroup v2 の memory.events の oom_kill
|
(メトリクスではなく cgroup のファイル。containerd が参照します) | 増えます | 増えます |
container_oom_events_total だけ挙動が異なるのは、前述のとおり global OOM ではパーサーが cgroup を抽出できず、root (/) として扱われるためです。これはログを解析できたかどうかの結果にすぎないため、経路の判定には使わず、SystemOOM の有無で判断してください。
container_oom_events_total はメトリクス名に container_ が付いているものの、v1.37 では cAdvisor ではなく kubelet 側でカウントしています (oom_counter.go の recordOOMKill())。値の意味は変わりませんが、コードを追う際は探す場所が変わります。
切り分けた後に見るもの
- global OOM と判断できる場合は、ノード全体のメモリ枯渇が原因です。同じノードに配置されている Pod の Memory Request と実使用量を比較し、Request が過少な Pod がないかを確認します
-
memcg OOM と判断できる場合は、そのコンテナの Memory Limit と実使用量を見直します。コンテナ単位で Limit に余裕があるように見える場合は、Pod 全体や
/kubepods.sliceの上限に達している可能性もあります - どちらとも判断できない場合は、両方の可能性を残したまま、コンテナの Limit と実使用量、ノードのメモリ使用率の両方を確認します
調査の進め方
メモリに起因する事象を調査する際は、次の順で確認すると原因を絞り込みやすくなります。
1. Pod が OOMKilled になった場合
-
container_memory_usage_bytesが Limit に迫っていないかを確認します。memcg OOM の判定対象はこの値です -
container_memory_rssと見比べて、匿名ページが主因かどうかを切り分けます。RSS が低くても OOM は起こりえます - Working Set や usage と RSS の差が大きい場合は、ページキャッシュ・
shmem・カーネルメモリが使用量を押し上げている可能性があります - Limit に対して余裕があるのに OOMKilled が起きている場合は、global OOM を疑います。ノードの Event に
SystemOOMが記録されていれば global OOM です。記録がない場合でも memcg OOM とは限りません - メトリクスにスパイクが見えない場合は、スクレイプ間隔より短い急増を疑います。確認する値はWorking Set が Limit に達していないのに OOMKilled になりますをご覧ください
2. Pod が Evict された場合
-
The node was low on resource: memory.の場合は、ノード全体のメモリ逼迫が原因です - 上記の PromQL で kubelet 視点のノードメモリ使用率を確認します。node-exporter 由来のノードメモリ使用率だけでは判断できません
- 同じノード上で、実使用量に対して Memory Request が小さい (または未設定の) Pod が同居していないかを確認します。Pod ごとの
container_memory_working_set_bytes(cAdvisor) とkube_pod_resource_request{resource="memory"}(kube-scheduler) を見比べます。Request が大きい Pod はスケジューラが配置密度を抑えるため、逼迫の原因になりにくい点に注意してください
3. 再発を防ぐ場合
- 実使用量に見合った Memory の Request を設定します。もっとも効果が大きい対策です。確認用の PromQL は Memory Request を実際の使用量に合わせるにまとめています
- memcg OOM が起きているコンテナについては、あわせて Limit も見直します
- メモリ使用量の大きい Pod が同一ノードに集まらないよう、Pod Anti-Affinity や Topology Spread Constraints で配置を制御します
PSI (Pressure Stall Information) で確認する
使用量が Limit に達していないのに遅い、というケースの補助指標です。メモリの回収待ちで実際に止まっていた時間が分かります。
PSI の見方と PromQL (クリックで展開)
コンテナがメモリの確保待ちで停止していた時間を PSI として取得できます。使用量が Limit に達していなくても、メモリの回収処理で待たされている状況を検知できます。
| メトリクス | 出力元コンポーネント | 説明 |
|---|---|---|
container_pressure_memory_waiting_seconds_total |
cAdvisor (kubelet 組み込み) | 一部のプロセスがメモリ待ちだった時間 (memory.pressure の some の total) |
container_pressure_memory_stalled_seconds_total |
cAdvisor (kubelet 組み込み) | すべてのプロセスがメモリ待ちで停止していた時間 (memory.pressure の full の total) |
これらのメトリクスは KubeletPSI feature gate で制御されており、v1.36 で GA となりデフォルトで有効 (無効化不可) になりました。ただしカーネル側が PSI に対応している必要があり、kubelet は cgroup の root に cpu.pressure があるかで判定しています (cadvisor_linux.go)。メトリクスが出てこない場合は、カーネルが CONFIG_PSI=y でビルドされているか、カーネルパラメータで psi=0 になっていないかを確認してください。
memory.pressure には avg10 / avg60 / avg300 という「直近 N 秒の平均 (%)」もありますが、Prometheus メトリクスとして公開されているのは累積値 (total) だけです。そのため割合を見るには rate() を使います。
単位は秒なので、rate() の結果は「1 秒あたり何秒待たされたか」= 0〜1 の比率になります。100 倍すればそのまま percent です。
# Pod ごとの「全プロセスがメモリ待ちで停止していた時間」の割合 (%)
100 * max by (namespace, pod) (
rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m])
)
# Pod ごとの「一部のプロセスがメモリ待ちだった時間」の割合 (%)
100 * max by (namespace, pod) (
rate(container_pressure_memory_waiting_seconds_total{container="", image="", pod!=""}[5m])
)
メモリに余裕がある状態では、どちらも増えません。つまり rate() は 0 のままになります。逼迫すると waiting (some) から先に立ち上がり、悪化すると stalled (full) が続きます。waiting は一部のプロセスが待っただけでも計上されるので、問題の深刻度を見るなら stalled のほうです。こちらはコンテナ内のすべてのプロセスが止まっていた時間なので、値が出ていればスループットに直接影響しています。
ノード全体のメモリ逼迫を見る場合は、root cgroup の系列を使います。
# ノード全体のメモリ待ちで完全に停止していた時間の割合 (%)
100 * max by (node) (
rate(container_pressure_memory_stalled_seconds_total{id="/"}[5m])
* on(instance) group_left(node) kubelet_node_name
)
「Limit に達していないのに苦しんでいるコンテナ」は、PSI と使用率を組み合わせると見つけられます。container_spec_memory_limit_bytes は cAdvisor が公開しているコンテナの Limit です。
# usage が Limit の 80 % 未満なのに、メモリ待ちで停止しているコンテナ
(
100 * rate(container_pressure_memory_stalled_seconds_total{container!="", image!=""}[5m]) > 1
)
and
(
container_memory_usage_bytes{container!="", image!=""}
/ container_spec_memory_limit_bytes{container!="", image!=""} < 0.8
)
and の両辺はラベルの組が完全に一致する系列だけを残します。Limit を設定していないコンテナは container_spec_memory_limit_bytes が 0 になり、除算結果が +Inf となって < 0.8 を満たさないため、自動的に除外されます。
アラートにする場合の例です。stalled が継続して発生している状態を拾います。
- alert: ContainerMemoryStalled
# 5 分平均で 5 % 以上、メモリ待ちで完全に停止している状態が 10 分続いたら発火
expr: |
100 * max by (namespace, pod) (
rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m])
) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} がメモリの確保待ちで停止しています"
description: "Memory Limit に達していなくても、回収処理で待たされている可能性があります。usage と RSS の差、ページキャッシュの量を確認してください。"
しきい値の 5 % は、環境によって適正値が変わります。まずはアラートを設定せずに値の分布を確認し、平常時の水準を把握してから決めてください。
PSI の詳細は Understand Pressure Stall Information (PSI) Metrics をご覧ください。
対処方法
原因が分かったあとの対処です。OOM と Eviction をまとめて減らすのにもっとも効くのは Memory Request の適正化なので、そこを中心に説明します。
Memory Request を実際の使用量に合わせる
ここまで見てきたとおり、OOM の発生を抑えるうえでもっとも効果があるのは、Memory Request を実際の使用量に合わせることです。
resources.requests.memory は cgroup には設定されないため、コンテナが使えるメモリ量を直接制限するものではありません。それにもかかわらず Request が重要なのは、Request が次の 3 つを通じて OOM Kill と Eviction の起こりやすさを決めているからです。
- 配置先の決定 (global OOM と Eviction の両方の起こりやすさ): スケジューラは Request だけを見て配置先を決めます。実際の使用量は見ません。Request が未設定または実使用量より小さい Pod は、ノードの空きメモリを過大評価したまま配置されます。その結果、メモリ使用量の大きい Pod が同一ノードに集中してノード全体が逼迫し、global OOM と Eviction の両方を招きます
- Eviction 対象の選定: kubelet は、実使用量が Memory Request を超えている Pod を優先的に Evict します。Request を実使用量より小さく設定していると、その Pod は Evict されやすくなります
-
oom_score_adj(global OOM で kill される順番): 前述のとおり Burstable のoom_score_adjは1000 - 1000 × Memory Request / ノードのメモリ容量です。Request を小さくするほどoom_score_adjが大きくなり、global OOM で kill 対象に選ばれやすくなります。Request 未設定なら BestEffort になり1000、つまり最優先で kill されます
「事象が起きるかどうか」と「どの Pod が選ばれるか」は別の話なので、対応を整理すると次のようになります。
| Request の経路 | memcg OOM の発生 | global OOM の発生 | global OOM の犠牲選定 | Eviction の発生 | Eviction の対象選定 |
|---|---|---|---|---|---|
| 配置先の決定 (スケジューラ) | — | ○ | — | ○ | — |
oom_score_adj |
— | — | ○ | — | — |
| Eviction 対象の選定 | — | — | — | — | ○ |
発生に効くのは配置先の決定だけで、これは global OOM と Eviction の両方に効きます。どちらも「ノードの逼迫」から始まるためです。ただし効き方は間接的で、memory.available の算出に Request は登場しないため (前述の makeMemoryAvailableSignalObservation())、Request を上げてしきい値が緩むわけではありません。配置密度が下がる結果として逼迫しにくくなるだけです。
残る 2 つは、逼迫したあとに自分の Pod が選ばれるかどうかにしか効きません。そして Request が効かないのは memcg OOM だけです。
逆に言えば、Request を実使用量に合わせるだけで、「ノードが逼迫しにくくなる」「逼迫しても自分の Pod が選ばれにくくなる」という 2 つの効果が同時に得られます。Limit を増やすのとは違い、Request を適正化してもノード全体で使えるメモリの総量は変わらないため、コストに跳ね返りにくいのも利点です。
Request の適正化は memcg OOM (コンテナ単位の Limit 超過) には効きません。memcg OOM を減らすには Limit の見直しが必要です。この章の内容は、global OOM と Eviction を減らすための対策です。どちらが起きているかはどちらの経路かを切り分けるで判別してください。
Request と実使用量を比べる PromQL
Request が実使用量に合っているかは PromQL で確認できます。Pod ごとの比率、Request 未設定の Pod の洗い出し、ノード単位での比較、過去の使用量から推奨値を求めるクエリを載せました。メトリクスの出どころによる落とし穴 (二重計上など) があるので、そこも含めて説明します。
確認用の PromQL とフィルタの注意点 (クリックで展開)
比較する前に知っておくこと
Pod の Request は、kube-scheduler が /metrics/resources エンドポイントで公開する kube_pod_resource_request で確認できます。これを実使用量 (container_memory_working_set_bytes) と比べるのですが、両者は出どころが違うため、そのまま割り算すると正しい結果になりません。次の 5 点を押さえておいてください。
| 注意点 | 内容 |
|---|---|
| スクレイプ設定が必要 |
kube_pod_resource_request は kube-scheduler のセキュアポート (デフォルト 10259) の /metrics/resources にあります。/metrics とは別のエンドポイントなので、Prometheus 側に明示的な設定が必要です |
| Request が未設定の Pod は系列が出ない | コレクタが if val.IsZero() { return } としているため、Request が 0 や未設定の Pod はメトリクスに現れません。もっとも問題のある Pod がクエリから漏れるので、後述の unless で別途洗い出します |
| Pod 単位の集計値 |
kube_pod_resource_request は Pod 全体の合計で、container ラベルはありません。実使用量も Pod 単位に揃えてから比べる必要があります (揃え方は後述) |
| 重複スクレイプ | kube-scheduler は HA 構成で複数レプリカが動くため、同じ Pod の系列が instance 違いで複数スクレイプされることがあります。合計すると二重計上になるので、max by (...) で正規化してから集計します |
| 終了した Pod は除外される | Succeeded / Failed の Pod は集計対象外です |
Pod 単位のメモリ使用量はどのメトリクスで取れるか
kube_pod_resource_request は Pod 単位の値なので、実使用量も Pod 単位に揃える必要があります。ここで「kube-state-metrics に Pod のメモリ使用量のメトリクスはないのか」が気になりますが、ありません。
kube-state-metrics は Kubernetes API のオブジェクト (spec と status) をそのままメトリクスに変換するコンポーネントで、リソースの実使用量は一切扱いません。kube_pod_container_resource_requests / kube_pod_container_resource_limits のようにマニフェストに書かれた設定値は取れますが、使用量は対象外です。使用量は cgroup を読む必要があり、それは cAdvisor と kubelet の役割です。
Pod 単位の実使用量を取る方法は次の 3 つがあります。
| 方法 | メトリクス | 出力元コンポーネント | 特徴 |
|---|---|---|---|
| コンテナを合算する | sum by (namespace, pod) (container_memory_working_set_bytes{...}) |
cAdvisor (/metrics/cadvisor) |
どの環境でも使える。フィルタを間違えると二重計上する (後述) |
| Pod の cgroup を直接見る | container_memory_working_set_bytes{container="", image="", pod!=""} |
cAdvisor (/metrics/cadvisor) |
合算が不要。pause コンテナや Pod 単位の cgroup の計上分も含むため、コンテナの合計より少し大きくなる。image="" が必須 (後述) |
| kubelet の Pod 単位メトリクス | pod_memory_working_set_bytes{namespace, pod} |
kubelet の /metrics/resource
|
もっとも素直だが、/metrics/cadvisor とは別のパスなので Prometheus にスクレイプ設定を足す必要がある |
2 番目と 3 番目は同じ値です。kubelet は pod_memory_working_set_bytes を Pod 単位の cgroup から取得しているため、コンテナの合計ではありません。
podUID := types.UID(podStats.PodRef.UID)
// Lookup the pod-level cgroup's CPU and memory stats
podInfo := getCadvisorPodInfoFromPodUID(podUID, allInfos)
if podInfo != nil {
cpu, memory := cadvisorInfoToCPUandMemoryStats(podInfo)
podStats.CPU = cpu
podStats.Memory = memory
pod_memory_working_set_bytes は STABLE なメトリクスなので、Prometheus が /metrics/resource をスクレイプしている環境ならこれを使うのがもっとも簡単です。
# Pod 単位の実使用量 (kubelet の /metrics/resource をスクレイプしている場合)
pod_memory_working_set_bytes
一方 /metrics/resource をスクレイプしていない環境では、Pod の cgroup の系列を直接指定します。/metrics/resource は /metrics/cadvisor とは別のパスなので、Prometheus 側に設定を追加していなければ収集されません。
# Pod 単位の実使用量 (Pod の cgroup の系列を直接見る)
max by (namespace, pod) (
container_memory_working_set_bytes{container="", image="", pod!=""}
)
container="" で Pod の cgroup が取れるのは、kubelet がメトリクスにラベルを付けるときに、Pod の cgroup の系列にも pod と namespace を付けているためです。コード上でも "Associate pod cgroup with pod so we have an accurate accounting of sandbox" と説明されています (server.go)。
image="" を省略してはいけません。 container="" だけで絞ると、Pod の cgroup に加えて pause コンテナ (サンドボックス) の系列も一緒に引っかかります。containerd 環境では pause コンテナの container ラベルは空文字で、image ラベルには pause イメージが入ります。
たとえば 100 個の Pod が動いているクラスタなら、系列数は次の 3 種類に分かれます。
| セレクタ | 系列数 | 実体 |
|---|---|---|
{container="", pod!=""} |
200 | Pod の cgroup + pause コンテナ |
{container="", image="", pod!=""} |
100 | Pod の cgroup のみ |
{container="", image!="", pod!=""} |
100 | pause コンテナのみ |
max by (namespace, pod) なら Pod の cgroup のほうが大きいため結果的に正しい値が返りますが、sum by にすると pause の分が足されて二重計上になります。ある Pod の値で見ると次のようになります。
Pod の cgroup 666,116,096 ← 正しい値
実コンテナの合計 665,882,624
pause コンテナ 225,280
sum{container="",pod!=""} 666,341,376 ← Pod cgroup + pause の二重計上
container のフィルタを付けずに sum by (namespace, pod) すると、各コンテナの分と Pod の cgroup の分が両方足されるため、値がほぼ 2 倍になります。コンテナを合算する場合は、次のように実コンテナだけに絞ってください。
# Pod 単位の実使用量 (コンテナを合算する場合)
sum by (namespace, pod) (
container_memory_working_set_bytes{container!="", image!=""}
)
containerd 環境では pause コンテナの container が空なので、container!="" だけで Pod の cgroup・root cgroup・pause のすべてを除外できます。image!="" を併記しているのは、実コンテナ以外の系列が image を持たないためで、container!="" だけでは取り切れない系列が残る環境への保険になります。CRI-O では pause コンテナが container="POD" として現れるため、その場合は container!="POD" も追加してください。
以降のクエリでは、環境を問わず使える「Pod の cgroup を直接見る」形を使います。
Pod ごとに Request と実使用量を比べる
Request に対して実際にどれだけ使っているかの比率です。1 を超えている Pod は Request が過少で、Eviction と global OOM の両方で狙われやすい状態です。
# Memory Request に対する実使用量の比率 (1 を超えていると Request が過少)
max by (namespace, pod) (
container_memory_working_set_bytes{container="", image="", pod!=""}
)
/
max by (namespace, pod) (
kube_pod_resource_request{resource="memory", unit="bytes"}
)
バイト数の差分で見たい場合は次のようにします。正の値が「Request を超過している量」です。
# Request の超過量 (バイト)
max by (namespace, pod) (
container_memory_working_set_bytes{container="", image="", pod!=""}
)
-
max by (namespace, pod) (
kube_pod_resource_request{resource="memory", unit="bytes"}
)
Request が未設定の Pod を洗い出す
前述のとおり Request 未設定の Pod は kube_pod_resource_request に現れないため、比率のクエリには出てきません。unless で「使用量の系列はあるが Request の系列がない Pod」を探します。これが BestEffort の Pod、つまり global OOM で最初に kill される候補です。
# Memory Request が未設定の Pod と、その実使用量
max by (namespace, pod) (
container_memory_working_set_bytes{container="", image="", pod!=""}
)
unless
max by (namespace, pod) (
kube_pod_resource_request{resource="memory", unit="bytes"}
)
ノード単位で Request の合計と実使用量の合計を比べる
ノードごとに「スケジューラが把握している使用量 (Request の合計)」と「実際の使用量」を比べます。実使用量のほうが大きいノードは、スケジューラが空きを過大評価しているノードで、global OOM と Eviction のリスクが高い状態です。
kube_pod_resource_request と kube_node_status_allocatable はどちらも node ラベルを持つため、この 2 つは label_replace なしで結合できます。
# ノードごとの Memory Request 合計 / Allocatable
sum by (node) (
max by (node, namespace, pod) (
kube_pod_resource_request{resource="memory", unit="bytes"}
)
)
/
max by (node) (
kube_node_status_allocatable{resource="memory"}
)
実使用量の側は cAdvisor 由来で node ラベルを持たないため、前述の PromQL と同じように label_replace でラベルを揃えます。/kubepods.slice の Working Set を使うと、Pod ごとの合計を取らずにノード上の Pod 全体の使用量が得られます。
# ノード上の Pod 全体の実使用量 / Allocatable
max by (node) (
container_memory_working_set_bytes{id=~"/kubepods|/kubepods.slice"}
* on(instance) group_left(node) kubelet_node_name
)
/
max by (node) (
kube_node_status_allocatable{resource="memory"}
)
この 2 つを並べて、下のクエリ (実使用量) が上のクエリ (Request) を上回っているノードを探します。
Request の推奨値を過去の使用量から求める
Request は瞬間値ではなく、一定期間の使用量から決めます。メモリは CPU と違って回収できない (圧縮不可能な) リソースなので、平均ではなくピークに近い値を基準にするのが安全です。
# 過去 7 日間の Pod ごとのメモリ使用量の最大値 (Request の目安)
max_over_time(
max by (namespace, pod) (
container_memory_working_set_bytes{container="", image="", pod!=""}
)[7d:5m]
)
一時的なスパイクに引っ張られたくない場合は分位点を使います。ただしこの値を Request にすると、ピーク時には Request 超過になり Eviction の候補に入る点は理解しておいてください。
# 過去 7 日間の 95 パーセンタイル
quantile_over_time(0.95,
max by (namespace, pod) (
container_memory_working_set_bytes{container="", image="", pod!=""}
)[7d:5m]
)
このクエリで得られる最大値は、Prometheus のスクレイプ間隔より短いスパイクを取りこぼしています。起動直後に一時的に大きくメモリを確保するアプリケーションでは、実際のピークがこの値より大きいことがあります。Working Set が Limit に達していないのに OOMKilled になりますと同じ理由です。余裕を持たせるか、アプリケーション側の起動時の挙動を把握した上で決めてください。
また Working Set には active なページキャッシュが含まれます。ログ出力やファイル I/O が多いコンテナでは、この値をそのまま Request にすると過大になります。container_memory_rss と見比べて、どちらを基準にするか判断してください。
kubectl top を Request 設計の根拠にしない
kubectl top pod は手軽ですが、Request の設計には向きません。理由は Metrics Server の設計そのものにあります。
- Metrics Server が kubelet から取得しているのは
/metrics/resourceエンドポイントの値で、メモリは Working Set です ("Memory is reported as the working set at the instant the metric was collected")。Prometheus 経由で見るcontainer_memory_working_set_bytesと同じ値なので、ページキャッシュを含む点も同じです - Metrics Server は直近の値しか保持しません。デフォルトの取得間隔は 60 秒 (
--metric-resolution) で、履歴は残らないため、過去のピークを調べられません - Metrics Server 自身の README が、用途として "Don't use Metrics Server when you need: ... An accurate source of resource usage metrics" と明記しています (README.md)
kubectl top は「いま何が多く使っているか」の当たりを付ける用途に使い、Request を決めるときはRequest と実使用量を比べる PromQLで期間を指定して確認してください。
VPA で Request を自動調整する
前節の PromQL を手で回す代わりに、Vertical Pod Autoscaler (VPA) に推奨値を計算させることもできます。考え方は同じ (過去の使用量の分位点から出す) で、VPA はそれを OOM 検知と組み合わせて継続的に回してくれます。
VPA の設定例とデフォルト値 (クリックで展開)
VPA は 3 つのコンポーネントで構成されます。
| コンポーネント | 役割 |
|---|---|
| Recommender | 使用量の履歴から推奨値を計算し、VPA オブジェクトの status.recommendation へ書き込む |
| Updater | 推奨値から離れている Pod を退去させ、再作成を促す |
| Admission Controller | Pod 作成時に Mutating Webhook で resources を推奨値へ書き換える |
まずは updateMode: Off で推奨値だけ見る
いきなり Pod を作り替えられると困るので、最初は updateMode: Off で推奨値の算出だけを行わせるのが安全です。この場合 VPA は Pod に一切手を出さず、推奨値を VPA オブジェクトに書くだけです。API の定義にも "This can be used for a "dry run"" と書かれています。
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: sample-vpa
namespace: default
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: sample
updatePolicy:
updateMode: "Off" # 推奨値の算出のみ。Pod は変更されない
resourcePolicy:
containerPolicies:
- containerName: app
controlledResources: ["memory"] # デフォルトは ["cpu", "memory"]
controlledValues: RequestsOnly # デフォルトは RequestsAndLimits
minAllowed:
memory: 128Mi
maxAllowed:
memory: 4Gi
推奨値は次のように確認します。
$ kubectl describe vpa sample-vpa
...
Status:
Recommendation:
Container Recommendations:
Container Name: app
Lower Bound:
Memory: 262144k
Target:
Memory: 367001600
Uncapped Target:
Memory: 367001600
Upper Bound:
Memory: 524288k
Target が推奨する Request 値です。Lower Bound / Upper Bound はそれぞれ「これ未満だと足りない」「これ以上は過剰」の目安で、Updater はこの範囲から外れた Pod を退去対象にします。
自動適用に切り替える
推奨値が妥当だと確認できたら、updateMode を変更して自動適用に切り替えます。モードは次のとおりです。
updateMode |
挙動 |
|---|---|
Off |
推奨値を算出するだけ。Pod は変更されません |
Initial |
Pod の作成時のみ推奨値を適用します。稼働中の Pod は変更されません |
Recreate |
作成時に適用し、稼働中の Pod も退去・再作成して更新します |
InPlaceOrRecreate |
まず In-Place Resize で更新を試み、できない場合は再作成へフォールバックします。クラスタの InPlacePodVerticalScaling feature gate が必要です |
InPlace |
In-Place Resize のみを試み、退去はしません。失敗した場合は kubelet の再試行に任せます。上記に加えて VPA 側の InPlace feature gate も必要です |
Auto |
非推奨。現状は Recreate と同等です |
Auto は VPA 1.7 時点で deprecated になっており、API のコメントでも "Use explicit update modes like "Recreate", "Initial", or "InPlaceOrRecreate" instead" と案内されています。新しく書くマニフェストでは Auto を使わないでください。
メモリの Request だけを自動調整する設定例です。
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: sample-vpa
namespace: default
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: sample
updatePolicy:
updateMode: "Recreate"
minReplicas: 2 # これ未満の replicas では退去させない (グローバルのデフォルトも 2)
resourcePolicy:
containerPolicies:
- containerName: app
controlledResources: ["memory"]
controlledValues: RequestsOnly
minAllowed:
memory: 128Mi
maxAllowed:
memory: 4Gi
controlledValues: RequestsOnly にしておくと、Limit は自分で管理したまま Request だけを VPA に任せられます。この記事の文脈 (global OOM と Eviction を減らす) ではこれが扱いやすい設定です。デフォルトの RequestsAndLimits では Request と Limit の比率を保ったまま両方が書き換わるため、Limit も動くことに注意してください。
OOM が起きたときの挙動
VPA はメモリに関して、OOM Kill を検知すると推奨値を引き上げる仕組みを持っています。デフォルト値は次のとおりです。
| 設定 | デフォルト値 | 意味 |
|---|---|---|
oomBumpUpRatio |
1.2 |
OOM Kill を検知したら、そのときの使用量を 1.2 倍した値をサンプルとして記録します |
oomMinBumpUp |
100Mi (104857600) |
引き上げ幅がこれを下回る場合は、最低この分だけ引き上げます |
memoryAggregationIntervalSeconds |
86400 (24 時間) |
この間隔ごとに 1 つ、ピーク値をサンプルとして記録します |
memoryAggregationIntervalCount |
8 |
上記の間隔を何個分保持するか。デフォルトでは 24 時間 × 8 = 8 日分の履歴からメモリの推奨値を計算します |
evictAfterOOMSeconds |
(未設定) | 起動からこの秒数以内に OOM した Pod を退去対象にします |
これらは VPA 1.7 以降、containerPolicies でコンテナごとに指定できます。
containerPolicies:
- containerName: app
controlledResources: ["memory"]
oomBumpUpRatio: "1.5" # OOM 時の引き上げを強めにする
oomMinBumpUp: 256Mi
memoryAggregationIntervalSeconds: 3600 # 1 時間ごとにピークを記録
memoryAggregationIntervalCount: 24 # 24 個 = 1 日分の履歴で計算
メモリの推奨値そのものは、使用量のヒストグラムの分位点から計算されます。Recommender のデフォルト値は次のとおりです。
| フラグ | デフォルト値 | 用途 |
|---|---|---|
--target-memory-percentile |
0.9 |
Target (推奨 Request) の算出に使う分位点 |
--recommendation-lower-bound-memory-percentile |
0.5 |
Lower Bound の算出に使う分位点 |
--recommendation-upper-bound-memory-percentile |
0.95 |
Upper Bound の算出に使う分位点 |
--recommendation-margin-fraction |
0.15 |
算出値に上乗せする安全マージン (15 %) |
つまりデフォルトでは、8 日分のピーク値の 90 パーセンタイルに 15 % のマージンを足した値が推奨 Request になります。前節で紹介した quantile_over_time(0.95, ...) の PromQL と発想は同じで、VPA はそれを OOM 検知と組み合わせて継続的に回している、と捉えると分かりやすいと思います。
VPA が見ている使用量も Working Set です。 Recommender は Metrics Server の API (metrics.k8s.io/v1beta1) から使用量を取得しているため、前述のとおりページキャッシュを含んだ値を元に推奨値を出します。
そのため、ログ出力やファイル I/O が多いコンテナでは VPA の推奨値が実際の必要量より大きく出ます。container_memory_rss と container_memory_working_set_bytes の差が大きいコンテナでは、maxAllowed で上限を設けるか、VPA に任せず手で決めるほうが結果的に無駄が少ないことがあります。
metrics.k8s.io は KEP-5207 により v1.37 で GA (v1) になりましたが、Metrics Server 側は執筆時点 (2026 年 9 月) の最新リリースである v0.9.0 でも未対応で、issue #1786 と PR #1855 で対応中です。そのため本記事では v1beta1 と記載しています。参照している値が Working Set である点は API バージョンによらず変わりません。
VPA を使う際の運用上の注意です。
-
HPA との併用: 同じリソースを対象に HPA と VPA を同時に使ってはいけません。公式にも "should not be used with the HPA on the same resource metric (CPU or memory)" と明記されています (known-limitations.md)。対象を分ければ併用でき、VPA はメモリ、HPA は CPU という組み合わせが公式の推奨例です。この記事の設定例のように
controlledResources: ["memory"]にしておけば、CPU は HPA に任せられます -
Recreateは Pod を作り替えます: Updater は Eviction API を使って Pod を退去させるため、PodDisruptionBudget を設定しておいてください。replicas がminReplicas(VPA で未指定ならグローバルの--min-replicas、デフォルト2) 未満のときは退去しません -
In-Place 系のモード: Pod を作り替えずに Request を変更できますが、クラスタの
InPlacePodVerticalScalingfeature gate が前提です - Metrics Server が前提: Recommender が使用量を取れないと推奨値が出ません
よくある質問
Working Set が Limit に達していないのに OOMKilled になります
memcg OOM で Limit と比較されるのは Working Set ではなく memory.current (container_memory_usage_bytes) です。さらに memory.current が Limit に達しただけでも OOM にはならず、回収しても収まらなかったときに kill されます。また、自分のコンテナが Limit を超えていなくても、ノード全体のメモリ枯渇 (global OOM) に巻き込まれて kill されることがあります。まずOOM Kill の 2 つの経路で経路を切り分けてください。
どの値も Limit に達していないように見える場合は、スクレイプ間隔より短いスパイクを疑います。Prometheus のスクレイプ間隔 (グローバル設定 scrape_interval のデフォルトは 1 分。Configuration) より短い時間で発生したメモリ使用量のスパイクは、メトリクスに現れません。起動直後のバッチ処理や、急激に大きなリクエストを受け付けた場合などが該当します。
この場合は、次のような値を確認してください。
| メトリクス | 出力元コンポーネント | 説明 |
|---|---|---|
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} |
kube-state-metrics | 直前の終了理由。OOM が繰り返されても値はカウントアップしないため、2 回目以降は kube_pod_container_status_restarts_total (同じく kube-state-metrics) の増加と組み合わせて検知します |
container_oom_events_total |
kubelet | OOM Kill の発生回数。ただしコンテナが終了すると cgroup ごと系列が消えるため、コンテナが継続したまま子プロセスだけが OOM Kill されたケースの確認に限られます |
node_vmstat_oom_kill |
node-exporter | ノード上で発生した OOM Kill の回数。Pod やコンテナは特定できませんが、コンテナの再作成に影響されません |
メトリクス以外では、kubectl describe pod の Last State も確認してください (終了コード 137 は SIGKILL による終了)。
MemAvailable から計算した使用率は低いのに Pod が Evict されます
MemAvailable はページキャッシュの一部を「空き」に数えますが、kubelet は Working Set ベースで active_file を全量「使用中」として扱います。ファイル I/O やログ出力が多いワークロードでは active_file が数 GiB まで積み上がるため、MemAvailable 由来の使用率が低く見えていても kubelet 側はしきい値に近づいていることがあります。
逆に、ページキャッシュがあまり積み上がっていないノードでは node-exporter 側のほうが高く出ます。どちらが高いかは環境によって変わるので、片方だけを見て判断できません。要因の内訳と、kubelet 側の使用率を求める PromQL はnode-exporter と kubelet で使用率が一致しない理由をご覧ください。
kubectl top pod と Prometheus の Working Set の値が違います
kubectl top pod は kubelet (Metrics Server 経由) から取得したコンテナの Working Set の合計を表示します。Prometheus の container_memory_working_set_bytes も出どころは同じ値ですが、スクレイプ間隔の分だけ古い値になるため、瞬間値には差が出ます。
Memory Working Set が増え続けます
まず container_memory_rss と container_memory_cache のどちらが増えているかを確認してください。
-
container_memory_rssが増えている: アプリケーション側のメモリリークを疑います -
container_memory_cacheが増えている: ページキャッシュの蓄積です。ただしディスク上のファイルのキャッシュだけでなく tmpfs や共有メモリも含まれます。どちらが増えているかは、ノード上でmemory.statのshmemを確認して切り分けます。tmpfs (emptyDirのmedium: Memoryなど) は Swap を使用しないノードでは回収されないため、蓄積すると OOM Kill につながります。shmemは anon LRU に載るのでinactive_fileに現れず、Working Set からも引かれません。つまり増えた分がそのまま Working Set を押し上げます
Memory Limit を設定していないコンテナでは、ノードのメモリ全体を上限としてページキャッシュが積み上がります。大きなファイルを扱うコンテナには Memory Limit を設定してください。
アプリケーション側で取得できる RSS と container_memory_rss の値が違います
測っているものが違います。Go・Java・Node.js などで「RSS」と呼ばれる値はプロセス単位の RSSですが、container_memory_rss はコンテナの cgroup に計上された匿名ページです。
| 対象 | 出力元 | 実体 | 含まれるもの |
|---|---|---|---|
Node.js の process.memoryUsage().rss、ps や top の RSS 列 |
言語ランタイムや ps などのツール (プロセス単位) |
/proc/[pid]/status の VmRSS
|
RssAnon + RssFile + RssShmem
|
container_memory_rss |
cAdvisor (kubelet 組み込み。cgroup 単位) | cgroup v2 の memory.stat の anon
|
匿名ページのみ |
違いは主に次の 2 点です。
-
ファイルのマッピングを含むかどうか: プロセスの RSS には、実行ファイル・共有ライブラリ・
mmapしたファイルのうち物理メモリ上にあるページ (RssFile) が含まれます。container_memory_rssには含まれません。そのため、同じ 1 プロセスを比べるなら、アプリケーション側で取得できる RSS のほうがRssFileとRssShmemの分だけ大きくなります -
集計の単位: プロセスの RSS は 1 プロセス分です。
container_memory_rssはコンテナの cgroup 単位なので、コンテナ内の全プロセスの合計になります。またプロセスの RSS は共有しているページをプロセスごとに数えますが、cgroup では 1 つのページはいずれか 1 つの cgroup にだけ計上されます
なお JVM の Runtime.totalMemory() や JMX のヒープ使用量は、そもそも RSS ではなくヒープの量です。Metaspace、Code Cache、Thread Stack、GC の管理領域は含まれないため、これらの値と Memory Limit を直接比べることはできません。
どちらの値も OOM Kill の判定には使われません。memcg OOM で Limit と比較されるのは memory.current (container_memory_usage_bytes) です。global OOM はノード全体のメモリが枯渇したときに発生し、個々のコンテナの使用量をしきい値として判定するわけではありません。
ノードのメモリ使用率はどのメトリクスで見ればよいですか
用途によって使い分けてください。
| 目的 | 見るメトリクス | 出力元コンポーネント |
|---|---|---|
| ノードの余力を大まかに把握する |
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
|
node-exporter |
| Eviction が起きるかどうかを判断する |
container_memory_working_set_bytes{id="/"} / kube_node_status_capacity{resource="memory"}
|
cAdvisor (kubelet 組み込み) / kube-state-metrics |
| Pod を配置できるかを判断する |
kube_pod_resource_request{resource="memory"} の合計 (使用量ではなく Request の合計) |
kube-scheduler の /metrics/resources
|
kube_pod_resource_request を出しているのは kube-state-metrics ではなく kube-scheduler です (resources.go)。kube-state-metrics にも似た名前の kube_pod_container_resource_requests がありますが、kube-state-metrics 自身が「より正確な kube-scheduler のメトリクスを使うことを推奨する」とヘルプ文に書いています。Prometheus に kube-scheduler の /metrics/resources をスクレイプする設定がないと、kube_pod_resource_request は収集されません。
今後の変更予定
この記事で説明している挙動に関わる、進行中の Kubernetes の変更です。
KEP-2371: cAdvisor-less, CRI-full Container and Pod Stats
KEP-2371 は、コンテナとノードのメトリクスの収集元を、kubelet 組み込みの cAdvisor からコンテナランタイム (CRI) へ移す変更です。kubelet が cAdvisor と CRI の両方から重複して統計を集めている状態を解消することが目的です。
-
/metrics/cadvisorエンドポイントとcontainer_*のメトリクス名は維持され、値の供給元が CRI に変わる計画です - feature gate
PodAndContainerStatsFromCRIは v1.23 で Alpha、v1.37 で Beta になりますが、Beta でもデフォルトでは無効です
したがって、この記事で説明している Working Set の定義や Eviction の判定方法は、当面は変わりません。将来この移行がデフォルトになった場合、値の供給元がランタイム側になるため、cgroup の読み取り方の違いによって細かな差異が生じる可能性があります。
KEP-2570: Support Memory QoS with cgroups v2
KEP-2570 は、requests.memory を cgroup v2 のメモリ保護設定へ反映する変更です。有効になると、Request の分のメモリが回収から保護され、Limit に達する前にスロットリングが働くようになります。
feature gate MemoryQoS は v1.22 から Alpha (デフォルトで無効) で、v1.37 で Beta となりデフォルトで有効になりました。ただし、feature gate が有効になっただけで requests.memory が cgroup へ反映されるわけではありません。反映するには kubelet 側で次の設定が必要で、v1.37 時点ではどちらもデフォルトで無効です。
-
memoryReservationPolicy: TieredReservation: Guaranteed はmemory.min、それ以外はmemory.lowにrequests.memoryを設定します。v1.36 で KubeletConfiguration に追加された設定で、デフォルトはNoneです。Noneの場合はmemory.minを設定しません -
memoryThrottlingFactor:memory.highをrequests.memory + 係数 × (limits.memory - requests.memory)に設定します。v1.36 までのデフォルト値は0.9でしたが、当時は feature gate 自体がデフォルトで無効だったため実際には効いていませんでした。v1.37 ではデフォルト値がnilになり、明示的に設定しない限りmemory.highは設定されません
どちらも KubeletConfiguration の型定義 で確認できます。
// MemoryThrottlingFactor specifies the factor multiplied by the memory limit or node allocatable memory
// ...
// Default: nil
MemoryThrottlingFactor *float64 `json:"memoryThrottlingFactor,omitempty"`
// MemoryReservationPolicy controls how the kubelet applies cgroup v2 memory protection.
// "None" (default): The kubelet does not set memory.min for containers and pods,
// ...
MemoryReservationPolicy MemoryReservationPolicy `json:"memoryReservationPolicy,omitempty"`
そのため v1.37 のクラスタでも、前述の表のとおり requests.memory は cgroup に反映されません。「反映されません」というのは、あくまでデフォルト設定での話で、上記 2 つを設定すれば反映されます。
デフォルト設定のときに実際に何が書き込まれるかは実装のとおりです。memoryReservationPolicy が None だと、requests.memory の代わりに明示的に 0 が書かれます (保護なしと同義です)。
if memoryRequest != 0 && m.memoryReservationPolicy == kubeletconfiginternal.TieredReservationMemoryReservationPolicy {
...
} else {
unified[cm.Cgroup2MemoryMin] = "0"
unified[cm.Cgroup2MemoryLow] = "0"
}
つまり v1.37 のデフォルト設定では、コンテナの cgroup は memory.min = 0 / memory.low = 0 / memory.high 未設定 (max) となり、memory.max (= limits.memory) だけが効いている状態です。マネージドサービスを使っている場合は、これらの設定が有効化されるかどうかを提供元のリリースノートで確認してください。
仮に有効にした場合、cgroup v2 の値はどうなるか
v1.36 までのデフォルト値だった memoryThrottlingFactor: 0.9 を明示的に設定し、あわせて memoryReservationPolicy: TieredReservation も有効にした場合に、cgroup v2 の各ファイルへ何が書かれるかを整理します。
kubelet の設定例と、QoS クラスごとに書き込まれる値 (クリックで展開)
kubelet の設定は次のようになります。MemoryQoS は v1.37 ではデフォルトで有効なので featureGates の指定は省略できますが、意図を明示するために書いています。
# /var/lib/kubelet/config.yaml (KubeletConfiguration)
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
featureGates:
MemoryQoS: true # v1.37 ではデフォルトで true
memoryReservationPolicy: TieredReservation # デフォルトは None
memoryThrottlingFactor: 0.9 # v1.37 のデフォルトは nil (= memory.high を設定しない)
この状態で次の Pod を作成したとします。ノードの Allocatable メモリは 16Gi とします。
apiVersion: v1
kind: Pod
metadata:
name: sample
spec:
containers:
- name: app
image: nginx
resources:
requests:
memory: 256Mi
limits:
memory: 1Gi
requests.memory と limits.memory が異なるので、この Pod の QoS クラスは Burstable です。コンテナの cgroup には次の値が書き込まれます。
| ファイル | 値 | 計算 |
|---|---|---|
memory.max |
1073741824 (1Gi) |
limits.memory そのまま (MemoryQoS とは無関係に従来から設定されます) |
memory.low |
268435456 (256Mi) |
Burstable なので requests.memory が memory.low へ |
memory.min |
0 |
Guaranteed のときだけ requests.memory が入ります。それ以外では明示的に 0 が書かれます |
memory.high |
993210368 (約 947 MiB) |
floor((256Mi + (1Gi - 256Mi) × 0.9) / 4096) × 4096 |
memory.high の計算は実装のとおり、ページサイズ (4 KiB) の倍数へ切り下げられます。
memoryHigh = int64(math.Floor(
float64(memoryRequest)+
(float64(memoryLimitVal)-float64(memoryRequest))*float64(*m.memoryThrottlingFactor))/float64(defaultPageSize)) * defaultPageSize
つまりこのコンテナは、1Gi に達して OOM Kill される前に、約 947 MiB の時点で強い回収圧力がかかりスロットリングされます。また 256Mi までは memory.low によって回収されにくくなります。
QoS クラスごとにまとめると次のようになります。同じ requests / limits でも、QoS クラスによって設定されるファイルが変わる点が重要です。
| QoS クラス | memory.min |
memory.low |
memory.high |
|---|---|---|---|
Guaranteed (requests = limits) |
requests.memory |
0 |
設定されません (max のまま。requests と limits が同じ場合は除外されます) |
Burstable (requests < limits) |
0 |
requests.memory |
floor((req + (lim − req) × 係数) / ページサイズ) × ページサイズ |
Burstable (limits なし) |
0 |
requests.memory |
limits の代わりにノードの Allocatable を使って同じ式で計算 |
BestEffort (requests/limits なし) |
0 |
0 |
floor(Allocatable × 係数 / ページサイズ) × ページサイズ |
上の例と同じノード (Allocatable 16Gi) の BestEffort な Pod なら、memory.high は floor(16Gi × 0.9 / 4096) × 4096 = 15461879808 (約 14.4 GiB) になります。Limit を設定していないコンテナにも memory.high が設定されることになるので、memoryThrottlingFactor を有効にするとページキャッシュの積み上がり方も変わります。
memory.min と memory.low は、コンテナの cgroup だけでなく上位の階層にも設定されます (qos_container_manager_linux.go)。保護はカーネルが祖先の cgroup までたどって評価するため、上位にも同じ保護が必要になるからです。
| 階層 | memory.min |
memory.low |
|---|---|---|
/kubepods.slice |
Guaranteed の Request 合計 + Burstable の Request 合計 | Burstable の Request 合計 |
/kubepods.slice/kubepods-burstable.slice |
0 |
Burstable の Request 合計 |
| Pod の cgroup | Guaranteed なら Pod の Request | Burstable なら Pod の Request |
| コンテナの cgroup | 上記の表のとおり | 上記の表のとおり |
ノード全体でどれだけ保護されているかは、kubelet が公開する次のメトリクスで確認できます (どちらも v1.37 時点で ALPHA)。
| メトリクス | 出力元コンポーネント | 説明 |
|---|---|---|
kubelet_memory_qos_node_memory_min_bytes |
kubelet | Guaranteed Pod のために memory.min として確保されている合計。カーネルが決して回収しないメモリ量
|
kubelet_memory_qos_node_memory_low_bytes |
kubelet | Burstable Pod のために memory.low として確保されている合計 |
memoryReservationPolicy: TieredReservation を有効にすると、Guaranteed Pod の Request の合計が memory.min としてノード上にハード予約されます。memory.min の分はカーネルが回収できないため、Request を過大に設定した Guaranteed Pod が多いノードでは、回収可能なメモリが減り、かえってノードが不安定になる可能性があります。memoryReservationPolicy のデフォルト値が None である理由も、型定義のコメントに "This is the default to maintain node stability by preventing "locked" memory." と書かれています。
有効化を検討する場合は、先にMemory Request を実際の使用量に合わせるで Request を適正化しておくのが前提になります。
kind で実際に確かめる
kind でローカルに v1.37 クラスタを立てて確認する手順 (クリックで展開)
ここまでの内容は、kind でローカルに v1.37 のクラスタを立てれば手元で確認できます。次の設定ファイルで MemoryQoS を有効にした環境が作れます。
# kind-memqos.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: memqos
nodes:
- role: control-plane
# kind のデフォルトイメージはバージョンによって変わるため、明示的に固定する
image: kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
kubeadmConfigPatches:
- |
kind: KubeletConfiguration
featureGates:
MemoryQoS: true
memoryReservationPolicy: TieredReservation
memoryThrottlingFactor: 0.9
image を省略すると kind に埋め込まれたデフォルトイメージが使われますが、それは kind のバージョンごとに変わります。上の digest は kind v0.33.0 のデフォルトと同じもので、別の Kubernetes バージョンで試す場合は kind のリリースノートに記載されている digest に置き換えてください。
$ kind create cluster --config kind-memqos.yaml
# kubelet に設定が入ったか確認する
$ kubectl get --raw "/api/v1/nodes/memqos-control-plane/proxy/configz" | jq '.kubeletconfig | {memoryThrottlingFactor, memoryReservationPolicy, featureGates}'
{
"memoryThrottlingFactor": 0.9,
"memoryReservationPolicy": "TieredReservation",
"featureGates": { "MemoryQoS": true }
}
cgroup のファイルはノードのコンテナに入って読みます。kind は cgroup root を /kubelet.slice/kubelet-kubepods.slice に置くので、実クラスタの /kubepods.slice とはパスが違う点に注意してください。
kind では再現できない挙動が 2 つあります。
1 つは前述の root cgroup のフォールバックです。kind のノードはコンテナなので cgroup namespace を持ち、ノード内から見える /sys/fs/cgroup は実際には委譲されたnon-rootの cgroup です。そのため memory.current が存在してしまい、anon + file による代用が起きません。実機との違いは次のとおりです。
# kind のノード内 (cgroup namespace の root = 実際は non-root cgroup)
$ docker exec memqos-control-plane ls /sys/fs/cgroup/memory.current
/sys/fs/cgroup/memory.current
# 本当のホスト (cgroup v2 の root cgroup)
$ colima ssh -- ls /sys/fs/cgroup/memory.current
ls: /sys/fs/cgroup/memory.current: No such file or directory
$ colima ssh -- ls /sys/fs/cgroup/memory.stat
/sys/fs/cgroup/memory.stat
もう 1 つは Eviction です。kind の kubelet は evictionHard を imagefs.available / nodefs.available / nodefs.inodesFree だけに上書きしており、memory.available のしきい値がありません。そのためメモリ起因の Eviction は起きず、Capacity と Allocatable も同じ値になります。Eviction を試すなら evictionHard を明示的に設定してください。
# 実コンテナの ID で cgroup を特定する (後述のとおり glob では pause 側を拾うことがあります)
$ CID=$(kubectl get pod sample-burstable \
-o jsonpath='{.status.containerStatuses[0].containerID}' | sed 's|containerd://||')
$ docker exec memqos-control-plane sh -c "
d=\$(find /sys/fs/cgroup/kubelet.slice -type d -name 'cri-containerd-${CID}.scope')
grep . \$d/memory.max \$d/memory.min \$d/memory.low \$d/memory.high"
/sys/fs/cgroup/.../cri-containerd-50fc89e1....scope/memory.max:1073741824
/sys/fs/cgroup/.../cri-containerd-50fc89e1....scope/memory.min:0
/sys/fs/cgroup/.../cri-containerd-50fc89e1....scope/memory.low:268435456
/sys/fs/cgroup/.../cri-containerd-50fc89e1....scope/memory.high:993210368
Allocatable が 2005512Ki (= 2053644288 バイト) のノードで、memoryThrottlingFactor: 0.9 を設定したときの実測値です。
| Pod | QoS | memory.min |
memory.low |
memory.high |
|---|---|---|---|---|
requests: 256Mi / limits: 1Gi
|
Burstable | 0 | 268435456 | 993210368 |
requests: 128Mi / limits なし |
Burstable | 0 | 134217728 | 1861701632 |
memory だけ requests = limits = 512Mi |
Burstable | 0 | 536870912 | max (未設定) |
cpu と memory を requests = limits
|
Guaranteed | 536870912 | 0 | max (未設定) |
| 指定なし | BestEffort | 0 | 0 | 1848279040 |
memory.high はいずれも前述の floor((req + (lim − req) × 係数) / ページサイズ) × ページサイズ に従った値です。limits を設定していない Pod では lim にノードの Allocatable が使われ、BestEffort では req が 0 になります。
memory だけ requests = limits にしても Guaranteed にはなりません。 Guaranteed は全コンテナで cpu と memory の両方が requests = limits である必要があります。memory だけ揃えた Pod は Burstable なので、requests.memory は memory.min ではなく memory.low に入ります。
一方 memory.high が未設定 (max) になるのは QoS クラスではなく requests.memory == limits.memory が条件なので (実装の memoryRequest != memoryLimitSpec の判定)、memory だけ揃えた Burstable Pod でも max のままになります。
memory.min memory.low memory.high
memory だけ揃えた Burstable 0 536870912 max
cpu と memory を揃えた Guaranteed 536870912 0 max
また、Pod には実コンテナのほかに pause (サンドボックス) コンテナの cri-containerd-*.scope も並ぶため、cri-containerd-*.scope の glob は 2 件マッチします。どちらが先に来るかはコンテナ ID の順次第なので、glob の先頭を使うと pause 側を読んでしまい、memory.low や memory.high が 0 / max に見えることがあります。上のコマンドのように .status.containerStatuses[].containerID で特定してください。
保護はコンテナの cgroup だけでなく上位階層にも設定されます。kubelet-kubepods.slice (実クラスタの /kubepods.slice に相当) では、memory.min が Guaranteed と Burstable の Request 合計、memory.low が Burstable の Request 合計になります。
kubelet-kubepods.slice memory.min 1243611136 memory.low 706740224
kubelet-kubepods-burstable.slice memory.min 0 memory.low 706740224
kubelet-kubepods-besteffort.slice memory.min 0 memory.low 0
1243611136 - 706740224 = 536870912 = 512Mi ← Guaranteed Pod の Request と一致
前述の kubelet メトリクスもこのエンドポイントから取得できます。memory_min_bytes は Guaranteed 分だけなので、root cgroup の memory.min (Guaranteed + Burstable) とは値が違う点に注意してください。
$ kubectl get --raw "/api/v1/nodes/memqos-control-plane/proxy/metrics" | grep kubelet_memory_qos
kubelet_memory_qos_node_memory_low_bytes 7.06740224e+08
kubelet_memory_qos_node_memory_min_bytes 5.36870912e+08
「上限に達しても回収が先」を kind で確かめる
ページキャッシュで上限に張り付かせても OOM が起きないことの確認 (クリックで展開)
前述の「memory.current が memory.max に達しただけでは OOM にならない」は、kind で簡単に再現できます。limits.memory: 256Mi のコンテナから、ディスク上の emptyDir へ上限の 6 倍のデータを書きます。
$ kubectl exec memtest -- sh -c 'dd if=/dev/zero of=/disk/big bs=1M count=1536'
1610612736 bytes (1.5GB) copied, 1.501464 seconds, 1023.0MB/s
コンテナの cgroup を見ると、使用量は上限の手前で張り付いていますが OOM は 1 度も起きていません。
memory.max 268435456 (256Mi)
memory.high 248299520 ← floor((64Mi + (256Mi - 64Mi) * 0.9) / 4096) * 4096
memory.current 246484992
memory.peak 249049088
--- memory.events ---
low 0
high 2666 ← memory.high を超えてスロットリングされた回数
max 0 ← memory.max には到達していない
oom 0
oom_kill 0 ← OOM Kill は発生していない
oom_group_kill 0
--- memory.stat (抜粋) ---
file 235982848
inactive_file 235937792 ← ほぼ全量が回収可能な inactive なページキャッシュ
active_file 45056
このとき Working Set は 246484992 - 235937792 = 10547200 (約 10 MiB) にすぎません。使用量が上限に張り付いていても、中身が回収可能なページキャッシュなら Working Set は小さいままで、OOM も起きない、という関係がそのまま観測できます。
一方、同じことを匿名ページでやると即座に OOM Kill されます。tail /dev/zero は入力を読み切るまでメモリを確保し続けるので、Swap のないノードでは回収できません。
$ kubectl run oomtest --image=busybox:1.37 --restart=Never \
--overrides='{"spec":{"containers":[{"name":"app","image":"busybox:1.37",
"command":["sh","-c","tail /dev/zero"],
"resources":{"limits":{"memory":"64Mi"}}}]}}'
$ kubectl get pod oomtest -o jsonpath='{.status.containerStatuses[0].state.terminated}'
{"exitCode":137,"reason":"OOMKilled",...}
カーネルログには oom_memcg= を含む行が出ます。前述のとおり cAdvisor のパーサーはこの形式に一致するため cgroup が抽出でき、結果として SystemOOM Event は記録されません。
oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-9be8306a....scope,
mems_allowed=0,oom_memcg=/docker/.../kubelet-kubepods-burstable-pod....slice,
task_memcg=/docker/.../cri-containerd-9be8306a....scope,task=tail,pid=4867,uid=0
Memory cgroup out of memory: Killed process 4867 (tail) total-vm:68880kB,
anon-rss:64768kB, file-rss:1280kB, shmem-rss:0kB, UID:0 pgtables:176kB oom_score_adj:984
$ kubectl get events -A --field-selector reason=SystemOOM
No resources found
なお OOM Kill 後はコンテナの cgroup ごと消えるため、container_oom_events_total の系列自体が無くなります。restartPolicy: Never で再作成させなくても同じです。OOM の回数を後から追うなら node_vmstat_oom_kill (ノード単位) や kube_pod_container_status_last_terminated_reason を使ってください。
おわりに
「メモリ使用量」と一言で呼んでいる値が、実際には Working Set・RSS・memory.current・MemAvailable と複数あり、しかも OOM Kill と Eviction の原因になる値がそれぞれ違う、というのがこの記事の要点です。
「コンテナのメモリ使用量が増え続けているので、メモリリークしているのではないか」「OOMKilled になっているが、なぜ起きたのか分からない」といった質問や相談を受けることが多く、そのたびに似たような説明をしてきました。Eviction や QoS クラスの仕組みは公式ドキュメントにあり、cgroup v2 の仕様もカーネルのドキュメントに揃っていますが、結局、コンテナのメモリ使用量として何になのか?、その結果なぜ OOM Kill や Eviction が起きるのかを通して説明したものが見当たらなかったので、自分で書くことにしました。
調査のときに押さえておくと便利なのは、次の 3 点だと思います。
-
kubectl topやcontainer_memory_working_set_bytesで見えているのは Working Set で、active なページキャッシュを含む - memcg OOM で Limit と比較されるのは
memory.current(=container_memory_usage_bytes) で、Working Set ではない。しかも上限に達しただけでは起きず、回収しても収まらなかったときに kill される - Eviction の原因になるのはノードの Working Set から計算した空きで、
MemAvailableではない
参考
参考リンク
本文で登場する順に並べています。
-
Memory Resource Controller (cgroup v2) (
memory.current/memory.max/memory.statなど各ファイルの定義) - About cgroup v2 (Kubernetes から見た cgroup v2)
- Resource Management for Pods and Containers (Request / Limit の基本)
- Reserve Compute Resources for System Daemons (Allocatable の計算)
- Node-pressure Eviction (Eviction の全体像。シグナル・しきい値・対象 Pod の選定)
- Concepts overview - OOM killer (OOM Killer が呼び出される条件と役割)
- Node out of memory behavior (Eviction が間に合わない場合の挙動)
- Pod Quality of Service Classes (QoS クラスの決まり方)
- proc.rst - oom_score / oom_score_adj (kill 対象プロセスの選定に使うスコア)
-
sysctl/vm.rst (
panic_on_oomなど OOM 関連の sysctl) - Understand Pressure Stall Information (PSI) Metrics (メモリ確保待ちの検知)
-
Metrics Server FAQ (
kubectl topの値の定義と制約) - Vertical Pod Autoscaler (使用量から Request / Limit を推奨する仕組み)
本記事で扱ったメトリクス一覧
出力元コンポーネントごとにまとめます。同じ「メモリ使用量」を表すメトリクスでも出力元が違えば値の定義も違うというのがこの記事の要点なので、調べる際はまずどのコンポーネントが出している値なのかを確認してください。
cAdvisor (kubelet の /metrics/cadvisor)
kubelet に組み込まれた cAdvisor が cgroup を読んで公開します。独立した cAdvisor を動かす必要はありません。
| メトリクス | cgroup v2 での実体 | 概要 |
|---|---|---|
container_memory_usage_bytes |
memory.current |
すべてを含んだ使用量。memcg OOM の判定対象 |
container_memory_working_set_bytes |
memory.current - inactive_file |
Kubernetes が使用量として扱う値。kubectl top や Eviction 判定の基準 |
container_memory_rss |
memory.stat の anon
|
匿名ページの量。本来の RSS とは範囲が異なります |
container_memory_cache |
memory.stat の file
|
ページキャッシュの量 (shmem を含む) |
container_memory_total_active_file_bytes |
memory.stat の active_file
|
active なページキャッシュ。Working Set に含まれます |
container_memory_total_inactive_file_bytes |
memory.stat の inactive_file
|
inactive なページキャッシュ。Working Set から除かれます |
container_memory_mapped_file |
memory.stat の file_mapped
|
mmap されたファイルの量 |
container_spec_memory_limit_bytes |
memory.max |
コンテナの Memory Limit。memory.max が max (上限なし) の場合は 0 として公開されます |
container_pressure_memory_waiting_seconds_total |
memory.pressure の some の total
|
一部のプロセスがメモリ待ちだった累積時間 (秒) |
container_pressure_memory_stalled_seconds_total |
memory.pressure の full の total
|
全プロセスがメモリ待ちで停止していた累積時間 (秒) |
ノード全体は id="/"、ノード上の Pod 全体は id=~"/kubepods|/kubepods.slice"、Pod 単位は container="", image="", pod!="" で絞り込めます。ただし id="/" だけは memory.current が存在しないため、usage が memory.stat の anon + file で代用されます (カーネルメモリを含みません)。詳細はkubelet が Eviction の判定に使う値をご覧ください。
| メトリクス | 出力元 | 概要 |
|---|---|---|
container_oom_events_total |
kubelet (名前は container_ ですが cAdvisor ではありません) |
カーネルログの oom-kill: 行を解析した OOM Kill 回数。同じ /metrics/cadvisor で公開されます |
kubelet (/metrics/resource)
CPU・メモリ・Swap の使用量に絞って、コンテナ単位・Pod 単位・ノード単位で公開するエンドポイントです。/metrics/cadvisor とは別のパスなので、Prometheus でスクレイプするには設定を追加する必要があります。
Metrics Server はここから値を取得しており、公式ドキュメントにも "The metrics-server fetches resource metrics from the kubelets" と書かれています (Resource metrics pipeline)。ただし Metrics Server 専用というわけではなく、Metrics Server 自身の README は監視用途にはこのエンドポイントを直接スクレイプするよう案内しています ("In such cases please collect metrics from Kubelet /metrics/resource endpoint directly.")。
| メトリクス | 概要 |
|---|---|
container_memory_working_set_bytes |
コンテナ単位の Working Set (/metrics/cadvisor の同名メトリクスと同じ値) |
pod_memory_working_set_bytes |
Pod 単位の Working Set。Pod の cgroup から取得されます |
node_memory_working_set_bytes |
ノード単位の Working Set |
kubelet (/metrics)
| メトリクス | 概要 |
|---|---|
kubelet_node_name |
値が常に 1 の Gauge (info メトリクスのパターン)。node ラベルにノード名が入るため、ノードラベルを持たない container_* に on(instance) group_left(node) で node を持ち込めます |
kubelet_memory_qos_node_memory_min_bytes |
Guaranteed Pod のために memory.min として確保されている合計 (MemoryQoS 有効時。ALPHA) |
kubelet_memory_qos_node_memory_low_bytes |
Burstable Pod のために memory.low として確保されている合計 (MemoryQoS 有効時。ALPHA) |
node-exporter
/proc の内容をそのままメトリクスにします。Kubernetes の概念は関知しません。
| メトリクス | 取得元 | 概要 |
|---|---|---|
node_memory_MemTotal_bytes |
/proc/meminfo |
ノードの物理メモリの総量 |
node_memory_MemAvailable_bytes |
/proc/meminfo |
カーネルが見積もった割り当て可能量。回収可能なページキャッシュを空きに数えます |
node_memory_Cached_bytes |
/proc/meminfo |
ページキャッシュの量 (tmpfs / shmem を含む) |
node_memory_Shmem_bytes |
/proc/meminfo |
上記のうち tmpfs / 共有メモリの分 |
node_vmstat_oom_kill |
/proc/vmstat |
ノード上で発生した OOM Kill の回数。memcg OOM と global OOM の両方を含みます |
kube-state-metrics
Kubernetes API のオブジェクト (spec / status) をメトリクスに変換します。リソースの実使用量は扱いません。
| メトリクス | 概要 |
|---|---|
kube_node_status_capacity{resource="memory"} |
ノードの Capacity。Eviction のしきい値評価の分母 |
kube_node_status_allocatable{resource="memory"} |
Pod に割り当て可能な量。スケジューラが使う上限 |
kube_pod_container_status_last_terminated_reason |
直前の終了理由 (reason="OOMKilled" で OOM Kill を検知)。一度も終了していないコンテナには系列が出ません
|
kube_pod_container_status_restarts_total |
コンテナの再起動回数 |
kube_pod_container_resource_requests |
マニフェストに書かれた Request (コンテナ単位) |
kube_pod_container_resource_limits |
マニフェストに書かれた Limit (コンテナ単位) |
kube-scheduler (/metrics/resources)
セキュアポート (デフォルト 10259) の /metrics/resources で公開されます。/metrics とは別のエンドポイントです。
| メトリクス | 概要 |
|---|---|
kube_pod_resource_request{resource="memory", unit="bytes"} |
Pod 単位の Request の合計。値が 0 の Pod は系列が出力されません |
kube_pod_resource_limit{resource="memory", unit="bytes"} |
Pod 単位の Limit の合計 |