这两天一直在研究这个话题,踩了几个坑,把遇到的东西整理成文,供有需要的朋友参考。
🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然
🏠博主简介
文章目录
二、建立一条固定的排障证据链三、日志:先抓住第一次失败 四、端口问题:区分容器端口和宿主机端口 五、挂载与权限:容器里是哪个用户? 六、环境变量与启动命令 七、网络问题:先确认“谁访问谁” 八、健康检查失败,但应用明明能访问 九、最小化复现比反复改配置有效 总结参考资料前言
Docker 容器启动失败时,很多人的第一反应是:
docker restart 容器名
不行就删容器、重新构建,再不行就清缓存、重启 Docker。
偶尔确实能恢复,但问题也被一起藏起来了。下一次换一台机器、换一个端口、换一个挂载目录,同样的错误还会重新冒出来。
容器排障最麻烦的地方,不是命令太多,而是故障可能来自不同层:
- 镜像本身没有问题,但启动命令写错了;
- 主进程能运行,但没有权限读取挂载目录;
- 容器显示 Running,但应用只监听
127.0.0.1; - 端口映射写对了,但宿主机端口已经被占用;
- 服务已经启动,但健康检查依赖了镜像里不存在的
curl; - 应用可以访问 IP,却不能解析 Compose 服务名;
- 日志只显示最后一次重启,真正的第一条错误已经被刷掉了。
这篇文章不背命令表,而是搭一条排障证据链。遇到容器起不来时,先判断它停在哪一层,再使用对应命令验证。
文中示例是我为排障流程重新设计的实验场景,不引用其他文章内容。命令以 Linux/Docker 通用写法为主,涉及宿主机端口检查时同时给出 Windows PowerShell 用法。
一、先别急着看日志,先看容器状态
1.1 “启动失败”说实话至少有四种状态
用户说“容器起不来”,可能指下面任何一种情况:
状态现象优先检查Created容器创建了,但主进程没真正启动启动参数、运行时错误Exited主进程已经退出退出码、日志、入口脚本Restarting主进程反复退出,被重启策略拉起第一段日志、健康检查、依赖Running容器活着,但业务访问不到监听地址、端口、网络、防火墙
所以第一条命令不是 docker logs,而是:
docker ps -a
只看目标容器:
docker ps -a --filter name=api-demo
进一步查看状态:
docker inspect api-demo \
--format 'status={{.State.Status}} exit={{.State.ExitCode}} error={{.State.Error}}'
示例:
status=exited exit=1 error=
这个结果至少说明两件事:
1. Docker 已经成功创建并启动过容器;
2. 容器里的主进程自己以状态码 1 退出。
这时继续研究宿主机防火墙没有意义,应先看进程为什么退出。
1.2 退出码能告诉我们多少?
常见退出码可以作为方向,但不能当作最终结论:
退出码常见含义0进程正常收尾,但服务型容器不应该立刻收尾1应用通用错误126命令存在,但不可执行127找不到命令137收到 SIGKILL,可能是 OOM 或人工强制停止139段错误143收到 SIGTERM,常见于正常停止流程
查看完整状态:
docker inspect api-demo --format '{{json .State}}'
如果有 jq:
docker inspect api-demo --format '{{json .State}}' | jq
重点字段包括:
Status
Running
ExitCode
Error
StartedAt
FinishedAt
OOMKilled
Health
ExitCode=137 时不要直接认定为 OOM,应继续看:
docker inspect api-demo --format 'oom={{.State.OOMKilled}}'
如果 OOMKilled=false,也可能是管理员执行了 docker kill,或者外部编排系统强制终止。
状态码负责缩小范围,不负责替我们下结论。
二、建立一条固定的排障证据链
我习惯按照下面顺序排查:
状态
→ 退出码和错误
→ 应用日志
→ 启动配置
→ 文件与权限
→ 监听地址和端口
→ 容器网络与依赖
→ 健康检查
→ 最小化复现
这条顺序不是绝对的,但能避免常见的跳跃:
网页打不开
→ 怀疑 Docker 网络
→ 重建 network
→ 清理镜像
→ 最后发现应用根本没有启动
排障时每一步都要留下证据。
例如,不要只说“端口应该没问题”,而要给出:
docker port api-demo
ss -lntp
curl -v http://127.0.0.1:18080/health
命令的结果能互相验证,才算把这一层排除。
三、日志:先抓住第一次失败
3.1 最常用的 docker logs
docker logs api-demo
容器重启很多次时,日志可能很长。先看末尾:
docker logs --tail 100 api-demo
持续跟踪:
docker logs -f --tail 50 api-demo
带时间戳:
docker logs -t --tail 100 api-demo
只看最近十分钟:
docker logs --since 10m api-demo
如果容器处于 Restarting,我更建议先关闭自动重启干扰:
docker update --restart=no api-demo
docker stop api-demo
docker start -a api-demo
docker start -a 会把当前启动过程直接附加到终端,第一条错误更容易看到。
这一组输出应该连着看:ps -a 确认容器状态,inspect 给出退出码和 OOM 标记,logs 保留应用报错。单独看到一个 Exited (1),信息还不够。
调完后再恢复策略:
docker update --restart=unless-stopped api-demo
3.2 为什么有时 docker logs 是空的?
docker logs 只能读取容器主进程写到标准输出和标准错误的内容。
如果应用把日志写进:
/var/log/app/app.log
而没有输出到 stdout/stderr,Docker 日志自然为空。
检查日志驱动:
docker inspect 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/sh
set -e
echo "preparing configuration"
./prepare-config.sh
$@
这里的 $@ 没有使用 exec。主应用变成 shell 的子进程,信号转发和退出状态处理可能冒出来问题。
更好的写法:
#!/bin/sh
set -eu
echo "preparing configuration" >&2
./prepare-config.sh
exec "$@"
exec 会让目标程序替换 shell,成为容器中的主进程。
另外还要检查:
file docker-entrypoint.sh
如果脚本在 Windows 下编辑,可能带 CRLF,容器中冒出来:
/bin/sh^M: bad interpreter
转换方法:
sed -i 's/\r$//' docker-entrypoint.sh
chmod +x docker-entrypoint.sh
四、端口问题:区分容器端口和宿主机端口
4.1 -p 18080:8080 到底是什么意思?
docker run -p 18080:8080 demo-api:1.0
含义是:
宿主机 18080 → 容器 8080
左边是宿主机端口,右边是容器内部应用监听的端口。
最常见的错误是应用实际监听 3000,却映射到 8080:
docker run -p 18080:8080 demo-api:1.0
容器可以正常 Running,但访问 18080 没有响应。
检查镜像配置:
docker image inspect demo-api:1.0 \
--format '{{json .Config.ExposedPorts}}'
检查容器端口绑定:
docker port api-demo
检查实际配置:
docker inspect api-demo \
--format '{{json .NetworkSettings.Ports}}'
注意:Dockerfile 中的 EXPOSE 8080 只是元数据和说明,不会自动把端口发布到宿主机。
4.2 宿主机端口已经被占用
Linux:
ss -lntp | grep ':8080'
或者:
lsof -iTCP:8080 -sTCP:LISTEN
Windows PowerShell:
Get-NetTCPConnection -LocalPort 8080 -State Listen
继续查进程:
Get-Process -Id 18724
如果端口被占用,不要第一反应杀进程。先判断那个进程是不是正常服务。
最安全的验证方法是换一个宿主机端口:
docker run --rm -p 18080: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 的区别发生在容器内部。前者只接收容器回环流量,后者才会接收进入容器网卡的连接。宿主机端口映射写对了,并不能替应用修改监听地址。
进入容器检查:
docker exec -it api-demo sh
如果镜像中有 ss:
ss -lntp
或者直接从容器内部请求:
wget -qO- http://127.0.0.1:8080/health
判断方法:
结果方向容器内也访问失败应用未监听或应用异常容器内成功,宿主机失败端口发布、防火墙或监听地址宿主机成功,外部机器失败宿主机防火墙、云安全组、绑定地址
4.4 不要随意把数据库端口发布到所有网卡
docker run -p 5432:5432 postgres
默认会把端口发布到宿主机地址上,具体暴露范围还受 Docker 和防火墙配置影响。
如果只供本机访问,可以明确绑定:
docker run -p 127.0.0.1:5432:5432 postgres
如果只供同一 Docker 网络中的容器访问,甚至不需要 -p。服务之间直接通过容器网络通信。
能访问不等于应该暴露。排障时临时打开的端口,解决后也要恢复最小暴露。
五、挂载与权限:容器里是哪个用户?
5.1 先看进程身份
docker exec api-demo id
输出可能是:
uid=10001(app) gid=10001(app) groups=10001(app)
再看挂载:
docker inspect 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 denied
Linux 判断权限时看的是 UID/GID,不是用户名长得是否一样。容器里的 app 和宿主机上的 app 即使同名,只要数字 ID 不同,对挂载目录的权限就可能完全不同。
5.2 不要用 chmod 777 当最终方案
chmod -R 777 能快速判断“是不是权限问题”,但不适合作为长期配置。
更合理的处理方式包括:
sudo chown -R 10001:10001 /srv/api-data
sudo chmod -R u+rwX,go-rwx /srv/api-data
或者让容器使用与宿主机文件一致的 UID/GID。
Compose:
services:
api:
image: demo-api:1.0
user: "1000:1000"
volumes:
- ./data:/app/data
但这里也不能机械照抄。镜像内部如果依赖特定用户、HOME 目录或文件所有权,强制切换 UID 可能引入新问题。
正确顺序是:
1. 确认容器进程 UID/GID;
2. 确认挂载源目录所有者和权限;
3. 确认应用需要读、写还是执行;
4. 只授予实际需要的权限。
5.3 文件存在,容器为什么说找不到?
检查挂载的真实结果:
docker inspect api-demo --format '{{json .Mounts}}'
常见问题:
- 相对路径相对于当前执行目录,而不是 Compose 文件所在目录;
- 宿主机文件不存在,Docker 创建了同名目录;
- 把文件挂载到应用期望的目录上,覆盖了镜像原内容;
- Windows 路径没有共享给 Docker Desktop;
- SELinux 阻止容器访问。
docker run -v ./config.json:/app/config.json demo-api:1.0
如果 ./config.json 不存在,某些情况下宿主机会创建目录,容器中 /app/config.json 也变成目录。
启动前先检查:
test -f ./config.json && echo ok
Compose 可以使用长语法,让意图更清晰:
volumes:
- type: bind
source: ./config.json
target: /app/config.json
read_only: true
5.4 SELinux 场景
在启用 SELinux 的 Linux 发行版上,即使 Unix 权限看起来正确,也可能被安全上下文阻止。
可以检查:
getenforce
ls -Z /srv/api-data
Docker 绑定挂载可使用合适的标签选项,例如 :Z 或 :z,但两者共享语义不同,不能盲目添加。
不要为了验证直接永久关闭 SELinux。可以先查审计日志,确认是否真的由策略拒绝。
六、环境变量与启动命令
6.1 容器中到底拿到了哪些变量?
docker inspect api-demo \
--format '{{range .Config.Env}}{{println .}}{{end}}'
或者进入容器:
docker exec api-demo env | 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}
这里至少有两次处理:
1. Compose 在宿主机读取 .env 或当前环境,替换 ${...};
2. 替换后的 APP_ENV 才进入容器。
查看最终配置:
docker compose config
这是排查 Compose 变量最有用的命令之一。
它能让我们看到:
- 变量替换后的镜像名;
- 最终端口;
- 最终挂载路径;
- 合并后的多个 Compose 文件;
- 网络和环境配置。
docker compose up 的行为和想象不同,先保存:
docker compose config > resolved-compose.yml
不要只盯着原始 YAML 猜。
七、网络问题:先确认“谁访问谁”
7.1 宿主机访问容器
路径:
浏览器/宿主机进程
→ 宿主机发布端口
→ Docker 转发
→ 容器监听端口
检查命令:
docker port api-demo
curl -v http://127.0.0.1:18080/health
docker exec api-demo wget -qO- http://127.0.0.1:8080/health
这三条分别验证端口配置、宿主机访问和容器内应用。
7.2 一个容器访问另一个容器
Compose:
services:
api:
image: demo-api:1.0
environment:
DATABASE_HOST: db
db:
image: postgres:17
api 应该访问:
db:5432
而不是:
localhost:5432
容器中的 localhost 指容器自己,不是宿主机,也不是另一个容器。
这类错误常出现在把本地开发配置原样搬进 Compose 时。API 容器访问数据库,要使用 Compose 服务名 db;写成 localhost,请求只会回到 API 容器自己。
检查网络:
docker network ls
docker inspect api-demo --format '{{json .NetworkSettings.Networks}}'
docker inspect db --format '{{json .NetworkSettings.Networks}}'
两个容器要通过服务名通信,必须处在可互通的用户定义网络中。
测试 DNS:
docker exec api-demo getent hosts db
测试端口:
docker exec api-demo sh -c 'nc -vz db 5432'
但精简镜像中可能没有 getent、nc、curl。不要为了排障把一堆工具永久装进生产镜像,可以临时启动调试容器进入同一个网络:
docker run --rm -it \
--network demo_default \
nicolaka/netshoot
如果环境不允许使用额外镜像,也可以使用已有基础镜像做最小测试。
7.3 depends_on 不等于依赖服务已经就绪
Compose 中:
depends_on:
- db
核心表达启动顺序,不代表数据库已经完成初始化并可以接受连接。
更稳妥的处理:
- 为数据库配置健康检查;
- 应用连接失败时做有限退避重试;
- 把“容器启动”与“服务就绪”分开;
- 不要用固定
sleep 30当长期方案。
八、健康检查失败,但应用明明能访问
8.1 HEALTHCHECK 在哪个环境执行?
健康检查命令在容器内部执行。
Dockerfile:
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
如果镜像里没有 curl,每次检查都会返回 127。
健康检查不是从宿主机替你访问服务,而是在容器里执行命令。因此命令是否存在、工作目录、环境变量和容器内端口都要成立。浏览器能打开页面,不代表这条检查命令一定能运行。
查看结果:
docker inspect api-demo \
--format '{{json .State.Health}}'
手动执行同一条命令:
docker exec api-demo sh -c \
'curl -f http://localhost:8080/health'
如果提示 curl: not found,问题不在应用。
可以改成镜像已有的工具,或者在应用中给出轻量自检命令:
HEALTHCHECK CMD ["node", "healthcheck.js"]
8.2 健康检查不要检查太多东西
一个 /health 接口同时检查:
- 数据库;
- Redis;
- 第三方支付;
- 邮件服务;
- 对象存储;
建议区分:
检查目的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 启动失败时,可以先绕开编排文件:
docker run --rm demo-api:1.0
再逐项加入:
docker run --rm \
-e APP_ENV=production \
demo-api:1.0
随后加入端口:
docker run --rm \
-e APP_ENV=production \
-p 18080:8080 \
demo-api:1.0
最后加入挂载和网络。
当某一步开始失败,新增的那一组配置就是优先检查对象。
9.2 覆盖入口命令进入镜像
容器一启动就退出,无法 docker exec,可以覆盖入口:
docker run --rm -it \
--entrypoint sh \
demo-api:1.0
进入后检查:
pwd
ls -la
id
env | sort
node --version
node server.js
这能区分:
- 文件没复制进去;
- 工作目录不对;
- 运行用户无权限;
- 依赖缺失;
- 应用本身启动失败。
9.3 比较镜像默认配置和容器实际配置
镜像:
docker image inspect demo-api:1.0 \
--format 'user={{.Config.User}} workdir={{.Config.WorkingDir}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}'
容器:
docker inspect 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 和真实挂载信息来判断。端口要分清三个层:宿主机端口、容器端口、应用监听地址。容器间访问和宿主机访问容器也要分开看。复杂配置失败就做最小化复现,修好以后别只记“重启好了”,把验证命令也留下来。
1. 先看状态,确认是 Exited、Restarting、Running 还是 unhealthy;
2. 再看退出码和第一段日志;
3. 检查最终启动命令、环境变量和 Compose 解析结果;
4. 用 UID/GID 和真实挂载信息判断权限;
5. 分清宿主机端口、容器端口与应用监听地址;
6. 分清宿主机访问容器和容器访问容器;
7. 手动执行健康检查命令;
8. 复杂配置失败时,做最小化复现;
9. 修好以后保留验证命令,不要只留下“重启好了”。
容器显示 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/
本次分享就到这里。技术这东西越研究越有意思,后续有新的收获我也会继续更新。
评论 (0)
暂无评论