ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

K8s节点DiskPressure治理:kubelet回收、日志轮转与常驻清理

K8s节点DiskPressure治理:kubelet回收、日志轮转与常驻清理

1. 磁盘告警总在凌晨三点响:先把清理这件事想明白

集群跑得好好的,突然一条告警弹出来:某节点 DiskPressure,Pod 开始被驱逐,业务方在群里问为什么服务飘了。登上机器df -h一看,根分区 100%,du -sh /var/lib/*扫一遍,发现/var/lib/containerd或者/var/lib/docker占了大头,日志目录也不小。这种场景我在不同规模的集群里都碰过,从几十个节点的小集群到上千节点的生产环境,磁盘清理这件事从来不是"删几个文件"这么简单,它本质上是一套分层治理 + 自动兜底 + 事后追溯的完整机制。

这篇内容聊的就是集群节点磁盘清理的实战打法:磁盘到底被谁吃掉了、kubelet 自带的回收机制该怎么配、手动清理的正确顺序和禁忌是什么、怎么把清理做成一套能长期跑的常驻机制。适合正在被 DiskPressure 骚扰的运维同学、需要给集群做容量规划的架构同学,以及刚接手 K8s 集群、还没踩过磁盘坑的开发者。哪怕你只会df和du两个命令,按下面的顺序走一遍也能定位到问题;如果你已经在写清理脚本了,后面关于镜像层复用和文件句柄的部分大概率能帮你避开几个隐蔽的坑。

先说个基本认知:K8s 节点的磁盘清理和单机服务器完全不是一回事。单机上你删日志、清缓存,重启一下服务就完事了;集群节点上你要考虑驱逐阈值会不会误伤正在运行的 Pod、要考虑镜像删了之后下次调度过来怎么拉、要考虑删卷的时候 PVC 还在不在、要考虑多个节点同时触发清理会不会把镜像仓库打爆。所以整个思路得从"应急删文件"升级成"分类治理"。

我一般把节点磁盘上的数据分成四类,这四类是后面所有操作的基础:

类别典型路径增长特点治理手段
容器镜像与容器层/var/lib/containerd、/var/lib/docker随发布频率线性增长,旧版本堆积镜像回收阈值 + 定期 prune
容器日志/var/log/pods、/var/log/containers随应用日志量增长,异常时暴增日志轮转参数
卷数据/var/lib/kubelet/pods/*/volumes随 emptyDir、临时卷使用增长生命周期约束 + 孤儿卷清理
系统与运行时日志/var/log/journal、/var/log/messages缓慢稳定增长journal 限额

这四类的清理风险和收益完全不同:镜像删错了无非是重新拉一次,代价是网络和仓库压力;日志删错了可能丢掉排障线索;卷删错了那就是数据事故。所以顺序上永远是先动镜像、再动日志、最后才碰卷,这个顺序我会在后面反复强调。

2. 定位:三步找出到底是谁在吃盘

清理之前必须先定位,盲目rm -rf的结果往往是空间没释放还惹一身麻烦。我习惯用三步走:看整体水位、层级下钻、找幽灵文件。这三步熟练之后,一台节点的磁盘问题基本能在五分钟内锁定嫌疑人。

2.1 第一步:确认挂载点和 inode 两个维度

很多人只看df -h,看到还有空间就以为没事,结果应用写文件报 "No space left on device",实际上是 inode 用光了。小文件特别多的场景——比如大量日志分片、缓存碎片、小的镜像层文件——很容易出现磁盘还有几十 G 但 inode 已经耗尽的情况。

# 看容量水位 df -hT # 看 inode 水位,这一步经常被忽略 df -i

两个命令的输出要一起看。如果df -h正常但df -i到了 100%,那清理方向就完全变了:你要找的是"哪个目录下有几百万个小文件",而不是"哪个大文件占了空间"。这种情况用du扫会非常慢,我的做法是先按目录统计文件数量:

