はじめに
ノードのディスク使用量が増えたとき、どの領域を確認すればよいのか、なぜ Pod が Eviction されたのかは分かりにくいことがあります。この記事では、ノードのディスク領域と Pod の ephemeral-storage、Disk Pressure の判定から Eviction までの流れを整理します。
コンテナの OOM Kill と kubelet による Pod Eviction は別の仕組みです。まず Pod の status・Event とコンテナの終了理由を確認して切り分けます。
OOM の仕組みについては以前記事にしたので、そちらを参考にしてください。
参考: Kubernetes のメモリ使用量と cgroup v2 の関係: OOM / Eviction の切り分けと Request の適正化
自分が経験した範囲では、Disk Pressure による Pod の Eviction の多くは、次のような流れで発生し、原因と対策は以下の通りです。
-
流れ
- ディスクの空き容量の割合が 10% 未満 (
nodefs.available<10%) になり、Disk Pressure と判定される - kubelet が各種 GC で空き容量の確保を試みるが、それでも解消しない
- Pod が Eviction される
- ディスクの空き容量の割合が 10% 未満 (
-
原因:
ephemeral-storageの request が未設定か過小な場合、ディスク需要がスケジューリングに十分反映されません。イメージ、ホスト側のログ、inode の枯渇などが原因になる場合もあります。 - 対策: 実際の消費量に見合った request を設定しておくことで回避できるケースが多いです。
Kubernetes をただ使うだけであれば、Disk Pressure が起きた際に上記の知識だけで十分対応できるケースも多いと思いますが、クラスタの構築や詳細なトラブルシュートを実施しようとすると、それだけでは知識が足りません。この記事では、ノードのディスク領域の構成から ephemeral-storage の内訳、Disk Pressure の発生条件・挙動、対策までを順番に整理します。
対象コンポーネントとバージョン
| コンポーネント | バージョン | リポジトリ | 本記事での役割 |
|---|---|---|---|
| kubelet / kube-scheduler | 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 実装 |
本記事では Linux ノードを対象にします。デフォルト値は特記しない限り kubelet 自体の値で、ディストリビューションやマネージド環境の追加設定とは区別します。設定例・YAML は該当部分の抜粋です。
TL;DR
-
ディスクの構成で回収方法が変わります。
nodefsとimagefsが同じファイルシステムか、分離されているかを確認します。containerfsの分離にはランタイム側の対応も必要です。 -
Pod の使用量とノードの空き容量は別の指標です。 Pod の
ephemeral-storageは Writable Layer・ログ・disk-backed emptyDir などの合計で、イメージ本体は含みません。 - DiskPressure は容量不足でも inode 不足でも発生します。 hard threshold に加え、設定した soft threshold も判定に使われます。5 分の transition period は、主に圧迫解消後の Condition の保持に使われます。
- Pod の退避前に、構成に応じた GC を試みます。 専用 imagefs がある場合、nodefs の圧迫をきっかけにした追加の GC は登録されていません。
- request・limit・ログ管理を組み合わせます。 request は配置と容量不足時の退避順位に関わり、limit は使用量超過の検知に使われます。どちらも書き込み時点の容量保証ではありません。
第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 のみ |
代表的な構成は次の 3 つです。これらは kubelet が統計を扱うための区分であり、3 つの名前があるからといって、3 台の独立したディスクを自由に組み合わせられるわけではありません。
| 構成 | nodefs 側 | imagefs 側 | containerfs の扱い |
|---|---|---|---|
| 単一ファイルシステム | kubelet の作業データ、ログ、emptyDir、イメージ、Writable Layer | nodefs と同じ | nodefs と同じ |
| ランタイム領域を分離 | kubelet の作業データ、ログ、emptyDir | イメージと Writable Layer | imagefs と同じ |
| イメージの読み取り専用層を分離 (対応ランタイムが必要) | kubelet の作業データ、ログ、emptyDir、Writable Layer | イメージの読み取り専用層 | nodefs と同じファイルシステム |
本記事では、主に containerd で利用する最初の 2 構成を説明します。3 つ目の構成と実装上の制約は 1.2 節と第8章で扱います。
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,nodefscontains/var/lib/kubelet.
imagefs: An optional filesystem that container runtimes use to store container images and container writable layers.
(参考: Node-pressure Eviction)
imagefs の統計自体は、専用のファイルシステムがなくても報告されます。専用化していなければ、通常は imagefs と nodefs が同じファイルシステムの空き容量を見ています。
この判定は自動で行われます。kubelet はコンテナランタイムが報告する imagefs のデバイスと、root filesystem のデバイスを比較し、通常の containerd 構成では、同じであれば「専用ではない」と判断します。split filesystem に対応する構成では、imagefs と containerfs の統計の違いも確認します。
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 = 3
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 は Writable Layer 側を表す区分です (KEP-4191、KubeletSeparateDiskGC feature gate)。イメージの読み取り専用層だけを分離する構成では、containerfs は nodefs と同じファイルシステムになります。Writable Layer を nodefs・imagefs の両方から独立した第3のディスクに置く構成とは区別します。
イメージと Writable Layer を分離しなければ、単一ファイルシステム構成では 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 の閾値はユーザーが個別に設定することはできません。単一ファイルシステム構成と、読み取り専用層を分離して containerfs=nodefs となる構成では nodefs の値を使います。イメージと Writable Layer が共通の専用 imagefs にある構成では imagefs の値を使い、ユーザーの containerfs 設定は上書きされます (参考: pkg/kubelet/eviction/helpers.go#L186-L253)。
ただし、imagefs と containerfs の分離は、本記事の containerd v2.4.1 では利用できません。containerd は ContainerFilesystems フィールドを実装しておらず、kubelet の CRI Stats Provider 側も分離した統計の取得に対応していないためです。設計の経緯と技術的な詳細は第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 の使用量を抑える仕組みの一つが、コンテナログのローテーションです。停止済みコンテナや削除済み Pod のログには Container 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 |
ローテーションを開始するファイルサイズの目安 (書き込み時のハード上限ではない) |
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 は並行度の設定であって、容量の上限ではありません。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 Architecture)
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)
通常の定期実行でも、停止済みコンテナに対して MinAge・MaxPerPodContainer・MaxContainers の保持条件を適用します。Pod が API 上に残っていても、保持数を超えた古いコンテナは削除対象です。
evictNonDeletedPods=true では、終了処理が完了した Pod に属する停止済みコンテナを、通常なら保持する分も含めて回収できます。sandbox には、停止状態か、コンテナが残っていないかなどの別の判定があります。
if cgc.podStateProvider.ShouldPodContentBeRemoved(key.uid) || (evictNonDeletedPods && cgc.podStateProvider.ShouldPodRuntimeBeRemoved(key.uid)) {
(参考: kuberuntime_gc.go#L227-L275)
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 側で全体のディスク使用量を制御する 設定も関わります。まずディスクを消費している対象を確認し、第4章・第7章の閾値設定やログ・一時データの管理と合わせて見直すのが良いと思います。
第3章: ephemeral-storage として計測されるもの
Pod の ephemeral-storage 使用量には、コンテナの Writable Layer、ログ、disk-backed emptyDir、/etc/hosts などが含まれます。コンテナイメージ本体の使用量は、Pod 単位のこの合計には入りません。
(参考: Resource Management for Pods and Containers)
実装側でも、この合計は 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
tmpfsemptyDir volumes as container memory use, rather than as local ephemeral storage.
(参考: Local ephemeral storage)
つまり「emptyDir を tmpfs にすれば ephemeral-storage の枠を消費しない」というのは事実ですが、その代わりコンテナのメモリ使用量を圧迫することになります。容量の見積もり先がディスクからメモリに変わるため、メモリ request/limit と合わせて考える必要があります。
ここまでの内容を、Pod の ephemeral-storage 使用量の対象になるかどうかで一覧にすると次のとおりです。
| 種類 | ephemeral-storage に含まれるか | 計測方法 | 備考 |
|---|---|---|---|
| コンテナの Writable Layer (rootfs) | ○ | ランタイム・snapshotter などが報告する使用量 | イメージ本体ではなく、コンテナが書き込んだ差分のみ。専用 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 のログディレクトリ直下のファイル | ○ | ファイルごとの使用量 | コンテナ別ディレクトリのログと重複しないように集計 |
/etc/hosts などシステムが Pod にマップするファイル |
○ | ファイルサイズ |
calcEphemeralStorage が個別に加算 |
configMap ボリューム |
○ | du |
IsLocalEphemeralVolume の対象。実際のデータ量はごく小さいことがほとんど |
| PersistentVolumeClaim (PVC) 経由のボリューム | ✕ | (PV 側で管理) |
IsLocalEphemeralVolume の対象外。local PV やローカルディスクを使う CSI 実装では、Pod の ephemeral-storage に含まれなくてもノードのディスクを消費する |
| コンテナイメージ本体 | ✕ (別枠で管理) | Image GC 側で管理 | Pod 単位の ephemeral-storage 使用量には計上されない。ノード全体では imagefs の空き容量を圧迫する |
(参考: host_stats_provider.go#L102-L134)
「○」の行が pkg/kubelet/stats/helper.go の calcEphemeralStorage および pkg/volume/util.IsLocalEphemeralVolume (参考: pkg/volume/util/util.go#L494-L498) で加算・分類される対象、「✕」がそこから明示的に除外されるか、そもそも対象外の項目です。
3.1 ディスク上の使用量の計測と上限の強制は別
disk-backed emptyDir は通常、ディレクトリ走査 (du 相当) で計測します。対応するファイルシステムと設定では project quota による計測も使えます。Writable Layer の計測方法はランタイムや snapshotter に依存します。emptyDir の構築処理では、まず du ベースの MetricsProvider が設定されます。
MetricsProvider: volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))
(参考: pkg/volume/emptydir/empty_dir.go#L182)
ディレクトリ走査では、削除済みでもプロセスが開いたままのファイルを使用量として捉えられない場合があります。その場合、Pod の集計値が小さくても、ファイルシステムの空き容量は戻りません。project quota による計測では、このような使用量も把握できます。
(参考: Resource monitoring for local ephemeral storage)
ボリューム統計の集計はデフォルトで 1 分ごとに行われます。
obj.VolumeStatsAggPeriod = metav1.Duration{Duration: time.Minute}
(参考: defaults.go#L162-L163)
つまり disk-backed emptyDir の 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
sizeLimitfor anemptyDirvolume, 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 ofemptyDirs that could potentially consume all available memory on the node
(参考: Resource Management for Pods and Containers)
つまり sizeLimit は「メモリ limit または node allocatable memory を超えない」という意味では上限が付きますが、tmpfs emptyDir を複数個作ると、それぞれが独立に (メモリ limit まで) 確保できてしまうため、tmpfs のマウントサイズの合計が、そのまま Pod の使用可能メモリになるわけではありません。書き込んだページはメモリ使用量に計上され、コンテナや Pod の cgroup のメモリ上限にも制約されます。メモリ limit がなければ、ノードのメモリ不足につながる可能性があります。
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 が収集した使用量に基づく sizeLimit 超過チェックによる Eviction」の両方の対象になります。使用量の収集周期と Eviction の評価周期は別であり、後者は通常 10 秒の監視ループで評価されます。sizeLimit を明示し、メモリ limit と合わせて設定するのが良いと思います。
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.namespace=="<Namespace名>" and .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 と見比べると、宣言した値に対してどれだけ使用しているかを確認できます。availableBytes / capacityBytes は nodefs の値であり、Pod の limit までの残容量ではありません。
第4章: Disk Pressure の判定閾値
Disk Pressure の判定から Pod の Eviction までの流れを表にまとめます。Node status の更新と Taint の付与は非同期で進み、回収・退避の前に API 上への反映完了を待つわけではありません。各ステップの詳細は本章から第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 はデフォルトなし) |
NodeCondition 判定と、grace period 経過の判定に使う。回収直前に優先度順で選択する |
| 3 | NodeCondition 反映 | hard/soft threshold の成立を観測 (soft の grace period 経過前も含む) | kubelet 内部で DiskPressure と判定し、Node status に反映する。5 分の transition period は解消後の Condition 保持に使う |
| 4 | Taint 付与・Admission 拒否 | DiskPressure=True |
NodeLifecycleController が node.kubernetes.io/disk-pressure (NoSchedule) を付与 (kubelet 自身ではない)。kubelet は非 Critical Pod の Admission をその場で拒否 |
| 5 | ノードレベル回収 | hard threshold、または grace period を満たした soft threshold がある | レイアウト依存の reclaim function (Container GC → Image GC など、詳細は第5章) を実行し、統計を再取得して再評価 |
| 6a | 回収で解消 | 再評価で必要な空き容量・inode 数を回復した | Pod Eviction は行わず終了 (次の監視ループへ) |
| 6b | 回収でも解消しない | 再評価でも必要な空き容量・inode 数に届かない | Pod Eviction ステップへ進む |
| 7 | Eviction 優先順位の決定 | — | 容量不足の場合は (1) request 超過 (2) Priority (3) 使用量 − request、の順でソート。inode 不足とは区別する (詳細は第6章) |
| 8 | Eviction 実行 | — | ソート順に候補を試し、退避に成功した Pod は 最大 1 つ。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 とその継続時間 (evictionSoftGracePeriod)、minimum reclaim のマップはデフォルトでは未設定です。Condition の保持時間や Pod の終了猶予とは別の設定です。
EvictionSoftDefault: nil
EvictionSoftGracePeriodDefault: nil
EvictionPressureTransitionPeriodDefault: "5m"
EvictionMaxPodGracePeriodDefault: 0
EvictionMinimumReclaimDefault: 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 への反映
Eviction Manager の通常の監視ループには、コード上で固定の 10 秒が渡されます。この値と、統計収集に関わる housekeeping-interval は別です。GC や Pod の後始末にかかる時間もあるため、判定が厳密に 10 秒周期で完了するという意味ではありません。
// 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)
公式ドキュメントは「housekeeping-interval という設定可能な値で、デフォルト 10s」と説明していますが、実際にはコード上の固定 10 秒です (参考: Node-pressure Eviction)。設定で変更できるものではない、という点は覚えておいたほうがよさそうです。この点については修正 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)
閾値の成立を観測すると、soft threshold の grace period を待たずに kubelet 内部で DiskPressure と判定します。Node の Condition は通常の status 更新を通じて反映されるため、API 上への反映には遅延があります。
EvictionPressureTransitionPeriod (デフォルト 5 分) は、最後に圧迫を観測してから Condition を保持するための時間です。True にするまで 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
Eviction の条件を満たしたとき、kubelet がどのデータを回収し、どの段階で Pod の退避に進むかを説明します。
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を順番に実行し、そのあと改めて統計を取得し直して閾値を再評価します。再評価ですべての閾値が解消していれば Pod Eviction はスキップされます。evictionMinimumReclaim が設定されている場合は、その回収量も考慮されます。
// 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)
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 は、Eviction 済みか、削除されて終了処理も完了した Pod などを対象にします。ShouldPodRuntimeBeRemoved は、Pod の終了処理が完了していれば true になります。
完了した Job の Pod が API 上に残っている場合、通常の GC は保持数などの条件に従って停止済みコンテナを残すことがあります。Disk Pressure 時の GC は、この通常なら保持される分も回収対象に含めます。「API 上に Pod が残る限り、通常の GC では一切削除されない」という意味ではありません。
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)
使用率に基づく定期 GC は、低閾値まで戻すために必要な量を目標に削除します。imageMaximumGCAge を設定した場合は、未使用期間による削除も行います。
Disk Pressure 時は math.MaxInt64 を指定し、容量の目標を設けずに削除可能なイメージの回収を試みます。ただし、使用中・pin 済み・最小 GC age 未満のイメージなどは除外されます。次の定期 GC を待たずに回収を試みる点も異なります。
5.2 どの signal で、どのreclaim functionが動くか (レイアウト依存)
ただし、この「回収を試みる」処理はファイルシステムのレイアウトによって内容が変わり、Writable Layer も専用 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 と同じ |
| 読み取り専用層を分離 (containerfs=nodefs) | 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 が積もった場合) が閾値を超えると、Eviction Manager は追加のノードレベル GC を実行せず、Pod の退避判定に進みます。通常のログローテーションや定期 GC は別に動いています。
公式ドキュメントはこのケースについて次のように説明しています。
If the
nodefsfilesystem meets the eviction thresholds, the kubelet garbage collects dead pods and containers. If theimagefsfilesystem meets the eviction thresholds, the kubelet deletes all unused images.
(参考: Node-pressure Eviction)
しかし実装を見る限り、専用 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 の優先順位と request の重要性
ノードレベルの回収を試みても必要な空き容量・inode 数を確保できなければ、いよいよ Pod の Eviction に進みます。
この Eviction は、Pod を別ノードへ直接移動する操作ではありません。kubelet は Pod を終了させ、status を Failed / Evicted にします。Deployment や Job などが必要に応じて代わりの Pod を作成します。node-pressure Eviction は PodDisruptionBudget によって阻止されません。
(参考: Node-pressure Eviction)
6.1 eviction の優先順位の基準
まず、空き容量不足 (*.available) の場合を説明します。Pod の Eviction 優先順位を決める関数は、次の 3 つの基準を順番に見ます。統計を取得できない Pod を先に扱う判定もあります。
// rankDiskPressureFunc returns a rankFunc that measures the specified fs stats.
func rankDiskPressureFunc(fsStatsToMeasure []fsStatsType, diskResource v1.ResourceName) rankFunc {
return func(pods []*v1.Pod, stats statsFunc) {
orderedBy(exceedDiskRequests(stats, fsStatsToMeasure, diskResource), priority, disk(stats, fsStatsToMeasure, diskResource)).Sort(pods)
(参考: pkg/kubelet/eviction/helpers.go#L828-L833)
- ephemeral-storage の request を超過しているかどうか — 超過している Pod を優先
- Priority — 低い Priority を優先
- 使用量から request を引いた値 — 値が大きい Pod を優先
p1Request := v1resource.GetResourceRequestQuantity(p1, v1.ResourceEphemeralStorage)
p1Disk.Sub(p1Request)
...
// prioritize evicting the pod which has the larger consumption of disk
(参考: pkg/kubelet/eviction/helpers.go#L777-L803)
ここが request の設定を重要にしている理由です。 ephemeral-storage の request を設定していない Pod は、request が実質 0 として扱われるため、少しでもディスクを使っていれば「request を超過している」と判定されてしまいます。ただし、その中の順位には Priority と超過量が関わるため、request 未設定の Pod が必ず最初に退避されるわけではありません。limit だけを指定して request が補われる場合や、LimitRange によるデフォルト設定もあるので、実際の Pod の値を確認します。
どの使用量を見るかはファイルシステムのレイアウトによって変わります。
| レイアウト |
nodefs で見る使用量 |
imagefs で見る使用量 |
|---|---|---|
| 単一ファイルシステム | Writable Layer + ログ + local volume | nodefs と同じ |
| imagefs を分離 (Writable Layer も imagefs) | ログ + local volume | Writable Layer |
| 読み取り専用層を分離 (containerfs=nodefs) | Writable Layer + ログ + local volume | 後述のとおり、イメージ使用量は加算されない |
(参考: pkg/kubelet/eviction/helpers.go#L1150-L1194)
ここには実装上の注意点があります。buildSignalToRankFunc は fsStatsImages を指定しますが、v1.37.1 の podDiskUsage / containerUsage はイメージの読み取り専用層の使用量を加算していません。したがって、イメージの大きい Pod がそのサイズを理由に優先して退避されるとは限りません。読み取り専用層だけの imagefs では、イメージ使用量による Pod 間の比較ができません。
(参考: helpers.go#L548-L607)
また、*.inodesFree による inode 不足には、容量を表す ephemeral-storage request をそのまま保護として使えません。inode の request は API にないため、容量不足時の順位と分けて考える必要があります。
なお、閾値が複数同時に成立している場合はメモリ側の signal が優先されます。
// Less ranks memory before all other resources, and ranks thresholds with no resource to reclaim last
(参考: pkg/kubelet/eviction/helpers.go#L841-L845)
6.2 QoS クラスは関係しない
kubelet の Memory Pressure Eviction は、メモリ request 超過・Priority・request に対する使用量で順位を決めます。QoS クラスと傾向は重なりますが、QoS クラスを直接ソートしているわけではありません。CPU 使用量を理由にした node-pressure Eviction もありません。ephemeral-storage は QoS クラスの判定材料に含まれていません。公式ドキュメントも明記しています。
QoS classification does not apply to EphemeralStorage requests, so the above scenario will not apply if the node is, for example, under
DiskPressure.
(参考: Node-pressure Eviction)
「うちは Guaranteed Pod だから Disk Pressure でも安全」という理屈は成立しません。容量不足時の順位には ephemeral-storage の request と Priority が関わりますが、request 以下の Pod も退避対象になり得ます。inode 不足には inode の request がないため、容量不足と同じ説明はできません。
6.3 ノードの閾値に基づく Eviction は 1 回につき最大 1 Pod
閾値ベースの Eviction は、1 回の synchronize あたり最大 1 Pod だけを evict します。
// we kill at most a single pod during each eviction interval
for i := range activePods {
...
gracePeriodOverride := int64(immediateEvictionGracePeriodSeconds)
if !isHardEvictionThreshold(thresholdToReclaim) {
gracePeriodOverride = m.config.MaxPodGracePeriodSeconds
...
}
...
if m.evictPod(...) {
...
return []*v1.Pod{pod}, nil
}
(参考: pkg/kubelet/eviction/eviction_manager.go#L428-L451)
hard threshold の場合、grace period はコード上 1 秒です。
// immediateEvictionGracePeriodSeconds is how long we give pods to shut down when we
// need them to evict quickly due to resource pressure
immediateEvictionGracePeriodSeconds = 1
(参考: pkg/kubelet/eviction/eviction_manager.go#L60-L62)
公式ドキュメントは「hard threshold では 0s の grace period (即座にシャットダウン)」と説明していますが、コード上の定数は 1 秒です (参考: Node-pressure Eviction)。実務上の違いはごくわずかですが、「猶予は一切ない」という説明は厳密には正確ではありません。
圧迫が続くと、複数回の監視ループにわたって Pod が退避されることがあります。Eviction 後は Pod の後始末を待つ処理もあるため、厳密に 10 秒ごとに 1 Pod 退避されるとは限りません。
(参考: eviction_manager.go#L207-L232)
ただし、これとは別に Pod 単位・コンテナ単位の ephemeral-storage limit 違反や emptyDir の sizeLimit 違反 (localStorageEviction) は、閾値ベースの Eviction より先に評価される別処理で、こちらは 1 回のループで複数 Pod を evict できます。
// evict pods if there is a resource usage violation from local volume temporary storage
// If eviction happens in localStorageEviction function, skip the rest of eviction action
(参考: pkg/kubelet/eviction/eviction_manager.go#L370-L376)
「ノード全体の Disk Pressure」と「個々の Pod の limit 超過」は別の処理です。後者は DiskPressure が False でも、収集した統計で超過が検知されれば Eviction の対象になります。書き込み時点で即座に停止されるわけではありません。
第7章: 対策まとめ
対策は、アプリケーションの実装・Pod の定義側でできるものと、クラスタ・ノード側の設定が必要なものに分かれます。
7.1 アプリケーション・Pod レベルの対策
7.1.1 ephemeral-storage の request を実際の使用量に合わせて設定する
ephemeral-storage の request は CPU/Memory と同じく、スケジューラの NodeResourcesFit プラグインが評価対象にしています。
if podRequest.EphemeralStorage > 0 &&
deltaEphemeralStorage > (nodeInfo.GetAllocatable().GetEphemeralStorage()-nodeInfo.GetRequested().GetEphemeralStorage()) {
insufficientResources = append(insufficientResources, InsufficientResource{
ResourceName: v1.ResourceEphemeralStorage,
...
(参考: pkg/scheduler/framework/plugins/noderesources/fit.go#L753-L761)
スケジューラは、実際の空き容量ではなく、Node Allocatable と配置済み Pod の request の合計を比較します。使用量に見合った request を設定すると、宣言したディスク需要を超えて Pod が集中することを抑えられます。
ただし、専用 imagefs の残量、共有イメージのサイズ、ホスト側の使用量、inode 数を直接評価する仕組みではありません。3.5 節の Summary API などで使用量を確認し、request とノードの空き容量の両方を監視するのが良いと思います。
resources:
requests:
ephemeral-storage: 2Gi
7.1.2 ephemeral-storage の limit も設定しておく
request とは別に、limit も設定しておくという対策もあります。目的が少し異なっていて、limit は自分の Pod を守るためではなく、ノード上の他の Pod を守るための設定です。
limit を超えた Pod は、6.3 で触れた localStorageEviction という処理で、ノード全体の Disk Pressure とは独立に、超過の検知後に Eviction の対象になります。
podEphemeralStorageLimit := podLimits[v1.ResourceEphemeralStorage]
if podEphemeralStorageTotalUsage.Cmp(podEphemeralStorageLimit) > 0 {
// the total usage of pod exceeds the total size limit of containers, evict the pod
message := fmt.Sprintf(podEphemeralStorageMessageFmt, podEphemeralStorageLimit.String())
if m.evictPod(logger, pod, immediateEvictionGracePeriodSeconds, message, nil, nil) {
(参考: pkg/kubelet/eviction/eviction_manager.go#L571-L591)
limit は、過剰に使用した Pod を検知して退避するための設定です。ただし計測と評価には遅延があるため、ノードの Disk Pressure より先に必ず退避できるわけではありません。通常の使用量と一時的な増加を見積もって設定するのが良いと思います。
resources:
limits:
ephemeral-storage: 4Gi
7.1.3 コンテナイメージサイズが大きい場合の対策
スケジューラが Pod のディスク消費量を知る手段は、7.1.1 で見た ephemeral-storage の request しかありません。ところがコンテナイメージ本体のサイズ (unpack 後のサイズ) や image volume で参照するイメージの中身は、この request の計算対象に一切入ってきません。
func IsLocalEphemeralVolume(volume v1.Volume) bool {
return volume.GitRepo != nil ||
(volume.EmptyDir != nil && volume.EmptyDir.Medium == v1.StorageMediumDefault) ||
volume.ConfigMap != nil
}
(参考: pkg/volume/util/util.go#L494-L498)
image volume に至っては、使用量取得そのものが未対応として扱われています。
func (*MetricsNil) GetMetrics() (*Metrics, error) {
return &Metrics{}, NewNotSupportedError()
}
(参考: pkg/volume/metrics_nil.go#L33-L35)
イメージを pull・unpack するための空き容量を、スケジューラは直接確認しません。大きなイメージを使う Job などでは、配置後の pull によって imagefs が圧迫される場合があります。
対策には、イメージの削減、Image GC の調整、専用ノードへの配置、imagefs の増設などがあります。request にイメージ用の余裕を上乗せする方法もありますが、これは運用上の見積もりです。Pod の使用量としてイメージが計上されるようになるわけではなく、専用 imagefs の残量をスケジューラに認識させることもできません。
同じイメージやレイヤーを複数 Pod が共有する場合は、Pod ごとに全サイズを上乗せすると過大な見積もりになります。圧縮された pull サイズだけでなく、展開後のレイヤーや content store が占める領域、snapshotter の方式も確認する必要があります。
7.1.4 emptyDir には sizeLimit を付ける
disk-backed な emptyDir の sizeLimit はカーネルレベルでは強制されない、緩いチェックです。それでも設定しないよりは設定したほうが、暴走した Pod を早めに検知できます。medium: Memory を使う場合は、省略時に allocatable memory やコンテナのメモリ limit まで確保できてしまうので、メモリを消費することを理解したうえで sizeLimit を明示しておくのも対策の一つです。
volumes:
- name: scratch
emptyDir:
sizeLimit: 1Gi
- name: cache
emptyDir:
medium: Memory
sizeLimit: 256Mi
7.1.5 ログは標準出力・標準エラー出力に出す
2.1 で見たコンテナログのローテーションは、CRI がコンテナランタイムから取得するログパス (標準出力・標準エラー出力を書き出すファイル) だけを対象にしています。アプリケーションが自前で /app/logs/app.log のような任意のパスにログを書き込んでいる場合、そのファイルは CRI のログパスではないため、kubelet のログローテーションの対象に一切入りません。放っておけば際限なく writable layer (ephemeral-storage) を消費し続けます。標準出力・標準エラー出力にログを出す運用にしておけば、自動的にローテーション対象になり、ディスク使用量が際限なく肥大化することを防げます。
7.1.6 コンテナの writable layer ではなく emptyDir を使う
writable layer は Pod 間で共有されないだけでなく、同じコンテナの再起動前後のインスタンス間でも共有されません。Container GC のデフォルト設定では、コンテナが再起動しても直前の 1 世代分は kubectl logs --previous 用に残り続けます。
MaxPerPodContainerCount: 1,
(参考: cmd/kubelet/app/options/options.go#L143)
klet.containerDeletor = newPodContainerDeletor(logger, klet.containerRuntime, max(containerGCPolicy.MaxPerPodContainer, minDeadContainerInPod))
(参考: pkg/kubelet/kubelet.go#L914、minDeadContainerInPod = 1)
つまりキャッシュや一時ファイルをコンテナの writable layer に書き込んでいると、クラッシュ・再起動のたびに「直前の世代の writable layer」がまるごと 1 セット、Container GC やコンテナの削除処理で回収されるまで、余分にディスク上に残ります。デフォルトで保持する直前の 1 世代は、次の定期 GC で必ず削除されるわけではありません。頻繁に再起動する Pod ではこの分が常態化します。キャッシュや一時データは writable layer ではなく emptyDir に書き込むようにすれば、一時データをコンテナ再起動後も共有でき、Writable Layer の世代ごとの重複を抑えられます。一方、emptyDir の中身は再起動で消えないため、アプリケーション側の削除処理と sizeLimit も必要です。
7.1.7 Job には ttlSecondsAfterFinished を設定する
完了した Job の Pod を放置すると、保持された停止済みコンテナの Writable Layer やログが、ノード上に残る場合があります。終了済み Pod の API オブジェクトが残っていることと、すべてのデータが使用量として計上され続けることは同じではありません。ttlSecondsAfterFinished を設定しておけば、Job 完了から指定時間後に Job と配下の Pod が自動的に削除され、このディスク使用量を回収できます。
apiVersion: batch/v1
kind: Job
spec:
ttlSecondsAfterFinished: 3600
(参考: staging/src/k8s.io/api/batch/v1/types.go#L417)
7.2 クラスタ・ノードレベルの対策
7.2.1 Image GC のチューニング
- 再利用されないイメージ (CI/CD の使い捨てビルドイメージなど) が多いクラスタでは、
imageGCHighThresholdPercentを下げて余裕を持たせる、あるいはimageMaximumGCAgeを設定して「使われていないが閾値に達していないイメージ」を定期的に掃除する -
FreeDiskSpaceFailedイベントが出ている場合、コメントのとおり「ディスクが埋まっている原因がイメージ以外 (ログ・ボリュームなど) にある」可能性が高いので、Image GC の設定を追い込むより先にログ・emptyDir 側を疑う
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
imageGCHighThresholdPercent: 70
imageGCLowThresholdPercent: 60
imageMinimumGCAge: 2m
imageMaximumGCAge: 168h
7.2.2 ログの運用
- コンテナ単位のローテーション設定 (
containerLogMaxSize/containerLogMaxFiles) だけではノード全体のログ量を制御できないことを理解しておく - ログ量が多いワークロードや、大量の短命 Pod を回す環境では、外部のロギングバックエンド (Loki など) へ転送し、ログを長期保存する。転送だけではノード上の CRI ログは削除されないため、ローテーション設定と Job / Pod の後始末も合わせて行う
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
containerLogMaxSize: 10Mi
containerLogMaxFiles: 5
containerLogMaxWorkers: 1
containerLogMonitorInterval: 10s
7.2.3 監視
上記の観点は、それぞれ次のメトリクスで確認できます。kubelet 自身が公開しているものと、node_exporter・kube-state-metrics 側の汎用メトリクスに頼るしかないものが混在している点に注意してください。
Eviction の判定と照合するには、kubelet の Summary API の .node.fs、.node.runtime.imageFs、.node.runtime.containerFs を確認できます。継続的なファイルシステム監視には、node_exporter の次のメトリクス (mountpoint ラベル) を、nodefs (/ や /var/lib/kubelet など) と imagefs (専用にしていればそのマウントポイント) それぞれに絞って見る必要があります。
-
node_filesystem_avail_bytes: 空き容量 -
node_filesystem_size_bytes: 全体容量 -
node_filesystem_files_free: 空き inode 数 -
node_filesystem_files: 全体 inode 数
sizeDesc := prometheus.NewDesc(
prometheus.BuildFQName(namespace, subsystem, "size_bytes"),
...
availDesc := prometheus.NewDesc(
prometheus.BuildFQName(namespace, subsystem, "avail_bytes"),
...
filesFreeDesc := prometheus.NewDesc(
prometheus.BuildFQName(namespace, subsystem, "files_free"),
(参考: collector/filesystem_common.go#L100-L130 [node_exporter v1.12.1])
DiskPressure Node Condition は、kube-state-metrics の kube_node_status_condition{condition="DiskPressure", status="true"} で直接見えます。この metric family は Node の Condition をすべて汎用的にラベル化しているだけなので、DiskPressure に限らず任意の Condition で使えます。
metric.LabelKeys = []string{"condition", "status"}
metric.LabelValues = append([]string{string(c.Type)}, metric.LabelValues...)
(参考: internal/store/node.go#L507-L535 [kube-state-metrics v2.20.0])
EvictionThresholdMet・FreeDiskSpaceFailed の Event 自体は、kube-state-metrics・node_exporter のどちらでも追えません (Event はメトリクスとは別の仕組みで、拾うには event exporter やログ転送が別途必要です)。ただし代わりに、kubelet 自身が kubelet_evictions_total{eviction_signal="..."} という Counter を公開しており、signal 別の eviction 発生回数はこちらで直接見られます。
Evictions = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: KubeletSubsystem,
Name: EvictionsKey,
Help: "Cumulative number of pod evictions by eviction signal",
StabilityLevel: metrics.ALPHA,
},
[]string{"eviction_signal"},
)
(参考: pkg/kubelet/metrics/metrics.go#L533-L541)
Image GC・Container GC の失敗は、専用の失敗メトリクスがありません。成功した Image GC の削除数は kubelet_image_garbage_collected_total{reason="..."} で見えますが、
ImageGarbageCollectedTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: KubeletSubsystem,
Name: ImageGarbageCollectedTotalKey,
Help: "Total number of images garbage collected by the kubelet, whether through disk usage or image age.",
StabilityLevel: metrics.ALPHA,
},
[]string{"reason"},
)
(参考: pkg/kubelet/metrics/metrics.go#L1108-L1115)
失敗の方は、Image GC・Container GC が呼び出す CRI 操作の汎用エラーカウンタ kubelet_runtime_operations_errors_total{operation_type="remove_image"} (Image GC) および {operation_type="remove_container"}/{operation_type="remove_podsandbox"} (Container GC) の増加として観測する形になります。
func (in instrumentedImageManagerService) RemoveImage(ctx context.Context, image *runtimeapi.ImageSpec) error {
const operation = "remove_image"
defer recordOperation(operation, time.Now())
err := in.service.RemoveImage(ctx, image)
recordError(operation, err)
return err
}
(参考: pkg/kubelet/kuberuntime/instrumented_services.go#L320-L327)
7.2.4 専用 imagefs を使う場合は nodefs 側の自衛が必要
第5章で見たとおり、Writable Layer も専用 imagefs に置く構成では、nodefs (ログ・emptyDir) の圧迫をきっかけにした追加のノードレベル GC はありません。定期 GC とログローテーションは動きますが、Image GC 任せにせず、ログローテーションの上限設定や emptyDir の sizeLimit、モニタリングによる早期検知で nodefs 側を守る必要があります。
第8章: なぜ containerfs は containerd で使えないのか
1.2 節で触れたとおり、本記事の対象バージョンでは containerd による imagefs / containerfs の分離は利用できません。ここではその設計意図と、実装がどこで止まっているのかを確認します。
8.1 KEP-4191 の設計意図
KEP-4191 は、すべてのランタイムが同時に対応することを要求していません。分離を利用するには、ランタイムと kubelet の統計取得処理の両方が対応する必要があります。ランタイムが container filesystem の情報を返さない場合は、image filesystem と同一とみなす、という設計です。
In a case where the container runtime does not return a container filesystem, we will assume that the image_filesystem=container_filesystem.
(参考: keps/sig-node/4191-split-image-filesystem/README.md#L420)
Graduation Criteria も「少なくとも 1 つの CRI 実装が対応すれば Alpha 卒業」「複数の実装が対応すれば Stable」という段階的な基準で、特定ランタイムの実装完了をこの KEP 自体が前提にしているわけではありません。
At least one CRI implementation supports split filesystem
(参考: keps/sig-node/4191-split-image-filesystem/README.md#L598)
More than one CRI implementation supports split filesystem
(参考: keps/sig-node/4191-split-image-filesystem/README.md#L610)
さらに KEP 自身が、Writable Layer を本当に独立ディスクとして扱うには CAdvisor や CRI への追加拡張が必要だと明言しています。
If one wants to support the writable layer as an entirely separate disk, then either extensions to CAdvisor or CRI may be needed as one will need to know information about the writable layer disk.
(参考: keps/sig-node/4191-split-image-filesystem/README.md#L997)
KEP本文は特定のタグを持たない living document なので、ここでは kubernetes/enhancements リポジトリの特定コミット (8c2bd2ef1、2026-09-29 時点) の内容を参照しています。将来の改訂で記述が変わる可能性があります。
8.2 containerd・kubelet の実装を見ると
KEP 本文によれば、CRI-O は CAdvisor Stats Provider 経由 (crio-containers/crio-images という新しいラベルを CAdvisor に追加提案、CAdvisor 側の対応 PR も作成済み) で対応する設計である一方、containerd は CRI Stats Provider 経由 (ImageFsInfo の FsId をラベル分けするだけで、CAdvisor 側の変更は不要) という異なる実装方針が示されています。
(参考: keps/sig-node/4191-split-image-filesystem/README.md#L430-L456)
しかし kubelet 側の実装を見ると、containerd が使う CRI Stats Provider の処理は、そもそも ContainerFilesystems を一切参照していません。
// CRI may return the stats of multiple image filesystems but we only
// return the first one.
...
fs := resp.GetImageFilesystems()[0]
// TODO: For CRI Stats Provider we don't support separate disks yet.
return imageFsRet, imageFsRet, nil
(参考: pkg/kubelet/stats/cri_stats_provider.go#L458-L490)
つまり、仮に containerd が将来 ContainerFilesystems フィールドを実装したとしても、kubelet の CRI Stats Provider 側に対応コードが無い限り、imagefs と containerfs は同じ値として返され続けます。現時点では containerd v2.4.1 自身もこのフィールドを返していないため、二重に未対応ということになります。実際 containerd 側のコードにも、この kubelet 側の制約を踏まえたコメントがあります。
// Currently kubelet always consumes the first entry of the returned array,
// so put the default snapshotter as the first entry for compatibility.
(参考: internal/cri/server/images/imagefs_info.go#L52-L56)
おわりに
調べてみると、ディスクの空き容量、Pod の使用量、GC の対象は、それぞれ別に考える必要があることが分かりました。特に imagefs を分離すると、nodefs の圧迫時に使われる回収処理も変わる点が印象に残りました。
Job や大量のログを扱う環境では、CPU・メモリと合わせて、ディスク容量と inode の余裕も見ておきたいと思います。
参考
参考リンク
本文で登場する順に並べています。