Docker 基础体系 · 第 19/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 故障排查:启动失败、网络、磁盘、OOM、构建和 Daemon

Docker 故障排查的难点,不在于记住更多命令,而在于先判断故障发生在哪一层:客户端是否连接到了正确的 Daemon,Daemon 是否能创建容器,容器内的主进程是否成功启动,网络命名空间和 DNS 是否正确,存储和内存是否满足约束,最后才是应用本身的错误。

本文以运行在 Linux 上的现代 Docker Engine、BuildKit 和 Compose 为范围。Docker Desktop、Windows 容器、Kubernetes CRI、Swarm 集群控制面有额外差异,不能直接套用所有路径和命令。


一、先建立故障模型:命令、Daemon、容器和应用是四个不同对象

一次常见的启动操作可以抽象为:

docker CLI
   │ Unix socket / TCP / SSH
   ▼
dockerd
   │ containerd / runc
   ▼
容器进程:PID 1
   │
   ├── 文件系统、挂载、用户、环境变量
   ├── 网络命名空间、DNS、端口发布
   └── cgroup 资源限制

这里有四类容易混淆的故障:

  1. 客户端故障docker 命令不存在、当前用户没有访问 Unix socket 的权限、DOCKER_HOST 或 Docker context 指向错误位置。
  2. Daemon 故障dockerd 没启动、启动配置错误、存储驱动无法初始化、iptables 或 cgroup 初始化失败。
  3. 容器生命周期故障:镜像拉取成功,但容器创建、挂载、网络配置或启动命令失败。
  4. 应用故障:容器已经启动,PID 1 执行后立即退出,或者应用运行中崩溃、拒绝连接、健康检查失败。

因此,第一步应当确认客户端实际连接的对象:

docker context show
docker context ls
docker version
docker info

典型含义如下:

  • docker context show 显示当前 context,例如 default
  • docker version 同时包含 ClientServer 部分;只有 Client 而没有 Server,通常表示 CLI 无法连接 Daemon。
  • docker info 能进一步显示存储驱动、容器数量、Cgroup、日志驱动和根目录等信息。

如果出现:

Cannot connect to the Docker daemon at unix:///var/run/docker.sock

这还不能直接证明 Daemon 已停止。也可能是:

  • 当前 context 指向了不存在的远程主机;
  • DOCKER_HOST 覆盖了预期配置;
  • 当前用户没有访问 /var/run/docker.sock 的权限;
  • Daemon 监听的是 TCP 或 SSH,而客户端仍连接 Unix socket。

可以先检查:

env | grep -E '^(DOCKER_HOST|DOCKER_CONTEXT)='
ls -l /var/run/docker.sock
docker context inspect "$(docker context show)"

Unix socket 的常见权限形态是:

srw-rw---- 1 root docker ... /var/run/docker.sock

把用户加入 docker 组等价于授予接近 root 的控制能力,因为 Docker API 可以创建特权容器、挂载宿主机根文件系统。因此这不是普通的“修复权限”操作,应按宿主机高权限变更处理。


二、统一证据顺序:先确定状态,再解释原因

1. 容器状态不等于应用健康状态

先列出所有容器,而不是只看运行中的容器:

docker ps -a

docker ps -a 中的状态至少要区分:

  • Created:容器对象已创建,但进程尚未启动;
  • Up ...:容器主进程仍在运行;
  • Exited (0):主进程正常退出;
  • Exited (1):应用以错误码退出;
  • Exited (137):常见于收到 SIGKILL,可能是 OOM,也可能是人工执行了 docker kill
  • Restarting:重启策略正在反复拉起失败的容器;
  • Dead:Docker 无法正常清理或完成容器状态转换。

然后查看结构化状态:

docker inspect --format '
Name={{.Name}}
Status={{.State.Status}}
Running={{.State.Running}}
ExitCode={{.State.ExitCode}}
Error={{.State.Error}}
OOMKilled={{.State.OOMKilled}}
StartedAt={{.State.StartedAt}}
FinishedAt={{.State.FinishedAt}}
' CONTAINER

State.Error 表示 Docker 在创建或启动容器时记录的错误;ExitCodeOOMKilled 更偏向主进程已经启动后的结果。若容器处于 Created,通常还没有应用进程可供 docker logs 查看。