# 统计各目录下的文件数量,找出小文件重灾区 for d in /var/lib/containerd /var/lib/kubelet /var/log /tmp; do echo -n "$d: " find "$d" -xdev -type f 2>/dev/null | wc -l done

还有一个细节:节点上可能有多个挂载点,/var/lib/kubelet和/var/log有可能被单独挂载到不同的盘上。清理之前一定要用findmnt确认目标目录到底落在哪个分区,否则你以为在清根分区,实际清的是数据盘。

findmnt -T /var/lib/kubelet findmnt -T /var/log

注意:容器运行时的数据目录如果单独挂盘,清理脚本里的路径判断必须跟着变。我见过清理脚本硬编码/var/lib/docker,结果集群早换成了 containerd,脚本跑了半年一次都没生效,直到告警爆发才发现。

2.2 第二步:按层级下钻,锁定大户目录

确认了是哪个分区的问题,接下来就是下钻。du -sh配合sort是最朴素也最有效的组合,关键在于限制目录深度,避免在大目录上卡死:

# 只看当前层级的一级子目录,按大小排序 du -h --max-depth=1 /var/lib 2>/dev/null | sort -rh | head -20

如果/var/lib/containerd是大头,继续往下钻。containerd 的目录结构里,io.containerd.content.v1.content存的是镜像层内容,io.containerd.snapshotter.v1.overlayfs存的是容器可写层快照。这两个目录加起来通常占 80% 以上。

du -h --max-depth=1 /var/lib/containerd 2>/dev/null | sort -rh | head -10

如果是 docker 运行时,路径是/var/lib/docker/overlay2,同样道理。这里有个性能提示:在几百万文件的目录上跑du可能要几分钟,而且会消耗不少 IO。生产环境上我一般会加ionice降低优先级,避免清理动作本身把节点 IO 打满影响业务:

ionice -c2 -n7 du -h --max-depth=1 /var/lib/containerd 2>/dev/null | sort -rh | head -10

ionice -c2 -n7的意思是使用 best-effort 调度类、最低优先级,这样磁盘扫描不会跟业务抢 IO。这个细节在文档里基本没人提,但在一台正在被磁盘压力折磨的节点上,它能让你的排查动作不至于雪上加霜。

2.3 第三步:找出删了也不释放空间的幽灵文件

这是最容易让人抓狂的情况:du显示某个目录只有 2G,df却说用了 90G。差值去哪了?答案通常是已经被删除但仍有进程持有文件句柄的文件。文件被rm之后,目录项没了,du扫不到,但 inode 和数据块还在,直到持有句柄的进程关闭文件或退出才真正释放。

日志文件是重灾区。应用用logrotate切日志,如果配置成copytruncate就没问题;但如果配置成create模式,而应用本身没有重新打开文件的逻辑(很多 Java 应用、Go 应用如果不做信号处理就是这样),旧文件被 rename 之后进程还持有旧句柄,新的写入继续落到那个"已删除"的文件里,空间就一直涨。

排查命令很直接:

# 列出所有已删除但仍被占用的文件,按大小排序 lsof -nP 2>/dev/null | grep deleted | awk '{print $7, $2, $1, $9}' | sort -rn | head -20

输出里第一列是文件大小(字节),第二列是 PID,第三列是进程名,第四列是路径。看到几个 G 的 deleted 文件挂在高频写入的进程上,基本就实锤了。

处理方式有两种:温和一点的是让进程重新打开日志文件,比如给应用发信号触发日志轮转(前提是应用支持);直接一点的是重启对应进程,句柄一释放空间立刻回来。在容器场景下,直接删掉对应 Pod 让它重建也是常见做法,但要注意别把有状态服务一起带走了。

注意:lsof在文件描述符特别多的节点上跑得很慢,可以先按进程维度缩小范围,比如lsof -p <pid>针对单个进程查。另外lsof输出里 deleted 文件的大小列是十进制字节数,跟df的单位换算要留意,别看着数字大就以为真有那么大。

