生产环境Docker连环故障:OOM、磁盘满与容器启动失败排障实战
发布时间:2026/9/14 21:01:47 作者:尧图编辑部 阅读量:1,286

凌晨三点被一连串告警短信吵醒这种事干运维久了谁都会遇到几次。更难受的是那次我面对的是一口气三个故障同时冒出来一个Java容器直接被内核OOM Kill掉整台机器的磁盘被日志和镜像层塞到100%然后剩下几个容器怎么启动都起不来。三者撞在一起的时候你甚至连“先救哪个”都要斟酌半天因为磁盘满了这件事会让docker start、docker exec、甚至docker logs这种最基础的操作全部卡死。这期内容我就把这套“生产环境Docker三连击”的完整排障过程整理出来OOM Killed背后到底是cgroup还是内核在发力磁盘是怎么被Overlay2和日志驱动一点一点吃掉的以及容器起不来时到底该从哪个维度去查。文章里所有命令都是我实际敲过、验证过的也附带了针对不同故障等级的应对顺序。如果你负责的服务器上也跑着Docker容器这篇内容应该能帮你省掉不少半夜头皮发麻的时间。1. 凌晨3点那场连环故障OOM只是引信真正要命的是连锁反应那天晚上监控平台先是飘红了一条内存告警紧接着另一条磁盘告警也来了等我想通过SSH上去看的时候发现某个核心业务的容器已经处于Exited (137)状态。当时第一反应是“内存不够Java进程被系统杀了”但排查到一半发现情况远比这个复杂磁盘被打满之后Docker的守护进程和容器运行时都开始出现I/O错误另外几个本来还在运行的容器也开始出现健康检查失败甚至docker-compose restart之后直接报OCI runtime start failed。这种连环故障在生产环境里其实非常典型因为它们之间天然存在因果链。我梳理了一下实际情况OOM Killed容器内存使用超过了docker run或Kubernetes Pod里设置的内存Limit触发cgroup级别的内存回收最后被内核OOM Killer选中杀掉。磁盘撑爆容器日志文件、Overlay2的镜像层/容器层、未清理的悬空镜像和构建缓存共同把根分区或Docker数据目录所在分区填满。容器起不来磁盘满会导致Docker无法写元数据容器运行目录如/var/lib/docker/containers里需要新建或者写入状态文件时报no space left on device即使勉强启动过载状态下进程也可能再次被杀形成死循环。这个三角关系的可怕之处在于只要磁盘满这个状态不解除你就算给内存扩容也白搭。所以我后来形成了一套固定的应对顺序优先保磁盘再解内存最后处理容器启动。整个过程里的每一步其实都能提炼成相对固定的排查动作。这段排序逻辑在实战中非常关键建议直接背下来2. OOM Killed为什么内核把你的Java容器杀了还一脸无辜内存告警和容器退出摆在面前时第一步绝对不是急着扩容或者调整JVM参数而是先确认到底是谁杀死了进程。很多朋友一看Exit Code 137就条件反射觉得是“内存超了”但137这个退出码的含义是128 9也就是进程收到了SIGKILL信号。这其中有可能是内核OOM Killer干的也有可能是你自己的编排系统比如Kubernetes或外部脚本干的。不先确认来源后面的优化方向就可能跑偏。2.1 所以那个“kill process”到底是谁干的在纯Docker环境里最直接的排查方式是看系统日志。以systemd作为init系统的发行版为例内核杀死进程后会留下明确的记录dmesg -T | grep -i -E out of memory|oom|killed process我那天看到的关键信息长这样Mar 12 03:17:35 webserver kernel: java invoked oom-killer: gfp_mask0x100cca(GFP_HIGHUSER_MOVABLE), order0, oom_score_adj0 Mar 12 03:17:35 webserver kernel: java cpusetwebservice mems_allowed0 Mar 12 03:17:35 webserver kernel: Memory cgroup out of memory: Killed process 3314 (java) total-vm:5242880kB, anon-rss:2097152kB, file-rss:0kB, shmem-rss:0kB这里面有个细节很关键日志写的是Memory cgroup out of memory而不是普通的内核全局OOM。这意味着触发点是你给容器设置的--memory限制而不是宿主机物理内存真的耗尽了。换句话说宿主机可能还有空闲内存但这个容器自己在cgroup范围内“爆了”。如果日志里没有“Memory cgroup”字样而是单纯的全局OOM那就要去看宿主机的整体内存水位和别的进程占用情况了。2.2 先定位再定责搞清楚哪个进程、哪个层级在吃内存定位到是哪一类OOM之后还得看具体是哪个进程吃掉了内存。常见场景是Java应用容器里跑着Spring Boot服务堆内存、元空间、线程栈、直接内存各自占一块。我曾经遇到过一个典型案例容器--memory限制设为2GB但JVM启动参数直接用了-Xmx3g结果Java进程还没跑到峰值就触发了cgroup限制被内核干掉。这种情况其实不完全是Docker的问题更多是JVM启动参数没有“认”容器限制。从Docker层面做复核可以查看容器退出状态和当时的内存峰值docker inspect container_id --format{{.State.OOMKilled}} {{.State.ExitCode}} {{.HostConfig.Memory}}OOMKilled字段如果返回true基本可以实锤是内存限制导致。想进一步确认真实内存峰值建议用docker stats监控或者在容器内使用cat /sys/fs/cgroup/memory/memory.usage_in_bytes老版本路径或/sys/fs/cgroup/memory.currentcgroup v2路径来读取。# cgroup v2 cat /sys/fs/cgroup/memory.current # cgroup v1老版本环境多用这个 cat /sys/fs/cgroup/memory/memory.usage_in_bytes这里顺便提醒一下cgroup v2和v1的内存接口路径完全不同很多网上复制来的命令在CentOS 7这类老系统上根本跑不通先确认你用的到底是哪一版再用。2.3 调容量和调参数哪个该先做定位到是容器内存限制太低之后不要急着把--memory往上提。正确做法是先看这个服务的“正常水位”和“峰值水位”。运维里常说“容量规划不能用平均值要用P99/P99.9的峰值”这句话放在容器内存上特别合适。我是这样操作的如果有Prometheus和cAdvisor或云厂商监控拉取目标容器近7天的内存使用率趋势以P95或P99的峰值乘以1.2到1.5的安全系数作为新Limit如果容器里跑的是Java优先确认JVM是否开启了容器感知。JDK 8u191之后包括8u191和JDK 10以上的版本JVM默认支持-XX:UseContainerSupport可以自动识别容器限制并调整默认堆大小。但如果你在启动脚本里手动指定了-Xmx这个自动能力就被覆盖了。我在处理Java容器时更推荐用相对比例的方式java -XX:MaxRAMPercentage75.0 -XX:InitialRAMPercentage50.0 -jar app.jar这样JVM就会根据容器Limit动态计算堆内存而不是写死一个绝对值。需要注意的一个细节是MaxRAMPercentage计算的基础是“容器可见内存”如果你同时设置了--memory又没设置--memory-swap默认情况下--memory-swap是--memory的两倍容器可用的总内存和swap区域也会影响JVM识别到的总量。为了简化问题我在生产环境通常只设--memory并配合--memory-swap为相同值把swap覆盖掉避免进程误判。docker run -d --name app \ --memory2g \ --memory-swap2g \ -e JAVA_OPTS-XX:MaxRAMPercentage75.0 \ app-image:v1.0如果确认服务本身需要的内存远大于当前Node可以供应的量那就不是调一个容器参数能解决的了优先考虑增加节点资源或者把服务拆分这才是治本的思路。3. 磁盘撑爆Overlay2和日志驱动是真正的大户第二连击是磁盘被撑爆。很多人遇到No space left on device时第一反应是删日志但实际清完之后没过多久又满了。原因很简单Docker占磁盘的空间并不只有“日志”这一项镜像层、容器可写层、构建缓存、挂载的volume全都在消耗空间。要找到磁盘是怎么一点点被吃完的得下沉到Docker自己的存储视角来查。3.1 自查清单从df到docker system df的完整链路我习惯按这个顺序做磁盘排查先看宿主机整体分区情况df -hT重点观察根分区/和Docker数据目录所在分区通常是/var/lib/docker所在挂载点的使用率。如果这里已经100%就该立即定位大文件方向了别再继续做无意义的重启。然后进入Docker自身的视角docker system df这个命令会显示四类内容Images、Containers、Local Volumes、Build Cache每一类都带SIZE和RECLAIMABLE列。实战里Build Cache经常是最容易忽视的“磁盘黑洞”特别是CI/CD频繁构建镜像的机器构建缓存能吃掉几十GB空间。docker system df看一眼就能知道是镜像层占大头还是悬空资源占大头。提示docker system df的RECLAIMABLE表示可回收份额代表这部分空间可以通过清理动作释放。如果这列数字巨大那磁盘还有救如果全是SIZE大但RECLAIMABLE为0说明是正在被容器使用的镜像层或数据卷清理需要更谨慎。再往下走就是定位哪些镜像和容器体积最大# 按镜像大小排序 docker images --format table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.ID}} | sort -k2 -h # 按容器可写层大小排序 docker ps -s --format table {{.Names}}\t{{.Size}}docker ps的SIZE列其实包含两部分内容容器可写层体积和它引用的镜像大小中间用virtual size分隔。如果你想精确看每个容器的可写层占了多少可以用du -sh /var/lib/docker/containers/*/ 2/dev/null | sort -rh | head -20但这里有个前置条件你需要知道Docker的数据目录和存储驱动是什么。查询方式docker info | grep -iE Docker Root Dir|Storage Driver大多数现代Linux环境默认是overlay2容器数据目录默认是/var/lib/docker。Overlay2的结构里每个容器的可写层在/var/lib/docker/overlay2/container_id/diff日志文件在/var/lib/docker/containers/container_id/*-json.log这两个目录往往是空间消耗最集中的区域。3.2 日志是“温水煮青蛙”的元凶磁盘打满的案例里排第一的永远是容器日志。容器内应用如果疯狂打日志并且Docker的日志驱动是默认的json-file那么日志文件会单文件不断增长。默认情况下json-file日志驱动没有大小限制它只会在docker logs时读取不会自动rotate。一个流量稍大的前端网关一天写几个GB日志太正常了。快速查看所有日志文件大小find /var/lib/docker/containers/ -name *-json.log -exec ls -lh {} \; | sort -k5 -rh | head -20我遇到过一次极端情况某个服务的日志文件单文件已经涨到26GB直接把数据盘打满。当时服务的log4j配置在容器里只输出到stdout没有直接将日志写到挂载的卷里等于所有日志都流进了*-json.log而且业务方因为历史原因不能直接停服最后只能靠临时清空日志文件的方式扛过去truncate -s 0 /var/lib/docker/containers/container_id/container_id-json.log注意千万别用rm直接删除正在写文件的日志文件因为Docker守护进程会保持这个文件描述符打开删除之后空间依然不会释放。正确做法是用truncate -s 0清空内容。临时救急之后治本方案是给日志驱动加上轮转和大小限制。修改Docker守护进程配置/etc/docker/daemon.json{ log-driver: json-file, log-opts: { max-size: 100m, max-file: 5 } }改完配置后需要重启Docker才会生效而docker restart并不会让已存在容器的配置更新必须重建容器才能使用新的log-opts。这是个容易踩坑的点你明明改了daemon.json但老容器还是继续单文件涨。生产环境真要平滑应用只能等容器滚动重启或手动重建。3.3 磁盘清理的实操顺序和注意事项确认磁盘大头之后清理动作也有严格顺序不可以上来就docker system prune -a无差别删东西尤其是生产环境。我常用的清理顺序如下先删除永远用不到的悬空镜像和构建缓存docker image prune -f docker builder prune -f这两个命令只清理“悬空”和“未使用”的资源一般不会影响正在使用的容器和镜像安全系数相对较高。但docker builder prune在CI节点上可能会让下一次构建需要重新拉取依赖执行前需要告知相关人员。然后是停止的无用容器和匿名卷docker container prune -f docker volume prune -f这步对磁盘释放效率很高但一定要确认容器确实是“该删的”。我曾经见过有人在这步直接把别组正在调试用的临时容器全部清掉结局当然是赔礼道歉加恢复数据。确认无误后如果想全量回收可以用docker system prune -a --volumes这个命令会把所有未被使用的镜像、容器、网络、构建缓存、匿名卷一锅端。我建议在小范围环境先演练一遍同时明确“当前正在使用的资源不会受影响”再上生产执行。最后如果磁盘长期紧张最稳的兜底是给Docker数据目录挂独立数据盘这样即使日志炸了也不会拖垮根分区Docker服务本身还能操作给了运维人员处理空间。迁移数据目录时注意先停Docker服务、rsync目录内容、修改daemon.json里的>docker inspect container_id --format{{.State.Status}} | {{.State.OOMKilled}} | {{.State.ExitCode}}我处理过一个非常迷惑的案例服务日志里没有任何OOM记录docker inspect也显示OOMKilledfalse但容器始终是Exited (137)。最后发现是宿主机上的监控脚本检测到内存吃紧用kill -9直接清掉了占用最高的进程。那个脚本是某位同事早期图省事加的进入了定时任务却连他自己都忘了。所以排查退出的根因不要只盯Docker内部的记录宿主机上有没有类似清理脚本、systemd的MemoryMax设置、云平台层面的自动重启策略都要一起过一遍。4.2 健康检查失败容器“起了又死”的隐蔽原因单容器场景下容器进程起来后如果健康检查失败Docker并不会主动杀死容器——除非你配置了--health-cmd并配合--health-interval、--health-retries等参数且编排系统或外部watchdog在检测到unhealthy后执行了重启。这种情况在docker-compose和Kubernetes里最常见。比如我后来遇到的那个服务磁盘满导致应用写缓存目录失败进程虽然还活着但健康检查接口返回500Kubernetes的livenessProbe连续失败3次就把Pod重启了。重启之后磁盘满的问题还没解决新Pod继续在启动阶段写入失败再次被kubelet杀掉。从Docker层面看就会出现容器一直Restarting的假象。排查这种“容器起不来”时不能只看docker ps -a的状态还要配合几件事一起看用docker logs --tail 200 container_id看应用启动日志是否卡在依赖组件数据库、Redis等或磁盘I/O等待上检查健康检查命令本身是否合理比如wget探活依赖容器内有wget命令而很多精简镜像里根本没有这个二进制健康检查就会永远失败这种属于配置错误而非业务问题检查Docker事件流看容器被谁重启docker events --since 30m --until 0 --filter containercontainer_name事件流里会显示die、oom、health_status等事件。如果是oom事件导致的die又是一轮OOM排查如果是外部控制器触发的kill事件里可能直接能看到源头。4.3 当磁盘满到连Docker的元数据都写不进去时这一小节是整个三连击里最难处理的阶段磁盘满已经影响到Docker自身。你会遇到各种奇怪表现docker ps能跑但很慢docker start直接卡住docker logs提示error opening log file容器启动报failed to create task for container: failed to create shim task: OCI runtime create failed: ... no space left on device。这些错误本质上是同一个根因Docker在启动容器时需要创建目录、写状态文件、为读写层分配空间磁盘满了自然全部失败。遇到这种情况docker start再试一百次都不会成功。正确姿势是先把磁盘空间释放出一个安全水位再重新启动第一步找到空间大户并临时释放删除构建缓存docker builder prune -f清空过大的*-json.logtruncate -s 0前面提过不要rm正在写入的文件清理/var/lib/docker/tmp下Docker自己遗留的临时文件如果机器上有大型业务临时文件目录比如/tmp或应用日志目录也可以先腾出空间第二步释放完成后确认空间水位df -hT /最好释放到至少20%的空闲留出一定的余量因为启动容器时不仅需要写入容器层Docker守护进程还会记录日志、更新元数据没有余量会导致二次卡死。第三步再执行容器启动docker start container_id如果还起不来查看是否有更深的配置问题。但绝大多数场景下只要磁盘水位降下来容器就会启动成功。遇到极端情况比如Docker守护进程完全无响应可能需要systemctl restart docker但这条命令要谨慎使用它会短暂中断节点上所有容器的I/O如果节点上跑着关键服务必须先评估影响面最好在业务低峰期操作。5. 生产环境的三道防线从“救火”到“不烧起来”排障文章如果只讲救火不讲防火价值会少一半。经历了那次连环故障之后我把整套Docker环境梳理了一番针对“OOM、磁盘、起不来”这三个故障分别建立了对应的防范机制。现在的运维思路不再追求故障发生后多快处理而是直接把故障发生的概率压到最低。5.1 资源限额和调度策略是底线所有新上线的容器在docker run或docker-compose.yml里都必须明确设置资源限额。这是最基础的一条红线不经过评审不允许关掉。我的常规配置长这样services: app: image: app-image:v1.0 mem_limit: 2g mem_reservation: 1g pids_limit: 512 restart: unless-stopped logging: driver: json-file options: max-size: 100m max-file: 5mem_limit是硬上限超过即触发OOM Killmem_reservation是软下限用于调度器在低内存时保留空间pids_limit限制容器内最大进程数防止线程池异常膨胀拖垮整个Node。这些参数和Kubernetes里的limits/requests语义类似关键是用起来而不能停留在文档里。另外Docker Swarm或Kubernetes这类编排工具里务必确保每个服务的limits.memory和requests.memory都有值并给每个节点预留system-reserved内存否则调度器会把Node打满然后出现“某个宿主机的所有容器集体OOM”的惨状。5.2 日志与存储层的兜底方案我在日志驱动方面的最终方案是所有容器统一使用json-filemax-size/max-file配置避免单个日志文件无限制增长对日志量特别大的服务网关、业务核心链路启用journald或fluentd驱动将日志直接送走不落在节点本地在宿主机上部署一个轻量定时任务每5分钟检查磁盘使用率超过80%自动清一轮docker builder prune超过90%自动轮转最大的容器日志文件。这个脚本我放在crontab里已经跑了大半年没有误伤过数据。如果你们公司有条件建议把Docker数据目录放到独立的大容量云盘上并且单独监控该盘的使用率而不是只盯根分区。Docker日志、镜像层和数据卷的I/O行为会让磁盘以非常快的速度消耗独立盘能有效避免“Docker把系统盘拖死导致SSH都上不去”的最坏情况。5.3 演练清单与告警阈值设计单有监控指标还不够故障要有预案预案要演练。我列一个目前团队内部在用的“三连击”演练清单你可以直接参考设计自己的演练场景模拟方法预期处理动作告警阈值建议容器OOM Killed用stress-ng --vm 4 --vm-bytes 2G在容器内压内存确认进程被cgroup杀掉后扩容或调JVM参数内存使用率连续5分钟 85% 告警磁盘即将打满用dd if/dev/zero of/tmp/diskfill bs1M count2048填充空间定位大文件、清理日志和镜像缓存磁盘使用率 80% 警告 90% 紧急告警容器反复重启在容器内故意让主进程退出或让健康检查失败分析退出码、日志、事件流修复应用或配置1分钟内重启次数 5 触发告警告警这件事有个反直觉的经验阈值设得太低容易“狼来了”疲劳设得太高又会让故障发现太晚。我个人习惯是分三级80%是Warning只通知值班群85%是Critical要求15分钟内开始处理90%以上是Page直接电话拉人。同时配合一个“磁盘增长速率”的指标——单看当前水位不够如果水位增速很猛即使当前只有50%也要提前介入因为可能半天后就满了。这个指标可以用Prometheus的deriv()函数或云监控的增长速率图来看非常值。最后再分享一个小经验碰到这类多故障叠加的情况永远记得保留“第一现场”的日志和docker inspect输出。很多时候故障处理完回头复盘才发现真正的根因藏在最初几行不起眼的日志里。每次事故都是一个套件不拆完下一个故障就会在几周后换一副面孔再来找你。我自己的习惯是把这套排障流程整理成一页A4纸贴在工位上平时也用不上但真正半夜被叫醒的时候照着走能少走很多弯路。容器化运维没有那么多玄学无非是磁盘留余量内存设限额日志有兜底排查有顺序。把这些基本功做扎实那个凌晨三点的电话迟早会变成别人给你打。