周六下午三点多,线上监控突然开始刷屏,网关层大面积返回502 Bad Gateway。刚开始页面还能打开,但用户点任何按钮都失败,后端服务集体表现出"半死不活"的状态。我在K8S集群里排查这个502错误时,先查了Ingress、查了Service、查了后端Pod状态,绕了将近二十分钟才发现,问题根本不在应用层,而是一个节点的磁盘空间不够用了,kubelet已经进入DiskPressure状态,新Pod调度不上去,后端副本数严重不足,网关自然疯狂报502。
这个案例非常有代表性:502是表象,节点磁盘空间是根因,中间隔着Ingress、Service、调度器好几层。这篇文章把这次排除过程完整复盘一遍,包括第一直觉怎么带偏我、哪两条线索把方向拉回来、定位磁盘占用大户的完整链路、清理和根治方案,以及最后怎么给节点磁盘加上"保险丝"。无论你是刚开始接触K8S,还是正在被线上502折磨的运维、开发、SRE,这篇文章应该能帮你少走不少弯路。
1. 502报警那一刻,我先怀疑了Ingress而不是磁盘
1.1 502在K8S里最常见的几种来源
502 Bad Gateway这个状态码很迷惑人,它出现在网关这一层,给人的第一感觉是"网关和后端之间出了问题"。在Kubernetes体系里,前端流量经过的路径通常是:LB → Ingress Controller → Service → Endpoint → Pod。502可能出现在这条链路的任何一环,常见原因大概有几类:
- Ingress规则配置错误:比如域名对应的Service名写错了,或者Ingress Controller自身Pod挂了。
- Service的Selector匹配不到Pod:后端Service指向的标签和Pod实际标签对不上,导致Endpoint为空。
- 后端Pod未就绪:readinessProbe失败,Pod虽然是Running状态,但kubelet不把流量调度过去。
- 后端Pod反复重启或数量不足:代码bug导致OOMKilled、健康检查失败,或者副本数根本不够。
- 节点级别的资源问题:CPU、内存、磁盘、网络异常导致Pod被驱逐、无法调度,这是最容易被忽略的一类。
大部分人的排查顺序,包括我自己,都是先查Ingress配置、再看Service和Pod,很少有人一上来就怀疑"节点磁盘满了"。因为磁盘问题平时暴露得少,它在K8S网络链路里属于"最底层的基础设施故障",用户侧的502根本不会直接提示"磁盘空间不足"。
1.2 我的误判实录:Ingress、Service、Pod查了个遍,毫无破绽
我的第一轮排查动作非常标准,甚至有点机械化:
kubectl get ingress -A kubectl get svc -A kubectl get pods -A -o wide kubectl get endpoints -A结果是:Ingress全部正常,域名、Service名、注解都看不出毛病;Service的Endpoints也存在,说明Selector没有失配;现有Pod的状态大部分显示Running,但有一部分新Pod一直Pending在那边,调度不起来。
我当时心里想着"三分靠看Pod状态,还得去翻Ingress Controller日志",于是执行:
kubectl logs -f -n ingress-nginx deployment/ingress-nginx-controller日志里大量"connect() failed (111: Connection refused)"。这个信号非常容易让人继续往"后端服务和Pod连接不上"的方向想。但实际上,连接被拒是因为后端Pod副本数已经严重不足,而副本数不足的根源是"新Pod调度不上去"。
真正的转折点是执行这条命令:
kubectl describe pod 卡住的Pod名称 -n 业务命名空间Events里有这么一句话:
Warning FailedScheduling 3m27s default-scheduler 0/3 nodes are available: 1 Insufficient cpu, 2 Insufficient disk space.看到"Insufficient disk space"那一刻我才反应过来,问题出在节点文件系统上,而不是应用层。现在回看,真正有价值的第一条线索,其实在Pod的调度事件里早就有了,只是我一开始没有去看卡住的Pod,而是一直在查老的、已经跑起来的Pod。
2. 两条反常线索把矛头指向节点文件系统
2.1 第一条线索:节点Conditions里的DiskPressure
确认磁盘相关之前,我快速对所有节点做了一次"体检":
kubectl describe node node-01输出中Conditions部分出现:
Conditions: Type Status LastHeartbeatTime... MemoryPressure False ... DiskPressure True ... PIDPressure False ... Ready True ...注意,这里有个非常诡异的地方:节点的Ready状态还是True,但DiskPressure已经是True了。也就是说,节点的Kubelet还没完全"认怂",API Server跟它通信仍然正常,实际上它已经处于压力状态,不会再接受新的Pod调度了。
Kubelet内部有个eviction manager,会周期性检查节点资源。磁盘这块它重点看两类分区:
- nodefs:/var/lib/kubelet所在的分区,主要存放Pod卷、容器日志、EmptyDir数据。
- imagefs:容器镜像存储所在的分区,容器运行时(containerd或docker)把镜像写在这里。
当DiskPressure变成True,kubelet按优先级驱逐既能被驱逐的Pod,并且调度器会自动跳过这个节点。最直接的影响就是:新PodPending,而老Pod如果因为探针失败或者重启,也不会再被拉起,最终导致服务副本数跌到阈值以下,Ingress后端没有足够的可用Pod,502就出现了。
2.2 第二条线索:运行中的组件开始写日志失败
另一条反常线索出现在正在运行的组件上。我当时发现,不只业务Pod,连coredns、kube-proxy这类系统组件的日志也开始不正常:
kubectl logs -n kube-system pod/coredns-xxxx输出里开始出现:
No space left on device failed to write log entry: no space left on device这个特征非常典型:配置没变、选择器没动、"业务逻辑没坏",但底层系统在报I/O错误。容器写日志写不进宿主机分区,Kubernetes节点上的"文件系统空间"这个资源先耗尽了。业务代码本身没问题,是承载它的环境出了问题。
我当时在节点上随手执行:
df -h瞬间确认:
Filesystem Size Used Avail Use% Mounted on /dev/vda1 100G 99G 1.0G 99% /根分区已经逼近100%。到这里,两条线索已经合流:一条是调度器说"Insufficient disk space",另一条是系统组件报"no space left on device",都指向同一个结论——节点磁盘空间不足,根因显然是磁盘。
2.3 把线索串起来:这种502的本质是"节点胖死了,而不是Pod病了"
排查到这里,我对整个故障链路有了一个清晰认识:
- 某节点磁盘使用率逼近100%。
- kubelet触发DiskPressure,调度器将该节点标记为不可调度。
- 新增Pod副本无法调度,同时部分存量Pod因为探针失败或OOM被驱逐。
- 后端Service可用的Endpoints数量下降,达不到副本数要求。
- Ingress Controller转发请求时,后端连接失败,返回502。
- 因为多个服务都挤在同一个问题节点上,故障范围进一步扩大。
如果只是盯着Ingress和Pod,很容易陷入"哪里看起来疼就查哪里"的怪圈。实际上,这次事故的根因,是节点的文件系统打满了。K8S控制面还活着,kubectl命令能正常返回,所以"控制面健康"不等于"节点健康"。排障不能只看K8S对象状态,节点本身的系统资源从一开始就应该纳入检查范围。
3. 磁盘满的定位:别急着rm,先回答"谁把空间吃了"
3.1 先看"满在哪",还是看"剩多少"
确认磁盘空间不足之后,很多人第一反应是"删文件"。但先别急着执行rm -rf,你得先搞清楚两件事:哪个分区满了,以及这个分区上到底什么目录在占空间。
在K8S节点上,分区规划有讲究。最常见的现象是根分区和镜像存储分区共用一个大分区,也就是/nodefs和/imagefs不分家。遇到这种情况,清理日志、镜像、临时文件都在同一个地方动手,相对集中。但也有的机器单独把/var/lib/docker或/var/lib/containerd挂到独立数据盘,需要分开看。
第一步必做命令:
df -h df -idf -h看空间,df -i看inode使用率。两个都要看,下面细说。
我这次的情况是根分区99%被占满,而数据盘还比较空,说明问题集中在系统分区上:日志、容器存储、镜像缓存这些基本都在根分区里。
3.2 用du从根目录一层层"剥洋葱"
定位大目录的核心工具是du,关键是按大小排序,一层层往下找。我在节点上执行:
du -sh /var/log/* 2>/dev/null | sort -rh | head -30 du -sh /var/lib/* 2>/dev/null | sort -rh | head -30 du -sh /var/lib/containerd/* 2>/dev/null | sort -rh | head -30从输出看,攒出来的"空间杀手"非常典型:
- /var/log/journal占了约31GB,systemd的journal日志在长期不清理的情况下会不断膨胀。
- /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs目录占了大约120GB,这里面有容器镜像的读写层、快照层,包括悬空镜像和构建缓存。
- /var/log/pods和/var/lib/kubelet下的Pod标准输出日志大约占了45GB,这部分就是容器打出来的stdout/stderr落盘结果。
- /tmp里有十几个GB的临时文件,部分来自业务侧解压包、测试脚本。
一套组合拳打下来,基本能锁定"谁最肥"。
一个经验:不要在/根目录直接du -sh *,那样会扫到/proc、/sys这些虚拟文件系统,命令卡半天不说,结果还没参考价值。要从/var/log、/var/lib这些真实落盘目录下手。
3.3 还有个阴险角色:inode耗尽
如果在排查中只看了df -h,没看df -i,很可能踩到一个更隐蔽的坑:空间还有剩余,但文件系统已无法创建新文件。这就是inode耗尽。
每个文件、目录都会占用一个inode,inode表是有限的。当目录里堆积了大量小文件,比如几十万个几KB的临时文件,inode用完了,任何创建文件的操作都会失败,哪怕磁盘还有几十GB空闲。容器启动写日志、kubelet写状态、应用初始化都可能直接报错。
遇到这种情况,排查命令要换成:
df -i for dir in /var/log /tmp /var/lib/containerd; do echo "$dir: $(find $dir -type f 2>/dev/null | wc -l) files" done我这次虽然没有彻底耗完inode,但那个节点的/tmp和/var/log/syslog目录下已经堆了几十万个碎文件,这本身就是一个巨大的隐患。如果只清理空间不清理文件数量,过一阵子又会爆发另一种形式的"磁盘满"。
3.4 为什么不能直接rm -rf整个目录
清理的时候最容易出现的问题,是有人图省事,直接rm -rf /var/lib/containerd或者/var/log/pods。千万别这么干。
容器运行时和kubelet可能有打开的文件句柄指向这些目录下面的文件,直接删掉后,空间并不会立刻释放,因为进程还握着句柄。更危险的是,如果删掉了镜像的元数据目录,整个节点的容器镜像就"失联"了,kubelet可能直接暴走,最坏情况是大量Pod被重建或者节点NotReady。
正确的处理方式是:先停止相关的Pod或让对应容器滚动重建,让句柄释放,再删除文件;或者对日志文件使用truncate -s 0而不是rm;对于镜像和容器,必须用crictl/docker命令来清理,而不是直接碰/var/lib/containerd下的目录。
4. 清理与根治:删文件只是止血,日志轮转才是续命
4.1 止血三板斧:journal、悬空镜像、容器日志
我当时的操作顺序是"先止血、再根治"。所谓止血,就是在最短时间内把空间释放回安全水位,让新Pod能调度上去,优先恢复业务。
第一板斧,压缩systemd journal日志:
journalctl --vacuum-size=500M journalctl --vacuum-time=7d这样journal会保留最近7天的日志,总量控制在500MB左右。执行完,/var/log/journal从31GB缩到了几百MB,瞬间释放30GB左右。
第二板斧,清理悬空镜像和构建缓存。这次节点用的运行时是containerd,所以用crictl:
crictl rmi --prune这个命令会删除所有没被容器使用的镜像。不用太担心删错,因为正在被Pod使用的镜像是处于引用状态的,crictl会保留它们,执行前也可以先看看:
crictl images如果是Docker运行时,对应的命令是:
docker image prune -f docker system prune -f第三板斧,处理容器日志。容器的标准输出日志占据的那几十GB,不能直接rm,最稳妥的释放方式是truncate:
truncate -s 0 /var/lib/docker/containers/*/*-json.log在containerd环境,容器标准输出通常落在/var/log/pods目录下,可以配合kubelet使用的日志目录,找到对应业务的日志文件,同样用truncate处理:
truncate -s 0 /var/log/pods/命名空间_服务名_xxxx/容器名/0.log三条命令跑完,再执行df -h,磁盘使用率已经从99%降到了60%左右。调度器和kubelet很快就会重新评估节点状态,新Pod也会慢慢被调度上来。
4.2 根治:给运行时日志加"循环阀"
止血只是把现有的垃圾清掉,如果不加限制,过几周磁盘又会满。所以第二步是给日志加轮转策略,避免日志无限增长。这里分两种运行时来看。
containerd的日志轮转,在/etc/containerd/config.toml里可以配置:
version = 2 [plugins."io.containerd.grpc.v1.cri"] [plugins."io.containerd.grpc.v1.cri".containerd] default_runtime_name = "runc" max_container_log_line_size = -1 [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".log] max_size = 104857600 max_file = 3max_size的单位是字节,104857600就是100MB;max_file表示保留3个文件。这样单个容器日志最多到300MB,剩下的老日志自动清掉。
Docker运行时,改/etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } }改完记得重启运行时:
systemctl restart containerd systemctl restart docker注意,重启containerd/docker的影响面是节点上的所有容器,最好在业务低峰期操作,或者分批处理多个节点。如果节点上已经有大量在跑的容器,重启会让它们全部重启一次,要评估好业务容忍度。
另外,对于已经堆积的日志,我后来写了一个简单的定时truncate脚本,每天凌晨清理一次超过一定大小的Pod日志文件,算是"轮转策略"落地前的过渡方案。脚本逻辑不复杂,就是遍历/var/log/pods下超过200MB的.log文件,执行truncate -s 0。这个方案虽然粗暴,但在生产环境很实用。
4.3 复盘:为什么磁盘会在不知不觉中被打满
这次故障一共造成了大约40分钟的线上502,事后复盘时间线,真相很清楚:
- 业务侧有一个模块在上线后开始疯狂打印错误日志,错误日志没有走日志平台,而是直接打到标准输出。
- 容器运行时保持了默认配置,Docker/containerd的json-file日志没有限制大小,于是日志一路增长。
- 节点上连续几轮发布产生了大量悬空镜像和构建缓存,没有人定期清理。
- 系统分区只有100GB,日志、镜像、临时文件挤在一起,最终把空间吃满。
这四件事单独拎出来,每一件都不会立刻致命,但叠在一起,就成了一颗定时炸弹。更麻烦的是,CPU和内存告警很常见,磁盘使用率告警往往被当作"小事",很多集群甚至根本没有配磁盘告警。等到kubelet开始驱逐Pod、集群大面积报502,才发现磁盘已经满了。
5. 这次的教训:给磁盘装上"监控+预警"双保险
5.1 Prometheus里的核心指标与告警阈值设置
吃一堑长一智,这次故障之后,我干的第一件事就是补齐磁盘监控。
在Prometheus生态里,最常用的指标是node_exporter提供的:
node_filesystem_avail_bytes node_filesystem_size_bytes node_filesystem_files_free node_filesystem_files前两个算空间使用率,后两个算inode使用率。
告警规则可以这样写:
groups: - name: node-disk-alerts rules: - alert: NodeDiskUsageHigh expr: (1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"})) * 100 > 85 for: 5m labels: severity: warning annotations: summary: "节点磁盘使用率超过85%" description: "节点 {{ $labels.instance }} 磁盘使用率超过85%,当前使用率 {{ $value }}%" - alert: NodeDiskUsageCritical expr: (1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlayf"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"})) * 100 > 92 for: 2m labels: severity: critical annotations: summary: "节点磁盘使用率超过92%" description: "节点 {{ $labels.instance }} 磁盘使用率超过92%,接近触发节点驱逐阈值,请立即处理"为什么阈值设在85%和92%,而不是95%?一个原因是kubelet的eviction hard阈值,默认的imagefs.available是15%,nodefs.available是10%。如果节点DiskPressure已经触发,系统级告警可能已经被事件淹没了。提前到85%告警,意味着我们还有至少5%-10%的余量去处理临时文件、镜像、日志,而不是等着kubelet开始驱逐Pod。另一个原因是,磁盘上除了可清理的内容,还有运行中的容器、正在写入的日志,这些是不能动的,必须留出"安全缓冲带"。
inode的告警也要单独配:
- alert: NodeInodeUsageHigh expr: (1 - (node_filesystem_files_free{mountpoint="/"} / node_filesystem_files{mountpoint="/"})) * 100 > 80 for: 5m labels: severity: warning5.2 一个简单的应急自动化脚本
监控只能提醒,真正处理还是需要动作。为了避免下次再出现"半夜磁盘满导致502",我写了一个轻量级的应急脚本,部署在每台节点上,每天凌晨定时跑一次。核心逻辑很简单:
#!/bin/bash # 磁盘使用率超过90%才执行清理 threshold=90 usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') if [ "$usage" -lt "$threshold" ]; then exit 0 fi # 1. 清理journal journalctl --vacuum-size=500M --vacuum-time=7d 2>/dev/null # 2. 清理containerd悬空镜像(如果是docker则用docker image prune) crictl rmi --prune 2>/dev/null # 3. truncate容器日志(找大于200MB的日志) find /var/log/pods -type f -name "*.log" -size +200M -exec truncate -s 0 {} \; 2>/dev/null注意,这个脚本只做"紧急释放空间"的动作,不做高危操作,比如不删除数据卷、不碰etcd目录。常驻的服务日志文件用truncate而不是rm,避免句柄问题。这个脚本配合Prometheus告警,能保证即使糊里糊涂忘记处理,磁盘也不会真的满到触发驱逐。
5.3 日常巡检清单和发布流程的配套
最后,我把这次事故沉淀成了几条日常机制:
巡检清单里必须有三项:
df -h # 空间使用率 df -i # inode使用率 journalctl --disk-usage最好是每台节点每周跑一次,或者接入巡检平台。另外,crictl images | wc -l和docker image ls -q | wc -l能反映悬空镜像积累速度,如果数量增长异常,就该检查发布流程里是否缺少镜像清理环节。
发布流程里加一个"镜像回收"步骤。每次CI/CD构建完镜像推到仓库后,要顺手清理构建节点上产生的缓存和悬空镜像,避免每个开发分支的镜像都残留在集群节点上。
节点水位分级。我给集群节点定了三个水位线:60%以下是安全,60%-85%需要关注,85%以上必须处置。一旦达到85%以上,就用上面的应急脚本处理,把日志、镜像、临时文件清一遍,不让磁盘有机会走到触发驱逐的临界点。
这次故障之后,我把我们集群里所有节点的磁盘监控补齐,把日志轮转配置滚动更新了一遍,又加了应急清理脚本。说实话,这类故障不复杂,但发现的过程容易被502这个"烟雾弹"带偏。希望你遇到类似现象时,能少走几个弯路,直接往节点磁盘这边多看一眼。