3. 让 kubelet 自己瘦身:回收参数怎么配才不误伤

定位清楚之后,第一件要做的不是手动删,而是把 kubelet 的自动回收机制配好。K8s 本身就带了镜像回收和容器日志轮转能力,配置得当的情况下,大部分节点根本不需要人工介入。问题在于默认参数偏保守,很多集群装完之后压根没调过。

3.1 镜像回收阈值:高低水位怎么算

kubelet 的镜像回收由四个参数控制,写在 kubelet 配置文件里(通常是/var/lib/kubelet/config.yaml):

imageGCHighThresholdPercent: 85 imageGCLowThresholdPercent: 80 imageMinimumGCAge: 2m imageMaximumGCAge: 0s

含义很直白:磁盘使用率到 85% 时开始回收,一直回收到 80% 以下停止。这个 5% 的区间就是回收的"缓冲区",设置得太小会导致频繁触发回收,每次回收都要扫描镜像列表、计算使用情况,本身就是开销。

这个区间大小怎么定?我给的经验算法是:缓冲区对应的绝对空间,至少要能容纳一次最大规模发布产生的镜像增量。举个例子,一个节点盘 200G,5% 就是 10G。如果单次全量发布会产生 8G 的新镜像层,那 10G 勉强够用;如果一次发布会带进来 15G 新镜像,那 5% 就不够了,得拉到 90/80 甚至 92/82。

imageMinimumGCAge也值得说一说,默认 2 分钟。它的作用是保护"刚刚拉下来但还没用上"的镜像,防止回收把正在被调度过来的 Pod 需要的镜像删掉。在大规模弹性扩容的场景下,节点刚被拉起、镜像刚拉完、Pod 还没完全启动,如果这个值设得太小,可能出现"边拉边删"的鬼畜现象——镜像仓库被打爆,Pod 一直 ImagePullBackOff。我一般会设到 10m 甚至更长,具体看集群的扩容节奏。

3.2 容器日志轮转:最容易被忽略的增长源

比镜像更容易失控的是容器日志。默认情况下 kubelet 的日志轮转配置是这样的:

containerLogMaxSize: 10Mi containerLogMaxFiles: 5

意思是单个容器日志文件到 10Mi 就切一份,最多保留 5 份,也就是单容器最多 50Mi。听起来挺合理,但实际环境里有两个坑。

第一个坑是多容器 Pod。一个 Pod 里如果有 3 个容器,每个 50Mi,那就是 150Mi;节点上跑 100 个 Pod,理论上限就是 15G。这个数量级在很多人的心理预期之外,所以经常出现"我明明配了日志轮转,怎么日志目录还是几十 G"的疑问。

第二个坑是应用日志级别失控。平时 INFO 级别每天几十 M,某天开了 DEBUG 排查问题忘了关,日志量翻十倍,轮转是正常工作的,但轮转速度赶不上写入速度,短时间内就能把磁盘打满。这种情况轮转参数救不了你,只能靠监控告警及时发现。

生产上我一般会把参数调成这样:

containerLogMaxSize: 50Mi containerLogMaxFiles: 3

单个容器封顶 150Mi,比默认的 50Mi 上限更宽松,避免正常业务日志被切得太碎影响排障;同时保留 3 份,足够覆盖一般的问题回溯窗口。如果你们的合规要求日志保留更久,那就不要靠容器本地日志,而是把日志采集到集中存储,本地只留最短窗口。

注意:改这两个参数需要重启 kubelet,重启过程中节点上的 Pod 不受影响(容器不会重启),但 kubelet 会有一小段时间不可用,这期间新的 Pod 调度和状态上报会受影响。滚动重启各节点的时候最好避开业务高峰,并且确认 PDB 配置到位。

3.3 驱逐阈值:磁盘预留是保命的

kubelet 的evictionHard配置决定了在什么水位下开始驱逐 Pod。默认的磁盘阈值是:

evictionHard: nodefs.available: "10%" imagefs.available: "15%"

