一个 emptyDir 没设 sizeLimit,整节点 30 个 Pod 全被驱逐

场景:Kafka Connect Pod 用 emptyDir 做 offset 存储,每天 2GB,第 3 天 /var 被吃光,同节点 30 个 Pod 全被 Evict 路径:series/series-k8s/container-storage-emptydir-hostpath/ K8s 版本:v1.25(默认 StorageClass provisioner 行为在 v1.26+ 有调整)

上篇我们聊了 NFS 版本号写错导致 PV 挂载失败的问题,这篇我们从单个故障拉高——看 K8s 存储体系中最基础的三个方案:emptyDir、hostPath、PVC,以及它们各自的使用边界。

凌晨 2 点,告警群炸了。消息队列 Kafka 的 consumer lag 从 100 飙升到 50 万,生产端不断重试,业务方投诉下单超时。值班同事第一反应看 Pod 状态——30 个 Kafka Connect Pod 全部 Evicted。不是 OOM,不是 liveness 探针失败,是被节点驱逐了。

"节点磁盘满了?"

df -h 一看,/var 使用率 95%。但昨晚才刚清过日志,不可能一夜之间写满。查了 20 分钟,发现是 Kafka Connect 的 offset 数据写进了 emptyDir,而且没有设 sizeLimit。每天 2GB,跑了 3 天,/var 被吃光,kubelet 标记 DiskPressure,全节点 30 个 Pod 一起被驱逐。

一个 YAML 没加几个字段,全节点的 Pod 一起遭殃。

$ kubectl get events -n kafka --sort-by='.lastTimestamp' | tail -3
30s  Warning  Evicted  Pod/kafka-connect-0  The node had condition: DiskPressure.
30s  Warning  Evicted  Pod/kafka-connect-1  The node had condition: DiskPressure.
30s  Warning  Evicted  Pod/kafka-connect-2  The node had condition: DiskPressure.

kubectl get events 显示 DiskPressure 驱逐

这不是个例。emptyDir 是 K8s 里最常用的存储类型,也是最容易被人忽视的定时炸弹——因为大部分人把它当成"容器的一部分",但它实际上是 Node 存储的租户窗口。

坐标:一个 emptyDir 把整节点写满了

K8s 的存储不是"Pod 的磁盘就是容器的磁盘"。这句话我见过太多人搞混了。

emptyDir 是创建在 Node 上的临时目录,Pod 删除时被清理。但它活着的每一秒都在消费 Node 的磁盘。不设 sizeLimit 等于给 Pod 开了一张空白支票——可以写满任意节点。

来看看场景细节。Kafka Connect 是一个数据管道框架,它会定期把消费偏移量(offset)写入磁盘,灾难恢复时从磁盘恢复消费位置。YAML 配置长这样:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kafka-connect-0
  namespace: kafka
spec:
  template:
    spec:
      containers:
      - name: connect
        image: confluentinc/cp-kafka-connect:7.5
        volumeMounts:
        - name: offset-data
          mountPath: /var/lib/kafka/data
      volumes:
      - name: offset-data
        emptyDir: {}    # ← 没设 sizeLimit——默认无限制

这个 emptyDir: {} 看起来人畜无害。一个空目录嘛,能写多少?

问题在于 Kafka Connect 的 offset 管理策略。默认情况下它会保留所有 partition 的 offset 历史,而不是只保留 latest。生产环境几百个 partition,每个 partition 每天产生几 MB 的 offset 记录,乘以 3 个 Pod、跑 3 天,加在一起就是一个数量级的磁盘消耗。

更致命的是:Pod 没有设 ephemeral-storage 的 requests 和 limits。kubelet 无从知道这个 Pod 可能消耗多少磁盘,不会提前做任何拦截。

Pod 跑了第 3 天,节点 /var 使用率冲到 95%。

kubelet 每 10 秒检查一次节点磁盘状态。当 nodefs(/var/lib/kubelet/)的可用空间低于 10% 阈值时,它会把 Node 标记为 DiskPressure=True。一旦标记:

  1. scheduler 停止向该节点调度新 Pod
  2. kubelet 开始驱逐现有 Pod,按 QoS 优先级从低到高执行
  3. 所有正在运行的 Pod 逐个收到 Evict 信号

