
周六下午三点多线上监控突然开始刷屏网关层大面积返回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变成Truekubelet按优先级驱逐既能被驱逐的Pod并且调度器会自动跳过这个节点。最直接的影响就是新PodPending而老Pod如果因为探针失败或者重启也不会再被拉起最终导致服务副本数跌到阈值以下Ingress后端没有足够的可用Pod502就出现了。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占了约31GBsystemd的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耗尽。每个文件、目录都会占用一个inodeinode表是有限的。当目录里堆积了大量小文件比如几十万个几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-size500M journalctl --vacuum-time7d这样journal会保留最近7天的日志总量控制在500MB左右。执行完/var/log/journal从31GB缩到了几百MB瞬间释放30GB左右。第二板斧清理悬空镜像和构建缓存。这次节点用的运行时是containerd所以用crictlcrictl rmi --prune这个命令会删除所有没被容器使用的镜像。不用太担心删错因为正在被Pod使用的镜像是处于引用状态的crictl会保留它们执行前也可以先看看crictl images如果是Docker运行时对应的命令是docker image prune -f docker system prune -f第三板斧处理容器日志。容器的标准输出日志占据的那几十GB不能直接rm最稳妥的释放方式是truncatetruncate -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就是100MBmax_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%才执行清理 threshold90 usage$(df -h / | awk NR2 {print $5} | tr -d %) if [ $usage -lt $threshold ]; then exit 0 fi # 1. 清理journal journalctl --vacuum-size500M --vacuum-time7d 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这个烟雾弹带偏。希望你遇到类似现象时能少走几个弯路直接往节点磁盘这边多看一眼。