2. 日志、事件和内核日志记录的是不同时间线

docker logs --timestamps --tail=200 CONTAINER
docker events --since 10m
journalctl -u docker.service --since "10 minutes ago"
dmesg -T | grep -i -E 'oom|killed process|memory cgroup'

四类信息的边界不同:

  • docker logs 读取容器进程的标准输出和标准错误,具体行为取决于 logging driver;
  • docker events 记录容器创建、启动、停止、销毁、网络和镜像等 Docker 事件;
  • journalctl -u docker 记录 Daemon 日志;
  • dmesg 记录 Linux 内核事件,例如 cgroup OOM、网卡、文件系统和内核级网络错误。

一个应用“没有日志”可能是应用没有写标准输出,也可能是容器在进程启动前就因挂载失败而退出,还可能是日志驱动把日志发送到远端后本地不再保留。不能仅凭 docker logs 为空判断应用没有执行。


三、启动失败:从创建、启动到应用退出逐步定位

1. 容器启动的实际阶段

一个容器大致经历以下阶段:

解析镜像和配置
  ↓
创建容器元数据
  ↓
准备 rootfs、可写层、挂载和网络
  ↓
创建 namespace 与 cgroup
  ↓
启动 OCI runtime 和 PID 1
  ↓
PID 1 执行 ENTRYPOINT/CMD
  ↓
应用运行或退出

不同阶段的错误表现不同:

  • 镜像不存在:通常在创建容器前失败;
  • bind mount 源路径不存在、权限不足或类型不匹配:容器可能无法启动;
  • exec format error:常见于镜像架构与宿主机架构不匹配,或入口脚本格式错误;
  • permission denied:可能是入口文件不可执行、用户权限不足、挂载文件系统带 noexec,或 SELinux/AppArmor 拒绝;
  • 应用立即退出:容器状态通常是 Exited,日志和退出码比 Daemon 日志更有价值。

先看镜像架构和容器配置:

docker image inspect IMAGE \
  --format 'OS={{.Os}} ARCH={{.Architecture}} Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}'

docker inspect CONTAINER \
  --format 'Path={{.Path}} Args={{json .Args}} User={{.Config.User}} WorkingDir={{.Config.WorkingDir}}'

ENTRYPOINTCMD 的组合决定实际执行命令。若镜像定义:

ENTRYPOINT ["./server"]
CMD ["--port", "8080"]

实际命令通常是:

./server --port 8080

而 Compose 中的 command 通常会覆盖镜像的 CMD,不等价于无条件替换 ENTRYPOINT。把 shell 形式和 exec 形式混用,可能导致信号处理和参数展开不同:

# exec form,PID 1 直接是程序
ENTRYPOINT ["/usr/local/bin/server"]

# shell form,可能通过 /bin/sh -c 执行
ENTRYPOINT /usr/local/bin/server

生产中如果应用需要正确接收 SIGTERM,通常应让真正的应用进程成为 PID 1,或使用能够正确转发信号的 init 进程。否则 docker stop 发送的终止信号可能没有到达子进程,最终等待超时并发送 SIGKILL

2. 用一次性容器区分镜像问题和编排问题

如果怀疑 Compose 的环境变量、挂载或网络配置,可以先脱离 Compose 测试镜像:

docker run --rm --entrypoint /bin/sh IMAGE -c '
  id
  pwd
  ls -l /usr/local/bin/server
'

这个命令的前提是镜像中存在 /bin/sh。Alpine、Debian、Distroless 镜像的工具集合不同;Distroless 镜像可能没有 shell,此时不能把“没有 /bin/sh”误判为应用损坏。

检查 Compose 最终展开结果:

docker compose config

它会把多个 Compose 文件、变量替换和 profiles 展开成实际配置。重点核对:

  • imagebuild 是否指向预期对象;
  • commandentrypoint 是否覆盖了镜像默认值;
  • 环境变量是否为空或被错误替换;
  • 相对路径是相对于 Compose 文件项目目录解析,还是来自当前 shell;
  • 端口、网络、卷和资源限制是否被覆盖。

3. 反例:容器“启动失败”其实是启动成功后正常退出

下面的容器会创建成功并立即退出:

docker run --name demo alpine sh -c 'echo started; exit 0'
docker ps -a --filter name=demo
docker logs demo

预期可以看到:

started
Exited (0)

容器的生命周期绑定 PID 1。docker run -d alpine 也不会自动保持运行,因为默认命令完成后容器就结束。调试时可以使用:

docker run --rm -it --entrypoint sh IMAGE

但这只证明 shell 能启动,不能证明原始入口程序、环境变量和挂载都正确。调试完成后仍需按实际入口命令复现。


四、网络故障:先区分容器内、Docker 网络和外部网络

1. Docker 网络的数据路径

Linux 容器通常拥有独立的 network namespace。连接到用户自定义 bridge 网络时,典型路径是:

容器进程
  ↓
容器 eth0
  ↓ veth pair
宿主机 bridge,例如 br-xxxx
  ↓
iptables/nftables 转发、NAT
  ↓
宿主机网卡 eth0
  ↓
外部网络

容器访问宿主机发布的端口、访问其他容器、访问互联网,走的路径并不相同:

  • 容器到容器:通常通过 Docker bridge 和容器 IP;
  • 宿主机到容器发布端口:通过端口发布规则;
  • 容器到外部网络:通常经过宿主机 IP 转发和 SNAT;
  • 外部到容器:只有发布端口等规则存在时,才会被 NAT 到容器。

查看网络对象和连接关系:

docker network ls
docker network inspect NETWORK
docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}'

2. EXPOSE 不等于端口发布

Dockerfile 中的:

EXPOSE 8080

主要是镜像元数据,表示应用预期监听 8080,并不会自动让宿主机可以访问。

运行时发布端口:

docker run -d --name web -p 127.0.0.1:8080:80 nginx

含义是:

宿主机 127.0.0.1:8080 → 容器 80

如果写成:

-p 8080:80

通常会绑定宿主机所有地址,而不只是回环地址。查看实际绑定:

docker port web
ss -lntp | grep 8080

若应用在容器内只监听 127.0.0.1:80,即使发布了端口,Docker 转发到容器 eth0 的流量也可能无法访问。容器内服务通常需要监听:

0.0.0.0:80

而不是仅监听容器内部回环地址。诊断可以在容器内执行:

docker exec web ss -lntp

前提是镜像中有 ss;也可以使用临时诊断容器加入目标网络命名空间进行测试。

3. DNS:服务名解析和端口连接是两件事

用户自定义网络通常提供 Docker 内置 DNS。Compose 中,同一网络上的服务可以使用服务名解析:

services:
  api:
    image: example/api
  db:
    image: postgres:16

api 容器访问数据库应使用:

db:5432

而不是:

localhost:5432

因为容器内的 localhost 指向当前容器自身。db 解析到的是数据库容器在该网络上的地址,连接使用的是数据库容器端口,不是宿主机发布端口。

诊断:

docker exec api getent hosts db
docker exec api cat /etc/resolv.conf
docker exec api sh -c 'nc -vz db 5432'

getentnc 不一定存在。可以运行临时工具容器:

docker run --rm --network container:api nicolaka/netshoot \
  sh -c 'getent hosts db && nc -vz db 5432'

--network container:api 让临时容器共享 api 的网络命名空间;它会看到相同的接口、路由和 DNS 配置。该操作只适合诊断,镜像来源和安全性应符合生产环境要求。

常见误区包括:

  • ping 失败不等于 TCP 服务不可用,镜像可能没有 ping,网络也可能过滤 ICMP;
  • DNS 能解析不等于目标端口有监听;
  • TCP 连接成功不等于应用协议正确,HTTP、TLS、认证仍可能失败;
  • Compose 服务名只在共享网络内有效,不是全局 DNS;
  • 容器重建后 IP 可能变化,应用不应硬编码容器 IP。

4. 宿主机防火墙和 Docker 规则

Docker 发布端口通常依赖 Linux 转发、NAT 及防火墙规则。若宿主机启用了 firewalld、ufw、nftables 自定义规则,或者云平台安全组拒绝流量,容器内服务可能正常而外部访问失败。

按层验证:

# 容器内是否监听
docker exec web ss -lnt

# 宿主机本地访问发布端口
curl -v http://127.0.0.1:8080/

# 查看路由和接口
ip addr
ip route