这两个值的含义要讲清楚。nodefs指的是 kubelet 所在的文件系统,一般是根分区,存的是卷数据、日志、Pod 元数据;imagefs是容器运行时存镜像和容器层的文件系统,可能是同一个盘也可能是独立盘。当nodefs.available低于 10%,kubelet 就会开始驱逐 Pod 来腾空间。

这里的关键认知是:15% 的 imagefs 阈值和 85% 的镜像回收高水位是同一个意思。当镜像目录使用率达到 85%,kubelet 先触发镜像回收;如果回收完还是不够,可用空间继续掉到 15% 以下,才开始驱逐。这个先后顺序很重要——它意味着镜像回收是第一道防线,驱逐是最后手段。

我的建议是给系统盘留足余量。曾经有个集群节点盘 100G,按默认 10% 算就是 10G 的余量,看着不少,但系统日志、容器日志、卷数据全在根分区上,几个大日志文件就能吃掉这个余量,然后触发大规模驱逐。后来我们把阈值调到nodefs.available: "20%",虽然可用容量少了,但节点稳定性质变。

如果节点有独立的镜像盘,配置要针对性调整:镜像盘留给 imagefs 阈值管,系统盘只留 nodefs 阈值,两块盘互不干扰。反过来,如果只有一个盘,两个阈值实际上作用于同一个文件系统,这时候取两者中更高的那个生效,得按更严格的标准来规划容量。

4. 手动清理的标准动作:顺序错了代价很大

自动回收能解决大部分问题,但你总会遇到需要手动介入的场景:告警已经响了、磁盘马上要满、等不到 kubelet 慢慢回收。这时候手动清理的顺序和禁忌就很关键了。我总结的原则是一句话:先镜像、再日志、后卷数据,每一步之前都留证据。

4.1 清镜像:prune 的三种姿势和它们的区别

镜像清理是最安全的一步,因为镜像本身是可重建的。但"清哪些"有讲究,不同的 prune 参数杀伤力完全不同。

containerd 环境下用crictl:

# 查看当前镜像占用 crictl images # 删除未被任何容器使用的镜像(推荐,最安全) crictl rmi --prune

crictl rmi --prune只删"没有被任何容器引用"的镜像,配上 kubelet 的imageMinimumGCAge保护机制,基本不会误伤正在使用的镜像。这是我在生产环境首选的方式,风险最低。

docker 环境下对应的是:

# 只清理悬空镜像(没有 tag 的中间层),最安全 docker image prune -f # 清理所有未被容器使用的镜像,杀伤力大 docker image prune -a -f

-a和不带-a的区别要搞清楚。不带-a只清悬空镜像,也就是构建过程中产生的中间层;带-a会把所有没有容器引用的镜像都清掉,包括你刚从仓库拉下来准备发布的新版本。在滚动发布过程中执行-a,很可能导致新 Pod 拉起时又要重新拉镜像,发布窗口被拉长,镜像仓库压力陡增。

还有一个更狠的:

docker system prune -a --volumes

这个命令会清镜像、清停止的容器、清未使用的网络、清未使用的卷。千万不要在节点上随手敲,--volumes会把所有没被容器引用的卷都删掉,如果有 PVC 绑定的 Pod 刚好在这时候被驱逐、卷暂时处于未挂载状态,数据就没了。我个人的做法是任何清理命令都先加--dry-run或者先看不带-f的交互式输出,确认要删的东西后再执行。

4.2 清日志:区分容器日志和系统日志

容器日志在/var/log/pods和/var/log/containers下,前者是实际文件,后者是软链接。清理容器日志的正确姿势是删文件本身,并且注意软链接指向的文件结构:

# 查看各 Pod 日志目录大小 du -h --max-depth=2 /var/log/pods 2>/dev/null | sort -rh | head -20

