website

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

4posts · 30d
Nov 30 posts
  1. K8s upgrade drops a path; fixed scripts read other values After a Kubernetes version upgrade replaces the node image, procedures and scripts that read container CPU and memory values from under /sys/fs/cgroup stop working. $ kubectl exec <pod> -- cat /sys/fs/cgroup/cpu/cpu.stat cat: can't open '/sys/fs/cgroup/cpu/cpu.stat': No such file or directory command terminated with exit code 1 Running ls inside the container shows no cpu/ and no memory/. cpu.stat and memory.stat, which used to live in separate directories, are listed directly. The node's /sys/fs/cgroup looks the same. $ kubectl exec <pod> -- ls /sys/fs/cgroup cgroup.controllers cgroup.events ... cpu.stat ... memory.current memory.events ... memory.stat ... The layout with one directory per controller is cgroup v1, and this single flat layout is cgroup v2. The switch from v1 to v2 happened when the node image changed. Removing cpu/ from the script's path opens a file with the same name. $ kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.stat usage_usec 768869 user_usec 684571 system_usec 84297 nice_usec 0 core_sched.force_idle_usec 0 nr_periods 38 nr_throttled 37 throttled_usec 1592698 nr_bursts 0 burst_usec 0 The throttled_time line from the old cpu.stat is missing, and there is a throttled_usec line in its place. A script that collected throttled_time finds no matching line and returns an empty result without raising an error. Even once the files open, some values read the v1 way come out different. This article covers three of them: CPU throttle time, memory usage, and OOM count. It is written for people who read the files under /sys/fs/cgroup with their own scripts or runbooks. If you see these values through a monitoring agent such as cAdvisor, the agent handles v2 itself, and that case is outside the scope here. An explanation of v2 itself and the migration steps are on the Kubernetes blog. Three reads that change Throttle time throttled_time, missing from the cpu.stat above, became throttled_usec. It is the total time the cgroup was stopped for hitting its CPU limit, and its unit changed from nanoseconds to microseconds.[^cpu-stat] If you change only the key name and keep the /1e9 that converts nanoseconds to seconds, you get a value 1/1000 of the real one. [^cpu-stat]: The cpu.stat entry in the kernel's cgroup v2 documentation. $ kubectl exec <pod> -- awk '$1=="throttled_usec"{printf "%.6f\n", $2/1e9}' /sys/fs/cgroup/cpu.stat 0.001593 With /1e6 instead, it comes out to 1.592698 seconds. nr_throttled and nr_periods, which give the fraction of periods that hit the limit, keep the same names and meanings as in v1, so the division works unchanged. Memory usage In v1, memory usage was calculated with this formula. memory.usage_in_bytes − total_inactive_file (a line in memory.stat) When a process reads or writes a file, the kernel keeps the contents in memory as page cache, and the cgroup's usage includes it. The part of the page cache that hasn't been used recently is inactive_file, which the kernel reclaims as soon as memory runs short. Usage minus inactive_file is the part that can't be released right away: the working set. The kubelet exposes this value as container_memory_working_set_bytes, and the memory column of kubectl top pod shows the same value.[^working-set] [^working-set]: The memory entry in the Kubernetes Resource metrics pipeline documentation. In v2, the formula becomes:[^cadvisor] memory.current − inactive_file (a line in memory.stat) [^cadvisor]: cAdvisor's handler.go subtracts total_inactive_file for v1 and inactive_file for v2. The memory-available-cgroupv2.sh example in the Kubernetes documentation writes the v2 formula the same way. v2's memory.stat has no lines starting with total_. The aggregate lines from v1 appear in v2 under their names with total_ removed. $ kubectl exec <pod> -- cat /sys/fs/cgroup/memory.current 108158976 $ kubectl exec <pod> -- grep ^inactive_file /sys/fs/cgroup/memory.stat inactive_file 104906752 The difference, about 3 MiB, is the working set. The corrected value matches container_memory_working_set_bytes from the kubelet. $ kubectl get --raw "/api/v1/nodes/<node>/proxy/metrics/resource" | grep ^container_memory_working_set_bytes OOM count In v1, a container's OOM count came from oom_kill in memory.oom_control.[^v1-oom] In v2, the same oom_kill is in memory.events, a file that counts the memory events in that cgroup by type. However, oom_kill in the container's memory.events reads 0 even after the container has restarted due to OOM. The count is kept in the memory.events of the Pod's slice, one level above the container. The rest of this section covers why it reads 0, how to find the Pod's slice, and how to read the slice's memory.events, in that order. [^v1-oom]: The memory.oom_control entry in the kernel's cgroup v1 memory documentation. oom_kill is the number of processes in that cgroup killed by OOM. Why the container's oom_kill reads 0 In a Pod that restarted due to OOM, the container's memory.events reads like this. $ kubectl exec <pod> -- grep oom /sys/fs/cgroup/memory.events oom 0 oom_kill 0 oom_group_kill 0 On v2 nodes, the kubelet sets the container's memory.oom.group to 1.[^oom-group] When OOM occurs in this cgroup, the kernel kills every process in the container, the container is recreated along with its cgroup, and the counters start again from 0. [^oom-group]: In the kubelet's kuberuntime_container_linux.go, memory.oom.group is set to 1 on v2 nodes unless singleProcessOOMKill is true. This was added in kubernetes/kubernetes#117793. To check whether your nodes behave this way, see whether the container's /sys/fs/cgroup/memory.oom.group is 1. If the kubelet's singleProcessOOMKill is true, it stays 0. Finding the Pod's slice The Pod's slice is the cgroup that groups all of the Pod's containers, and its memory.events adds up the counts of the containers under it. The slice survives container restarts, so the count survives too. /sys/fs/cgroup/ └── kubepods.slice/ └── kubepods-burstable.slice/ └── kubepods-burstable-pod<uid>.slice/ ← Pod's slice └── cri-containerd-<container-id>.scope/ ← container's scope To locate the slice, search for the scope by container ID. The container ID is the part of the Pod's containerID after containerd://. When you enter the node with kubectl debug node, the node's files appear under /host. $ kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[*].containerID}' containerd://<container-id> $ kubectl debug node/<node> -it --image=busybox # find /host/sys/fs/cgroup -type d -name 'cri-containerd-<container-id>.scope' The parent of the directory it finds is the Pod's slice. cri-containerd- is the prefix used when the runtime is containerd. If you can log in to the node as root, drop /host from the path. Reading the slice's memory.events In the slice's memory.events, oom_group_kill is the OOM count, and it matches restartCount. oom_group_kill is the number of times all processes in the cgroup were killed together because of memory.oom.group.[^memory-events] For a Pod with a restartCount of 3, it looks like this. [^memory-events]: The memory.events entry in the kernel's cgroup v2 documentation. # grep oom <slice>/memory.events oom 3 oom_kill 9 oom_group_kill 3 oom_kill goes up each time the kernel kills a process, so a single OOM raises it by more than one. In this Pod, three OOMs brought it to 9.

  2. Kubernetes 更新でパスが消え、直すと別の値を読む Kubernetes のバージョンを上げてノードイメージが替わったあと、コンテナの CPU やメモリの値を /sys/fs/cgroup の下から取っていた手順やスクリプトが止まる。 $ kubectl exec <pod> -- cat /sys/fs/cgroup/cpu/cpu.stat cat: can't open '/sys/fs/cgroup/cpu/cpu.stat': No such file or directory command terminated with exit code 1 コンテナの中で ls を打つと、cpu/ も memory/ もなく、ディレクトリに分かれていたはずの cpu.stat や memory.stat が直に並んでいる。ノードの /sys/fs/cgroup でも同じだ。 $ kubectl exec <pod> -- ls /sys/fs/cgroup cgroup.controllers cgroup.events (略) cpu.stat (略) memory.current memory.events (略) memory.stat (略) コントローラごとにディレクトリが分かれていたのが cgroup v1、1つにまとまったこの並びが cgroup v2 だ。ノードイメージが替わったときに、v1 から v2 に切り替わっている。 スクリプトのパスから cpu/ を外すと、同じ名前のファイルが開く。 $ kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.stat usage_usec 768869 user_usec 684571 system_usec 84297 nice_usec 0 core_sched.force_idle_usec 0 nr_periods 38 nr_throttled 37 throttled_usec 1592698 nr_bursts 0 burst_usec 0 これまでの cpu.stat にあった throttled_time の行がない。代わりに throttled_usec がある。throttled_time を集計していたスクリプトは、行が見つからないまま、エラーも出さずに空を返す。 ファイルが開くようになったあとも、v1 と同じ読み方で別の値が出るところがある。この記事では、そのうち CPU の throttle の時間、メモリの使用量、OOM の回数を扱う。 この記事は、/sys/fs/cgroup のファイルを自前のスクリプトや手順書で読んでいる人向けに書く。cAdvisor などの監視エージェント経由で見ているなら、エージェントが v2 を読み分けるので、この記事の範囲の外だ。v2 そのものの説明と移行の手順は Kubernetes 公式ブログにある。 読み方が変わる3か所 throttle の時間 冒頭の cpu.stat で消えていた throttled_time は、throttled_usec になった。CPU の上限に当たって止められていた時間の合計で、単位はナノ秒からマイクロ秒に変わっている1。キー名だけ書き換えて、ナノ秒を秒にする /1e9 を残すと、1000 分の 1 の値が出る。 $ kubectl exec <pod> -- awk '$1=="throttled_usec"{printf "%.6f\n", $2/1e9}' /sys/fs/cgroup/cpu.stat 0.001593 /1e6 に直すと 1.592698 秒になる。 上限に当たった周期の割合を出す nr_throttled と nr_periods は、名前も意味も v1 と同じで、割り算の式はそのまま使える。 メモリの使用量 メモリの使用量は、v1 ではこの式で出していた。 memory.usage_in_bytes − total_inactive_file(memory.stat の行) ファイルを読み書きすると、カーネルはその内容を page cache としてメモリに残す。cgroup の使用量にはこの分も入る。そのうち最近使われていない分が inactive_file で、メモリが足りなくなればすぐ回収される。使用量から inactive_file を差し引いた、すぐには手放せない分が working set だ。kubelet はこの値を container_memory_working_set_bytes として出していて、kubectl top pod のメモリの列もこの値だ2。 v2 の式はこうなる3。 memory.current − inactive_file(memory.stat の行) v2 の memory.stat には total_ で始まる行が1つもなく、v1 の集計の行は、v2 では total_ を外した名前になっている。 $ kubectl exec <pod> -- cat /sys/fs/cgroup/memory.current 108158976 $ kubectl exec <pod> -- grep ^inactive_file /sys/fs/cgroup/memory.stat inactive_file 104906752 引き算の結果の約 3 MiB が working set だ。 直した値は、kubelet が出す container_memory_working_set_bytes と同じになる。 $ kubectl get --raw "/api/v1/nodes/<node>/proxy/metrics/resource" | grep ^container_memory_working_set_bytes OOM の回数 v1 では、コンテナの OOM の回数を memory.oom_control の oom_kill で数えていた4。v2 では同じ oom_kill が memory.events にある。memory.events は、その cgroup でメモリについて起きた出来事を種類ごとに数えたファイルだ。ただ、コンテナの memory.events の oom_kill は、OOM で再起動したあとに読んでも 0 になる。回数が残るのは、コンテナの1つ上にある Pod の slice の memory.events だ。 以下では、0 になる理由、Pod の slice の探し方、slice の memory.events の読み方の順に書く。 コンテナの oom_kill が 0 になる理由 OOM で再起動した Pod で、コンテナの memory.events を読むとこうなる。 $ kubectl exec <pod> -- grep oom /sys/fs/cgroup/memory.events oom 0 oom_kill 0 oom_group_kill 0 v2 のノードでは、kubelet がコンテナの memory.oom.group を 1 にする5。この cgroup で OOM が起きると、カーネルはコンテナのプロセスを全部強制終了し、コンテナは cgroup ごと作り直されて、カウンタも 0 から数え直す。 自分のノードがこうなるかは、コンテナの /sys/fs/cgroup/memory.oom.group が 1 かどうかで分かる。kubelet の singleProcessOOMKill が true なら 0 のままだ。 Pod の slice を探す Pod の slice は、Pod のコンテナ全部をまとめた cgroup で、memory.events には配下のコンテナの分が合計される。コンテナが再起動しても slice は残るので、回数も残る。 /sys/fs/cgroup/ └── kubepods.slice/ └── kubepods-burstable.slice/ └── kubepods-burstable-pod<uid>.slice/ ← Pod の slice └── cri-containerd-<コンテナ ID>.scope/ ← コンテナの scope slice の場所は、コンテナ ID で scope を探して調べる。コンテナ ID は、Pod の containerID の containerd:// のあとの部分だ。kubectl debug node でノードに入ると、ノードのファイルは /host の下に見える。 $ kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[*].containerID}' containerd://<コンテナ ID> $ kubectl debug node/<node> -it --image=busybox find /host/sys/fs/cgroup -type d -name 'cri-containerd-<コンテナ ID>.scope' 見つかったディレクトリの1つ上が Pod の slice だ。cri-containerd- はランタイムが containerd の場合の名前になる。ノードに root で入れるなら、パスの /host を外す。 slice の memory.events を読む slice の memory.events では、oom_group_kill が OOM の回数で、restartCount と一致する。oom_group_kill は、memory.oom.group によって cgroup のプロセスをまとめて強制終了した回数だ6。restartCount が 3 の Pod では、次のようになる。 grep oom <slice>/memory.events oom 3 oom_kill 9 oom_group_kill 3 oom_kill は、カーネルがプロセスを強制終了するたびに増えるので、1回の OOM で複数増える。この Pod では、3回の OOM で 9 になった。 脚注 カーネルの cgroup-v2 文書の cpu.stat の項。 ↩︎ Kubernetes の Resource metrics pipeline の memory の項。 ↩︎ cAdvisor の handler.go で、v1 は total_inactive_file、v2 は inactive_file を差し引いている。Kubernetes の文書にある memory-available-cgroupv2.sh も、v2 の式を同じ形で書いている。 ↩︎ カーネルの cgroup-v1 の memory 文書の memory.oom_control の項。oom_kill は、その cgroup で OOM により強制終了されたプロセスの数。 ↩︎ kubelet の kuberuntime_container_linux.go で、v2 のノードでは singleProcessOOMKill が true でない限り memory.oom.group を 1 にしている。この処理は kubernetes/kubernetes#117793 で入った。 ↩︎ カーネルの cgroup-v2 文書の memory.events の項。 ↩︎

  3. Qiita · · @yosshi_

    Kubernetes ノードのディスク領域と Disk Pressure Eviction について はじめに ノードのディスク領域がよくわからないのと、Pod が意図せずに evict される場合は、OOM か Disk Pressure の場合が多いですが、Disk Pressure についてはよくわからないという話をよく聞くので記事にしました。 OOM の仕組みについては以前記事にしたので、そちらを参考にしてください。 参考: Kubernetes のメモリ使用量と cgroup v2 の関係: OOM / Eviction の切り分けと Request の適正化 自分が経験した範囲では、Disk Pressure による Pod の Eviction の多くは、次のような流れで発生し、原因と対策は以下の通りです。 - 流れ 1. ディスクの空き容量が nodefs.available<10% を下回り、Disk Pressure と判定される 2. kubelet が各種 GC で空き容量の確保を試みるが、それでも解消しない 3. Pod が Eviction される - 原因: 多くのケースで ephemeral-storage の request が未設定か過小に設定されていて、スケジューラが機能していません。 - 対策: 実際の消費量に見合った request を設定しておくことで回避できるケースが多いです。 Kubernetes をただ使うだけであれば、Disk Pressure が起きた際に上記の知識だけで十分対応できるケースも多いと思いますが、クラスタの構築や詳細なトラブルシュートを実施しようとすると、それだけでは知識が足りません。 この記事では、ノードのディスク領域の構成から ephemeral-storage の内訳、Disk Pressure の発生条件・挙動、対策までを順番に整理します。 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |:---|:---|:---|:---| | Kubernetes | v1.37.1 | kubernetes/kubernetes | kubelet の Eviction Manager、Image GC Manager、Container Log Manager、emptyDir ボリュームプラグインの実装 | | node_exporter | v1.12.1 | prometheus/node_exporter | nodefs/imagefs の空き容量・inode 使用率を監視する際の filesystem メトリクス | | kube-state-metrics | v2.20.0 | kubernetes/kube-state-metrics | DiskPressure Node Condition を監視する際の kube_node_status_condition メトリクス | | containerd | v2.4.1 | containerd/containerd | 専用 imagefs/containerfs を構成する際の config、CRI 経由の ImageFsInfo 実装 | TL;DR - ノードのディスクは nodefs (root filesystem) と imagefs (image filesystem) の 2 つに分けて考えます。 - コンテナログのローテーションは kubelet 自身が担当しますが (ContainerLogMaxSize デフォルト 10Mi、ContainerLogMaxFiles デフォルト 5)、コンテナ単位の上限でしかありません。ノード全体のログ量には上限がなく、コンテナ数が多い環境ではログだけで nodefs を圧迫できます。 - ephemeral-storage として計測されるのは コンテナの Writable Layer・ログ・disk-backed な emptyDir・/etc/hosts などです。 - hard threshold を下回ると Disk Pressure と判定されます。 デフォルト値は nodefs.available<10%、imagefs.available<15%、nodefs.inodesFree<5%、imagefs.inodesFree<5% です。 - kubelet はまず Pod を退避せずに 「ノードレベルの回収」 を試みます。不要なコンテナ・Pod の GC、そして Image GC (未使用イメージの削除) です。これで閾値を下回れば Pod Eviction は起こりません。 - Image GC は 5 分おきの定期実行 に加えて、Disk Pressure 発生時 にも呼び出されます。後者は閾値に関係なく、削除できるイメージを LRU 順にすべて削除しようとします。 - Disk Pressure での Pod 退避の優先順位は次の順で決まります。request を設定していない Pod は真っ先に「超過している」扱いになり、優先的に狙われます。 1. ephemeral-storage の request を超過しているか 2. Priority 3. request を超えた使用量 - QoS クラスは CPU・Memory だけで決まり、ephemeral-storage は関係しません。Disk Pressure で QoS は機能しません。 ノードのディスク領域 Disk Pressure の判定に使われる nodefs/imagefs の空き容量がそれぞれ何を指すのか、まずノードのディスク領域そのものを整理します。 第1章: ディスクの中身 — nodefs / imagefs / containerfs まず nodefs・imagefs・containerfs という 3 つの用語の位置づけを、概要レベルで表にまとめます。nodefs/imagefs の詳細な判定方法や具体例は 1.1 で、containerfs は 1.2 で説明します。それぞれの領域の GC の方法は第2章でまとめて説明します。 | 用語 | 概要 | 主に含まれるもの | |:---|:---|:---| | nodefs | ノードの基本ファイルシステム | ログ、disk-backed な emptyDir、kubelet の作業ディレクトリなど | | imagefs | イメージ用ファイルシステム (nodefs から分離できる) | コンテナイメージ、コンテナの Writable Layer | | containerfs | Writable Layer 用ファイルシステム (imagefs から分離できる) | コンテナの Writable Layer のみ | 具体的にどのデータがどこに格納されるかを、nodefs・imagefs・containerfs を最大限分離した場合で一覧にすると、次のようになります。 | 種類 | nodefs | imagefs | containerfs | |:---|:---:|:---:|:---:| | kubelet の作業ディレクトリ (/var/lib/kubelet など) ホスト側ファイル | ○ | - | - | | コンテナログ (/var/log/pods 配下) | ○ | - | - | | disk-backed な emptyDir | ○ | - | - | | コンテナイメージ | △ | ○ | - | | コンテナの Writable Layer | △ | △ | ○ | - ○: 専用に分離した場合、実際に格納される場所 - △: 専用領域を用意しない場合に吸収される上位の区分 - -: 対象外 この表のとおり imagefs を専用化しなければ △ の行は nodefs に吸収され、containerfs を専用化しなければさらに imagefs (または nodefs) に吸収されます。この判定の詳細は 1.1 で、containerfs 固有の事情は 1.2 で説明します。 1.1 nodefs と imagefs kubelet はノードのディスクを nodefs と imagefs という 2 つの論理的なファイルシステムとして扱います。 > nodefs: The node's main filesystem, used for local disk volumes, emptyDir volumes not backed by memory, log storage, ephemeral storage, and more. For example, nodefs contains /var/lib/kubelet. > > imagefs: An optional filesystem that container runtimes use to store container images and container writable layers. (参考: node-pressure-eviction.md#L122-L131) imagefs は「専用のディスクを別に用意した場合だけ意味を持つ」ものです。専用 imagefs を用意していなければ、imagefs は nodefs と同じデバイスを指し、実質的に nodefs のみになります。 この判定は自動で行われます。kubelet はコンテナランタイムが報告する imagefs のデバイスと、root filesystem のデバイスを比較し、同じであれば「専用ではない」と判断します。 device, err := p.containerStatsProvider.ImageFsDevice(ctx) ... rootFsInfo, err := p.cadvisor.RootFsInfo() ... if device == rootFsInfo.Device { ... } return device != rootFsInfo.Device, nil (参考: pkg/kubelet/stats/provider.go#L179-L201) この判定結果は Eviction Manager 起動時に一度だけキャッシュされます。 hasImageFs, imageFsErr := diskInfoProvider.HasDedicatedImageFs(ctx) ... m.dedicatedImageFs = &hasImageFs (参考: pkg/kubelet/eviction/eviction_manager.go#L267-L275) 実際のノード上でこれらがどう見えるか、containerd + overlayfs を使う単一ファイルシステム構成 (最も一般的なパターン) を例に確認してみます。以下は代表的なディレクトリ構成の一例です (実際のパスはディストリビューション・ランタイムの設定次第で変わります)。 # / と /var/lib/containerd が同じデバイスを指している = 専用 imagefs ではない $ df -h / /var/lib/containerd Filesystem Size Used Avail Use% Mounted on /dev/sda1 50G 18G 30G 38% / /dev/sda1 50G 18G 30G 38% /var/lib/containerd # nodefs 側: kubelet の作業ディレクトリ (Pod の emptyDir など) $ tree -L 2 /var/lib/kubelet/pods/<pod-uid>/volumes/ /var/lib/kubelet/pods/<pod-uid>/volumes/ ├── kubernetes.io~empty-dir │ └── cache-volume ├── kubernetes.io~projected │ └── kube-api-access-xxxxx └── kubernetes.io~secret └── my-secret # nodefs 側: Pod のログ (/var/log/pods/<namespace>_<pod名>_<pod-uid>/<コンテナ名>/) $ tree /var/log/pods/default_myapp-6f9d8c9c9b-abcde_1b2c3d4e-.../myapp/ /var/log/pods/default_myapp-6f9d8c9c9b-abcde_1b2c3d4e-.../myapp/ └── 0.log # imagefs 側: イメージ本体とコンテナの Writable Layer (containerd の snapshotter 配下) $ tree -L 1 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ ├── 1 ├── 2 ├── 3 └── 4 専用 imagefs を切っている構成 (/var/lib/containerd を別ディスクにマウントするなど) では、df の出力上でデバイスが分かれます。 $ df -h / /var/lib/containerd Filesystem Size Used Avail Use% Mounted on /dev/sda1 20G 9G 10G 48% / /dev/sdb1 200G 140G 50G 74% /var/lib/containerd kubelet はこの 2 つのマウントポイントのデバイス番号を比較して「専用かどうか」を自動判定しています (1.1 で見た HasDedicatedImageFs の判定ロジックそのものです)。 ここで重要なのは、imagefs を専用化するための設定は kubelet 側にはない という点です。kubelet は単にコンテナランタイムが実際に使っているディレクトリを検出しているだけなので、実際に設定するのはコンテナランタイム側 (containerd であれば root) です。 version = 2 root = "/mnt/imagefs/containerd" state = "/run/containerd" (参考: cmd/containerd/server/config/config.go#L58-L65) root がコンテナイメージ・Writable Layer (snapshotter) の格納先で、これを別ディスクのマウントポイントに向けることで「専用 imagefs」になります。state はソケットなど一時データ用で、imagefs/nodefs の区分とは別物です。 imagefs からさらにコンテナの Writable Layer 用のファイルシステムを分離する containerfs という 3 つ目の区分もありますが、これは次の 1.2 で別に説明します。 1.2 containerfs containerfs は「imagefs からさらに Writable Layer 用のファイルシステムを分離した場合だけ意味を持つ」ものです (KEP-4191、KubeletSeparateDiskGC feature gate)。分離していなければ、containerfs は単一ファイルシステム構成では nodefs と、専用 imagefs がある構成では imagefs と同じデバイスを指します。 // In case of a single filesystem, containerfs=nodefs. // In case of a image filesystem, containerfs=imagefs. // We will override user settings and set to either imagefs or nodefs depending on configuration. (参考: pkg/kubelet/eviction/api/types.go#L31-L52) KubeletSeparateDiskGC は 1.29 で Alpha、1.31 で Beta・デフォルト有効になっています。 {Version: version.MustParse("1.31"), Default: true, PreRelease: featuregate.Beta} (参考: pkg/features/kube_features.go#L1803-L1806) containerfs の閾値はユーザーが個別に設定することはできません。単一ファイルシステム構成では nodefs と同じ値、専用イメージ用ファイルシステムがある構成では imagefs と同じ値に、kubelet 側で強制的に上書きされます (参考: pkg/kubelet/eviction/helpers.go#L186-L253)。 ただし、この containerfs は containerd 構成では実際には使えません。containerd は ContainerFilesystems フィールドを実装しておらず、kubelet 側の実装にも対応コードが無いためです。設計の経緯と技術的な詳細は第8章で改めて説明します。 第2章: ノードの GC Disk Pressure の閾値に達する前に、kubelet はノードのディスク使用量を抑えるためにいくつかの GC の仕組みを持っています。対象領域・対象・実行タイミングをまず表にまとめ、それぞれの詳細を 2.1〜2.3 で順に説明します。 | GC | 対象領域 | 対象 | 実行タイミング | |:---|:---|:---|:---| | コンテナログのローテーション | nodefs | コンテナログ (/var/log/pods 配下) | 10 秒おきにチェック | | Image GC | imagefs | 未使用のコンテナイメージ | 5 分おきの定期実行 + Disk Pressure 発生時 | | Container GC | imagefs (専用 containerfs があれば containerfs) | 停止済みコンテナ・Pod sandbox | 1 分おきの定期実行 + Disk Pressure 発生時 | 2.1 コンテナログのローテーション (nodefs 側) nodefs 側で唯一の自動的な GC と呼べる仕組みが、コンテナログのローテーションです。disk-backed な emptyDir 自体には GC の仕組みはなく、Pod が削除されれば一緒に消えるだけです (サイズ超過時の扱いは第3章で扱います)。 コンテナログのローテーションは containerd などのランタイム側の機能ではなく、kubelet 自身が実装しています。 kubelet が起動時にログ管理用のコンポーネントをセットアップします。 // setup containerLogManager for CRI container runtime containerLogManager, err := logs.NewContainerLogManager( (参考: pkg/kubelet/kubelet.go#L749-L761) ローテーション自体は「ファイルをリネームしてから CRI 経由でランタイムにログファイルの再オープンを依頼する」という手順です。 rotated := fmt.Sprintf("%s.%s", log, timestamp) if err := c.osInterface.Rename(log, rotated); ... if err := c.runtimeService.ReopenContainerLog(ctx, id); err != nil { (参考: pkg/kubelet/logs/container_log_manager.go#L414-L436) デフォルト値は次のとおりです。 obj.ContainerLogMaxSize = "10Mi" obj.ContainerLogMaxFiles = ptr.To[int32](5) obj.ContainerLogMaxWorkers = ptr.To[int32](1) obj.ContainerLogMonitorInterval = &metav1.Duration{Duration: 10 * time.Second} (参考: defaults.go#L256-L267) | 設定 | デフォルト値 | 意味 | |:---|:---|:---| | containerLogMaxSize | 10Mi | 1 ファイルあたりの上限サイズ | | containerLogMaxFiles | 5 | コンテナごとに保持するログファイル数 | | containerLogMaxWorkers | 1 | ローテーションを並行実行するワーカー数 | | containerLogMonitorInterval | 10s | ローテーション対象をチェックする間隔 | ここが log rotation を過信できない理由です。ローテーションのチェックは 10 秒おきに、実行中の各コンテナのログファイルサイズを個別に確認する形で行われます。 path := resp.GetStatus().GetLogPath() info, err := c.osInterface.Stat(path) ... if info.Size() < c.policy.MaxSize { ... return } (参考: pkg/kubelet/logs/container_log_manager.go#L240-L263) KubeletConfiguration の中を見ても、「ノード全体で保持できるログの合計サイズ」に相当する設定項目はありません。containerLogMaxWorkers は並行度の設定であって、容量の上限ではありません。つまり、1 コンテナあたりの上限は MaxSize × (MaxFiles - 2) 程度に抑えられていても、コンテナの数が増えれば増えるほど、ノード全体でのログ使用量は際限なく増えていきます。 // Other than those 2 files, we can have at most MaxFiles-2 rotated log files. maxRotatedFiles := c.policy.MaxFiles - 2 (参考: pkg/kubelet/logs/container_log_manager.go#L351-L371) さらに、チェックは 10 秒に 1 回のポーリングなので、その間に MaxSize を大きく超えて書き込まれる余地もあります。書き込み時点でブロックするような、ハードなキャップではありません。 大量の短命 Pod (Job や CronJob) を高頻度に流す環境では、1 コンテナあたりのログは小さくても、コンテナの総数が多いためにログだけで nodefs を圧迫する、という事象が起こり得ます。Job 中心の運用ではこの圧迫に気づきにくいので、注意が必要です。 Disk Pressure によるコンテナ削除で kubectl logs が失敗するケースもあり、その原因調査については以下で記事を書いたので、そちらも参考にしてください。 参考: Kubernetes での "unable to retrieve container logs" エラーの原因調査 なお、Pod が evict された場合はログも一緒に失われます。 > If a pod is evicted from the node, all corresponding containers are also evicted, along with their logs. (参考: logging.md#L121-L122) Disk Pressure のトラブルシュート中に kubectl logs でエビデンスを取ろうとしても、対象の Pod がすでに evict されていて取得できない、という事態が起こりやすいのはこのためです。ログを長期保持したい場合は、Loki などの外部ロギングバックエンドへ転送しておくことが実質的な対策になります。 ログローテーションの仕組みやログ収集エージェント (Fluent Bit、OpenTelemetry Collector、Vector など) の実装の違いについては、以前別の記事にまとめているので、そちらも参考にしてください。 参考: Kubernetesのログ入門 2.2 Image GC (imagefs 側) 公式ドキュメントでも Garbage collection of unused containers and images としてまとめられています。 reclaim function としての Image GC は、次のシンプルなインターフェースを実装しています。 // ImageGC is responsible for performing garbage collection of unused images. type ImageGC interface { // DeleteUnusedImages deletes unused images. DeleteUnusedImages(ctx context.Context) error } (参考: pkg/kubelet/eviction/types.go#L83-L87) Image GC は、5 分おきの定期実行によって imagefs の使用量を普段から抑えています。 // ImageGCPeriod is the period for performing image garbage collection. ImageGCPeriod = 5 * time.Minute (参考: pkg/kubelet/kubelet.go#L229-L232) 定期実行は閾値ベースです。使用率が HighThresholdPercent (デフォルト 85%) を超えたら、LowThresholdPercent (デフォルト 80%) まで減らそうとします。 usagePercent := 100 - int(available*100/capacity) if usagePercent >= im.policy.HighThresholdPercent { amountToFree := capacity*int64(100-im.policy.LowThresholdPercent)/100 - available ... if freed < amountToFree { // This usually means the disk is full for reasons other than container images, such as logs, volumes, or other files. (参考: pkg/kubelet/images/image_gc_manager.go#L349-L418) if obj.ImageGCHighThresholdPercent == nil { // default is below docker's default dm.min_free_space of 90% obj.ImageGCHighThresholdPercent = ptr.To[int32](85) } if obj.ImageGCLowThresholdPercent == nil { obj.ImageGCLowThresholdPercent = ptr.To[int32](80) } (参考: defaults.go#L155-L161) 削除対象は最終使用時刻の古い順 (LRU) です。実行中コンテナが使っているイメージや、ランタイム側で pin されたイメージは除外されます。 // Queries all of the image records and arranges them in a slice of evictionInfo, // sorted based on last time used, ignoring images pinned by the runtime. ... if isImageUsed(image, imagesInUse) { ... continue } // Check if image is pinned, prevent garbage collection if record.pinned { ... continue (参考: pkg/kubelet/images/image_gc_manager.go#L548-L593) 削除には ImageMinimumGCAge (デフォルト 2 分) の猶予もあります。「最近 pull したばかりのイメージがすぐに使われる可能性」を考慮したものです。 obj.ImageMinimumGCAge = metav1.Duration{Duration: 2 * time.Minute} (参考: defaults.go#L152-L154) さらに ImageMaximumGCAge という「使われていなくても一定期間で削除する」設定もあります。デフォルトは 0s (無効) です。 > imageMaximumGCAge is the maximum age an image can be unused before it is garbage collected. The default of this field is "0s", which disables this field--meaning images won't be garbage collected based on being unused for too long. (参考: types.go#L365-L370) CI/CD やバッチジョブのように、git SHA ごとのユニークタグなど 再利用されないイメージ を大量に生成するクラスタでは、imageMaximumGCAge を設定して「使われていないが HighThresholdPercent に達していないので居座り続けているイメージ」を定期的に掃除する、という運用が選択肢になります。効くかどうかを左右するのはコンテナの寿命そのものではなく、生成されたイメージタグがその後再利用されるかどうかです。 ここまでの Image GC の設定は、いずれも KubeletConfiguration のフィールドとして指定できます。 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration imageGCHighThresholdPercent: 85 imageGCLowThresholdPercent: 80 imageMinimumGCAge: 2m imageMaximumGCAge: 0s なお Image GC には、この定期実行とは別に Disk Pressure 発生時に閾値を無視して全件削除を試みる経路 もあります。これは Disk Pressure 発生時特有の挙動なので、第5章で改めて説明します。 2.3 Container GC (imagefs/containerfs 側) reclaim function としての Container GC は、次のインターフェースを実装しています。 // ContainerGC is responsible for performing garbage collection of unused containers. type ContainerGC interface { // DeleteAllUnusedContainers deletes all unused containers, even those that belong to pods that are terminated, but not deleted. DeleteAllUnusedContainers(ctx context.Context) error } (参考: pkg/kubelet/eviction/types.go#L89-L93) Container GC は、Image GC とは別に 1 分おきの定期実行でも動いています。これは Disk Pressure の有無とは無関係な、kubelet 起動時からの独立したループです。 // ContainerGCPeriod is the period for performing container garbage collection. ContainerGCPeriod = time.Minute go wait.UntilWithContext(ctx, func(ctx context.Context) { if err := kl.containerGC.GarbageCollect(ctx); err != nil { ... } }, ContainerGCPeriod) (参考: pkg/kubelet/kubelet.go#L229、pkg/kubelet/kubelet.go#L1722-L1736) この定期実行 (GarbageCollect) と、Disk Pressure 発生時にも呼ばれる DeleteAllUnusedContainers は、同じ内部処理を呼びますが、最後の引数 (evictNonDeletedPods) だけが異なります。 func (cgc *realContainerGC) GarbageCollect(ctx context.Context) error { return cgc.runtime.GarbageCollect(ctx, cgc.policy, cgc.sourcesReadyProvider.AllReady(), false) } func (cgc *realContainerGC) DeleteAllUnusedContainers(ctx context.Context) error { ... return cgc.runtime.GarbageCollect(ctx, cgc.policy, cgc.sourcesReadyProvider.AllReady(), true) } (参考: pkg/kubelet/container/container_gc.go#L81-L88) evictNonDeletedPods が false の通常の定期実行は、すでに完全に削除された Pod に属するコンテナ・sandbox だけを対象にします。一方 true になる DeleteAllUnusedContainers は、まだ API サーバー上では削除しきれていないが Pod のランタイム (コンテナ) 自体は終了させてよい状態の Pod も対象に含めるため、より踏み込んだ削除になります。 if cgc.podStateProvider.ShouldPodContentBeRemoved(key.uid) || (evictNonDeletedPods && cgc.podStateProvider.ShouldPodRuntimeBeRemoved(key.uid)) { (参考: pkg/kubelet/kuberuntime/kuberuntime_gc.go#L239) Container GC 自体の挙動 (MinAge/MaxPerPodContainer/MaxContainers) には、KubeletConfiguration に対応するフィールドがありません。設定できるのは --minimum-container-ttl-duration/--maximum-dead-containers-per-container/--maximum-dead-containers という非推奨の CLI フラグだけで、いずれも「代わりに --eviction-hard/--eviction-soft を使ってください」という非推奨メッセージ付きで将来的に削除される予定です。 fs.DurationVar(&f.MinimumGCAge.Duration, "minimum-container-ttl-duration", f.MinimumGCAge.Duration, "Minimum age for a finished container before it is garbage collected. Examples: '300ms', '10s' or '2h45m'") fs.MarkDeprecated("minimum-container-ttl-duration", "Use --eviction-hard or --eviction-soft instead. Will be removed in a future version.") (参考: cmd/kubelet/app/options/options.go#L310-L315) つまり Container GC は、Image GC のように細かく調整するものではなく、eviction の hard/soft threshold 側で全体のディスク使用量を制御する という設計になっています。Container GC 自体のチューニングを試みるより、第4章・第7章で説明する eviction threshold の設定を見直すほうが筋が良い、ということになります。 ephemeral-storage ここまでで見た nodefs/imagefs の使用量のうち、Pod 単位で管理されるのが ephemeral-storage です。その内訳と、各領域の詳細を見ていきます。 第3章: ephemeral-storage として計測されるもの Pod の ephemeral-storage は、次の要素の合計として計算されます。 > * Writing to the container's writable layer (rootfs), container images, or both. > * Writing to local emptyDir volumes. > * The Pod's own logs (usually stored under /var/log/pods). > * System files managed by Kubernetes that are mapped into the Pod, such as /etc/hosts. (参考: manage-resources-containers.md#L409-L418) 実装側でも、この合計は 1 か所の関数 calcEphemeralStorage にまとまっています。 for _, container := range containers { addContainerUsage(result, &container, isCRIStatsProvider) } for _, volume := range volumes { result.UsedBytes = addUsage(result.UsedBytes, volume.FsStats.UsedBytes) ... } if podLogStats != nil { ... } if etcHostsStats != nil { ... } (参考: pkg/kubelet/stats/helper.go#L434-L462) コンテナ側は Writable Layer (rootfs) とログを足し合わせます。 if rootFs := container.Rootfs; rootFs != nil { ... stat.UsedBytes = addUsage(stat.UsedBytes, rootFs.UsedBytes) if logs := container.Logs; logs != nil { stat.UsedBytes = addUsage(stat.UsedBytes, logs.UsedBytes) (参考: pkg/kubelet/stats/helper.go#L464-L477) ここで重要なのは、この合計に渡される volume の一覧が「local ephemeral storage」に分類されたものだけという点です。emptyDir はデフォルトの medium: "" (disk-backed) の場合だけこの分類に入り、medium: Memory (tmpfs) は入りません。 return volume.GitRepo != nil || (volume.EmptyDir != nil && volume.EmptyDir.Medium == v1.StorageMediumDefault) || volume.ConfigMap != nil (参考: pkg/volume/util/util.go#L494-L498) 公式ドキュメントも同じことを明記しています。 > The kubelet tracks tmpfs emptyDir volumes as container memory use, rather than as local ephemeral storage. (参考: ephemeral-storage.md#L104-L107) つまり「emptyDir を tmpfs にすれば ephemeral-storage の枠を消費しない」というのは事実ですが、その代わりコンテナのメモリ使用量を圧迫することになります。ディスクの逼迫をメモリの逼迫にすり替えているだけ、という理解が正確です。 ここまでの内容を、Pod の ephemeral-storage 使用量の対象になるかどうかで一覧にすると次のとおりです。 | 種類 | ephemeral-storage に含まれるか | 計測方法 | 備考 | |:---|:---:|:---|:---| | コンテナの Writable Layer (rootfs) | ○ | du (rootfs 使用量) | イメージ本体ではなく、コンテナが書き込んだ差分のみ。専用 imagefs 構成では imagefs 側の使用量として扱われる | | コンテナのログ | ○ | ログファイルのサイズ | 第2章 2.1 のローテーション設定 (containerLogMaxSize/containerLogMaxFiles) の対象 | | emptyDir (medium 未指定、disk-backed) | ○ | du (1 分ごとに集計) | IsLocalEphemeralVolume で local ephemeral storage に分類される | | emptyDir (medium: Memory、tmpfs) | ✕ (memory として計上) | tmpfs の使用量 = コンテナのメモリ使用量 | IsLocalEphemeralVolume の判定から除外される。カーネルの size= によるハードな上限は付くが、ephemeral-storage の request/limit には反映されない | | Pod のログディレクトリ (/var/log/pods 配下) | ○ | ログディレクトリの du | calcEphemeralStorage が個別に加算 | | /etc/hosts などシステムが Pod にマップするファイル | ○ | ファイルサイズ | calcEphemeralStorage が個別に加算 | | configMap ボリューム | ○ | du | IsLocalEphemeralVolume の対象。実際のデータ量はごく小さいことがほとんど | | PersistentVolumeClaim (PVC) 経由のボリューム | ✕ | (PV 側で管理) | IsLocalEphemeralVolume の対象外。ノードのローカルディスクを消費しない (hostPath 等を除く) | | コンテナイメージ本体 | ✕ (別枠で管理) | Image GC 側で管理 | Pod 単位の ephemeral-storage 使用量には計上されない。ノード全体では imagefs の空き容量を圧迫する | 「○」の行が pkg/kubelet/stats/helper.go の calcEphemeralStorage および pkg/volume/util.IsLocalEphemeralVolume (参考: pkg/volume/util/util.go#L494-L498) で加算・分類される対象、「✕」がそこから明示的に除外されるか、そもそも対象外の項目です。 3.1 計測方法は同じ、上限の強さが違う どちらも実体はホスト上のディレクトリで、使用量は du 相当の方法で計測されます。emptyDir のメトリクスプロバイダは明示的に du ベースです。 MetricsProvider: volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host)) (参考: pkg/volume/emptydir/empty_dir.go#L182) この集計はデフォルトで 1 分ごとに行われます。 obj.VolumeStatsAggPeriod = metav1.Duration{Duration: time.Minute} (参考: defaults.go#L162-L163) つまり disk-backed な emptyDir や Writable Layer に sizeLimit (あるいは ephemeral-storage の limit) を設定していても、それは カーネルレベルで書き込みをブロックするものではありません。kubelet が定期的に使用量を測定し、超過を見つけたら Pod を eviction する、という「事後的な」仕組みです。ソースコードのコメントにもその旨がはっきり書かれています。 // If setting up the quota fails, just log a message but don't actually error out. // We'll use the old du mechanism in this case, at least until we support // enforcement. (参考: pkg/volume/emptydir/empty_dir.go#L300-L306) 実際に超過を検知した場合の Eviction Manager 側の処理は次のとおりです。 size := source.EmptyDir.SizeLimit used := podVolumeUsed[pod.Spec.Volumes[i].Name] if used != nil && size != nil && size.Sign() == 1 && used.Cmp(*size) > 0 { // the emptyDir usage exceeds the size limit, evict the pod if m.evictPod(logger, pod, immediateEvictionGracePeriodSeconds, ... (参考: pkg/kubelet/eviction/eviction_manager.go#L542-L569) 一方、medium: Memory (tmpfs) の emptyDir は挙動が異なります。まずマウント自体が tmpfs として作られます。 case ed.medium == v1.StorageMediumMemory: err = ed.setupTmpfs(dir) (参考: pkg/volume/emptydir/empty_dir.go#L286-L295) sizeLimit はそのまま tmpfs マウントオプションの size= になります。これは Linux カーネルが強制する ハードな上限 です。 if ed.sizeLimit != nil && ed.sizeLimit.Value() > 0 { options = append(options, fmt.Sprintf("size=%d", ed.sizeLimit.Value())) } if noswapSupported { options = append(options, swap.TmpfsNoswapOption) } (参考: pkg/volume/emptydir/empty_dir.go#L601-L612) 3.2 sizeLimit を設定しなかったら何が上限になるのか disk-backed な emptyDir で sizeLimit を省略した場合、実質的な上限は「Pod の ephemeral-storage limit (設定していれば)」か「ノードのディスク容量そのもの」になります。 これに対し medium: Memory の場合は少し事情が違います。sizeLimit を省略しても、tmpfs のマウントサイズは必ず計算されます。デフォルトではノードの allocatable memory、Pod にメモリ limit が設定されていればその値との小さいほうです。 // size limit defaults to node allocatable (pods can't consume more memory than all pods) sizeLimit := nodeAllocatableMemory ... podMemoryLimit ... if podMemoryLimit.Cmp(zero) > 0 && podMemoryLimit.Cmp(*sizeLimit) < 1 { sizeLimit = podMemoryLimit } // volume local size is used if and only if less than what pod could consume (参考: pkg/volume/emptydir/empty_dir.go#L128-L168) 公式ドキュメントも同様の注意を書いています。 > If you do not specify a sizeLimit for an emptyDir volume, that volume may consume up to that pod's memory limit ... If you do not set a memory limit, the pod has no upper bound on memory consumption, and can consume all available memory on the node. ... It is possible to create any number of emptyDirs that could potentially consume all available memory on the node (参考: manage-resources-containers.md#L360-L371) つまり sizeLimit は「メモリ limit または node allocatable memory を超えない」という意味では上限が付きますが、tmpfs emptyDir を複数個作ると、それぞれが独立に (メモリ limit まで) 確保できてしまうため、合計ではノードのメモリを使い切る可能性があります。1 個あたりの上限であって、Pod 全体・ノード全体の上限ではない点に注意が必要です。 3.3 medium: Memory でも eviction の対象から完全に外れるわけではない ここは調査前の想定と違っていた点です。「tmpfs はカーネルが上限を強制するのだから、kubelet 側の polled チェックはスキップされるはず」と考えていましたが、実際にそのスキップが有効になるのは InPlacePodVerticalScalingMemoryBackedVolumes という Alpha feature gate が有効な場合だけで、これはデフォルトで無効です。 if utilfeature.DefaultFeatureGate.Enabled(features.InPlacePodVerticalScalingMemoryBackedVolumes) && source.EmptyDir.Medium == v1.StorageMediumMemory { // Memory-backed emptyDir volumes are backed by tmpfs, which has its size limit // enforced by the kernel. Eviction manager enforcement is not needed. continue } (参考: pkg/kubelet/eviction/eviction_manager.go#L549-L553) {Version: version.MustParse("1.37"), Default: false, PreRelease: featuregate.Alpha} (参考: pkg/features/kube_features.go#L1717-L1719) つまりデフォルトの設定では、medium: Memory の emptyDir は「tmpfs の size= によるカーネルレベルの上限」と「kubelet の 1 分おきの polled チェックによる eviction」の両方の対象になります。実務上はどちらにしても sizeLimit を明示しておくべき、という結論になります。 3.4 コンテナのログ コンテナのログと Pod のログディレクトリも、前掲の表のとおり ephemeral-storage に計上されます。ただしその上限や自動的な削除 (ローテーション) の仕組みは、ディスクの区分そのものと合わせて第2章 2.1 で説明したとおりです。ここでは ephemeral-storage の集計対象になる、という点だけ押さえておけば十分です。 3.5 kubelet の Summary API で実際の使用量を見る ここまでの計算結果は、kubelet 自身が /stats/summary という HTTP エンドポイントで公開しています。Eviction Manager が内部で参照しているのと同じ Summary Provider の出力で、3 で見た calcEphemeralStorage の計算結果がそのまま含まれています。 endpoints := []struct { path string handler restful.RouteFunction }{ {"/summary", h.handleSummary}, } (参考: pkg/kubelet/server/stats/handler.go#L110-L121) Pod ごとの ephemeral-storage 使用量は PodStats.EphemeralStorage フィールドとして返されます。 // EphemeralStorage reports the total filesystem usage for the containers and emptyDir-backed volumes in the measured Pod. // +optional EphemeralStorage *FsStats `json:"ephemeral-storage,omitempty"` (参考: staging/src/k8s.io/kubelet/pkg/apis/stats/v1alpha1/types.go#L145-L147) このフィールドには、3 で見た calcEphemeralStorage の戻り値がそのまま代入されます。 s.EphemeralStorage = calcEphemeralStorage(s.Containers, ephemeralStats, rootFsInfo, logStats, etcHostsStats, isCRIStatsProvider) (参考: pkg/kubelet/stats/helper.go#L497-L520) 実際に確認するには、kube-apiserver の node proxy 経由で叩くのが手軽です。 $ kubectl get --raw "/api/v1/nodes/<ノード名>/proxy/stats/summary" \ | jq '.pods[] | select(.podRef.name=="<Pod名>") | {name: .podRef.name, ephemeralStorage: .["ephemeral-storage"]}' { "name": "myapp-6f9d8c9c9b-abcde", "ephemeralStorage": { "time": "2026-10-13T12:00:00Z", "availableBytes": 32212254720, "capacityBytes": 53687091200, "usedBytes": 10485760, "inodesFree": 3276500, "inodes": 3276800, "inodesUsed": 300 } } usedBytes が、3 で説明した「コンテナの Writable Layer + ログ + disk-backed emptyDir + /etc/hosts 等」の合計値です。Pod の ephemeral-storage request/limit と見比べることで、request を超過している Pod をノード全体の Eviction より前に特定できます。 Disk Pressure について ノードのディスク領域と ephemeral-storage の内訳を踏まえて、ここからは実際に Disk Pressure が発生したときに何が起こるかを見ていきます。判定条件から Pod Eviction に至るまでの実際の処理順を、閾値判定 (第4章) → ノードレベルの回収 (第5章) → Pod の eviction 優先順位と退避 (第6章) の順に追っていきます。 第4章: Disk Pressure の判定閾値 Disk Pressure が発生してから Pod が evict されるまでの判定条件と処理順を、詳細に入る前に一枚の表にまとめます。各ステップの詳細は本章から第6章で順に説明します。 | 順序 | フェーズ | 判定条件 | 処理内容 | |:---:|:---|:---|:---| | 1 | 監視 | 10 秒ごと (固定値) | 6 つの disk signal (nodefs.available/inodesFree、imagefs.available/inodesFree、containerfs.available/inodesFree) を評価 | | 2 | 閾値判定 | signal が hard/soft threshold を下回る (デフォルト hard: nodefs.available<10%、imagefs.available<15%、nodefs.inodesFree<5%、imagefs.inodesFree<5%。soft はデフォルトなし) | 該当する threshold を eviction priority 順にソート | | 3 | NodeCondition 反映 | 閾値超過が PressureTransitionPeriod (デフォルト 5 分) の間観測され続ける | Node の DiskPressure Condition を True に設定 | | 4 | Taint 付与・Admission 拒否 | DiskPressure=True | NodeLifecycleController が node.kubernetes.io/disk-pressure (NoSchedule) を付与 (kubelet 自身ではない)。kubelet は非 Critical Pod の Admission をその場で拒否 | | 5 | ノードレベル回収 | 閾値を満たした signal がある | レイアウト依存の reclaim function (Container GC → Image GC など、詳細は第5章) を実行し、統計を再取得して再評価 | | 6a | 回収で解消 | 再評価で閾値を下回った | Pod Eviction は行わず終了 (次の 10 秒サイクルへ) | | 6b | 回収でも解消しない | 再評価でもまだ閾値超過 | Pod Eviction ステップへ進む | | 7 | Eviction 優先順位の決定 | — | (1) ephemeral-storage の request を超過しているか (2) Priority (3) request を超えた使用量、の順でソート (詳細は第6章) | | 8 | Eviction 実行 | — | 優先順位が最も高い Pod 1 つだけ を evict。grace period は hard threshold なら 1 秒、soft threshold なら MaxPodGracePeriodSeconds か Pod 自身の terminationGracePeriodSeconds の小さいほう | | (別経路) | Pod/コンテナ単位の limit 違反 | Pod の ephemeral-storage limit 超過、または emptyDir の sizeLimit 超過 | 上記のノード全体の閾値判定より 先に 評価され、条件を満たせば同じループで複数 Pod を evict できる (grace period は 1 秒) | 4.1 ディスク関連の eviction signal kubelet はこのバージョンで 6 つのディスク関連 signal を持っています。 SignalNodeFsAvailable Signal = "nodefs.available" SignalNodeFsInodesFree Signal = "nodefs.inodesFree" SignalImageFsAvailable Signal = "imagefs.available" SignalImageFsInodesFree Signal = "imagefs.inodesFree" SignalContainerFsAvailable Signal = "containerfs.available" SignalContainerFsInodesFree Signal = "containerfs.inodesFree" (参考: pkg/kubelet/eviction/api/types.go#L31-L52) 4.2 デフォルトの hard threshold Linux ノードのデフォルト値は次のとおりです。 var DefaultEvictionHard = map[string]string{ "memory.available": "100Mi", "nodefs.available": "10%", "nodefs.inodesFree": "5%", "imagefs.available": "15%", "imagefs.inodesFree": "5%", } (参考: pkg/kubelet/eviction/defaults_linux.go#L21-L28) | Signal | Linux デフォルト値 | |:---|:---| | memory.available | 100Mi | | nodefs.available | 10% | | nodefs.inodesFree | 5% | | imagefs.available | 15% | | imagefs.inodesFree | 5% | containerfs.* は前述のとおりユーザーが直接設定できず、レイアウトに応じて nodefs か imagefs の値がそのまま適用されます。 なお KubeletConfiguration の API ドキュメントコメントには imagefs.inodesFree が抜けています。 > Default: memory.available: "100Mi" nodefs.available: "10%" nodefs.inodesFree: "5%" imagefs.available: "15%" (参考: types.go#L574-L583) 実際のコード上のデフォルト値には imagefs.inodesFree: "5%" が含まれているので、ドキュメントコメントが更新漏れになっていると考えられます。 これらのデフォルト値は SetDefaults ではなく cmd/kubelet/app/server.go 側で、EvictionHard が未設定のときにまとめて適用されます。個別に値を設定した場合、MergeDefaultEvictionSettings (デフォルト false) が true でない限り、指定していない signal は 0 扱い、つまり実質無効になります。 if kc.EvictionHard == nil { kc.EvictionHard = eviction.DefaultEvictionHard } else if kc.MergeDefaultEvictionSettings { for k, v := range eviction.DefaultEvictionHard { if _, exists := kc.EvictionHard[k]; !exists { kc.EvictionHard[k] = v (参考: cmd/kubelet/app/server.go#L469-L480) --eviction-hard を一部だけ上書きしたつもりが他の signal を無効化してしまう、というのはよくある事故のパターンです。個別上書きをする場合は MergeDefaultEvictionSettings: true を検討してください。 4.3 soft threshold はデフォルトなし soft threshold・grace period・minimum reclaim には、いずれもデフォルト値がありません。 > EvictionSoft Default: nil > EvictionSoftGracePeriod Default: nil > EvictionPressureTransitionPeriod Default: "5m" > EvictionMaxPodGracePeriod Default: 0 > EvictionMinimumReclaim Default: nil (参考: types.go#L584-L612) soft threshold には grace period の指定が必須で、指定しないと kubelet が起動時にエラーを返します。 return nil, fmt.Errorf("grace period must be specified for the soft eviction threshold %v", signal) (参考: pkg/kubelet/eviction/helpers.go#L147-L154) grace period が 0 の threshold が hard threshold として扱われます。 // isHardEvictionThreshold returns true if eviction should immediately occur func isHardEvictionThreshold(threshold evictionapi.Threshold) bool { return threshold.GracePeriod == time.Duration(0) } (参考: pkg/kubelet/eviction/helpers.go#L1141-L1144) 4.4 監視間隔と NodeCondition への反映 Disk Pressure の判定は 10 秒ごとに行われます。この値は KubeletConfiguration のフラグではなく、コード上の固定値です。 // Period for performing eviction monitoring. // ensure this is kept in sync with internal cadvisor housekeeping. evictionMonitoringPeriod = time.Second * 10 (参考: pkg/kubelet/kubelet.go#L195-L197) :::note warn 公式ドキュメントは「housekeeping-interval という設定可能な値で、デフォルト 10s」と説明していますが、実際にはコード上の固定 10 秒です (参考: node-pressure-eviction.md#L265-L268)。設定で変更できるものではない、という点は覚えておいたほうがよさそうです。この点については修正 PR (kubernetes/website#58036) を提出しています。 ::: すべてのディスク関連 signal は NodeDiskPressure にマッピングされています。 signalToNodeCondition[evictionapi.SignalImageFsAvailable] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalContainerFsAvailable] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalNodeFsAvailable] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalImageFsInodesFree] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalNodeFsInodesFree] = v1.NodeDiskPressure signalToNodeCondition[evictionapi.SignalContainerFsInodesFree] = v1.NodeDiskPressure (参考: pkg/kubelet/eviction/helpers.go#L78-L95) 判定された Condition は EvictionPressureTransitionPeriod (デフォルト 5 分) の間保持されます。フラッピングを防ぐための仕組みです。 // node conditions report true if it has been observed within the transition period window nodeConditions = nodeConditionsObservedSince(nodeConditionsLastObservedAt, m.config.PressureTransitionPeriod, now) (参考: pkg/kubelet/eviction/eviction_manager.go#L337-L353) 4.5 DiskPressure が True になると何が起こるか DiskPressure が True になると、大きく 2 つのことが起こります。 1点目は、kubelet 自身がその場で「Critical でない Pod」の Admission を拒否するようになることです。Admission とは、スケジューラが Node を決めた後、kubelet が実際にコンテナを起動する直前に行う「本当にこの Pod をこのノードで動かしていいか」の最終チェックです。スケジューラの判断時点とkubeletが処理する時点の間にノードの状態が変わっていないかを、ノード上であらためて確認する役割を持っています。 // Admit Critical pods even under resource pressure since they are required for system stability. ... // Conditions other than memory pressure reject all pods ... return lifecycle.PodAdmitResult{ Admit: false, Reason: Reason, Message: fmt.Sprintf(nodeConditionMessageFmt, m.nodeConditions), (参考: pkg/kubelet/eviction/eviction_manager.go#L155-L183) 2点目は、node.kubernetes.io/disk-pressure (NoSchedule) という Taint が付与されることです。この Taint を付けているのは kubelet ではなく、kube-controller-manager 内の NodeLifecycleController です。 // TaintNodeDiskPressure will be added when node has disk pressure // and removed when node has enough disk. TaintNodeDiskPressure = "node.kubernetes.io/disk-pressure" (参考: well_known_taints.go#L37-L39) nodeConditionToTaintKeyStatusMap = map[v1.NodeConditionType]map[v1.ConditionStatus]string{ ... v1.NodeDiskPressure: { v1.ConditionTrue: v1.TaintNodeDiskPressure, }, (参考: pkg/controller/nodelifecycle/node_lifecycle_controller.go#L87-L101) kubelet は Node Status に Condition を書き込むだけで、Taint への変換は kube-controller-manager 側の別コンポーネントの責務です。役割が分かれていることは、トラブルシュートの際に「kubelet のログだけ見ていても Taint が付いた理由が分からない」ことにつながるので覚えておくとよいです。 第5章: 閾値超過後の挙動 — ノードレベル回収 → Pod Eviction 5.1 Pod を eviction する前に、まずノードレベルの回収を試みる synchronize ループの中心的な処理は次のとおりです。閾値を満たす signal を優先度順にソートしたあと、まずノードレベルの回収を試み、それで十分ならそこで終了します。 sort.Sort(byEvictionPriority(thresholds)) ... m.recorder.Eventf(m.nodeRef, v1.EventTypeWarning, "EvictionThresholdMet", "Attempting to reclaim %s", resourceToReclaim) // check if there are node-level resources we can reclaim to reduce pressure before evicting end-user pods. if m.reclaimNodeLevelResources(ctx, thresholdToReclaim.Signal, resourceToReclaim) { logger.Info("Eviction manager: able to reduce resource pressure without evicting pods.", ...) return nil, nil } logger.Info("Eviction manager: must evict pod(s) to reclaim", ...) (参考: pkg/kubelet/eviction/eviction_manager.go#L383-L400) reclaimNodeLevelResources は、signal ごとに登録されたreclaim functionを順番に実行し、そのあと改めて統計を取得し直して閾値を再評価します。1 回でも閾値を下回れば Pod Eviction はスキップされます。 // reclaimNodeLevelResources attempts to reclaim node level resources. returns true if thresholds were satisfied and no pod eviction is required. ... for _, nodeReclaimFunc := range nodeReclaimFuncs { // attempt to reclaim the pressured resource. if err := nodeReclaimFunc(ctx); err != nil { ... if len(nodeReclaimFuncs) > 0 { summary, err := m.summaryProvider.Get(ctx, true) ... thresholds := thresholdsMet(logger, m.config.Thresholds, observations, true) ... if len(thresholds) == 0 { return true } ... return false (参考: pkg/kubelet/eviction/eviction_manager.go#L480-L512) reclaim functionの実体は、第2章 2.2・2.3 で見た Image GC Manager (ImageGC.DeleteUnusedImages) と Container GC (ContainerGC.DeleteAllUnusedContainers) です。 つまり 「Disk Pressure = 即 Pod Eviction」ではなく、まず Image GC・Container GC で回収を試みる というのが基本の流れです。冒頭の TL;DR にも書きましたが、ここが本記事のいちばんの要点です。 このうち Image GC は、第2章 2.2 で見た 5 分おきの定期実行とは別に、Disk Pressure 発生時にも呼び出されます。この経路では閾値を無視して、削除できる不要イメージを可能な限り全部削除しようとします。 func (im *realImageGCManager) DeleteUnusedImages(ctx context.Context) error { logger.Info("Attempting to delete unused images") ... remainingImages, _, err := im.freeSpace(ctx, math.MaxInt64, freeTime, images) (参考: pkg/kubelet/images/image_gc_manager.go#L458-L475) 「Image GC・Container GC はもともと定期実行されているのだから、Disk Pressure 専用の呼び出しは不要では」と思うかもしれませんが、実際には定期実行では拾えない範囲を埋める役割があります。 Container GC は、定期実行 (GarbageCollect) と Disk Pressure 時 (DeleteAllUnusedContainers) で、削除対象の判定条件そのものが異なります。 func (p *podWorkers) ShouldPodRuntimeBeRemoved(uid types.UID) bool { if status, ok := p.podSyncStatuses[uid]; ok { return status.IsTerminated() } ... } func (p *podWorkers) ShouldPodContentBeRemoved(uid types.UID) bool { if status, ok := p.podSyncStatuses[uid]; ok { return status.IsEvicted() || (status.IsDeleted() && status.IsTerminated()) } ... } (参考: pkg/kubelet/pod_workers.go#L704-L723) 定期実行が使う ShouldPodContentBeRemoved は「API サーバー上で Pod が削除されている」ことまで要求しますが、Disk Pressure 時だけ追加で効く ShouldPodRuntimeBeRemoved は「コンテナが終了している」だけで true になります。つまり、Job が完了したのに TTL 未設定で誰も削除していない Pod のように、「コンテナは終了しているが Pod 自体は API サーバー上に残っている」ケースのコンテナは、1 分おきの定期実行では永遠に掃除されません。Disk Pressure 時の呼び出しだけが、この分のディスクを回収できます。 Image GC も、定期実行と Disk Pressure 時で削除する量とタイミングが異なります。 // 定期実行: 低閾値まで戻すのに必要な分だけ削除 amountToFree := capacity*int64(100-im.policy.LowThresholdPercent)/100 - available remainingImages, freed, err := im.freeSpace(ctx, amountToFree, freeTime, images) (参考: pkg/kubelet/images/image_gc_manager.go#L395-L404) 定期実行は「HighThresholdPercent を LowThresholdPercent まで戻す」という決まった量しか削除しません。一方 Disk Pressure 時は math.MaxInt64 を指定し、削除可能なイメージを量の上限なく削除しようとします (前掲のコード)。タイミングも、定期実行は最大 5 分待たされるのに対し、Disk Pressure 時は 10 秒おきの eviction monitoring サイクルの中で即座に動きます。ディスクが急速に埋まる状況では、この「量の上限を設けない」「5 分待たずにすぐ動く」という 2 点が、Pod Eviction まで進まずに済むかどうかを左右します。 5.2 どの signal で、どのreclaim functionが動くか (レイアウト依存) ただし、この「回収を試みる」処理はファイルシステムのレイアウトによって内容が変わり、専用 imagefs がある構成では nodefs に対するreclaim functionが空になります。 signalToReclaimFunc := map[evictionapi.Signal]nodeReclaimFuncs{} (挙動の分岐は pkg/kubelet/eviction/helpers.go#L1201-L1237 に実装されています) | レイアウト | nodefs.* のreclaim function | imagefs.* のreclaim function | containerfs.* のreclaim function | |:---|:---|:---|:---| | 単一ファイルシステム | Container GC → Image GC | (nodefs と同じ扱い) | (nodefs と同じ扱い) | | imagefs を分離 (containerfs 分離なし) | なし (空リスト) | Container GC → Image GC | imagefs と同じ | | imagefs と containerfs を分離 | Container GC のみ | Image GC のみ | Container GC のみ | 専用 imagefs 構成でのコード上のコメントは次のとおりです。 // with an imagefs, nodefs pressure should just delete logs signalToReclaimFunc[evictionapi.SignalNodeFsAvailable] = nodeReclaimFuncs{} ... // with an imagefs, imagefs pressure should delete unused images signalToReclaimFunc[evictionapi.SignalImageFsAvailable] = nodeReclaimFuncs{containerGC.DeleteAllUnusedContainers, imageGC.DeleteUnusedImages} (参考: pkg/kubelet/eviction/helpers.go#L1205-L1213) コメントには "nodefs pressure should just delete logs" とありますが、実際に登録されるreclaim functionは空リストです。つまり 専用 imagefs 環境で nodefs (ログや emptyDir が積もった場合) が閾値を超えると、kubelet は何の自動回収も試みずにいきなり Pod Eviction に進みます。 :::note warn 公式ドキュメントはこのケースについて次のように説明しています。 > If the nodefs filesystem meets the eviction thresholds, the kubelet garbage collects dead pods and containers. If the imagefs filesystem meets the eviction thresholds, the kubelet deletes all unused images. (参考: node-pressure-eviction.md#L302-L336) しかし実装を見る限り、専用 imagefs 構成での nodefs 側にはreclaim functionが登録されていません。ドキュメントの記述と実装が一致していないように見えます。専用 imagefs を使っている環境では、nodefs 側 (ログ・emptyDir) の余裕を Image GC 頼みにせず、ログローテーションの設定や emptyDir の sizeLimit で自衛する必要がある、という結論になります。 ただし、全く何も起きないわけではありません。Container GC は ContainerGCPeriod (デフォルト 1 分) ごとに、Disk Pressure とは無関係な独立したループでも実行されており、そちらはすでに削除された Pod のログディレクトリ (/var/log/pods 配下) を掃除します。あくまで nodefs の閾値超過をきっかけにした追加の回収が行われない だけで、この定期清掃自体は専用 imagefs 構成でも変わらず動いています。 ::: 第6章: どの Pod が退避されるか — eviction の優先順位と reques

  4. Issue、これっぽい。 website#42359 #k8snovice

    1 like · 37 views

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

No packages found.

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
kubernetes/website mentions in the last 30 days