🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然
🏠博主简介
文章目录
- 前言
- 一、先别急着看日志,先看容器状态
- 1.1 “启动失败”其实至少有四种状态
- 1.2 退出码能告诉我们多少?
- 二、建立一条固定的排障证据链
- 三、日志:先抓住第一次失败
- 3.1 最常用的 `docker logs`
- 3.2 为什么有时 `docker logs` 是空的?
- 3.3 入口脚本为什么经常让错误变得模糊?
- 四、端口问题:区分容器端口和宿主机端口
- 4.1 `-p 18080:8080` 到底是什么意思?
- 4.2 宿主机端口已经被占用
- 4.3 端口映射正确,为什么还是访问不到?
- 4.4 不要随意把数据库端口发布到所有网卡
- 五、挂载与权限:容器里是哪个用户?
- 5.1 先看进程身份
- 5.2 不要用 `chmod 777` 当最终方案
- 5.3 文件存在,容器为什么说找不到?
- 5.4 SELinux 场景
- 六、环境变量与启动命令
- 6.1 容器中到底拿到了哪些变量?
- 6.2 Compose 变量替换和容器环境不是一回事
- 七、网络问题:先确认“谁访问谁”
- 7.1 宿主机访问容器
- 7.2 一个容器访问另一个容器
- 7.3 `depends_on` 不等于依赖服务已经就绪
- 八、健康检查失败,但应用明明能访问
- 8.1 HEALTHCHECK 在哪个环境执行?
- 8.2 健康检查不要检查太多东西
- 8.3 启动宽限时间
- 九、最小化复现比反复改配置有效
- 9.1 从最小命令开始
- 9.2 覆盖入口命令进入镜像
- 9.3 比较镜像默认配置和容器实际配置
- 总结
- 参考资料
前言
Docker 容器启动失败时,很多人的第一反应是:
dockerrestart 容器名不行就删容器、重新构建,再不行就清缓存、重启 Docker。
偶尔确实能恢复,但问题也被一起藏起来了。下一次换一台机器、换一个端口、换一个挂载目录,同样的错误还会重新出现。
容器排障最麻烦的地方,不是命令太多,而是故障可能来自不同层:
- 镜像本身没有问题,但启动命令写错了;
- 主进程能运行,但没有权限读取挂载目录;
- 容器显示 Running,但应用只监听
127.0.0.1; - 端口映射写对了,但宿主机端口已经被占用;
- 服务已经启动,但健康检查依赖了镜像里不存在的
curl; - 应用可以访问 IP,却不能解析 Compose 服务名;
- 日志只显示最后一次重启,真正的第一条错误已经被刷掉了。
这篇文章不背命令表,而是搭一条排障证据链。遇到容器起不来时,先判断它停在哪一层,再使用对应命令验证。
文中示例是我为排障流程重新设计的实验场景,不引用其他文章内容。命令以 Linux/Docker 通用写法为主,涉及宿主机端口检查时同时给出 Windows PowerShell 用法。
一、先别急着看日志,先看容器状态
1.1 “启动失败”其实至少有四种状态
用户说“容器起不来”,可能指下面任何一种情况:
| 状态 | 现象 | 优先检查 |
|---|---|---|
| Created | 容器创建了,但主进程没真正启动 | 启动参数、运行时错误 |
| Exited | 主进程已经退出 | 退出码、日志、入口脚本 |
| Restarting | 主进程反复退出,被重启策略拉起 | 第一段日志、健康检查、依赖 |
| Running | 容器活着,但业务访问不到 | 监听地址、端口、网络、防火墙 |
所以第一条命令不是docker logs,而是:
dockerps-a只看目标容器:
dockerps-a--filtername=api-demo进一步查看状态:
dockerinspect api-demo\--format'status={{.State.Status}} exit={{.State.ExitCode}} error={{.State.Error}}'示例:
status=exited exit=1 error=这个结果至少说明两件事:
- Docker 已经成功创建并启动过容器;
- 容器里的主进程自己以状态码 1 退出。
这时继续研究宿主机防火墙没有意义,应先看进程为什么退出。
1.2 退出码能告诉我们多少?
常见退出码可以作为方向,但不能当作最终结论:
| 退出码 | 常见含义 |
|---|---|
| 0 | 进程正常结束,但服务型容器不应该立刻结束 |
| 1 | 应用通用错误 |
| 126 | 命令存在,但不可执行 |
| 127 | 找不到命令 |
| 137 | 收到 SIGKILL,可能是 OOM 或人工强制停止 |
| 139 | 段错误 |
| 143 | 收到 SIGTERM,常见于正常停止流程 |
查看完整状态:
dockerinspect api-demo--format'{{json .State}}'如果有jq:
dockerinspect api-demo--format'{{json .State}}'|jq重点字段包括:
Status Running ExitCode Error StartedAt FinishedAt OOMKilled HealthExitCode=137时不要直接认定为 OOM,应继续看:
dockerinspect api-demo--format'oom={{.State.OOMKilled}}'如果OOMKilled=false,也可能是管理员执行了docker kill,或者外部编排系统强制终止。
状态码负责缩小范围,不负责替我们下结论。
二、建立一条固定的排障证据链
我习惯按照下面顺序排查:
状态 → 退出码和错误 → 应用日志 → 启动配置 → 文件与权限 → 监听地址和端口 → 容器网络与依赖 → 健康检查 → 最小化复现这条顺序不是绝对的,但能避免常见的跳跃:
网页打不开 → 怀疑 Docker 网络 → 重建 network → 清理镜像 → 最后发现应用根本没有启动排障时每一步都要留下证据。
例如,不要只说“端口应该没问题”,而要给出:
dockerport api-demo ss-lntpcurl-vhttp://127.0.0.1:18080/health命令的结果能互相验证,才算把这一层排除。
三、日志:先抓住第一次失败
3.1 最常用的docker logs
dockerlogs api-demo容器重启很多次时,日志可能很长。先看末尾:
dockerlogs--tail100api-demo持续跟踪:
dockerlogs-f--tail50api-demo带时间戳:
dockerlogs-t--tail100api-demo只看最近十分钟:
dockerlogs--since10m api-demo如果容器处于 Restarting,我更建议先关闭自动重启干扰:
dockerupdate--restart=no api-demodockerstop api-demodockerstart-aapi-demodocker start -a会把当前启动过程直接附加到终端,第一条错误更容易看到。
这一组输出应该连着看:ps -a确认容器状态,inspect给出退出码和 OOM 标记,logs保留应用报错。单独看到一个Exited (1),信息还不够。
调完后再恢复策略:
dockerupdate--restart=unless-stopped api-demo3.2 为什么有时docker logs是空的?
docker logs只能读取容器主进程写到标准输出和标准错误的内容。
如果应用把日志写进:
/var/log/app/app.log而没有输出到 stdout/stderr,Docker 日志自然为空。
检查日志驱动:
dockerinspect api-demo--format'{{.HostConfig.LogConfig.Type}}'常见结果:
json-file local journald none如果是none,docker logs不会返回应用日志。
对容器应用更推荐把普通运行日志写到 stdout/stderr,让日志收集系统统一处理。把日志只写在容器文件系统中,容器删除后很容易一起丢失。
3.3 入口脚本为什么经常让错误变得模糊?
例如 Dockerfile:
COPY docker-entrypoint.sh /usr/local/bin/ ENTRYPOINT ["docker-entrypoint.sh"] CMD ["node", "server.js"]脚本:
#!/bin/shset-eecho"preparing configuration"./prepare-config.sh$@这里的$@没有使用exec。主应用变成 shell 的子进程,信号转发和退出状态处理可能出现问题。
更好的写法:
#!/bin/shset-euecho"preparing configuration">&2./prepare-config.shexec"$@"exec会让目标程序替换 shell,成为容器中的主进程。
另外还要检查:
filedocker-entrypoint.sh如果脚本在 Windows 下编辑,可能带 CRLF,容器中出现:
/bin/sh^M: bad interpreter转换方式:
sed-i's/\r$//'docker-entrypoint.shchmod+x docker-entrypoint.sh四、端口问题:区分容器端口和宿主机端口
4.1-p 18080:8080到底是什么意思?
dockerrun-p18080:8080 demo-api:1.0含义是:
宿主机 18080 → 容器 8080左边是宿主机端口,右边是容器内部应用监听的端口。
最常见的错误是应用实际监听 3000,却映射到 8080:
dockerrun-p18080:8080 demo-api:1.0容器可以正常 Running,但访问18080没有响应。
检查镜像配置:
dockerimage inspect demo-api:1.0\--format'{{json .Config.ExposedPorts}}'检查容器端口绑定:
dockerport api-demo检查实际配置:
dockerinspect api-demo\--format'{{json .NetworkSettings.Ports}}'注意:Dockerfile 中的EXPOSE 8080只是元数据和说明,不会自动把端口发布到宿主机。
4.2 宿主机端口已经被占用
Linux:
ss-lntp|grep':8080'或者:
lsof-iTCP:8080-sTCP:LISTENWindows PowerShell:
Get-NetTCPConnection-LocalPort 8080-State Listen继续查进程:
Get-Process-Id 18724如果端口被占用,不要第一反应杀进程。先判断那个进程是不是正常服务。
最安全的验证方式是换一个宿主机端口:
dockerrun--rm-p18080:8080 demo-api:1.0端口冲突时不要先改一串配置。先证明 8080 被谁监听,再换 18080 验证镜像内部服务。两步能把“宿主机端口问题”和“容器内服务问题”拆开。
4.3 端口映射正确,为什么还是访问不到?
检查应用在容器中监听的地址。
错误示例:
server.listen(8080,"127.0.0.1");这只监听容器自己的回环地址。端口发布流量进入容器后,不一定能到达这个监听。
容器服务一般应监听:
server.listen(8080,"0.0.0.0");127.0.0.1和0.0.0.0的区别发生在容器内部。前者只接收容器回环流量,后者才会接收进入容器网卡的连接。宿主机端口映射写对了,并不能替应用修改监听地址。
进入容器检查:
dockerexec-itapi-demosh如果镜像中有ss:
ss-lntp或者直接从容器内部请求:
wget-qO- http://127.0.0.1:8080/health判断方式:
| 结果 | 方向 |
|---|---|
| 容器内也访问失败 | 应用未监听或应用异常 |
| 容器内成功,宿主机失败 | 端口发布、防火墙或监听地址 |
| 宿主机成功,外部机器失败 | 宿主机防火墙、云安全组、绑定地址 |
4.4 不要随意把数据库端口发布到所有网卡
dockerrun-p5432:5432 postgres默认会把端口发布到宿主机地址上,具体暴露范围还受 Docker 和防火墙配置影响。
如果只供本机访问,可以明确绑定:
dockerrun-p127.0.0.1:5432:5432 postgres如果只供同一 Docker 网络中的容器访问,甚至不需要-p。服务之间直接通过容器网络通信。
能访问不等于应该暴露。排障时临时打开的端口,解决后也要恢复最小暴露。
五、挂载与权限:容器里是哪个用户?
5.1 先看进程身份
dockerexecapi-demoid输出可能是:
uid=10001(app) gid=10001(app) groups=10001(app)再看挂载:
dockerinspect api-demo\--format'{{range .Mounts}}{{println .Source "->" .Destination .Mode}}{{end}}'假设宿主机目录:
/srv/api-data权限:
ls-ld/srv/api-data输出:
drwx------ 2 root root 4096 Jun 11 10:20 /srv/api-data容器内的 UID 10001 无法写入这个目录,应用就可能报:
EACCES: permission deniedLinux 判断权限时看的是 UID/GID,不是用户名长得是否一样。容器里的app和宿主机上的app即使同名,只要数字 ID 不同,对挂载目录的权限就可能完全不同。
5.2 不要用chmod 777当最终方案
chmod -R 777能快速判断“是不是权限问题”,但不适合作为长期配置。
更合理的处理方式包括:
sudochown-R10001:10001 /srv/api-datasudochmod-Ru+rwX,go-rwx /srv/api-data或者让容器使用与宿主机文件一致的 UID/GID。
Compose:
services:api:image:demo-api:1.0user:"1000:1000"volumes:-./data:/app/data但这里也不能机械照抄。镜像内部如果依赖特定用户、HOME 目录或文件所有权,强制切换 UID 可能引入新问题。
正确顺序是:
- 确认容器进程 UID/GID;
- 确认挂载源目录所有者和权限;
- 确认应用需要读、写还是执行;
- 只授予实际需要的权限。
5.3 文件存在,容器为什么说找不到?
检查挂载的真实结果:
dockerinspect api-demo--format'{{json .Mounts}}'常见问题:
- 相对路径相对于当前执行目录,而不是 Compose 文件所在目录;
- 宿主机文件不存在,Docker 创建了同名目录;
- 把文件挂载到应用期望的目录上,覆盖了镜像原内容;
- Windows 路径没有共享给 Docker Desktop;
- SELinux 阻止容器访问。
例如:
dockerrun-v./config.json:/app/config.json demo-api:1.0如果./config.json不存在,某些情况下宿主机会创建目录,容器中/app/config.json也变成目录。
启动前先检查:
test-f./config.json&&echookCompose 可以使用长语法,让意图更清晰:
volumes:-type:bindsource:./config.jsontarget:/app/config.jsonread_only:true5.4 SELinux 场景
在启用 SELinux 的 Linux 发行版上,即使 Unix 权限看起来正确,也可能被安全上下文阻止。
可以检查:
getenforcels-Z/srv/api-dataDocker 绑定挂载可使用合适的标签选项,例如:Z或:z,但两者共享语义不同,不能盲目添加。
不要为了验证直接永久关闭 SELinux。可以先查审计日志,确认是否真的由策略拒绝。
六、环境变量与启动命令
6.1 容器中到底拿到了哪些变量?
dockerinspect api-demo\--format'{{range .Config.Env}}{{println .}}{{end}}'或者进入容器:
dockerexecapi-demoenv|sort注意不要在公开日志中直接打印密码、Token 和数据库连接串。
更安全的做法是只检查变量是否存在:
test-n"${DATABASE_URL:-}"\&&echo"DATABASE_URL is set"\||echo"DATABASE_URL is missing"6.2 Compose 变量替换和容器环境不是一回事
Compose 文件:
services:api:image:demo-api:${APP_VERSION}environment:APP_ENV:${APP_ENV:-production}这里至少有两次处理:
- Compose 在宿主机读取
.env或当前环境,替换${...}; - 替换后的
APP_ENV才进入容器。
查看最终配置:
dockercompose config这是排查 Compose 变量最有用的命令之一。
它能让我们看到:
- 变量替换后的镜像名;
- 最终端口;
- 最终挂载路径;
- 合并后的多个 Compose 文件;
- 网络和环境配置。
如果docker compose up的行为和想象不同,先保存:
dockercompose config>resolved-compose.yml不要只盯着原始 YAML 猜。
七、网络问题:先确认“谁访问谁”
7.1 宿主机访问容器
路径:
浏览器/宿主机进程 → 宿主机发布端口 → Docker 转发 → 容器监听端口检查命令:
dockerport api-democurl-vhttp://127.0.0.1:18080/healthdockerexecapi-demowget-qO- http://127.0.0.1:8080/health这三条分别验证端口配置、宿主机访问和容器内应用。
7.2 一个容器访问另一个容器
Compose:
services:api:image:demo-api:1.0environment:DATABASE_HOST:dbdb:image:postgres:17api应该访问:
db:5432而不是:
localhost:5432容器中的localhost指容器自己,不是宿主机,也不是另一个容器。
这类错误常出现在把本地开发配置原样搬进 Compose 时。API 容器访问数据库,要使用 Compose 服务名db;写成localhost,请求只会回到 API 容器自己。
检查网络:
dockernetworklsdockerinspect api-demo--format'{{json .NetworkSettings.Networks}}'dockerinspect db--format'{{json .NetworkSettings.Networks}}'两个容器要通过服务名通信,必须处在可互通的用户定义网络中。
测试 DNS:
dockerexecapi-demo getent hosts db测试端口:
dockerexecapi-demosh-c'nc -vz db 5432'但精简镜像中可能没有getent、nc、curl。不要为了排障把一堆工具永久装进生产镜像,可以临时启动调试容器进入同一个网络:
dockerrun--rm-it\--networkdemo_default\nicolaka/netshoot如果环境不允许使用额外镜像,也可以使用已有基础镜像做最小测试。
7.3depends_on不等于依赖服务已经就绪
Compose 中:
depends_on:-db主要表达启动顺序,不代表数据库已经完成初始化并可以接受连接。
更稳妥的处理:
- 为数据库配置健康检查;
- 应用连接失败时做有限退避重试;
- 把“容器启动”与“服务就绪”分开;
- 不要用固定
sleep 30当长期方案。
固定等待时间在自己电脑上可能够,换到慢磁盘或 CI 环境就不够;设置太长又会浪费每次启动时间。
八、健康检查失败,但应用明明能访问
8.1 HEALTHCHECK 在哪个环境执行?
健康检查命令在容器内部执行。
Dockerfile:
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1如果镜像里没有curl,每次检查都会返回 127。
健康检查不是从宿主机替你访问服务,而是在容器里执行命令。因此命令是否存在、工作目录、环境变量和容器内端口都要成立。浏览器能打开页面,不代表这条检查命令一定能运行。
查看结果:
dockerinspect api-demo\--format'{{json .State.Health}}'手动执行同一条命令:
dockerexecapi-demosh-c\'curl -f http://localhost:8080/health'如果提示curl: not found,问题不在应用。
可以改成镜像已有的工具,或者在应用中提供轻量自检命令:
HEALTHCHECK CMD ["node", "healthcheck.js"]8.2 健康检查不要检查太多东西
一个/health接口同时检查:
- 数据库;
- Redis;
- 第三方支付;
- 邮件服务;
- 对象存储;
只要任何一个外部服务短暂抖动,容器就变成 unhealthy,甚至被编排系统重启。
建议区分:
| 检查 | 目的 |
|---|---|
| liveness | 进程是否还能工作,失败时可考虑重启 |
| readiness | 当前是否适合接收流量 |
| startup | 启动是否完成,给慢启动程序宽限时间 |
Docker 原生HEALTHCHECK只有一套状态,复杂系统要在应用和编排层继续区分。
8.3 启动宽限时间
应用初始化需要 40 秒,但健康检查从第 5 秒开始,每 3 次失败就 unhealthy。
可以设置:
HEALTHCHECK \ --interval=10s \ --timeout=3s \ --start-period=45s \ --retries=3 \ CMD ["node", "healthcheck.js"]start-period不是越长越好,而是要覆盖正常启动的合理上限。
九、最小化复现比反复改配置有效
9.1 从最小命令开始
复杂 Compose 启动失败时,可以先绕开编排文件:
dockerrun--rmdemo-api:1.0再逐项加入:
dockerrun--rm\-eAPP_ENV=production\demo-api:1.0然后加入端口:
dockerrun--rm\-eAPP_ENV=production\-p18080:8080\demo-api:1.0最后加入挂载和网络。
当某一步开始失败,新增的那一组配置就是优先检查对象。
9.2 覆盖入口命令进入镜像
容器一启动就退出,无法docker exec,可以覆盖入口:
dockerrun--rm-it\--entrypointsh\demo-api:1.0进入后检查:
pwdls-laidenv|sortnode--versionnodeserver.js这能区分:
- 文件没复制进去;
- 工作目录不对;
- 运行用户无权限;
- 依赖缺失;
- 应用本身启动失败。
9.3 比较镜像默认配置和容器实际配置
镜像:
dockerimage inspect demo-api:1.0\--format'user={{.Config.User}} workdir={{.Config.WorkingDir}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}'容器:
dockerinspect api-demo\--format'user={{.Config.User}} workdir={{.Config.WorkingDir}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}'如果两者不同,说明运行参数或 Compose 覆盖了镜像设置。
把前面的检查串起来,最终应该得到一组能互相印证的输出:容器状态说明它停在哪,inspect给出退出码,日志保留第一条可执行的错误。
总结
Docker 排障不是记住越多命令越好,而是每次都知道当前在验证哪一层。
排查的顺序其实很简单:先看状态(Exited / Restarting / Running / unhealthy),再看退出码和第一段日志,然后检查启动命令、环境变量和 Compose 解析结果。权限问题用 UID/GID 和真实挂载信息来判断。端口要分清三个层:宿主机端口、容器端口、应用监听地址。容器间访问和宿主机访问容器也要分开看。复杂配置失败就做最小化复现,修好以后别只记“重启好了”,把验证命令也留下来。
- 先看状态,确认是 Exited、Restarting、Running 还是 unhealthy;
- 再看退出码和第一段日志;
- 检查最终启动命令、环境变量和 Compose 解析结果;
- 用 UID/GID 和真实挂载信息判断权限;
- 分清宿主机端口、容器端口与应用监听地址;
- 分清宿主机访问容器和容器访问容器;
- 手动执行健康检查命令;
- 复杂配置失败时,做最小化复现;
- 修好以后保留验证命令,不要只留下“重启好了”。
容器显示 Running 只证明主进程没挂,业务能不能用还得看端口、依赖和健康检查。排查的目标不是“这次起来了”,是找到能复现、能解释、能验证的根因。
参考资料
- Docker Docs:查看容器日志
https://docs.docker.com/reference/cli/docker/container/logs/ - Docker Docs:发布和暴露端口
https://docs.docker.com/get-started/docker-concepts/running-containers/publishing-ports/ - Docker Docs:Port publishing and mapping
https://docs.docker.com/engine/network/port-publishing/ - Docker Docs:Dockerfile HEALTHCHECK
https://docs.docker.com/reference/dockerfile/#healthcheck - Docker Docs:Bind mounts
https://docs.docker.com/engine/storage/bind-mounts/ - Docker Docs:Compose 配置解析
https://docs.docker.com/reference/cli/docker/compose/config/