30 个 Pod 不是同时消失的。kubelet 会先驱逐 BestEffort 级别的 Pod,再驱逐 Burstable,最后才动 Guaranteed。但在这个节点上,QoS 最低的那一批 Pod(包括 Kafka Connect)在几分钟内全被干掉了。

如果这是一台核心节点,上面的 API 网关、缓存代理、监控采集全都会受影响。一台节点的沦陷,可能引发整个可用区的雪崩。

kubectl get pods 显示 Evicted 状态

那 emptyDir 到底用谁的磁盘?为什么一个 Pod 的 emptyDir 能影响其他 Pod?我们进入分层排查。

分层:K8s 存储三层排查全景

Pod 层:三种存储类型

K8s 给 Pod 挂载存储有三种基础选项,理解这三个是排查所有存储问题的前提:

类型 生命周期 容量边界 典型用途
emptyDir 跟随 Pod,Pod 删除即清除 默认无限制(用 Node 磁盘) 临时缓存、日志
hostPath 跟随 Node,Node 重启数据仍在 宿主路径的实际容量 日志采集、DaemonSet
PVC + PV 独立于 Pod,删除 PVC 后才释放 按 StorageClass 定义 生产数据库、持久数据

emptyDir——创建在 Node 的 /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir/ 下。Pod 删除后被 kubelet 清理。注意这个路径——它在 Node 的 kubelet 工作目录下,不是容器内部。

hostPath——直接把宿主目录挂进容器。写 hostPath 等于写宿主目录。如果宿主 /etc/var/lib/kubelet 等敏感路径被挂进容器,容器进程就可以读到宿主敏感数据。生产建议:hostPath 只给 DaemonSet 用(如 fluentd 采集日志),应用 Pod 不碰。

PVC——PersistentVolumeClaim,存储的"声明式需求"定义。真正的存储空间由 PV(PersistentVolume)提供,两者通过 StorageClass 绑定。PVC 独立于 Pod 生命周期,Pod 删了 PVC 还在。这是生产环境持久化存储的标准方案。

还有一点很多人忽略:以上三种存储类型可以共存于同一个 Pod。一个 Pod 可以同时挂 emptyDir(临时缓存)+ hostPath(日志落盘)+ PVC(数据持久化)。理解每个 mount 走的是哪种存储、挂在哪里、谁在消费,才是排查存储问题的核心能力。

K8s 存储三层架构:emptyDir/hostPath/PVC

CRI 层:容器看磁盘的盲区

很多人排查容器磁盘问题时,第一反应是 kubectl exec df -h,看到 /overlay 满了就以为是容器层的问题:

$ kubectl exec -it kafka-connect-0 -- df -h /
Filesystem      Size  Used Avail Use% Mounted on
overlay         100G   95G   5G   95% /

"容器磁盘 95%?镜像太大了?日志不轮转?"

都不是。这个 /overlay 根本不是你该看的东西。

解释一下 overlay2 的工作原理。容器镜像由多个只读层组成(lowerdir),容器运行时在上面叠加一个可写层(upperdir),最终通过 overlay merge 展示为统一的文件系统。你在容器内 df / 看到的 overlay filesystem,展示的是 upperdir 所在文件系统的容量——也就是 imagefs(/var/lib/containerd/)所在磁盘的总容量。

所以 /overlay 的 95% 告诉你的不是"容器层用了多少",而是"节点上 imagefs 所在的盘还有多少空间"。emptyDir 不占 imagefs,占的是 nodefs。

真正的 emptyDir 挂载点是这样的:

$ kubectl exec -it kafka-connect-0 -- df -h /var/lib/kafka/data
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       100G   95G   5G   95% /var/lib/kafka/data

Filesystem 从 overlay 变成了 /dev/vda1——Node 的块设备。这才是 emptyDir 写入的真正落脚点。

很多人看到 /dev/vda1 并不敏感,觉得"反正也是某个磁盘"。但关键的区别是:overlay 的磁盘是容器运行时的专属空间,而 /dev/vda1 是 Node 所有进程共享的根盘。emptyDir 在跟 kubelet、containerd、system 日志、audit 日志抢同一块盘。

