有段时间没更新这个系列了,正好最近又碰到一个挺典型的容器故障,顺手记录一下排查过程。情况是这样的:开发同事反馈某个服务一直起不来,docker ps一看,状态列挂着Restarting (1) Less than a second ago,重启间隔连一秒都不到。这种“秒崩循环”在 Docker 日常运维里非常常见,但也特别容易让新手一头雾水,因为日志往往还没来得及输出,容器就已经被重启策略拉起来下一轮了。
这篇文章就把这类问题的排查思路完整梳理一遍,从状态字段解读、退出码分析,到日志抓取手法和常见的坑,争取让遇到同样问题的人能少走点弯路。
1. 先看明白 Restarting 状态到底在告诉你什么
很多人第一眼看到Restarting (1) Less than a second ago就开始慌,其实这个状态本身已经带了不少信息。把状态拆开看,(1)是重启次数,说明容器已经被调度器重启了至少一次,后面的Less than a second ago是距离上一次启动的时间,换句话说,这个容器当前正处在“启动了又挂掉、挂掉马上重启”的死循环里。
出现这种状态,说明你给容器配置了重启策略,最常见的是--restart=always或者--restart=unless-stopped。设计初衷是好的,进程崩了自动拉起来,但副作用就是:如果你的程序本身启动就失败,Docker 并不会帮你判断“这是不是一次有效启动”,它只负责执行重启策略。结果就是容器以极高的频率反复创建、退出、再创建,系统资源被白白消耗,日志也可能被刷爆。
这里有个细节值得注意:重启间隔。Less than a second ago意味着这个容器基本上是“秒退”的,连一个完整的进程生命周期都没撑过去。这种情况和那种跑几分钟才崩一次的容器,故障原因往往完全不同。秒退的,大概率是启动阶段的硬性错误——命令不存在、依赖缺失、权限不够、配置解析失败;撑几分钟再挂的,则更可能是运行时的资源耗尽、连接超时或者业务逻辑问题。排查之前先把这两类区分开,能节省很多时间。
还有一个小坑:如果你用docker logs看不到任何输出,不要急着怀疑日志驱动配置,很可能是容器主进程在初始化阶段就失败,还没来得及往 stdout/stderr 写东西。这种情况下,需要换一条排查路径,后面会详细讲。
2. 排查第一步:退出码是故障的第一把钥匙
容器每次退出,都会留下一个退出码。这个数字基本上就是程序给你的最后遗言,千万别忽略。我一般习惯先把退出码拿到手,再决定下一步往哪个方向查。
docker inspect -f '{{.State.ExitCode}}' 容器名或ID拿到退出码之后,对照常见情况来缩小范围:
| 退出码 | 常见含义 | 典型场景 |
|---|---|---|
| 0 | 正常退出 | 进程主动结束,但配合 Restarting 状态就很奇怪,多半是启动脚本有逻辑分支直接 exit 0 |
| 1 | 通用错误 | 程序自身报错,配置不对、连接失败、校验不过都有可能 |
| 126 | 权限问题或命令不可执行 | 脚本没有执行权限,或解释器无权限访问 |
| 127 | 命令不存在 | PATH 问题,或启动命令里的可执行文件根本没打进镜像 |
| 137 | 被 SIGKILL 杀掉 | 最常见的是 OOM,也可能是有人手动 kill -9 |
| 139 | 段错误 | 程序本身的内存访问越界,通常是原生依赖编译问题 |
| 143 | SIGTERM 终止 | 进程收到终止信号,配合重启策略看像是被系统或编排器清理 |
| 2 | 误用 shell 内建命令 | 比如脚本里把exit写错位置,或者命令语法错误 |
| 130 | SIGINT 中断 | 终端 Ctrl+C 或者容器收到中断信号 |
光看退出码还不够,退出码只是结果,你得把“为什么退”挖出来。比如 137 这个退出码,十次里有九次是 OOM,但也不能排除有外部进程对容器发起了kill -9。再比如说 1,这个最含糊,Java 程序启动时抛异常退出是 1,MySQL 初始化失败也是 1,Python 脚本报错也是 1,唯一的共性就是“程序自己不干了”。所以退出码用来定性,真正定位还得靠日志和 inspect 信息。
另外提醒一句:docker inspect里除了ExitCode,还有个Error字段,有时候会直接告诉你容器启动失败的原因,比如oci runtime error。很多人在这一步会忽略:
docker inspect -f '{{.State.Error}}' 容器名或ID这条命令在日志为空的时候特别救命,它拿到的信息往往比日志更底层、更接近运行时的问题。
3. 日志空空如也怎么办:用 inspect 和 events 还原现场
真正让人头疼的是那种docker logs什么都不输出的情况。我见过不少人卡在这一步就不知道怎么继续了,其实还有三条路可以走。
第一条路,看docker inspect的完整输出。除了刚才提到的ExitCode和Error,还可以查看State.FinishedAt、State.StartedAt、RestartCount这些字段,把容器的“死亡时间线”拼出来。另外,HostConfig.RestartPolicy能确认重启策略的配置,Config.Cmd和Config.Entrypoint能帮你确认启动命令到底是怎么定义的。很多时候,问题就出在启动命令上,而 inspect 里正好能看到实际生效值。
第二条路,用docker events实时追踪容器状态变化。在容器反复重启的过程中,后台跑一条命令:
docker events --filter 'container=容器名' --filter 'event=die' --since 1mevents会打印出容器每次死亡时的事件信息,里面往往带有退出码甚至错误描述。这比干等日志直观得多,尤其适合现场复现时使用。
第三条路,临时覆盖重启策略和启动命令,手动前台运行容器。这是最暴力也最有效的一招:
docker run --rm -it --entrypoint sh 镜像名如果能通过这个方式进入容器,说明镜像本身是完整的,问题大概率出在原本的启动命令或应用配置上。如果连这个都进不去,说明镜像构建阶段就埋了雷,比如基础镜像损坏或者动态库缺失。这个方式和之前的启动方式不一样,不会触发重启策略,反而能让你拿到第一手的报错信息。
还有一个容易被忽视的细节:有些容器秒退,不是因为程序的问题,而是因为入口脚本里用了错误的方式。比如 Dockerfile 里写的是CMD ["/start.sh"],但start.sh没有#!/bin/bash这行 shebang,或者没有给执行权限,容器启动时就会直接报exec format error或者 permission denied,而且日志里可能只会出现一行报错,非常容易漏看。
4. 六个高频原因和对应解法,基本上能覆盖九成问题
排查思路有了,下面整理一下我实际运维中遇到最多的几类原因。每一个都附上典型的报错特征和解决方式,可以直接对照你的容器状态来判断。
4.1 前台进程和后台进程的经典误区
这是新手最容易踩的坑,而且报错特别隐蔽。在 Dockerfile 里写CMD ["/etc/init.d/nginx", "start"],或者写service mysql start,表面上看没毛病,但容器启动后几秒就退出。原因很简单:容器里必须有一个前台运行的进程,Docker 判断容器是否存活,看的不是“你有没有启动过服务”,而是“PID 1 进程是不是还活着”。
service nginx start这类命令会把 nginx 放到后台运行,然后命令本身很快就执行完了,PID 1 直接退出,容器自然也跟着停。然后重启策略一拉,又起来一轮,同样的事情再发生一遍,于是你就看到了Restarting (1)。
解决办法也很直接:用前台方式运行服务。比如 nginx 镜像里的nginx -g "daemon off;",或者 supervisor 配置里把daemonize关掉。如果你自己写启动脚本,脚本里最后一行一定要启动一个前台进程,并且建议用exec替换掉当前进程,让应用进程直接变成 PID 1。这样可以避免 shell 作为父进程带来的信号处理问题,也方便 Docker 正确转发停止信号。
4.2 启动命令本身有问题:命令不存在、路径不对
另一种常见情况是镜像里根本找不到启动命令里写的那个可执行文件。比如你在 Dockerfile 里把应用装在/app/bin,但CMD里写的是run_server,而没有写成绝对路径/app/bin/run_server。如果 PATH 环境变量没有包含/app/bin,容器启动时就会报exec: "run_server": executable file not found in $PATH,退出码通常是 127。
这种问题定位起来很快,docker logs会直接给出报错。但有一种变体比较坑:命令路径不对,不是报找不到文件,而是报no such file or directory。这个往往出现在动态链接库缺失的场景下——文件是存在的,但加载器找不到依赖库,系统给出的报错也是“找不到文件”,容易被误导。
解决方式分两层:一是确认启动命令的路径是对的,建议一律使用绝对路径;二是确认执行权限,特别是自定义脚本,进入容器看一眼ls -l,确认有-rwxr-xr-x这样的权限位。
4.3 动态库和运行时环境不匹配
说到动态库缺失,这是从源码编译进容器的程序特别容易犯的毛病。经常听人问:“我在本机编译好二进制,丢进容器就跑不起来,退出码是 127 或者 1。”原因通常是宿主机和新容器的基础镜像 libc 版本不一致,程序依赖的 glibc 版本偏高,而镜像里的版本偏低,装的时候装不上。
有一种更隐蔽的情况:如果程序是用 musl 静态编译的,但你对镜像做了奇怪的操作,导致基础镜像本身不完整,也会出现莫名其妙的启动问题。排查方式是用 ldd 检查依赖库:
ldd /path/to/your/binary如果有任何一个依赖显示not found,就说明基础镜像的库环境不满足要求。解决方案有三种:换更完整的基础镜像;把缺失的库拷贝进去但要注意 glibc 版本兼容;或者用静态编译的方式,重新构建应用,彻底摆脱动态库依赖。
4.4 资源限制:OOM 是 137 的头号来源
退出码 137 基本等于被SIGKILL,而SIGKILL在容器场景里最典型的原因就是内存超限。如果容器设置了--memory限制,而应用实际占用的内存超过了这个上限,内核的 OOM killer 会直接杀掉进程,不留任何商量余地。
怎么确认是不是 OOM?先看退出码是不是 137,然后看系统日志:
dmesg | grep -i oom如果要确认是不是某一个特定容器触发的,可以结合docker inspect里的State.OOMKilled字段:
docker inspect -f '{{.State.OOMKilled}}' 容器名或ID如果输出true,那就是内存不够用了。解决思路一般分三步:第一,分析应用真实内存占用,是堆内存设置太高,还是缓存机制把内存吃满;第二,适当放宽--memory限制,但不要无脑放宽到不限制;第三,如果同时运行多个容器,要检查宿主机整体内存是否够用,swap 配置是否合理。
还有一类容易忽略的资源问题:磁盘空间和 inode 耗尽。容器写日志把磁盘写满,或者镜像层太多导致 overlay 分区 inode 被占满,都会让容器启动失败。这种报错在日志里可能只有一行no space left on device,退出码是 1,很容易被当成业务问题去排查,绕一大圈才发现根因在宿主机。
4.5 应用配置或依赖服务未就绪
这一类的特点是:容器本身能启动,但启动后业务初始化时发现配置缺失、数据库连不上、依赖的 Redis 还没就绪,于是直接报错退出。退出码可能是 1,也可能是 0——有些启动脚本在检测失败后会显式exit 0,这个最容易迷惑人。
典型场景:docker-compose 里一个应用依赖 MySQL,但没有配置depends_on的健康检查条件,MySQL 容器虽然起来了,但初始化还需要几十秒,应用这边已经尝试连接数据库并失败了。如果应用没有重试机制,启动即崩溃,然后被重启策略拉起来,再崩,如此往复。
解决办法有几个层次:最简单的,在应用侧加启动重试和延迟;稍微好一点的,在 docker-compose 里用depends_on: condition: service_healthy配合健康检查;再往上是引入 init 容器或编排平台级别的探活机制。另外,应用本身要写好配置校验逻辑,不要一缺配置就把进程打死,至少要把缺什么配置打出来,方便排查。
4.6 镜像构建时埋下的隐性炸弹
最后一类比较“阴间”,问题不在应用,而在镜像构建阶段。比如 Dockerfile 里用了ADD或COPY把宿主机的文件拷进去了,但宿主机的文件和镜像架构不匹配;再比如基础镜像本身是个奇怪的精简版,缺一些基本工具和动态库;还有构建时使用了错误的ENTRYPOINT写法,导致每次启动都会去执行一个不存在的命令。
有一个特别经典的案例:Dockerfile 里ENTRYPOINT写成了 JSON 数组格式["/entrypoint.sh"],但entrypoint.sh没有执行权限,或者没有 shebang。这种情况下容器启动时直接报permission denied或者exec format error。关键是,这类错误不会给应用层面任何机会去写日志,docker logs输出几乎为空。
更隐蔽的是环境变量问题:有些应用启动时需要读取配置文件里的路径变量,而 Dockerfile 里通过ENV设置的值,如果在运行时被外部-e参数覆盖成了空值,就会导致应用找不到路径。这种问题定位起来特别折磨人,因为配置看起来是好的,容器也启动了,但应用就是起不来。排查时建议把docker inspect输出里的Env字段拉出来仔细对一遍,确认运行时实际生效的环境变量是不是符合预期。
这个时候还有个特别实用的工具:在本地先把镜像跑起来,覆盖入口,进容器里手动执行启动命令,一点点排除。只要能把启动命令在交互模式下跑通,问题就基本定位在环境变量、目录挂载或者服务发现上,和镜像本身关系不大了。
5. 进阶一点的排查手法:换个角度看容器状态
上面这些常规手段搞不定的话,可以试试下面几个进阶思路。这些手法不一定每次都用到,但遇到难缠问题时往往能打开局面。
先用一个冷门命令看进程在容器内的真实行为:
docker top 容器名如果容器恰好在一个“还活着”的窗口期,这个命令能看到容器内的进程列表和资源占用,帮你判断进程到底有没有起来、PID 1 是哪个、子进程又是什么状态。有时候你会发现主进程是起来了,但它不断 fork 子进程然后失败退出,这说明问题已经不在启动阶段,而是运行逻辑的 bug,排查方向就完全不一样了。
再一个方法,临时关掉重启策略,让容器停在退出的状态,所谓“死透”,方便你观察:
docker update --restart=no 容器名或ID docker stop 容器名或ID docker start 容器名或ID这样容器如果还是秒退,就不会再被自动拉起了。你可以直接查它的退出码、日志和状态。这个操作不影响容器配置的持久化,后面重新设置回--restart=always就行。
还有一招,用docker diff看容器退出后文件系统相比镜像新增或修改了哪些文件。有时候应用的报错信息不是写到 stdout,而是落在某个日志文件里,比如/var/log/app.log。容器退了但你还能看到文件系统快照:
docker diff 容器名配合docker cp把日志文件拽出来看,有时候能拿到应用层面写下来的关键报错信息,比到处猜要省事得多。
值得一提的是,如果你的容器是由 compose 编排的,还可以用docker compose logs直接看整个服务的日志聚合,并且docker compose ps能一次看所有服务的状态。多个服务一起出现类似问题时,往往意味着宿主机层面有公共的东西出了问题,比如端口被占、网桥异常、磁盘满等等。
6. 写在最后的几个日常习惯,能帮你少踩一半的坑
排查归排查,真有问题的项目,提前养成几个小习惯,能让你少走很多弯路。第一个习惯,镜像里写启动脚本时,务必用exec启动进程,并且脚本开头加上set -e和set -x。前者保证任何一步出错就退出,不会带着半死不活的状态硬撑;后者会把执行过程打到日志里,方便排查。
第二个习惯,启动容器时额外挂一个调试用的目录,把应用日志落盘。很多容器镜像默认只把日志写到 stdout,遇到秒退问题,日志还没 flush 就被杀掉了。如果你把应用日志写到挂载出来的持久化目录里,即使容器秒退,日志也留得住。做法是在 docker-compose 里加一个volumes映射,然后应用配置里把日志路径指到挂载目录。
第三个习惯,资源限制一定要尽早设。--memory和--cpus别等上线了再补,开发阶段就加上。这样很多资源相关的问题在开发期就会暴露,而不是等到生产环境才炸出来。而且早发现早调整,应用的内存基线也更容易摸清楚。
第四个习惯,不要轻易把整个宿主机交给一堆--restart=always的容器。这个策略是方便,但也会掩盖问题。最好针对核心服务单独配置,且启动前必须有健康检查兜底。像数据库这类有状态服务,重启策略更要谨慎,避免反复重启把数据搞坏。
我个人的经验是,Restarting (1)这个状态本身并不可怕,可怕的是它背后的信息没有被正确解读。把退出码、日志、inspect 信息、events 事件串起来分析,绝大多数容器秒退的问题都能在十几分钟内定位出来。最后再分享一个小技巧:每次排查完这类问题,随手把退出码和对应的根因记到一个笔记里。积累半年之后你就会发现,大部分容器启动问题都是有规律可循的,而且你的排查速度会越来越快。