Docker容器排障不靠背命令:一套从宿主机到退出码的检查清单
我搞了快十年容器运维,摸过的故障案例少说几百个,现在遇到 Docker 相关问题,反而不太着急上手敲命令。一直以来,排障的熟练度不取决于你背了多少命令,而是你对"先确认什么、再确认什么"有一套固定的反应。很多时候,大家一上来就去翻应用日志、改配置、重启容器,折腾半天发现是宿主机的磁盘满了,或者是容器时间不同步,这类基础问题根本不在于应用层。
所以这篇文章我把自己的固定动作分享出来。标题说"核对几个点",其实就是我的一套排障前置清单。它不能帮你解决所有问题,但能帮你把大部分伪故障挡在门外,真正把时间花在值得排查的事情上。
1. 资源与环境层面:先确认宿主机动没动、饿没饿
1.1 Docker 守护进程本身活着吗
这一步听起来像废话,但我确实见过太多人忽略它。容器起不来、网络不通、volumes 挂载不上,第一反应别去翻应用,先确认 docker daemon 的状态。
如果这一行显示 Active: active (running),再看一眼是不是有大量重启记录。很多情况下服务处于 activating (auto-restart) 状态,像打地鼠一样不断拉起又崩溃,这往往说明 dockerd 启动时某个环节没过,比如存储驱动加载失败、iptables 操作被拒绝、镜像存储目录权限异常。
进一步确认守护进程状态,还可以用:
docker info 如果输出了完整的系统信息、存储驱动、容器运行时,说明 daemon 至少活着,API 是通的。如果卡住不动或报 Cannot connect to the Docker daemon,那问题就出在守护进程这一层,不用急着去看应用日志。
我碰到过一种情况是系统刚重启完,docker 服务设置的是 disabled,直接没拉起。这个问题用 journalctl -u docker --no-pager -n 50 看日志特别快,一眼能看到是正常停掉还是 crash 了。
1.2 宿主机的 CPU、内存、磁盘和水位线
容器跑不动,很多情况下不是容器本身的问题,是宿主机没资源了。
这三个命令是我每次排障必敲的:
free -h看内存是否耗尽,尤其是 swap 是否被大量占用。宿主机内存紧的时候,容器里的进程会变得异常缓慢,表现为超时、连接拒绝,但应用日志里没有报错。df -h看磁盘。Docker 的 overlay2 存储驱动和日志文件(json-file)都非常能吃空间。根分区满了以后会有一系列连锁反应,比如容器无法创建、镜像无法拉取、数据库写不进数据。df -i看 inode。这是个冷门坑,磁盘显示有空间,但 inode 用完了,照样本地文件创建失败。/var/lib/docker 目录下小文件太多时,很容易踩这个。
这三个指标对不上,后面排查再深都是白费力气。
1.3 系统时间同步:容器错乱的第一大隐形坑
容器时间跟宿主机走,宿主机跟硬件时间或者 NTP 走。一旦宿主机时间漂移,服务请求校验失败、日志时间戳乱套、定时任务乱触发,排障方向会被带偏。
检查时间和 UTC 时间,顺带看一眼时间同步服务是否正常。
System clock synchronized: yes 和 NTP service: active 这两个状态要同时满足。如果显示 NTP 服务没起来,容器内的服务调用外部 HTTPS API 时大概率会挂——证书有效期校验直接失败,表现却是"网络不通",非常迷惑人。
这个点我吃过一次大亏。有一次线上环境所有容器间的双向 TLS 握手都在报错,查了证书有效期、SAN、CA 链,折腾了一晚上,最后发现宿主机时间慢了整整八小时。从那之后,时间同步检查永远排在我的前三步。
2. 镜像与容器状态机:看懂退出码和生命周期比啥都强
2.1 容器状态总共就那么几种,先定位它现在处于哪个阶段
排查容器问题的关键在于先弄清楚容器处于哪个生命周期状态。docker ps 只能看到 running 和 exited 两类,但容器状态有六种:
| 状态 | 含义 | 典型原因 |
|---|---|---|
| created | 已创建,未启动 | 只是 docker create 了,没 start |
| running | 正在运行 | 正常 |
| paused | 已暂停 | 执行了 docker pause |
| restarting | 正在重启 | 启动失败,处于重启策略回旋中 |
| exited | 已退出 | 启动失败,或内存 OOM,或进程正常结束 |
| dead | 僵死 | 极其异常,通常需要强制清理 |
restarting 和 exited 是排障中最常见的两个状态,两者的排查方向完全不同。在 restarting 状态下的容器,要用 docker inspect 看它的 RestartCount 和 State.Status:
如果 RestartCount 不断变大,说明容器在启动脚本阶段持续失败。这时重点看日志,而不是怀疑应用内部业务逻辑。启动即崩溃的问题,大多出现在入口脚本、环境变量缺失、配置文件解析失败上。
2.2 退出码是容器递给你的一张纸条
容器退出码是整个排查链条里信息密度最高的单个字段。
常见退出码速记:
0:正常退出,进程主动结束了生命周期。如果是服务类容器一直退出,要检查是不是前台运行模式问题,很多镜像需要-it或者关联 TTY 保持前台运行。1:通用错误。程序自身逻辑失败或启动检查没通过,极需要结合日志继续查。2:参数类错误或程序内部错误,常见于解释器/运行时解析出错。127:命令找不到,容器启动脚本里某个二进制不存在,常见于 entrypoint 写错路径或镜像缺少依赖。137:被 SIGKILL 杀掉,最常见的是 OOM Kill。这个退码背后有两种情况:宿主机内存耗尽,或容器超过了 memory 限制。139:段错误,程序自身崩溃,通常是 C 扩展、底层库问题。143:被 SIGTERM 终结,一般是 docker stop 超时后系统升级信号。
中途我处理过一个经典案例:一个 Go 服务容器每三分钟退出一次,ExitCode 是 137。当时也很奇怪,明明容器没有被设置 --memory 限制,但仔细排查发现,宿主机可用内存不足,内核 OOM 机制直接随机挑进程杀掉,结果每次杀的都是这个容器进程。加了 --memory 限制后再看退出码,才真正定位到应用内存泄漏上了。
2.3 镜像本身:Tag、Digest 和语言写死
跑不起来还有一个常在盲区里的事:镜像 Tag 是否对得上。团队里最常见的是 latest 标签被更新了,某次重新 pull 之后行为跟以前完全不同。
对比 RepoDigest 比看 Tag 靠谱得多。生产环境我强烈建议直接 pin 摘要:
这只锁定具体镜像内容,不依赖 Tag 变化。
另外很多 Java、Node 应用存在语言运行时版本兼容问题。Java 17 的 class 文件在 Java 11 镜像里跑不起来,Node 18 的多阶段构建产物丢到 Node 16 基础镜像,有些原生模块会直接启动失败。这类问题在 docker build 阶段不会报错,要到运行阶段才会暴露。
3. 网络类问题排查:从端口、链路到 DNS,三个最容易走眼的点
容器网络是排障中范畴最大也最容易晕的一个层。我按性价比排序理出三个固定动作:端口是否发布、网络模式是否匹配、DNS 是否解析。
3.1 端口发布:容器里听着,不代表宿主机连着
容器内服务在 8080 端口监听,那是容器网络的地址,宿主机不能直接 localhost:8080 访问。要宿主机访问,必须做端口发布:
形式是 -p 宿主机端口:容器端口。反过来容易被人忽略:EXPOSE 8080 只是镜像里的元数据声明,不产生任何实际发布行为。很多人读 Dockerfile 里写了 EXPOSE 就以为跑起来就能从外部访问,这是很典型的误解。
验证端口到底有没有发布出去,用:
输出什么也没有,说明没发布。也可以用:
看宿主机上有没有对应监听。本地访问不了容器服务,先走这一步,别急着查应用日志。
3.2 网络模式:bridge、host、none 与自定义网络
Docker 的默认网络模式是 bridge。bridge 网络的特点就是容器有独立 IP,通过 NAT 访问外网。host 模式则是容器直接共享宿主机网络栈,好处是性能好、端口不用发布,坏处是网络隔离基本消失。
排障时我常让同事做这个自检:如果容器用了 --network host,但代码里监听的是 127.0.0.1,那么外部即使通过宿主机 IP 也访问不到服务。因为 host 模式下端口存在宿主机的网络栈里,监听地址就是容器进程自己绑定的地址。所以 code 里 localhost 的监听,就只对宿主机本机生效。
容器间互联场景下,不建自定义网络直接用默认 bridge,会发现 容器名 做 DNS 解析不通。默认 bridge 虽然支持容器间通过 IP 通信,但不支持 --link 之外的名称解析。创建自定义网络后同一网络下的容器才能直接用容器名互访:
这样 web-app 里直接访问 http://api-server:8080 就能通。我们团队为了避免"在测试环境能跑、换网络就挂"的问题,在 compose 文件里永远显式声明网络名。
3.3 DNS 与代理链路故障的伪装
容器内域名解析不通,表现起来很像外部服务挂了或者网络断了。先手动进容器内看一下:
如果容器里没有 nslookup,也可以用 getent hosts:
解析不了就查 daemon 的 DNS 配置。默认情况下 Docker 会继承宿主机 /etc/resolv.conf。宿主机用 systemd-resolved 时,/etc/resolv.conf 往往指向 127.0.0.53,这个地址在容器网络里不一定可达,结果是容器内 DNS 全挂。
解决办法是在启动命令里显式指定 DNS:
或者修改 daemon.json,全局加上 "dns" 配置后重启 docker。
还有一种情况是容器内访问外网要通过代理,但环境变量没传进去。HTTP_PROXY、HTTPS_PROXY、NO_PROXY 这三个变量必须显式传给容器,不然 curl 应用侧看到的响应是连接超时。排到这一步时先别怀疑服务端,得看代理链路有没有走通。
4. 资源限制与应用崩溃:为什么容器突然没了
4.1 OOM Kill:从 137 退出码开始追
容器退出码 137 几乎百分之百指向被 SIGKILL 杀掉,其中又绝大多数是和内存相关。但要分清楚两个方向:容器自身超过了限制被 kill,还是宿主机整体内存耗尽被内核找上。
先看容器当前限制:
输出 0 表示无限制。如果这里设置了 512MB,而应用实际需要 1GB,那 OOM 就很正常了。
然后看宿主机层面:
dmesg 能找到内核 OOM killer 的记录。如果出现了 oom-kill 且带容器进程 PID,说明确凿。这一步能直接区分是"容器限制"还是"宿主机资源不足"。
4.2 文件句柄和线程数:两个非常隐蔽的上限
内存和 CPU 好理解,文件句柄和线程数限制属于那种"不逼到墙角根本想不起来"的问题。
容器内 ulimit 并非默认无限:
高并发服务默认 1024 的 nofile 很快就撑爆了。表现是应用日志里疯狂报 too many open files,或者 EMFILE 错误。查宿主机的整体句柄使用率,用:
三个数字分别代表已分配、已用、最大。如果第二个数接近第三个数,那是全宿主机层面的句柄瓶颈。
线程数限制同理,--ulimit nproc 或系统全局 kernel.threads-max 不足时,进程会莫名其妙起新线程失败。这类问题最难排查之处在于:它在应用日志里几乎不留痕迹,只在某些特定高并发场景下偶发。
4.3 写 Java 和 Node 应用时常犯的容器内内存盲区
Java 应用在容器里跑是内存重灾区。很多人会在启动脚本里写:
同时容器限制设成了 1GB。这其实是两头踩线:JVM 堆就占了 1GB,还要加上 Metaspace、线程栈、JIT 编译区这些堆外内存,实际用量轻松超过 1GB。容器内存限制一触发,直接 OOM Kill。
我看到过大量 Java 服务在 Docker 里"偶发崩溃",ExitCode=137,但应用日志完全没异常。最终解决方案是把 -Xmx 降下来,保留 Heap 之外的 Headroom,或者干脆容器限制设成堆上限的 1.5 倍。
Node 也有类似问题,默认 Heap 上限老版本里是 1.4GB,新版 4GB 左右。对内存密集型服务得用 NODE_OPTIONS=--max-old-space-size=1024 来约束,不然容器限制和 V8 的分配逻辑会打架。
5. 数据持久化与权限边界:挂载卷是最能"藏雷"的地方
5.1 挂载目录权限:no permission 不是权限问题,是 UID 错位
容器内进程用指定用户运行,宿主机挂载目录的权限是宿主机的,两边默认不匹配。这是 Docker 数据卷最经典的坑。
宿主机 /host/data 属主是 root、权限 755,容器内进程是 uid=1000,那写入时铁定报 Permission denied。
处理方式有三种:
- 宿主机目录权限放宽松,
chmod 777或改属主:
-
启动容器时用
--user指定 UID,和宿主机目录属主对齐。 -
用命名卷(named volume)代替 bind mount。Docker 初始化命名卷时会自动设置属主为镜像内声明的用户。这是最省事的方式。
我习惯在 compose 里用命名卷来避免这一系列问题。特别是数据库类容器,MySQL、Postgres 镜像都会在镜像内创建数据目录并设置 owner,用命名卷能把权限问题绕开大半。
5.2 文件所有权、SELinux 与 AppArmor
RedHat/CentOS/Fedora 系宿主机上跑容器的挂载访问问题,还有一层 SELinux 因素。宿主机开启了 SELinux,但挂载的目录没有对应的类型标签时,容器进程即使 UID 匹配了也会被 SELinux 拒之门外。
日志里能看到 avc: denied 记录:
回避方式有两个:临时加 --security-opt label=disable,或者给目录打标签:
Ubuntu/Debian 系的 AppArmor 类似。默认 docker 的 AppArmor profile 对某些写入路径有约束。应用跑起来说"我明明有权限为什么写不进去",有相当一部分是这类 LSM 模块在做拦截。排查时如果所有传统权限检查都正常,记得往这个方向看。
5.3 持久化数据掉皇帝的"枯井":长期运行容器与 named volume 的坑
有一种情况很隐蔽:容器用 -v /host/data:/app/data 挂载正常,但容器内服务是软链方式写数据的。比如 /app/logs 是软链指向 /var/log/app,而挂载点是 /app,那容器目录覆盖时会把软链一起覆盖掉,写出来的数据实际落在容器可写层,容器一删全没。
排查时要进容器内确认真实写入路径:
看 df 的输出挂载点是 overlay 还是宿主机路径,一眼便知数据有没有真的落进卷里。这个问题坑过很多团队,因为所有东西看起来都正常,日志还在输出,只是重启后全部蒸发。
6. 日志定位技巧:从三千行日志里捞出关键线索
6.1 stdout 是容器日志的唯一通道
先给新手敲个警钟:Docker 里 docker logs 只能看到进程写到标准输出(stdout)和标准错误输出(stderr)的内容。应用如果写日志到文件而不输出到 stdout,docker logs 永远看不到内容。
很多镜像里的应用默认写文件日志,比如 Tomcat 写 catalina.out,Nginx 写 access.log。用 docker logs 查不到时,不要觉得是日志系统坏了,先确认应用日志有没有配置为控制台输出。通常在 Dockerfile 里会用软链或参数调整把日志重定向到 stdout:
或启动命令里加上 2>&1。
6.2 docker logs 的用法不是只有 tail
这是我用得最多的组合。但有三个阶段要留意:
- 第一阶段看启动日志:容器刚起来那几十秒的输出,能反映初始化是否成功。
- 第二阶段看错误前后的日志:用
--since定位时间窗口,比从头翻日志高效得多。 - 第三阶段看崩溃前的最后输出:容器退出后还能
docker logs <container_id>,这是第一时间能拿到的现场。
6.3 docker events:实时观察处于 Restart 状态的容器
有些容器反复重启的时间间隙非常短,docker logs 还没来得及看,容器又退出了。这时用事件流来跟踪:
可以看到这个容器的完整事件序列:start、die、start、die。配合 inspect 的 State.FinishedAt 和 State.StartedAt,能算出每次存活时长。
我见过一种模式:容器每 45 秒重启一次,日志里每次都是同一条配置文件解析失败的报错。没有事件流的辅助,眼睛盯着 docker logs 的滚动很容易漏掉这种周期性规律。
6.4 日志驱动调 json-file 时的空间问题
默认日志驱动是 json-file,日志文件无限制增长,最终吃满磁盘。这就是第一节提到的"磁盘满、容器挂"的直接来源之一。
daemon.json 里建议配置轮转:
改完重启 Docker 只对新容器生效,已有容器需要重新创建或者配置 --log-opt 单独起。排障时看磁盘突然爆满,优先查 /var/lib/docker/containers/<id>/*-json.log 的大小,这块往往是最容易被忽略的日志大头。
7. 一套可复制的排障优先级,送给刚入门的人
理了这么多检查点,收敛成一套固定动作,是我现在遇到容器问题的第一反应:
- 看宿主机资源水位:
free -h、df -h、df -i,顺手敲一下date确认时间同步。 - 确认 daemon 状态:
docker info能通,再看服务本身有没有频繁重启。 - 定位容器状态和退出码:
docker ps -a找容器,docker inspect看 ExitCode、RestartCount、OOMKilled 标记。 - 查网络三件套:
docker port看发布、进容器getent hosts看 DNS、docker network ls看模式。 - 翻日志看现场:
docker logs --tail 200,如果有周期性重启规律就结合docker events一起看。
这套链路走下来,大多数"莫名其妙"的问题都能定位到某个具体的层次。剩下真正需要深入代码级排查的问题,也已经把环境变量、网络、权限、资源这些外围因素清理干净了,排查面会小很多。
我自己有个体会:排障时最大的浪费不是排查时间长,而是方向错了还不自知。前面那些基础检查只需要几分钟,却能把整个问题的定位从"大海捞针"收敛成"井底捞针"。每多确认一个底层变量正常,后面的定位就越精确。如果你现在正卡在某一个容器问题里,先冷静下来,按这个顺序过一遍,大概率能从日志海洋里捞出那条真正有价值的线索。