crictl inspect 看 mount 的真实来源更清晰:

$ crictl inspect <container-id> | jq '.info.runtimeSpec.mounts[] | select(.destination == "/var/lib/kafka/data")'
{
  "destination": "/var/lib/kafka/data",
  "type": "bind",
  "source": "/var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir/offset-data",
  "options": ["rbind", "rprivate", "rw"]
}

源路径是 Node 的 /var/lib/kubelet/pods/...,本质上是 bind mount。容器内的写入直接穿透到 Node 文件系统。

CRI——Container Runtime Interface,K8s 与容器运行时的接口层,kubelet 通过它管理容器生命周期。

这种"从容器内 df 误判到 overlay"的踩坑故事,我见过至少 5 次。每次都是团队花半小时排查容器日志轮转,最后发现 emptyDir 挂在节点盘上。

crictl inspect 显示 emptyDir mount 源路径

Node 层:ephemeral-storage 资源管理与 kubelet 驱逐机制

Kubelet 对节点磁盘的监控分为两类:

资源 路径 用途 触发驱逐默认阈值
nodefs /var/lib/kubelet/ Pod emptyDir、容器日志、卷插件 nodefs.available < 10%
imagefs /var/lib/containerd/ 容器镜像层、可写层 imagefs.available < 15%

这个区分很关键。一个 Pod 的 emptyDir 写爆了 nodefs,system 组件(kubelet、containerd)就无法正常工作。如果写爆的是 imagefs(比如大量容器镜像堆积),容器运行时可能无法拉取新镜像或创建新容器。

但默认配置有个大坑:nodefs 和 imagefs 在同一块磁盘上(比如云服务器的 /dev/vda1 只有一个根分区)。这时候无论 emptyDir 写爆了哪个,结果都一样——整台 Node 不可用。

Kubelet 的驱逐机制比大部分人想的要复杂。它不只是一条 if 判断:

  1. 检查周期:kubelet 默认每 10 秒执行一次 --node-status-update-frequency 检查
  2. 硬驱逐(hard eviction):--eviction-hard=nodefs.available<10%,imagefs.available<15%——达到阈值立即触发,不给 Pod 任何缓冲时间
  3. 软驱逐(soft eviction):--eviction-soft=nodefs.available<15%——达到阈值后给 Pod 一个 grace period(--eviction-soft-grace-period,默认 2 分钟),到期未恢复才驱逐
  4. 最小回收量--eviction-minimum-reclaim——每次驱逐至少回收 100MB nodefs 或 1GB imagefs,防止频繁触发
$ kubectl describe node prod-node-03 | grep -A 5 'Conditions:'
Conditions:
  Type                 Status  LastHeartbeatTime
  ----                 ------  -----------------
  DiskPressure         True    2026-07-28T14:30:00Z
  MemoryPressure       False   ...
  PIDPressure          False   ...
  Ready                True    ...

查看节点 ephemeral-storage 容量:

$ kubectl describe node <node-name> | grep -A 10 'Allocatable:'
  cpu:                8
  memory:             32768Mi
  ephemeral-storage:  100Gi

但注意,ephemeral-storage 的 Allocatable 仅对设置了 requests/limits 的 Pod 做准入控制。如果 Pod 没有设 ephemeral-storage 的 requests 和 limits,kubelet 根本不知道这个 Pod 会消耗多少磁盘,自然也不会提前做任何拦截。

很多人以为"我设了 requests/limits ephemeral-storage 就安全了"——错了。requests/limits 影响的是 Pod 调度和准入控制,不影响 emptyDir 的写入限制。emptyDir 的 sizeLimit 是独立于 Pod 资源限制的另一个维度。即使你设了 limits.ephemeral-storage: 10Gi,Pod 仍然可以通过 emptyDir 写 100Gi 的节点磁盘——只要你没设 sizeLimit。

这就是"一个 Pod 写爆整节点"的根本原因:三个独立机制失效了——emptyDir 没设 sizeLimit、Pod 没设 ephemeral-storage limits、节点没做 nodefs 资源预留。三个漏洞叠在一起,一个 Pod 的写入就能让全场下线。

kubectl describe node 显示 DiskPressure 和 ephemeral-storage

