结论先行:K8s 节点磁盘打满是"三层水龙头同时开、排水管只有一根"的问题——容器日志无限增长、孤儿镜像堆积、僵尸文件(deleted but open)占用空间。kubelet 的驱逐机制只解决了"Pod 层面"的问题,不会清理日志文件和僵尸文件。治本的关键是提前配好
log_max_size+ 定期清理孤儿镜像 + 在磁盘 80% 时告警。
一、现象:200G 磁盘 97%,12 个 Pod 全被驱逐,但磁盘只降了 2%
先交代规模。一个 K8s 1.28.9 集群(containerd 1.7.13,cgroup v2,节点 16C / 64G / 200G SSD),一个节点上跑了 12 个业务 Pod(3 个 Java、5 个 Go、2 个 Node.js、2 个 Python)。
某天早上 9:15,监控告警:节点 node-03 的 /var/lib/containerd 分区使用率 97%(200G 只剩 6G)。9:20,kubelet 日志开始刷 Evicting pod ... due to insufficient node disk,12 个 Pod 在 3 分钟内全部被驱逐。
诡异的地方:
- Pod 全被驱逐后,
df -h显示使用率只降了 2%(97% → 95%)——200G 的盘,驱逐 12 个 Pod 只释放了 4G? du -sh /var/lib/containerd/* | sort -rh | head找不出大文件,最大的目录只有 3G;lsof +L1 +n +P | grep deleted发现有 7 个已删除但句柄未释放的文件,合计 182G——这些文件"不存在"了,rm也删不了。
最终花了 40 分钟才把节点救回来(重启节点释放僵尸文件 + 清理孤儿镜像 + 配 log_max_size)。下面是完整的排查路径与治本方案。
二、根因:三层"水龙头"同时开
拆开看是三层机制叠加,单独一层通常都能撑住:
第一层:容器日志没配 log_max_size,无限增长。
containerd 默认配置(/etc/containerd/config.toml)里,如果没显式设 log_max_size 和 log_max_file,容器日志会无限追加到 /var/log/containers/<pod-uid>.log。我们当时 3 个 Java 服务(日志级别 DEBUG,Spring Boot 2.7.18)每天产生 8~12G 日志,一周累积 60~80G。Go 和 Node.js 服务日志量小(1~2G/周),但 3 个 Java 服务就够把 200G 的盘吃掉了。
第二层:镜像层积累了大量"孤儿"镜像。
过去 3 个月做了 15 次发布,每次发布后旧镜像没有被 crictl rmi 清理。kubelet 的镜像 GC 阈值 imageGCHighThresholdPercentage=80% 还没触发(因为磁盘还有 20% 余量),所以 GC 没动手。15 个旧镜像 × 平均 1.2G = 18G 的"孤儿"镜像层躺在 /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ 里。
第三层:僵尸文件(deleted but open)——最阴的一层。
一个 Go 服务在 9 月初的一次发布中,有一个 bug:它在启动时 os.Truncate 了日志文件,但没有 close 旧的文件描述符。当 containerd 尝试轮转日志时,旧的文件被 unlink 了,但 Go 进程还持有 fd,内核不会真正释放磁盘空间。df 看到磁盘满,du 找不到文件(因为 inode 已经从目录树里移除了),只有 lsof +L1 能看到。
类比:你删了一个文件,但还有一个人在"读"它。内核不会把磁盘空间还给文件系统,直到这个人读完(
closefd)或者进程被杀。
三、具体做法:四层排查 + 三层治本
3.1 排查:找到"看不见的大文件"
# 1. 确认磁盘使用率
df -h /var/lib/containerd
# 输出:/dev/vdb1 200G 194G 6.0G 97% /var/lib/containerd
# 2. 找大目录(通常找不到,因为大文件是"僵尸"的)
du -sh /var/lib/containerd/* 2>/dev/null | sort -rh | head -10
du -sh /var/log/containers/* 2>/dev/null | sort -rh | head -10
# 3. 找僵尸文件(关键!)
lsof +L1 +n +P 2>/dev/null | grep deleted | awk '{print $7, $NF}' | sort -rn | head -20
# 输出示例:
# 182345678900 /var/log/containers/pod-abc123.log (deleted)
# 23456789012 /var/lib/containerd/io.containerd.../blobs/sha256/xxx (deleted)
# 4. 确认是哪个进程持有
lsof +L1 +n +P 2>/dev/null | grep "pod-abc123.log"
# 输出:java 12345 root 234 1W REG 8,17 182345678900 0 (deleted) /var/log/containers/pod-abc123.log
# 5. 确认 containerd 的日志配置
crictl inspect <container-id> 2>/dev/null | grep -A5 "logPath"
grep -r "log_max" /etc/containerd/config.toml
# 如果没输出,说明没配(默认无限)
3.2 治本第一层:配 log_max_size
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
# 每个日志文件最大 10MB
log_max_size = "10m"
# 最多保留 3 个轮转文件(当前 + 2 个 .1 .2)
log_max_file = 3
# 重启 containerd(会短暂中断容器,选低峰期操作)
systemctl restart containerd
# 验证:新建一个容器,看日志是否被截断
crictl run --runtime runc <image-ref> <sandbox-id>
# 写入 >10M 日志后检查
ls -lh /var/log/containers/ | head
效果:每个容器最多占 10m × 3 = 30M。12 个容器 = 360M,远小于不配时的 60~80G/周。
3.3 治本第二层:清理孤儿镜像
# 查看当前所有镜像
crictl images
# 查看正在运行的容器引用的镜像
crictl ps -a --no-trunc 2>/dev/null | awk '{print $NF}' | sort -u
# 对比找出孤儿镜像(简化版,生产上建议用脚本)
for img in $(crictl images --quiet); do
if ! crictl ps -a --no-trunc 2>/dev/null | grep -q "$img"; then
echo "Orphan: $img"
fi
done
# 批量清理(谨慎!先确认没有容器在用)
crictl rmi --prune
# 查看释放空间
df -h /var/lib/containerd
注意:crictl rmi --prune 会跳过被任何容器(包括已停止的)引用的镜像。要彻底清理,得先 crictl rm 删掉已停止的容器。
3.4 治本第三层:释放僵尸文件
# 方法 1:重启持有句柄的进程(最干净)
# 如果 Pod 已经被驱逐,直接重启节点即可
sudo systemctl reboot
# 方法 2:如果进程还在运行(比如 DaemonSet),删 Pod 让 kubelet 重建
kubectl delete pod <pod-name> --force --grace-period=0
# 方法 3:如果进程是节点上的系统服务(不是容器),直接重启该服务
sudo systemctl restart <service-name>
3.5 治本第四层:加磁盘使用率告警
# Prometheus alert rule
groups:
- name: node-disk
rules:
- alert: NodeDiskUsageWarning
expr: node_filesystem_usage{mountpoint="/var/lib/containerd"} > 0.80
for: 5m
labels:
severity: warning
annotations:
summary: "Node {{ $labels.instance }} disk usage > 80%, 5m sustained"
- alert: NodeDiskUsageCritical
expr: node_filesystem_usage{mountpoint="/var/lib/containerd"} > 0.90
for: 2m
labels:
severity: critical
annotations:
summary: "Node {{ $labels.instance }} disk usage > 90%, eviction imminent"
四、踩过的坑(5 个反直觉点)
-
kubelet 驱逐 Pod 不会删除日志文件。 驱逐只是把 Pod 从节点上"踢走"(SIGTERM → SIGKILL),
/var/log/containers/下的日志文件还在磁盘上。要清理日志,得手动rm或重启节点。这是"驱逐后磁盘只降了 2%"的直接原因。 -
crictl rmi --prune不会删除被已停止容器引用的镜像层。 如果某个镜像被至少一个容器引用(包括Exited/Created状态的容器),--prune会跳过它。要彻底清理,得先crictl rm删掉已停止的容器,再crictl rmi。 -
df和du的"矛盾"不是 bug,是僵尸文件的特征。df看的是文件系统级别的块使用量(包括已删除但句柄未释放的文件),du看的是目录树里的文件(已删除的文件不在目录树里)。所以df >> du时,第一反应应该是lsof +L1,而不是怀疑du有 bug。 -
containerd 的
log_max_size是"每个日志文件"的大小,不是"每个容器"的总大小。 配log_max_size=10m+log_max_file=3意味着每个容器最多占10m × 3 = 30M(当前文件 + 2 个轮转文件)。12 个容器就是 360M。但注意:如果容器同时输出 stdout 和 stderr,containerd 会把它们合并到同一个日志文件里,所以这个 30M 是 stdout + stderr 的总和。 -
imageGCHighThresholdPercentage的基准是"节点根分区",不是"containerd 分区"。 如果你把 containerd 放在一个独立的 200G 分区上(/var/lib/containerd),但 kubelet 的--root-dir在系统盘(50G)上,GC 的触发阈值是 50G 的 80% = 40G,而不是 200G 的 80% = 160G。这意味着 containerd 分区还有 160G 空间时,GC 就已经在删镜像了。反过来,如果 containerd 分区快满了(190G/200G),但系统盘还有空间,GC 可能还没触发——这就是我们这次事故的触发条件之一。
五、结论与可复用清单
结论:K8s 节点磁盘打满是"三层水龙头同时开"的问题——容器日志无限增长、孤儿镜像堆积、僵尸文件占用空间。kubelet 的驱逐机制只解决了"Pod 层面"的问题(把 Pod 踢走),不会清理日志文件和僵尸文件。治本的关键是提前配好 log_max_size + 定期清理孤儿镜像 + 在磁盘 80% 时告警,而不是等驱逐发生了再救火。
可复用清单:
| 配置项 | 推荐值 | 位置 |
|--------|--------|------|
| log_max_size | 10m | /etc/containerd/config.toml |
| log_max_file | 3 | /etc/containerd/config.toml |
| imageGCHighThresholdPercentage | 80 | kubelet --image-gc-high-threshold |
| imageGCLowThresholdPercentage | 60 | kubelet --image-gc-low-threshold |
| 磁盘告警阈值 | 80%(warning)/ 90%(critical) | Prometheus alert |
| 孤儿镜像清理频率 | 每周一次(cron) | crictl rmi --prune |
| 僵尸文件检查 | 磁盘 > 90% 时 | lsof +L1 +n +P |
开放问题(想听听大家的经验):
- 你们生产上 containerd 的
log_max_size配了多少?有没有人踩过"日志配了 10m 但 Java 服务还是把磁盘打满"的坑?我怀疑是 GC 日志(-Xlog:gc*:file=...)和 stdout 日志是两条独立的文件,log_max_size只管 stdout,GC 日志得靠 JVM 自己的-XX:+UseGCLogFileRotation+-XX:GCLogFileSize=10m来限。 - 有没有人用
node-problem-detector的NodeFilesystemAlmostOutOfSpace告警替代 Prometheus 的node_filesystem_usage?两者的触发条件(node-problem-detector默认 85%)和精度有什么区别? imageGCHighThresholdPercentage的基准到底是"节点根分区"还是"containerd 所在分区"?我翻了 K8s 1.28 的源码(pkg/kubelet/eviction/factory.go里的thresholdManager),看到它用的是nodefs(节点文件系统)的AvailableBytes,但没找到明确的注释说nodefs是哪个挂载点。有没有人确认过?