# 查看防火墙规则,命令依发行版而异
sudo nft list ruleset
sudo iptables -t nat -S

不要为了“快速验证”直接清空 iptables/nftables 规则。这样可能断开 SSH、暴露其他服务,或破坏系统防火墙管理工具的状态。应先保存规则并明确修改来源。

5. Overlay 的边界

Overlay 网络用于跨 Docker 主机连接容器,典型场景是 Swarm service。它需要节点间的控制和数据路径,并涉及 VXLAN、节点发现以及相应端口;具体端口和实现取决于部署方式与防火墙配置。

普通 docker compose up 在单机上通常使用 bridge 网络。Compose 文件中写出:

networks:
  app:
    driver: overlay

并不意味着单机 Compose 自动获得完整的多主机编排能力。排查 Overlay 时必须同时检查:

  • 节点是否加入同一 Swarm;
  • 节点间控制平面和数据平面是否连通;
  • service/task 是否实际连接了该 Overlay 网络;
  • 加密、MTU 和底层网络是否匹配。

因此,不能用“单机容器之间能通信”推断 Overlay 跨节点一定正常。


五、磁盘故障:区分镜像层、容器可写层、卷、日志和 BuildKit 缓存

1. Docker 使用的磁盘不是一个单一空间

Docker 相关空间至少包括:

镜像只读层
容器可写层
命名卷
bind mount 对应的宿主机目录
容器日志
BuildKit 构建缓存
镜像和容器元数据

查看概览:

docker system df
docker system df -v
df -h
df -i

df -h 查看文件系统块空间;df -i 查看 inode。磁盘还有空间但 inode 耗尽时,创建文件同样会失败,例如:

no space left on device

docker system df 主要展示 Docker 认为可回收或正在使用的对象,不一定覆盖 bind mount 目录中应用自己写入的所有文件。

2. 容器写入层的增长机制

镜像层是只读的。容器运行时对已有文件的修改通常触发 copy-up:文件从只读层复制到容器可写层,再在可写层中修改。删除镜像中的大文件也不会让镜像层缩小,因为只是在上层创建删除标记。

例如:

FROM alpine
RUN dd if=/dev/zero of=/bigfile bs=1M count=200
RUN rm /bigfile

最终镜像仍可能包含接近 200 MB 的历史层,因为 rm 只发生在后续层。正确的构建方式是将生成和删除放在同一层:

FROM alpine
RUN dd if=/dev/zero of=/tmp/bigfile bs=1M count=200 \
    && rm /tmp/bigfile

实际生产中不应为了示例生成无意义的大文件,但这个差异说明了镜像层的不可变性质。

查看容器写入量:

docker ps -s
docker inspect CONTAINER \
  --format 'UpperDir={{.GraphDriver.Data.UpperDir}}'

GraphDriver.Data.UpperDir 属于实现细节,存储驱动不同、rootless 模式或未来版本下可能不同;不要把它作为应用写入路径。更可靠的是让应用数据写入卷或 bind mount,再从宿主机直接检查该目录。

3. 卷和 bind mount 的重要区别

docker volume ls
docker volume inspect VOLUME
findmnt

命名卷由 Docker 管理,默认位于 Docker 数据根目录下的某个卷路径;bind mount 使用宿主机指定目录。两者都绕过容器可写层,但生命周期和权限语义不同:

  • 删除容器通常不会自动删除命名卷;
  • bind mount 中的数据不由 Docker 的镜像/容器清理命令管理;
  • 挂载点下镜像原有文件会被遮蔽,而不是合并;
  • 宿主机 UID/GID、SELinux 标签、只读属性可能导致应用无法写入。

在 Linux 上,若容器以非 root 用户运行,应验证宿主机目录的数值 UID/GID 和权限,而不是只看用户名:

stat -c '%u:%g %a %n' /srv/app-data
docker exec CONTAINER id

4. 日志可能是磁盘增长的主要来源

查看当前容器的日志配置:

docker inspect CONTAINER \
  --format '{{json .HostConfig.LogConfig}}'

默认日志驱动和配置由 Docker 安装及 Daemon 配置决定,不能假设所有环境都是 json-file。若使用 json-file,日志通常位于 Docker 数据根目录下的容器目录,但直接删除正在使用的日志文件可能造成日志驱动状态异常,不应把 rm 当作清理方案。