Pod 驱逐后的恢复流程同样重要。kubelet 不会自动恢复——即使你手动清理了磁盘空间,Node 的 DiskPressure 条件也需要一段时间才能恢复。Kubelet 每 10 秒检查一次,连续 3 次检查都恢复阈值以上,才会把 DiskPressure 设为 False。然后 scheduler 才能重新调度 Pod。整个过程至少 30 秒——对高可用系统来说,30 秒的调度中断已经足够引发雪崩。

集群层:StorageClass 与存储解耦

当 emptyDir 和 hostPath 都满足不了需求时(需要持久化、跨节点迁移、容量独立管理),就需要 PVC + PV。但 PVC 和 PV 之间的绑定是 StorageClass 在管理:

$ kubectl get sc
NAME                 PROVISIONER            RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION
standard (default)   kubernetes.io/csi      Delete          WaitForFirstConsumer true
fast-ssd             csi-hostpath           Retain          Immediate           false

几个参数的含义:

  • Provisioner:谁来创建 PV。kubernetes.io/csi 走 CSI 驱动(如 cinder、EBS、Ceph),kubernetes.io/host-path 是本地存储
  • ReclaimPolicy:PV 释放后怎么处理。Delete 自动删除后端存储,Retain 保留数据但不可复用,Recycle(已废弃)执行清理脚本后复用
  • VolumeBindingMode:什么时候绑定 PV。WaitForFirstConsumer 等 Pod 被调度到 Node 后才绑定——确保 PV 与 Pod 在同一个可用区。Immediate 立即绑定——可能导致 PV 在 A 区而 Pod 被调度到 B 区
  • allowVolumeExpansion:是否支持在线扩容。设为 true 时可以在 PVC 创建后修改 storage 大小

StorageClass——存储类的描述,定义 PV 的 Provisioner(谁来创建)、ReclaimPolicy(回收策略:Delete/Retain/Recycle)、VolumeBindingMode(绑定模式)等参数。

核心原则:emptyDir 适合"用完即弃"的 scratch 空间,hostPath 仅限 DaemonSet 或调试场景,生产级持久数据走 PVC + PV。

但这里有一条更容易踩的坑:StorageClass 的默认行为你可能不知道。比如说,你在 K8s v1.25 上创建一个 PVC 但不指定 storageClassName,它会用名为 standard 且 annotation 为 storageclass.kubernetes.io/is-default-class: "true" 的 StorageClass。如果集群里没有 default StorageClass,PVC 会一直 Pending。很多人在测试环境写 PVC 忘记指定 sc,卡了几个小时才发现。

StorageClass 和 emptyDir 排查命令

路径:排查存储容量归属的 3 条命令

当你再遇到"Pod 报 No space left"时,按这个顺序排查。我推荐你把这个流程记下来,下次直接套用。

命令 1:判断是 nodefs 还是 imagefs 满了

$ df -h /var/lib/kubelet
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       100G   95G   5G   95% /

$ df -h /var/lib/containerd
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       100G   95G   5G   95% /

如果两者都在同一设备上(如上例),一个盘满了全线告急。这也是最常见的场景——云服务器通常只有一个根分区。如果你发现 nodefs 和 imagefs 在不同的设备上(比如 kubelet 在系统盘、containerd 在数据盘),问题就更容易定位了。

什么时候用这条命令:只要看到 DiskPressure condition,第一件事就是跑这个。它告诉你"还有多少空间"而不是"谁吃了空间"。

命令 2:查 Pod 的 Volume 类型