找到大目录后直接删里面对应的.log文件即可。这里有个细节:删完之后如果有进程(比如日志采集的 sidecar 或者 kubelet 的日志读取逻辑)还持有句柄,空间不会立刻释放,回到了上一节说的幽灵文件问题。所以更稳妥的做法是优先用 truncate 而不是 rm:

# 清空文件内容但保留 inode 和句柄,空间立即释放 truncate -s 0 /var/log/pods/<namespace>_<pod>_<uid>/<container>/0.log

truncate -s 0把文件大小置零,但文件本身还在,持有句柄的进程看到的还是同一个文件,写入继续正常进行,不会出现"删了文件还在写"的怪现象。这个技巧在容器日志清理上特别实用,我在多个集群上都验证过,比 rm 干净得多。

系统日志方面,如果用 systemd-journald:

# 看 journal 占用 journalctl --disk-usage # 清理到只剩 500M journalctl --vacuum-size=500M # 或者保留最近 7 天 journalctl --vacuum-time=7d

journal 的清理是安全的,它自己管理轮转,--vacuum-size会把最旧的归档删掉直到总量低于阈值。但要注意,如果 journal 配置了Storage=persistent,/var/log/journal会一直增长到限额;如果配置成volatile,只在内存里存,机器一重启就没了。有些发行版默认是 persistent 但没配限额,几个月下来能涨到几十 G。

4.3 清卷数据:最危险的一步,务必看清路径

/var/lib/kubelet/pods/<pod-uid>/volumes下面存的是各种卷数据:emptyDir、configmap、secret、downwardAPI 的挂载副本,以及 hostPath 和 CSI 卷的挂载点。这里的清理要格外小心。

先说哪些能安全删。如果一个 Pod 已经被删除,它的<pod-uid>目录理论上会被 kubelet 清掉,但有时候会因为卷卸载失败、kubelet 异常退出等原因留下孤儿目录。判断方法是对照当前运行的 Pod 列表:

# 列出 kubelet 目录下的 pod uid ls /var/lib/kubelet/pods/ # 对照当前运行的 pod uid kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.uid}{"\n"}{end}' | sort > /tmp/running-uids.txt sort /var/lib/kubelet/pods/ > /tmp/disk-uids.txt comm -23 /tmp/disk-uids.txt /tmp/running-uids.txt

comm -23输出的是"在磁盘上存在但当前没有运行"的 UID,这些就是潜在的孤儿目录。但在看到结果之后不要立刻删,先du -sh看看每个目录多大,再ls看看里面是什么内容。如果里面是 emptyDir 的缓存数据,删了没影响;如果里面挂着 CSI 卷的挂载点,直接删可能影响存储侧的一致性,得先确认卸载干净。

我遇到过最坑的一次是某个 CSI 驱动异常,Pod 已经删了但卷没卸载成功,mount里还能看到挂载记录,/var/lib/kubelet/pods/<uid>/volumes目录占了几十 G。正确做法是先把残留的挂载点卸载掉(umount),再删目录。顺序反了会报 "device is busy",而且还可能影响存储后端的卷状态。

# 找残留挂载 mount | grep kubelet # 卸载后再删目录 umount /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~csi/<volume-name>/mount rm -rf /var/lib/kubelet/pods/<uid>

注意:任何涉及/var/lib/kubelet/pods的删除操作,执行前一定要确认对应 Pod 确实不存在了。用kubectl get pod -A --field-selector metadata.uid=<uid>查一下最保险,虽然这个 field selector 支持度一般,但至少用kubectl get pods -A | grep <pod-name>核对一下。删掉正在运行 Pod 的卷目录,轻则应用报错重则数据损坏。

5. 把清理做成常驻机制:别指望人肉运维

手动清理解决的是"今天",机制化解决的是"以后每一天"。我在几个集群上都部署过清理机制,踩下来的经验是:能用 kubelet 自带的就别自己写,非要自己写的就做成 DaemonSet + 定时任务的组合。

5.1 用 DaemonSet 做节点级定期清理