较安全的方向是:

  • 为适用的 logging driver 配置轮转或远端收集;
  • 限制应用日志级别和单条消息大小;
  • 对配置变更后的新容器进行验证;
  • 通过 docker inspect 确认实际 driver,而不是只检查配置文件。

使用 docker system prunedocker image prunedocker builder prune 前要明确回收对象。尤其是:

docker system prune --volumes

可能删除未被容器引用的卷,卷中的数据库数据可能因此丢失。生产清理应先列出候选对象、确认备份和引用关系,再执行回收。

5. “Docker 占满磁盘”也可能是删除但仍被进程打开的文件

Linux 文件只有在目录项删除且没有进程持有打开描述符时,空间才真正释放。可检查:

sudo lsof +L1

如果应用删除了旧日志但仍保持文件描述符,du 可能看不到文件,df 却显示空间未释放。此时通常需要让应用重新打开日志文件或重启相关进程,而不是继续删除目录。


六、OOM:退出码、cgroup 事件和内核证据必须同时成立

1. OOM 的定义和故障路径

OOM(Out Of Memory)表示内存分配需求无法满足。Linux 上至少有两种相关情形:

  1. 容器 cgroup 达到内存限制:内核在该 cgroup 内选择进程杀死;
  2. 宿主机整体内存不足:全局 OOM killer 选择进程,可能是 Docker 容器,也可能是宿主机上的其他进程。

因此,Exited (137) 只能说明进程通常收到了 SIGKILL

137 = 128 + 9

其中 9 是 SIGKILL。它不能单独证明发生了 OOM。

检查容器状态:

docker inspect CONTAINER \
  --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}}'

检查内核证据:

dmesg -T | grep -i -E 'oom|killed process|memory cgroup'
journalctl -k --since "10 minutes ago" | grep -i -E 'oom|killed process|memory cgroup'

现代 Linux 许多发行版使用 cgroup v2。可以先查看 Docker 使用的 cgroup 信息:

docker info | grep -i -E 'Cgroup|Memory'
docker stats --no-stream CONTAINER

docker stats 展示的是运行时观测值;它不是完整的内核 OOM 证据。cgroup v2 中还可以在对应 cgroup 目录查看:

memory.current
memory.max
memory.events
memory.stat

路径依发行版和 Docker 模式而异,不应硬编码 /sys/fs/cgroup/docker/...。通过 docker inspect、systemd slice 或 /proc/<pid>/cgroup 定位实际 cgroup 更可靠。

2. 内存限制的因果关系

如果容器配置:

docker run --memory=512m --memory-swap=512m IMAGE

含义通常是容器内存上限约为 512 MiB,并且内存加 swap 的总上限也为 512 MiB,等价于没有额外 swap 空间可用。精确行为受内核 cgroup、swap 是否启用和 Docker 版本影响。

Compose 中常见配置是:

services:
  api:
    image: example/api
    mem_limit: 512m

Compose 规范中的资源字段与 Swarm 部署字段并不完全等价。deploy.resources.limits 在不同 Compose 实现和部署目标上的处理可能不同;使用前应通过 docker compose config 和实际 docker inspect 验证最终容器配置。

docker stats 中内存使用还可能包含 page cache。对于排查“应用堆内存不高但容器达到限制”的现象,应结合 memory.stat、应用运行时指标和内核事件,而不是只看一个百分比。

3. 诊断 OOM 的完整算例

假设容器状态为:

ExitCode=137
OOMKilled=true

并且 memory.events 显示:

oom 3
oom_kill 1

可以按以下逻辑推导:

  1. 137 表明主进程被强制杀死;
  2. OOMKilled=true 表明 Docker 记录到容器级 OOM;
  3. oom_kill=1 表明 cgroup 内确实发生过 OOM kill;
  4. memory.max536870912,则限制约为 512 MiB;
  5. 应用在峰值期间超过该上限,内核选择进程终止;
  6. 若容器配置了自动重启,Docker 又启动新实例,于是表现为反复 Restarting

这个推导不能反过来简化为“所有 137 都是 OOM”。例如管理员执行:

docker kill CONTAINER

也可能产生 137,但不会得到同样的 OOM 证据。