$ kubectl describe pod kafka-connect-0 | grep -A 10 'Volumes:'
  Volumes:
   offset-data:
    Type:       EmptyDir (a temporary directory that shares a pod's lifetime)
    Medium:     
    SizeLimit:  <unset>   ← 没设!默认无限制

SizeLimit: <unset> 就是问题所在。如果这里显示的是一个具体值(如 1Gi),那 emptyDir 的写入会被限制在这个值内。

什么时候用这条命令:当命令 1 确认 nodefs 满后,查是哪个 Pod 在写。只看 Volume 类型是不够的,还需要看对应的 Pod 有没有设 sizeLimit。

命令 3:定位 emptyDir 的物理位置

$ du -sh /var/lib/kubelet/pods/$(kubectl get pod kafka-connect-0 \
  -o jsonpath='{.metadata.uid}')/volumes/kubernetes.io~empty-dir/
2.0G    .../empty-dir/offset-data

看节点上所有 emptyDir 的总用量,从大到小排序:

$ du -sh /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/ | sort -rh | head -10
2.0G    .../empty-dir/offset-data
1.5G    .../empty-dir/logs
500M    .../empty-dir/tmp

这条命令能让你三分钟内找到"谁在吃磁盘"。直接看 /var/lib/kubelet/pods/ 下各个 Pod 的 emptyDir 目录大小,不用猜。

什么时候用这条命令:当命令 2 确认 emptyDir 类型后,定位具体哪个 emptyDir 消耗最大。如果排序后发现 top 3 加起来远低于 nodefs 使用量,那说明问题不在 emptyDir,要去排查容器日志(/var/log/pods/)或镜像堆积。

du -sh emptyDir 排行和 sizeLimit 检查

排查顺序流程图

Pod 报 No space left / DiskPressure
    │
    ├─ df -h /var/lib/kubelet           → nodefs 满?
    │   └─ df -h /var/lib/containerd    → imagefs 满?
    │
    ├─ kubectl describe node → Conditions:DiskPressure
    │
    ├─ du -sh /var/lib/kubelet/pods/*/volumes/ | sort -rh
    │   └─ 找到 top emptyDir 消费者
    │
    ├─ kubectl describe pod → Volumes:SizeLimit
    │   └─ SizeLimit: <unset>? → 问题确认
    │
    └─ du -sh /var/log/pods/  ← 如果 emptyDir 不大,查容器日志

定位:最大的认知反直觉

很多人以为容器内的 disk 和 Node 的 disk 是隔离的,像 cgroup 对 CPU/内存的限制一样。这个错觉来自一个日常经验——你在一台服务器上跑 Docker 容器,用 df -h 看到的是容器自己的文件系统,觉得"这不就隔离了吗?"

但 emptyDir 不是容器文件系统的一部分。它是通过 bind mount 挂进去的宿主机目录。容器内的进程以为在写本地磁盘,实际上在写 Node 的 /var/lib/kubeletemptyDir 没有 sizeLimit = 直接写节点磁盘

下面四个误判,每一个都是我见过真实案例的。

误判 1:emptyDir 是容器的一部分,不占节点空间

有一次,一个团队在排查"容器磁盘满了"的问题。运维进容器 df -h / 看到 overlay 使用率 80%,觉得还好。但节点上跑着 50 个 Pod,每个 Pod 的 emptyDir 加起来几百 GB,把 Node 的 /var 挤爆了。

他们花了 2 小时排查日志轮转、镜像清理,最后发现 emptyDir 才是真凶。

真相:所有 emptyDir 写入量直接落在 /var/lib/kubelet,走节点盘。Pod 内 df 看到 overlay 的容量是 imagefs 所在盘的容量,emptyDir 不占用 overlay。

误判 2:hostPath 和 emptyDir 都是临时存储

如果你的应用用了 hostPath,需要搞清一件事:Pod 删除后数据还在不在?

emptyDir 在 Pod 删除后清除;hostPath 数据持久到 Node 上。你不是在"写 Pod 的临时目录"——你是在"写宿主机的路径"。

这就引出另一个问题:假设你的 Pod 被重新调度到另一台 Node,hostPath 的数据不会跟着走。你的应用启动时会发现数据目录是空的。很多人把 hostPath 当共享存储用,结果一滚动更新全乱了。

真相:hostPath 的"持久"不等于"数据安全"或"数据可迁移"。节点挂了数据就丢了。它只在特定场景下有意义——日志采集器(日志本就该在宿主机)、系统监控工具。

误判 3:emptyDir 满了会 OOM Kill 容器

这个误解来自"资源超限"概念混淆。cgroup 对内存的约束是 OOM Kill——超过 memory.limit_in_bytes,内核直接杀进程。但 emptyDir 的约束不在 cgroup 层,在 kubelet 层。

OOM Kill 是 cgroup 的内存超限行为,受害者是容器进程自身。 DiskPressure 是 kubelet 的节点级驱逐行为,受害者是同节点 QoS 最低的 Pod——不一定是你自己。

真相:你的 emptyDir 写满了,被杀的可能是别人的 Pod。这才是 emptyDir 不设 sizeLimit 最危险的地方——你自己没事,隔壁的 API 网关先倒了。

误判 4:设置了 ephemeral-storage 的 requests/limits 就能防止写满节点

ephemeral-storage 的 requests/limits 确实可以防止调度到磁盘不足的节点,但它不限制 emptyDir 的写入

# 以下配置只能防止 Pod 被调度到磁盘不足的节点
resources:
  limits:
    ephemeral-storage: 10Gi
  requests:
    ephemeral-storage: 5Gi

# 但 Pod 的 emptyDir 仍然可以写 100Gi——只要没设 sizeLimit
volumes:
- name: scratch
  emptyDir: {}

sizeLimit 是 emptyDir 自己的字段,与 Pod 资源限制完全无关。它们在 K8s 中走的是两套不同的控制路径: - ephemeral-storage limits → 准入控制(Admission Controller) - emptyDir sizeLimit → kubelet 的卷使用监控(Volume Usage)

真相:两个都要设。emptyDir 必须加 sizeLimit,Pod 必须加 ephemeral-storage limits。缺一个都不完整。

QoS 优先级决定谁先被驱逐

Kubelet 驱逐 Pod 的顺序不是随机的。QoS 等级决定优先级:

$ kubectl get pod kafka-connect-0 -o jsonpath='{.status.qosClass}'
Burstable

驱逐链:BestEffort(无 requests/limits)→ Burstable(设置有部分 requests)→ Guaranteed(requests == limits)

30 个 Pod 在同一个节点上,被驱逐的顺序由 QoS 决定。如果你想让自己的 Pod 在被驱逐时排在最后,设 Guaranteed QoS。

但注意:Guaranteed QoS 只能让你排到最后,不能防止被驱逐。DiskPressure 严重到一定程度(比如 < 5%),Guaranteed Pod 也会被赶走。

emptyDir 挂载的是节点盘,不是容器 overlay

标点:三种存储方案的选型边界

本节我们来解决"到底该用哪个"的问题。下面 4 个方案分别对应不同的场景。

方案 1:emptyDir + sizeLimit(推荐临时场景)

volumes:
- name: scratch
  emptyDir:
    sizeLimit: 1Gi        # ← 必须设,否则默认无限制

最佳实践:不管你的 emptyDir 看起来多小,都设一个 sizeLimit。这是成本最低的防护。哪怕设 10Gi,也比不设强——至少你知道上限在哪。

方案 2:emptyDir medium: Memory(高速临时存储)

volumes:
- name: tmp
  emptyDir:
    medium: Memory        # ← 写入 tmpfs,不占节点盘,但吃容器内存
    sizeLimit: 512Mi

这个方案适合对 IO 延迟敏感的临时数据——比如应用缓存的临时文件、排序中间结果。写入速度是磁盘的 10-100 倍,但消耗的是容器的内存配额,容器 OOM 时数据丢失。

tmpfs——基于内存的临时文件系统,写入速度快,但占用容器的内存配额,容器 OOM 时数据丢失。

方案 3:hostPath(仅限 DaemonSet 或调试场景)

volumes:
- name: var-log
  hostPath:
    path: /var/log        # ← 直接暴露宿主目录
    type: DirectoryOrCreate

hostPath 最大的问题不是性能,是安全。如果 Pod 容器被攻破,攻击者可以通过 hostPath 读写宿主机任意路径。所以生产环境建议:

  1. 始终指定 type——DirectoryOrCreateFileOrCreate,不要留空
  2. 只在 DaemonSet 中使用(fluentd、node-exporter 等系统组件)
  3. 应用 Pod 永远不要用 hostPath

警告:type: ""(不指定 type)时,K8s 不会检查 path 是否存在,容器可以读/写宿主任意目录。建议始终指定 type。

方案 4:PVC + PV(生产级持久存储)

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-pvc
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 10Gi
  storageClassName: fast-ssd

PVC + PV 是生产环境持久化存储的标准方案。它比 hostPath 多了一层抽象——存储和计算解耦。Pod 只需要声明"我要 10Gi 存储",不需要知道底层是云盘、NFS 还是 Ceph。

从 emptyDir 迁移到 PVC 的步骤

如果你的应用已经在用 emptyDir 但需要持久化(比如消息队列、数据库、有状态应用),迁移步骤:

# 步骤 1:创建 PVC
kubectl apply -f pvc.yaml

# 步骤 2:更新 Deployment,把 emptyDir 替换为 PVC
#   volumes:
#   - name: data
#     persistentVolumeClaim:
#       claimName: data-pvc

# 步骤 3:滚动更新
kubectl rollout restart deployment/app

# 步骤 4:验证旧 emptyDir 数据是否已迁移
kubectl exec <new-pod> -- ls -la /data

数据迁移建议:不要直接在空 PVC 上启动应用然后期望数据从 emptyDir 自动过来。先在旧 Pod 上用 kubectl cp 或临时 initContainer 把 emptyDir 里的数据复制到 PVC。

选择决策树

是否需要持久数据?
├── 否 → Pod 内的临时 scratch
│   ├── 要高性能 → emptyDir medium: Memory
│   └── 要容量 → emptyDir + sizeLimit
└── 是 → 数据持久化
    ├── 仅限本节点 → hostPath(DaemonSet 日志采集)
    └── 跨节点/独立管理 → PVC + PV(生产首选)

三种存储方案的 YAML 配置对比

Check-list(同类问题排查清单)

# 排查项 命令
1 查 Pod Volume 类型 kubectl describe pod <pod> \| grep -A10 'Volumes:'
2 查 emptyDir sizeLimit kubectl describe pod <pod> \| grep 'SizeLimit'
3 查 Node DiskPressure kubectl describe node <node> \| grep -A5 'Conditions:'
4 查 Node 磁盘使用率(nodefs) df -h /var/lib/kubelet
5 查 Node 磁盘使用率(imagefs) df -h /var/lib/containerd
6 查 Pod emptyDir 物理占用 du -sh /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir/
7 查节点所有 emptyDir 排行 du -sh /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/ \| sort -rh \| head -10
8 查容器 mount 真实来源 crictl inspect <id> \| jq '.info.runtimeSpec.mounts[]'
9 查 Pod QoS 等级 kubectl get pod <pod> -o jsonpath='{.status.qosClass}'
10 查 StorageClass kubectl get sc
11 查 PVC 绑定状态 kubectl get pvc

故障排查的终点不是修好了,是把排查路径写成 check-list。下次你再遇到"Pod 报 No space left",先跑这 3 条命令——看 Volume 类型、看 sizeLimit、看节点磁盘。大多数关于 emptyDir 的故障,这三步就够定位。

下篇我们聊 PV 删除不掉的老大难问题——Terminating 状态的 PV 背后到底是什么在阻止回收。

附:完整命令清单

# 1. 查 Pod 存储类型和 sizeLimit
kubectl describe pod <pod-name> | grep -A 10 'Volumes:'

# 2. 查 Node DiskPressure 条件
kubectl describe node <node-name> | grep -A 5 'Conditions:'

# 3. 查 Node 磁盘使用率(nodefs vs imagefs)
df -h /var/lib/kubelet
df -h /var/lib/containerd

# 4. 查 Pod emptyDir 真实磁盘占用
du -sh /var/lib/kubelet/pods/$(kubectl get pod <pod-name> \
  -o jsonpath='{.metadata.uid}')/volumes/kubernetes.io~empty-dir/

# 5. 查节点上所有 emptyDir 从大到小排序
du -sh /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/ | sort -rh | head -10

# 6. 查容器 mount 真实来源
crictl inspect <container-id> | \
  jq '.info.runtimeSpec.mounts[] | select(.destination | test("data|log"))'

# 7. 查节点 ephemeral-storage 容量
kubectl describe node <node-name> | grep -A 10 'Allocatable:'

# 8. 查 Pod QoS 等级
kubectl get pod <pod-name> -o jsonpath='{.status.qosClass}'

# 9. 查 StorageClass
kubectl get sc

# 10. 查 PVC 绑定状态
kubectl get pvc
kubectl describe pvc <pvc-name>

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」 🔗 个人博客:https://opencao.cn