这个标题我看着特别眼熟,因为前天刚在生产环境里踩完。当时线上一个容器化服务在凌晨三点悄悄进入Restarting (1) Less than a second ago状态,监控告警触发的时候,业务已经断了将近二十分钟。跑上服务器一看,docker ps里那行刺眼的红字让人头皮发麻——容器不是没起来,而是起来一瞬间就死,死了又被拉起来,反反复复。这种故障比单纯的Exited更难受,它像一个永远在重启的机器人,表面上看"还在运行",实际上每次存活时间不到一秒钟。
这篇文章把这一类问题彻底掰开。不管你是刚接触 Docker 的新人,还是已经维护了一段时间生产环境的开发者,只要遇到容器反复Restarting,都可以按这篇文章的排查路径一步步来。我会从状态含义、日志分析、底层字段挖掘,到具体根因和解决模板,再到 restart policy 配置,全部用实际案例讲透。
1. 这个状态真正想告诉你的事:容器在"反复猝死",不是"启动中"
很多人第一次看到Restarting (1) Less than a second ago时,第一反应是"容器还在启动中,等一会儿就好"。这个误解非常危险,因为两者本质完全不同。启动中的容器 STATUS 列显示的是Up,后面跟着启动时长,比如Up 3 seconds,而Restarting表示一个已经启动过、但又退出的容器,被 Docker 守护进程按重启策略拉起来之后又退出了。说白了,它不是在慢慢启动,而是在反复猝死。
1.1 读好一行 STATUS:括号数字和时间分别代表什么
先看懂这一行字。拿Restarting (1) Less than a second ago来说,拆开看就三部分:
Restarting:容器当前处于"重启中"状态,也就是说它刚刚退出,Docker 正在或已经按 restart policy 再次启动它。(1):这个数字是 Docker 统计到的重启次数,随着每次崩溃拉起重启,数字会持续往上加。你看到(1),说明已经重启了 1 次;看到(15),说明这个容器已经反复死了 15 次。Less than a second ago:这是上次容器退出的时间距现在多久。这里特别关键——"不到一秒前",意味着容器每次起来后撑不过 1 秒就挂了,而且退出事件极其频繁。
这里有个非常重要的画像:如果docker ps里显示的是Restarting (1) 2 minutes ago,说明容器每次能撑上几分钟再死,这种往往是因为程序内部有间歇性错误,比如内存慢慢涨满、连接池耗尽、定时任务里某个异常等。如果显示Less than a second ago,说明程序连启动阶段都没能完成就退出了,问题大概率出在环境、权限、依赖、命令这些"起跑线"层面,而不是业务逻辑层面。这个诊断方向先确立,排查思路就不会跑偏。
1.2 为什么 Docker 会不厌其烦地拉起重启
很多新人会疑惑:容器都退出了,为什么 Docker 还非要一遍遍拉起来?这就要说到 Docker 的重启策略(restart policy)。如果在docker run时用了--restart always、--restart unless-stopped或--restart on-failure,Docker 守护进程会监测容器退出事件,然后根据策略自动重新启动它。生产环境里大家为了服务自动恢复,普遍会给容器配置 restart policy,这本意是好的,但副作用就是:如果容器自身有问题,就会陷入无限重启循环。
可以这样理解:一台电脑系统崩溃了会自动重启,第一次你可能觉得是偶发,但如果它每次开机不到一秒就蓝屏,还自动重启一百次,你就该意识到这不是"等它启动好"的问题,而是这台机器在系统层面就有什么硬伤。容器反复Restarting (1)、Restarting (2)、Restarting (3),就是 Docker 在告诉你:这里有个东西连起跑线都跨不过去。接下来要做的,就是找到那个让它跨不过去的硬伤。
2. 第一板斧:先用 docker logs 找出程序自己的遗言
面对一个反复重启的容器,我的习惯是先看日志,而且一定要在容器刚退出、还没被再次拉起的时间窗口里看。那有人会问:容器都已经挂了还看什么日志?能看到的。容器退出的那一瞬间,程序在退出前打印到标准输出(stdout)和标准错误(stderr)的内容,会被 Docker 保存下来,docker logs就是把这份"遗言"读出来。
2.1 三种常用的日志姿势,按场景选用
docker logs 容器名:查看容器从创建以来的全部日志,如果日志太多会刷屏,建议配合 tail 使用。docker logs --tail 200 容器名:只看最近 200 行。这是排查重启类问题最常用的姿势,因为崩溃前的最后几行日志往往就是线索。docker logs -f 容器名:实时跟踪输出。如果容器重启很频繁,用docker logs -f盯着看,能看到每次崩溃前程序到底打出了什么。
还有一个小技巧:如果一个容器崩溃得特别快,日志可能每次只产生几行,用docker logs -f反复刷新观察几次,基本能抓到规律。比如一段日志在每次重启后都只输出一行error: config file not found就没下文了,说明程序是死在配置加载阶段;如果日志是完整打完启动流程、最后才报错,那就要往资源限制、OOM 那个方向想。
2.2 日志里最常见的几类典型现场
我把实际排障中高频出现的日志类型和判断方式整理了一下,你可以对照着自己容器里的日志看:
| 日志特征 | 大概率原因 | 下一步动作 |
|---|---|---|
chown: changing ownership of '/var/lib/mysql/': Operation not permitted之类权限报错 | 容器内用户对挂载目录没有写权限 | 检查挂载目录权限、用户 UID 映射 |
exec: "bash": executable file not found或exec: no such file or directory | 启动脚本的换行符有问题,或脚本内命令路径不存在 | 检查 CRLF 换行符、CMD 中命令路径 |
Can't open the mysql.plugin table. Please run mysql_upgrade to create it | 数据目录损坏或初始化不完整 | 清理或重建数据卷 |
/bin/sh: 1: xxx: not found | 启动命令里的可执行文件不在容器的 PATH 中 | 用绝对路径启动,或确认镜像内确实装了该程序 |
Out of memory或直接没有日志、进程被秒杀 | 容器内存超限被内核 OOM Kill | 查 inspect 的 OOMKilled 字段 |
dial tcp 127.0.0.1:3306: connect: connection refused | 依赖的服务还没起来就启动主程序 | 增加等待依赖、调整启动顺序 |
database is locked | 多进程并发访问 SQLite 等文件型数据库 | 检查是否启动多个实例 |
有几个值得多说两句。第一种是"有没有日志"本身就是一个巨大信号。如果一个容器每次都是Restarting (1) Less than a second ago,但docker logs显示No log output,说明容器的主进程还没走到第一条日志输出就挂了。这时候问题多半不在业务代码,而在"主进程根本没起来成功"——可能是 CMD 写错、动态库缺失、权限不对、被信号杀掉,后面 inspect 退码那节我会展开。
第二种要注意的是,有些程序会把日志写到文件里面而不是标准输出,比如一些 Java 应用和传统中间件。这种容器在崩溃时docker logs可能只有一行 JVM 启动信息,真正的错误埋在容器内日志文件里,或者直接通过挂载卷对外暴露。如果遇到这种情况,先别急着给程序"判死刑",想办法进容器看看日志文件,或者看挂载出来的日志目录。
3. 第二板斧:docker inspect 能挖出比日志更底层的死亡报告
日志能看到程序自己说了什么,但有时候程序连话都没来得及说就死了。这时候就得靠docker inspect,它能直接读取 Docker 守护进程记录的"死亡报告",包括退码、错误信息、是否被 OOM 杀死,这些信息是日志之外最客观的现场证据。
3.1 一条命令拿到关键状态字段
先给出一条我排障时一定会用的组合命令:
docker inspect -f '{{.State.Status}} | RestartCount={{.RestartCount}} | ExitCode={{.State.ExitCode}} | OOMKilled={{.State.OOMKilled}} | Error={{.State.Error}} | StartedAt={{.State.StartedAt}} | FinishedAt={{.State.FinishedAt}}' 容器名执行后你会看到类似这样的输出:
running | RestartCount=7 | ExitCode=1 | OOMKilled=false | Error=<nil> | StartedAt=2025-01-15T09:22:31.123Z | FinishedAt=2025-01-15T09:22:30.987Z几个字段各有用处:
RestartCount:确认重启次数,如果数字在持续增长,说明循环还没停止。State.ExitCode:上一次退出的退出码,这是判断死因最重要的一把钥匙。State.OOMKilled:如果为true,说明上一次退出是内存超限被内核杀掉,基本不用再看别的了。State.Error:Docker 在启动过程中的报错信息,如果非空,直接看它。StartedAt和FinishedAt:两个时间一减,能算出来容器每次存活了多久。如果FinishedAt比StartedAt只晚了几毫秒,那就是起来即死。
3.2 ExitCode 退码速查表
我做了张表,基本覆盖了实践中遇到的 90% 的退出码场景:
| 退出码 | 含义 | 典型场景 | 排查方向 |
|---|---|---|---|
| 0 | 程序正常退出 | 主进程执行完主动退出,或后台化后父进程返回 | 检查 CMD 是否启动的是前台进程 |
| 1 | 通用程序错误 | 程序抛异常、配置校验失败 | 重点看日志里报错信息 |
| 2 | 误用 shell 内建命令 | 脚本语法错误、命令拼接问题 | 检查启动脚本和 ENTRYPOINT |
| 126 | 命令无法执行 | 没有执行权限 | 检查脚本是否有 x 权限 |
| 127 | 命令找不到 | CMD/ENTRYPOINT 路径写错,或镜像中缺命令 | 检查命令路径、PATH 环境变量 |
| 137 | SIGKILL,多数是 OOM | 内存超过容器 limit 被内核杀死,或人为 kill -9 | 查 OOMKilled 字段、调大内存、优化进程 |
| 139 | SIGSEGV 段错误 | 二进制兼容问题、底层库损坏 | 考虑镜像架构、glibc 版本问题 |
| 143 | SIGTERM,优雅终止 | 收到停止信号 | 检查是否被脚本主动 kill,或 Docker stop |
3.3 OOMKilled 怎么确认最靠谱
很多人一看 ExitCode 是 137,就认定是内存超限。这个判断通常没错,但最好还是用OOMKilled字段确认,因为kill -9也会产生 137,两者处理方式完全不同。确认方法:
docker inspect -f '{{.State.OOMKilled}}' 容器名如果是true,基本可以锁定是内存问题。另外还可以看内核日志确认:
dmesg | grep -i oom或者查宿主机上有没有对应的内核记录,比如oom-killer杀掉了容器内 PID。如果内存超限,常规解法是把启动命令里的 JVM 堆参数、Node 内存限制调下来,同时把容器的--memory限制抬高。但还是建议先用docker stats观察下容器的正常内存占用量,再定合理的 limit。上来直接加内存也不是不行,但搞清楚程序到底为什么吃这么多内存才是长久之策。
4. 覆盖九成场景的四个根因,每个都附排查模板
看完了日志,也查了 inspect,下面进入实操环节。根据我维护了几十个容器化服务的经验,Restarting (1) Less than a second ago这类"起来即死"的问题,绝大多数逃不出这四类根因。
4.1 主进程没有前台化,一启动就"跑完收工"
这是新手上路时最经典的一坑,也是最容易被忽略的一条。Docker 容器的生命周期是由它的主进程(PID 1)决定的。如果主进程执行完就退出,容器就会进入退出状态,即使 restart policy 设置了 auto restart,也会循环重启。
我见过一个真实例子:有人用CMD service mysql start或CMD /etc/init.d/mysql start启动数据库。service命令启动完守护进程后,它自己就退出了,容器里的 PID 1 消失,容器整体退出。Docker 一拉起重启,又跑一遍service mysql start,又退出,周而复始。
排查模板也很简单:看看 Dockerfile 或 docker-compose.yml 里 CMD 是不是一个"启动后立即返回"的命令。如果是,常见的改法有两种:
- 如果程序本身支持前台运行,像 Nginx 有
nginx -g 'daemon off;',直接改成前台模式:
CMD ["nginx", "-g", "daemon off;"]- 如果服务必须后台跑,就需要一个能托住前台进程的启动脚本,通常用
exec把服务进程提升为 PID 1。比如:
#!/bin/bash exec /usr/local/bin/my-server这个exec很关键,它让脚本进程被目标程序替换,目标程序直接成为容器的主进程,信号也能正确传递。
4.2 CMD/ENTRYPOINT 路径不存在:127 退码的经典场景
还有一种很典型的现场:docker logs没有任何业务日志,docker inspect里 ExitCode 是 127,Error字段提示exec: "xxx": executable file not found或exec: "xxx": no such file or directory。
这个报错的含义是:Docker 尝试在容器里执行你指定的命令,但找不到这个可执行文件。这种情况多半是因为 Dockerfile 里写死了CMD ["/usr/local/bin/start.sh"],但镜像更新后脚本路径变了;或者基础镜像从 CentOS 换成了 Alpine,包管理器和命令都变了,原来装的程序根本不存在。
处理方式分两步。第一步,先用docker run --rm --entrypoint sh 镜像名 -c "ls -l /usr/local/bin/start.sh"进容器确认文件到底存不存在、有没有执行权限。第二步,如果文件存在但报 no such file,反而要往解释器缺失方向想,比如脚本第一行是#!/bin/bash,但基础镜像里连 bash 都没有,那就换成#!/bin/sh,或者重新安装 bash。
这里还有个细节很容易踩:很多人用docker run --rm 镜像名 命令直接在镜像里执行程序,但容器环境里没有交互式终端,如果你执行的是类似python3 -i这种需要标准输入的命令,它也会立即退出。这种不是路径问题,是启动方式不对。
4.3 启动脚本的权限与 CRLF 换行符,最容易被忽视的两件小事
有一次一个 Java 服务每次起来不到一秒就死,我看日志看到一句exec: "/app/start.sh": permission denied,当时排查了很久才发现是脚本没有执行权限。Docker 在 Exec 一个文件之前会校验它的权限位,如果start.sh没有 x 权限,直接报 permission denied。解法很简单,在 Dockerfile 里加上:
RUN chmod +x /app/start.sh另一件事更隐蔽:Windows 上开发的脚本,拷贝到 Linux 容器后,换行符是\r\n(CRLF),而 Linux 下 shell 期待的是\n(LF)。这会导致脚本执行时报出类似exec: "/app/start.sh": no such file or directory或者/bin/sh^M: bad interpreter这种诡异错误。为什么文件明明存在却报 no such file?因为脚本第一行#!/bin/sh\r被 shell 读成了#!/bin/sh\r,解释器路径里带着一个回车符,自然找不到。解法是把脚本转成 LF 换行符,可以在 Dockerfile 里用sed -i 's/\r$//' /app/start.sh,也可以直接用dos2unix。
4.4 内存超限被内核杀死:137 退码与 OOMKilled 字段
程序本身写得没问题,命令也对,权限也对,但容器就是反复重启,而且每次存活时间越来越短,最后看一眼 exit code 是 137。这种大概率是"容器内进程在干重活时吃掉了所有分配到的内存,被内核 OOM Killer 一梭子带走"。
比如我之前有一次用 Docker 启动一个 Java 服务,默认 JVM 堆内存向宿主机申请了总内存的 1/4,容器 limit 只给了 1GB,结果 JVM 一跑就爆。优化方式就是把 JVM 的内存参数写明白:
CMD ["java", "-Xms256m", "-Xmx512m", "-jar", "app.jar"]如果是 Node 应用,可以用环境变量限制老生代内存大小:
docker run -e NODE_OPTIONS="--max-old-space-size=512" 镜像名如果排查下来程序确实需要大内存,那就调高容器的--memory上限。但注意,如果宿主机内存也不够了,光调容器参数解决不了问题,得考虑加机器或做服务降级。
5. 别让故障无限重试:restart policy 要与场景匹配
排查完根因、修复问题之后,还有一件一定要做的事:重新审视容器的 restart policy。很多服务反复重启导致事故扩大的原因,不是故障本身,而是无限重试把负载打满了。我甚至见过一个容器每分钟重启几十次,把宿主机的磁盘 I/O 吃掉大半,连带其他正常业务一起遭殃。
5.1 四种常见策略,各自适合什么场景
Docker 的 restart policy 有四种,区别用一张表说清楚:
| 策略 | 行为 | 适合场景 |
|---|---|---|
no | 退出后不自动重启,即默认策略 | 一次性任务、批处理容器 |
on-failure[:max-retries] | 非零退出码时自动重启,可限制最大次数 | 生产服务,搭配重试次数限制 |
always | 无论退出码是什么,总是重启 | 需要持续在线的常驻服务 |
unless-stopped | 总是重启,但手动 stop 后不再自动拉起 | 需要手动控制的常驻服务 |
生产环境里我最推荐的是on-failure:3或on-failure:5,也就是允许容器在异常退出时重试几次,但不要无限重试。这样既留了"偶发故障自动恢复"的余地,又避免了"必现故障反复烧资源"的灾难。如果服务本身是绝对不能中断的,还要另外配健康检查和外部告警,不能只靠 restart policy 兜底。
5.2 已经陷入重启循环时如何紧急止损
如果容器已经处于Restarting循环,第一时间要做的是停止这种无效消耗。两种方式:
# 方式一:直接关掉重启策略再停止 docker update --restart=no 容器名 docker stop 容器名 # 方式二:直接强制停止(当前运行中的进程会收到 SIGKILL) docker kill 容器名注意,光docker stop可能不够,因为 restart policy 会把它再次拉起来。一定要先用docker update --restart=no把策略摘掉,再 stop,否则会看到"怎么 stop 了又自己起来了"的怪现象。这个顺序很重要。
然后保留现场:不要急着删除容器,凡是和故障相关的容器都留着,等收集完docker logs、docker inspect之后再做清理。
5.3 给"重启次数过多"加一道监控闸
生产环境里不能只依赖docker ps肉眼观察。我给自己的服务加了两层保护:第一层是健康检查(healthcheck),第二层是容器重启次数监控。
健康检查可以在 Dockerfile 或 compose 里声明:
healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 5s retries: 3 start_period: 10s有了健康检查,监控系统可以直接拉取容器状态,unhealthy状态一出现就能告警,不用等到整个容器挂掉。
重启次数监控则看docker inspect -f '{{.RestartCount}}'这个字段,定期采集,如果发现某个容器的重启次数短时间内快速增长,就触发告警。这本质上是在给 Docker 的自动恢复机制系一根安全绳,防止它变成连环事故的发源地。
6. 日志和退码都看了还是没头绪?试试这几个进阶调试手段
剩下不到 10% 的情况,日志看了、退码也查了、restart policy 也改了,问题依旧。这时候常规手段就不够用了,需要上点"手术刀"级别的排查方式。我自己用的最多的是这三招:钻进临时容器、盯住事件流、处理挂载目录权限的边界问题。
6.1 用临时容器手动跑一下原命令
遇到容器反复崩溃又找不到原因,最快的办法是先不让它跑,用同一个镜像起一个临时容器,手动执行启动命令,看看真实报错是什么。因为docker logs只能看到输出到 stdout/stderr 的内容,某些场景下主进程崩溃是因为依赖的子进程拉起失败,这些细节在 logs 里不一定有。
# 用 sh 覆盖原入口点,进容器手动跑 docker run --rm -it --entrypoint sh 镜像名 # 然后在容器里手动执行原入口命令 /app/start.sh手动执行时你是在终端里看,遇到缺依赖、缺权限、脚本卡住之类的问题,感受会直观很多。如果手动执行时命令能正常跑起来,说明问题出在运行时环境差异,比如环境变量没有传进来、挂载卷没挂对。这时再对比docker run和原容器之间的环境差异,基本就能定位了。
6.2 用 docker events 观察容器生死的完整时间线
另一种进阶手段是开一个实时事件通道,观察容器的完整生命周期:
docker events --filter 'container=容器名'输出会显示容器每次的 start、die、restart 事件。如果你发现容器每次都是启动后几十毫秒就 die,且 die 事件的 exitCode 始终是某个固定值,那问题就相当明确了。结合FinishedAt和StartedAt的差值,还能算出每次存活时长,帮助判断崩溃行为是否有规律。
这种方法的优势在于能看到docker ps那一刻之外的连续过程。比如你刚看到Restarting (1)的时候容器其实已经被拉起过多次了,事件流能还原整个过程的完整节奏,不会被一帧画面误导。
6.3 挂载目录权限、SELinux 与容器内用户映射
最后一类坑出现在使用数据卷挂载、或者跑一些需要写文件的容器时。典型报错包括:
chown: changing ownership of '/var/lib/mysql/': Operation not permitted或
mkdir: cannot create directory '/data': Permission denied原因通常很直接:宿主机上挂载进去的目录,其属主和权限跟容器内进程的用户 UID 不匹配。比如 MySQL 镜像内进程默认以mysql用户运行,UID 通常是 999,而你宿主机挂载的目录属主是 root,容器内用户没有写权限,它就起不来。
解决办法有几个方向。一个是直接把挂载目录的属主改成容器内用户对应的 UID:
chown -R 999:999 /data/mysql另一个是在容器启动参数里指定用户:
docker run --user 0:0 镜像名或者给挂载目录开足够权限。如果宿主机开启了 SELinux,还可能需要加:z或:Z结尾:
-v /data/mysql:/var/lib/mysql:Z这类问题最麻烦的地方在于,报错往往不是直接写在业务日志里,而是藏在系统调用的报错中,但好在docker logs一般还是能看到一句 Permission related 的错误,抓到了就往挂载目录权限方向查,很快就能解决。
最后再分享一点个人的小习惯吧。每次我把一个容器从Restarting状态救回来,都会顺手做两件事:一是把完整排查过程记录到团队的故障文档里,包括退出码、日志样本、修复命令;二是给这个容器的 restart policy 和健康检查做一次"压力测试"——停掉依赖、删掉数据卷、改错配置,人为制造几种故障,看它会不会再出现那种"起来即死"的循环。这个习惯帮我提前发现过好几次隐蔽问题,比每次都等线上告警再慌慌张张去查要省心太多。Docker 容器化的坑几乎踩不完,但排查思路一旦固定下来,再花式的故障也就是多花点时间的事。