4. 恢复策略必须针对原因

短期恢复可以增加容器内存限制或释放宿主机资源,但长期修复要区分:

  • 应用堆泄漏:修复对象生命周期、缓存策略或连接池;
  • 峰值过高:调整并发、批量大小、启动时预加载;
  • 子进程未回收:检查 PID 1 和 worker 管理;
  • 容器限制过低:根据工作集、峰值和宿主机容量重新设置;
  • 宿主机整体内存不足:检查所有进程,而不仅是 Docker。

不要直接禁用 OOM 行为或无限提高限制。没有足够物理内存和 swap 时,单个容器的限制调整可能只是把故障转移为宿主机级 OOM。


七、构建失败:BuildKit 的输入、执行器、缓存和输出要分别检查

1. 构建不是“把 Dockerfile 发给 Daemon”这么简单

现代 Docker 默认使用 BuildKit。一次构建包含:

客户端确定构建上下文
  ↓
读取 Dockerfile 和 .dockerignore
  ↓
前端解析 Dockerfile 为构建图
  ↓
BuildKit 并行执行步骤
  ↓
拉取基础镜像、执行 RUN、导出缓存
  ↓
导出镜像或推送 registry

构建上下文是发送给构建器的文件集合,不是 Dockerfile 所在目录的抽象引用。以下命令将当前目录作为上下文:

docker build -t demo:dev .

如果 COPY app /app 报错,首先检查 app 是否在上下文内,以及是否被 .dockerignore 排除:

find . -maxdepth 2 -type f | sort
cat .dockerignore
docker build --progress=plain -t demo:dev .

.dockerignore 排除文件后,即使宿主机目录中确实存在该文件,Dockerfile 也不能 COPY 它。这是输入边界导致的错误,不是容器运行时挂载错误。

2. 用 --progress=plain 保留可检索证据

BuildKit 默认输出可能是动态 TTY。排查时使用:

docker build --progress=plain -t demo:debug .

重点观察:

  • 基础镜像解析和拉取是否成功;
  • 失败发生在 RUNCOPY、导出镜像还是推送阶段;
  • 是否因为代理、DNS、证书或 registry 认证失败;
  • 是否因为上下文过大、磁盘不足或缓存导出失败。

如果怀疑缓存掩盖了问题:

docker build --no-cache --progress=plain -t demo:debug .

--no-cache 会让构建重新执行,增加网络、CPU、磁盘和时间消耗;它适合验证“缓存是否导致旧结果”,不应作为所有构建失败的固定答案。

3. Dockerfile 层和缓存的真实边界

例如:

FROM alpine:3.20

WORKDIR /app
COPY package-list.txt .
RUN apk add --no-cache $(cat package-list.txt)

COPY . .
RUN ./build.sh

把变化频繁的源码放在后面,有机会复用依赖安装层。但缓存是否命中取决于指令、输入文件、基础镜像和构建参数等内容;不能假设“上一层成功过就永远复用”。

反例:

COPY . .
RUN apk add --no-cache ...

任何源码文件变化都可能使后续依赖安装层失效。这个问题首先是缓存设计问题,不一定是 BuildKit 损坏。

BuildKit 可能并行执行互不依赖的构建阶段,因此日志显示顺序不总是等于 Dockerfile 从上到下的单线程执行顺序。多阶段构建可以把编译环境和运行环境分开:

FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM alpine:3.20
COPY --from=build /out/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]

这个示例依赖项目存在 ./cmd/server,且生成的二进制适合目标运行环境。CGO_ENABLED=0 只是示例选择;如果程序依赖 glibc、动态库或 CGO,直接复制到 Alpine 可能运行失败。

4. 架构、网络和秘密信息

构建时出现:

exec format error

可能不是运行阶段问题,也可能发生在 RUN 执行中:构建器执行了不适合当前构建平台的二进制。先查看平台:

docker buildx ls
docker version --format '{{.Server.Os}}/{{.Server.Arch}}'

跨平台构建需要确认 builder、目标平台、模拟器或原生节点是否支持。即使镜像成功构建,运行时仍可能因架构不匹配失败。

构建访问私有 registry、软件源或 Git 仓库时,失败路径可能是:

BuildKit → DNS → 代理/防火墙 → registry 或软件源

