笔记 | 【Linux排障实战】Docker容器启动失败怎么查:端口、日志、权限与网络(学习笔记)

这两天一直在研究这个话题,踩了几个坑,把遇到的东西整理成文,供有需要的朋友参考。

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、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

如果是 nonedocker 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.10.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'

但精简镜像中可能没有 getentnccurl。不要为了排障把一堆工具永久装进生产镜像,可以临时启动调试容器进入同一个网络:

docker run --rm -it \
  --network demo_default \
  nicolaka/netshoot

如果环境不允许使用额外镜像,也可以使用已有基础镜像做最小测试。

7.3 depends_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。

健康检查不是从宿主机替你访问服务,而是在容器里执行命令。因此命令是否存在、工作目录、环境变量和容器内端口都要成立。浏览器能打开页面,不代表这条检查命令一定能运行。

查看结果:

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;
  • 第三方支付;
  • 邮件服务;
  • 对象存储;
只要任何一个外部服务短暂抖动,容器就变成 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 启动失败时,可以先绕开编排文件:

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 只证明主进程没挂,业务能不能用还得看端口、依赖和健康检查。排查的目标不是“这次起来了”,是找到能复现、能解释、能验证的根因。


参考资料


本次分享就到这里。技术这东西越研究越有意思,后续有新的收获我也会继续更新。

评论 (0)

暂无评论