DaemonSet 的好处是每个节点自动部署一份,新节点加入集群立刻覆盖,不用维护节点清单。清理逻辑可以写在容器里,通过挂载宿主机的/var/log、/var/lib/kubelet等目录来操作。

一个简化的清理 DaemonSet 大致长这样:

apiVersion: apps/v1 kind: DaemonSet metadata: name: node-disk-cleaner namespace: kube-system spec: selector: matchLabels: app: node-disk-cleaner template: metadata: labels: app: node-disk-cleaner spec: hostPID: true tolerations: - operator: Exists containers: - name: cleaner image: <internal-registry>/node-cleaner:1.0 securityContext: privileged: true volumeMounts: - name: varlog mountPath: /host/var/log - name: varlibkubelet mountPath: /host/var/lib/kubelet command: ["/bin/sh", "-c"] args: - | while true; do # 只清理超过 7 天的容器日志文件 find /host/var/log/pods -name "*.log" -mtime +7 -exec truncate -s 0 {} \; # 清理 systemd journal 到 500M journalctl --vacuum-size=500M || true sleep 3600 done volumes: - name: varlog hostPath: path: /var/log - name: varlibkubelet hostPath: path: /var/lib/kubelet

几个设计要点值得展开。hostPID: true是为了让容器内的工具能操作宿主机进程(比如给进程发信号让它重开日志),但如果清理逻辑不需要这个能力就别开,减少权限暴露。tolerations: - operator: Exists让 DaemonSet 能调度到有污点的节点上,包括带有 DiskPressure 污点的节点——这一点很关键,否则节点一出问题清理 Pod 反而被赶走了。

清理策略上我倾向于只清超过一定时间的文件,不动当天的。-mtime +7保证最近 7 天的日志完整保留,排障窗口不受影响。用truncate -s 0代替rm的理由前面说过了,避免句柄问题。

镜像清理就不要放在这个循环里了,交给 kubelet 自己的回收机制更合适。容器里调crictl需要挂载运行时的 socket,权限更大而且容易跟 kubelet 的回收动作冲突。真要做的话,用 CronJob 在低峰期跑一次,并且只做--prune,别做全量清理。

5.2 用 CronJob 做低峰期的深度清理

DaemonSet 负责日常的轻量清理,CronJob 负责周期性的深度清理。比如每周日凌晨对全集群做一次镜像 prune、清理孤儿卷目录、清理超过 30 天的容器日志归档。

apiVersion: batch/v1 kind: CronJob metadata: name: weekly-disk-deep-clean namespace: kube-system spec: schedule: "0 3 * * 0" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 jobTemplate: spec: template: spec: nodeSelector: node-role.kubernetes.io/worker: "" tolerations: - operator: Exists containers: - name: deep-clean image: <internal-registry>/node-cleaner:1.0 securityContext: privileged: true volumeMounts: - name: varlibcontainerd mountPath: /host/var/lib/containerd - name: runtime-sock mountPath: /run/containerd/containerd.sock command: ["/bin/sh", "-c"] args: - | # 清理 30 天以上的日志归档 find /host/var/log/pods -name "*.log.*" -mtime +30 -delete volumes: - name: varlibcontainerd hostPath: path: /var/lib/containerd - name: runtime-sock hostPath: path: /run/containerd/containerd.sock

concurrencyPolicy: Forbid保证同一时刻只有一个清理 Job 在跑,避免多个节点同时被清理动作压满 IO。schedule选在凌晨低峰期,这个时段业务流量小,清理带来的 IO 抖动影响最小。

深度清理的风险控制有个小技巧:先 dry-run 再实跑。清理脚本里加个环境变量开关,第一次部署时设置成只打印要删的文件不实际删,观察一周输出确认没有误伤,再切到实删模式。这个流程在多个集群上帮我避开了至少两次误删事故。

5.3 监控告警:清理机制的眼睛

清理机制做得再好,没有监控就是盲跑。我自己搭的监控面板上固定放四个指标:

指标采集方式告警阈值建议
节点磁盘使用率node_exporternode_filesystem_avail_bytes85% 预警,90% 告警
节点 inode 使用率node_exporternode_filesystem_files_free85% 预警,90% 告警
容器日志目录大小自定义 textfile collector单节点超过 20G 预警
镜像目录大小自定义 textfile collector单节点超过阈值预警

前两个用 node_exporter 自带指标就能搞定。后两个需要自己在 DaemonSet 里定期du -s然后把结果写到 textfile collector 的目录下,node_exporter 读出来暴露成指标:

# 在清理 DaemonSet 里定期执行 echo "node_log_dir_size_bytes $(du -sb /host/var/log/pods | awk '{print $1}')" > /host/var/lib/node_exporter/textfile/log_size.prom

告警阈值我建议分级:85% 预警是给人看的,提醒"该关注了";90% 告警是给人打电话的,说明清理机制可能失效了;再往上到驱逐阈值就该自动化处理直接介入了。分级的目的是把"需要人决策"和"需要人立刻行动"区分开,避免所有告警都变成狼来了。

6. 常见问题与排查技巧实录

这一节把我这些年遇到的典型问题整理成速查表,每条都是实际踩过的,不是从文档里抄的。

6.1 磁盘清理高频问题速查

现象大概率原因排查命令处理方式
du和df差很多已删除文件仍被进程持有lsof -nP | grep deleted重启进程或 truncate 日志
空间释放了但磁盘还是满inode 耗尽df -i找小文件重灾区清理
镜像 prune 后空间没变overlay2 层被其他镜像复用crictl images看镜像列表删更多镜像或整目录重建
清理脚本执行了但没效果路径写错或运行时换了findmnt -T <路径>修正路径判断逻辑
Pod 频繁被驱逐驱逐阈值设置过紧kubectl describe node看 Conditions调整 evictionHard
日志轮转不生效应用持有句柄未重开lsof -p <pid> | grep log配置 copytruncate 或发信号
节点 DiskPressure 不消失清理后 kubelet 未及时更新状态kubectl describe node看磁盘条件等待 10 秒刷新或重启 kubelet
卷目录删不掉挂载点残留mount | grep kubelet先 umount 再删

du和df不一致是最高频的问题,我再强调一遍处理逻辑。第一步用lsof | grep deleted找出持有句柄的进程;第二步判断这个进程能不能重启,能重启就重启,不能重启就看它持有的是日志还是数据——日志就 truncate 对应的文件路径(虽然目录项没了,但可以通过/proc/<pid>/fd找到),数据文件就联系应用方处理。

# 通过 /proc 找到被删除文件的句柄并清空 ls -l /proc/<pid>/fd | grep deleted truncate -s 0 /proc/<pid>/fd/<fd-number>

这个操作有点黑魔法,但确实管用:/proc/<pid>/fd/<n>是一个指向已删除文件的符号链接,truncate它可以释放空间而不影响进程写入。单位里用之前最好在测试环境验证一遍,别上来就对生产进程操作。

6.2 镜像层复用:prune 之后空间没降的真相

这个坑值得单独讲。很多人发现crictl rmi --prune执行完之后镜像列表少了一堆,但磁盘占用几乎没变。原因是 overlayfs 的镜像层是共享的:多个镜像 tag 可能共用同一批底层 layer,删掉某个 tag 只是删了引用,只要还有别的镜像用着这些 layer,数据块就不会释放。

验证方法是对比 prune 前后的镜像数量和数据目录大小:

# prune 前 crictl images | wc -l du -sh /var/lib/containerd/io.containerd.content.v1.content # prune 后 crictl images | wc -l du -sh /var/lib/containerd/io.containerd.content.v1.content

如果镜像数量下降明显但目录大小没变,那就是层复用导致的。这种情况下要真正腾空间,得删到"共用层的所有镜像都不剩"才行。极端情况下就得用crictl rmi指定镜像 ID 逐个删,或者干脆停掉运行时清空数据目录重建——但后者意味着节点上所有容器都要重启,只适合在可接受的维护窗口做。