这与“容器运行网络正常”不是同一条路径。构建器所在节点、代理配置和证书信任链都要单独检查。

不要把密码、云凭据写入:

ARG TOKEN=...
ENV TOKEN=...
RUN echo "$TOKEN" ...

因为它们可能进入构建历史、缓存或镜像元数据。应使用 BuildKit 支持的 secret 或 SSH mount,并验证最终镜像中没有秘密文件。秘密 mount 的具体命令和语法依赖 BuildKit/Buildx 版本,使用前应以当前 Docker 文档和 docker buildx build --help 为准。

5. 构建缓存占满磁盘

查看构建缓存:

docker buildx du

清理未使用 BuildKit 缓存:

docker buildx prune

命令可能提示删除缓存对象。生产环境中应先确认没有其他项目依赖这些缓存;清理后下一次构建会重新下载依赖并增加构建时间。镜像空间和 BuildKit 缓存是相关但不同的回收对象,不能只执行 docker image prune 就认为构建缓存已清空。


八、Daemon 故障:先判断是服务、配置、存储还是内核依赖

1. systemd 主机上的第一组命令

sudo systemctl status docker.service --no-pager
sudo journalctl -u docker.service -b --no-pager -n 200
sudo systemctl cat docker.service

如果 Daemon 反复启动失败,重点看第一次失败的根因,而不是最后一条 systemd 的“进程退出”摘要。常见根因包括:

  • /etc/docker/daemon.json JSON 格式错误或配置项不兼容;
  • Docker 数据根目录所在文件系统不可写或已满;
  • overlay2、containerd、runc 或内核能力不可用;
  • cgroup 配置不匹配;
  • iptables/nftables 初始化失败;
  • 监听 socket、TLS 证书或端口已被占用;
  • 代理、registry 或证书配置导致依赖初始化失败。

检查配置文件和磁盘:

sudo sed -n '1,240p' /etc/docker/daemon.json
sudo df -hT /var/lib/docker /var/lib/containerd
sudo df -i /var/lib/docker /var/lib/containerd

daemon.json 必须是合法 JSON。例如,下面是合法形式:

{
  "log-driver": "local",
  "data-root": "/var/lib/docker"
}

但配置项是否被当前 Engine 支持,仍需结合版本验证。不要把网上其他版本的配置直接复制到生产主机。

2. 客户端错误和 Daemon 错误的区分

下面两类错误根因不同:

permission denied while trying to connect to the Docker daemon socket

通常是客户端访问 socket 的权限问题。

failed to start daemon: error initializing graphdriver

通常是 Daemon 初始化存储驱动失败。

而:

pull access denied for IMAGE

通常表示 registry 认证、仓库权限或镜像名称问题,不等于本地 Daemon 停止。

可用:

docker version
docker info

确认 Server 是否可用,再处理镜像、网络或权限问题。

3. dockerd 的数据根目录不是普通缓存目录

默认数据目录常见为 /var/lib/docker,但可以通过 data-root 改变;rootless Docker 的路径也不同。数据目录包含镜像层、容器元数据、卷和其他状态。不要在 Daemon 运行时直接删除其中的目录,例如:

sudo rm -rf /var/lib/docker/*

这不是清理,而是破坏 Docker 状态,可能造成所有镜像、容器和卷不可恢复。磁盘修复应优先使用 Docker 自身的对象清理、日志策略和缓存回收;若数据目录损坏,应先停止服务、制作文件系统级备份或快照,再按存储驱动和灾备方案恢复。

4. 重启、reload 和恢复验证

修改 Daemon 配置后,在 systemd 主机上通常需要:

sudo systemctl daemon-reload
sudo systemctl restart docker
sudo systemctl status docker --no-pager
docker info

daemon-reload 只重新读取 systemd unit,不会验证 daemon.jsonrestart 才会让 Docker 重新初始化。重启 Daemon 会影响容器运行、网络和 API 请求,是否自动恢复容器取决于 restart policy 和具体故障阶段。

验证恢复不能只看 systemd:

docker info
docker run --rm hello-world
docker network create diag-net
docker run --rm --network diag-net alpine getent hosts alpine
docker network rm diag-net

最后两个命令依赖镜像内存在 getent,且需要能够拉取 Alpine。若生产环境禁止外网,应换成已批准的本地诊断镜像。验证应覆盖 API、镜像创建、网络创建和容器启动,而不仅是“服务状态 active”。


九、一个可重复的现场排障流程

第一步:记录环境和时间范围

date -Is
hostname
docker version
docker info
docker context show
uname -a

记录 Engine 版本、内核、架构、存储驱动、Cgroup 版本、日志驱动和 Docker 根目录。版本差异会影响 BuildKit、Compose 字段、iptables/nftables 和 cgroup 行为。

第二步:判断故障层级

docker ps -a
docker events --since 30m
sudo journalctl -u docker.service --since "30 minutes ago"

问题是:

  • 所有容器都无法启动:优先检查 Daemon、存储、cgroup 和网络初始化;
  • 单个容器失败:检查镜像、入口、挂载和应用;
  • 容器都在运行但互相不可达:检查网络和 DNS;
  • 构建失败但已有容器正常:检查 BuildKit、上下文、registry 和构建磁盘;
  • 宿主机卡顿且多个服务被杀:检查系统级内存和磁盘,而不是只看单个容器。

第三步:对单个容器建立证据链

docker inspect CONTAINER > inspect.json
docker logs --timestamps --tail=500 CONTAINER
docker stats --no-stream CONTAINER
docker port CONTAINER
docker network inspect NETWORK

inspect.json 保存下来,避免容器被重建后状态丢失。特别记录 ExitCodeOOMKilled、挂载、环境变量、网络 IP、restart policy 和日志配置。

第四步:用最小复现缩小范围

  • 去掉 Compose,仅运行镜像;
  • 去掉原始入口,进入 shell;
  • 去掉外部网络,仅测试本地进程;
  • 去掉持久化挂载,确认是否是权限或数据损坏;
  • 使用 --progress=plain--no-cache 区分 BuildKit 缓存问题;
  • 使用临时网络诊断容器,但保持相同 network namespace 或目标网络。

每减少一个变量,都要记录“哪一步恢复、哪一步再次失败”。否则重试本身不会产生因果证据。


十、常见误判与正确解释

“容器是 Up,所以服务正常”

Up 只说明 PID 1 仍存在。应用可能已经死锁、无法接受连接或健康检查失败。若配置了健康检查,应查看:

docker inspect --format '{{json .State.Health}}' CONTAINER

健康检查失败不会自动等价于容器退出;是否重启取决于编排和外部控制器。

“能 ping,所以端口没问题”

ICMP、DNS、TCP 和应用协议是不同层次。正确顺序应是:

名称解析 → 路由 → TCP 建连 → TLS/HTTP/数据库协议 → 业务响应

每层成功只证明该层,不证明后续层。

“磁盘满了,执行 system prune 就行”

system prune 只能清理符合条件的 Docker 对象,不能清理 bind mount 中的应用数据、被进程打开的已删除文件,也不能代替日志轮转。带 --volumes 的清理还可能删除数据卷。

“退出码 137 就一定是 OOM”

137 只表示 SIGKILL 的常见编码。必须同时检查 OOMKilled、内核日志和 cgroup 事件。

“构建用了缓存,所以结果一定正确”

缓存保证的是在满足缓存键条件时复用已有结果,不保证外部网络内容、时间、远程仓库分支或未固定依赖永远不变。需要可重复构建时,应固定基础镜像摘要、依赖版本和构建输入,并明确缓存导入导出策略。


结语:故障排查的核心是还原状态转换

Docker 故障不是一个统一的“容器问题”。应沿着真实状态转换还原:

CLI 是否连到正确 Daemon
  → Daemon 是否能读取配置和存储
  → 镜像和构建输入是否有效
  → rootfs、挂载、网络和 cgroup 是否创建成功
  → PID 1 是否启动并保持运行
  → DNS、路由、端口和应用协议是否逐层通过
  → 磁盘、内存和内核是否在运行期间触发限制

每个结论都应至少由一个可观察证据支持:容器状态、结构化 inspect、容器日志、Docker 事件、Daemon journal、内核日志、cgroup 文件、网络连接或文件系统统计。这样才能把“重启后暂时恢复”进一步还原为可验证的故障原因,并选择不会破坏数据和宿主机安全边界的恢复动作。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。