注意:清空/var/lib/containerd会丢掉节点上所有镜像和容器。容器重启后会被重新调度、重新拉镜像,对于无状态服务问题不大,但有本地存储的 Pod 会丢数据。执行前务必确认节点上没有这类 Pod,或者先把它们迁移走。

6.3 几个只有踩过才知道的细节

细节一:清理时机比清理动作更重要。我见过清理脚本在业务高峰期自动触发,du扫描把磁盘 IO 打满,业务 RT 直接翻倍。所有清理动作都应该限制在低峰期,或者用ionice降低优先级。这个前面提过,但在自动化脚本里特别容易忘。

细节二:kubelet 的磁盘状态有延迟。清理完磁盘,kubectl describe node里的 DiskPressure 条件可能还要十几秒到一分钟才更新。这期间新 Pod 依然会被拒绝调度。不要以为清理没用,等一等或者看 kubelet 日志journalctl -u kubelet -f确认状态刷新。

细节三:containerd 的 content store 不会立刻回收。即使镜像删了,content store 里的 blob 可能因为 GC 延迟还占着空间。containerd 默认的 GC 是有间隔的,急的时候可以手动触发:

# 查看 containerd 的 GC 状态 ctr -n k8s.io content ls | wc -l

正常情况下等待几分钟会自己回收,不用强行干预。

细节四:节点盘和镜像盘分开挂载时,kubelet 的参数要分开配。如果imagefs和nodefs在同一个盘,两个阈值实际作用于同一文件系统;如果分开,nodefs.available只管系统盘,imagefs.available只管镜像盘,配置时要按各自容量重新算百分比。我见过把 500G 镜像盘按 10% 配,结果 50G 余量长期闲置,而旁边的系统盘早就快满了——阈值没跟着挂载结构调整,等于白配。

细节五:清理日志之前先确认日志有没有被采集走。如果集群里跑着日志采集组件(比如各种日志 agent),本地日志只是缓冲,删了不影响;如果没跑采集,本地日志就是唯一副本,删之前最好先归档到对象存储或者至少确认没有排障需求。这个判断在应急时很容易被忽略,事后要查问题时才发现日志没了。

细节六:给清理脚本加锁。多个清理任务同时跑(比如 DaemonSet 和 CronJob 撞车)会互相干扰,甚至删到对方正在处理的文件。用flock加个简单的文件锁就能避免:

exec 9>/var/run/disk-clean.lock flock -n 9 || exit 0

这个几行代码的小事,在清理任务多起来之后能省掉很多莫名其妙的问题。

7. 最后分享几条我自己的实践体会

磁盘清理这件事,做久了会发现它其实不是技术问题而是容量治理问题。真正健康的集群不应该频繁需要手动清理,告警响起来说明前面的容量规划、参数配置、监控预警至少有一环没做到位。我现在接手新集群,第一件事就是把 kubelet 的镜像回收阈值、日志轮转参数、驱逐阈值三个配置过一遍,按节点盘的实际容量重新算百分比,这比事后救火省心太多。

另一条体会是清理动作要可审计。每个清理脚本都记录清楚删了什么、删了多少、什么时候删的,日志统一收集。出了数据丢失的问题能快速回溯,也能从清理记录里看出哪些目录在持续增长,反过来指导容量规划。我见过太多团队清理脚本跑了两年,问起来删了啥没人说得清,等到某天发现某个重要日志被清掉了才开始追查。

还有一个思路是从源头减少磁盘增长,而不是一味清理。镜像层数能不能压?基础镜像能不能统一?日志级别在发布时能不能固定在 INFO?emptyDir 的使用有没有加 sizeLimit?这些上游的约束做好了,下游的清理压力会小一个数量级。清理是兜底,不是目标,这个定位想清楚了,整个节点的磁盘治理思路就顺了。

返回列表