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

Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付

Docker 的学习不能停留在“会写 Dockerfile、会执行 docker run”。一个可用于生产的 Docker 知识体系,至少要回答以下问题:

  • 镜像究竟保存了什么,容器启动时又增加了什么?
  • docker CLI 发出的请求经过哪些组件,最终如何成为 Linux 进程?
  • Docker 的隔离边界是什么,哪些资源被隔离,哪些仍然共享宿主机?
  • Dockerfile、BuildKit 和 Compose 分别解决构建、编排中的什么问题?
  • 如何处理凭据、权限、网络暴露、供应链和内核攻击面?
  • 容器出了问题,如何从日志、指标、事件、进程和内核限制中定位原因?
  • 如何把一个镜像安全地交付到生产环境,并支持灰度、回滚、容量管理和故障恢复?

本文以现代 Docker Engine、BuildKit 和 Compose 规范为背景,重点讨论 Linux 容器。Docker Desktop 在 macOS 和 Windows 上通常通过 Linux 虚拟机运行 Linux 容器,因此“容器看到的宿主机”可能是 Docker Desktop 管理的 Linux 虚拟机,而不是用户操作系统本身。


一、先建立 Docker 的整体模型

1. 镜像、容器和镜像仓库

**镜像(image)**是一个不可变的、可分发的文件系统和运行配置模板。它通常包含:

  • 根文件系统中的目录和文件;
  • 默认启动命令;
  • 环境变量;
  • 工作目录;
  • 暴露端口声明;
  • 用户、入口点等元数据。

**容器(container)**是镜像的一次运行实例。容器不是“一个被压缩的镜像”,而是:

容器根文件系统=镜像只读层+容器可写层+挂载的外部数据\text{容器根文件系统} = \text{镜像只读层} + \text{容器可写层} + \text{挂载的外部数据}

其中:

  • 镜像层通常只读;
  • 容器可写层保存运行期间对文件系统的修改;
  • volume、bind mount、tmpfs 等挂载会覆盖容器内对应路径;
  • 容器删除后,可写层通常也会删除;
  • volume 的生命周期可以独立于容器存在。

**镜像仓库(registry)**负责存储和分发镜像。仓库不是镜像本身,而是镜像的远程分发服务。一个镜像引用通常形如:

registry.example.com/team/api:2025-03-01

它包含:

  • registry:registry.example.com
  • repository:team/api
  • tag:2025-03-01

tag 是可变的人类可读名称,摘要(digest)才是内容寻址的不可变身份:

registry.example.com/team/api@sha256:abc...

生产部署若只保存 tag,可能在后续拉取时得到不同内容;保存 digest 才能明确部署的二进制输入。

2. 镜像层不是运行时快照

镜像通常由多个层组成。Dockerfile 中多数会改变文件系统的指令,例如 RUNCOPYADD,可能形成新的层。下层内容不被修改,上层通过覆盖关系呈现最终文件系统。

例如:

FROM alpine:3.20
RUN echo "v1" > /message
RUN echo "v2" > /message

最终读取 /message 时得到 v2,但第二个 RUN 并没有修改第一层;它在更上层记录了新的文件内容。删除文件也不是让下层文件真正消失,而是在上层增加删除标记。因此:

RUN apt-get update
RUN apt-get install -y curl

与:

RUN apt-get update && apt-get install -y curl

不仅影响缓存,还可能影响最终层中残留的包索引文件。第一种写法中,包索引可能留在前一个层里,即使后续删除,镜像历史中仍然存在。

镜像层的共享带来空间效率:多个镜像可以复用相同的基础层。但层共享不等于容器之间共享可写状态。默认情况下,一个容器的 /tmp/a 不会直接出现在另一个容器中。

3. 容器的生命周期

容器至少有以下几个重要状态:

created -> running -> exited
             |          |
             +-> paused  +-> removed

常用操作与状态关系如下:

docker create --name demo alpine:3.20 sh
docker start demo
docker exec demo sh
docker stop demo
docker rm demo
  • create 创建容器及其元数据,但不启动主进程;
  • start 启动已经创建的容器;
  • run 通常等价于 createstart,并可附加前台等待;
  • exec 在运行中的容器内创建另一个进程,不会替换主进程;
  • stop 先发送终止信号,等待超时后再强制杀死;
  • rm 删除容器对象及其可写层,但默认不删除独立 volume。

容器是否“存活”,主要取决于容器的主进程,也称为 PID 1。下面的容器会立即退出:

docker run --name short alpine:3.20 echo done

因为 echo done 执行结束,主进程退出,容器状态变为 Exited。这不是 Docker 出错,而是容器生命周期的直接结果。

下面的容器会持续运行:

docker run -d --name long alpine:3.20 sleep 3600
docker ps
docker inspect -f '{{.State.Status}} {{.State.Pid}}' long

-d 只表示客户端后台等待,不改变容器内部的进程语义。容器仍然会在 sleep 结束后退出。


二、Docker 的组件与 Linux 执行路径

1. 从 CLI 到容器进程

典型的 Linux Docker Engine 包含以下组件:

  • Docker CLI:用户侧命令行客户端,例如 docker builddocker run
  • Docker daemon(dockerd:接收 API 请求,管理镜像、容器、网络和存储;
  • containerd:负责容器生命周期、镜像传输和管理;
  • containerd-shim:让容器运行时与 daemon 解耦,使 daemon 重启不必然终止容器;
  • runc:常见的 OCI runtime,负责依据 OCI 配置创建 Linux 容器进程;
  • Linux 内核:真正提供 namespace、cgroup、capability、seccomp 等机制。

简化后的路径是:

sequenceDiagram
    participant U as 用户
    participant C as Docker CLI
    participant D as dockerd
    participant T as containerd
    participant S as containerd-shim
    participant R as runc
    participant K as Linux 内核
    participant P as 容器进程

    U->>C: docker run image command
    C->>D: Docker API 请求
    D->>T: 创建容器、准备镜像和挂载
    T->>S: 创建 shim
    S->>R: 传递 OCI runtime 配置
    R->>K: 创建 namespace/cgroup/挂载/进程
    K->>P: 启动容器 PID 1
    P-->>S: 退出状态
    S-->>T: 生命周期事件
    T-->>D: 状态更新
    D-->>C: 返回结果

这不是说每一个 Docker 版本和运行时组合都严格只有这些调用,但它准确表达了职责边界:CLI 不直接创建 Linux namespace,runc 也不负责完整的镜像仓库、网络和 Docker API 管理。

2. OCI 的位置

**OCI(Open Container Initiative)**定义了容器镜像格式、运行时规范和分发相关规范。OCI 规范描述了标准化的内容,例如:

  • 镜像 manifest 如何指向配置和层;
  • 运行时配置如何描述 root filesystem、进程、环境和 namespace;
  • runtime 如何接收 bundle 并启动容器。

Docker 是 OCI 生态中的一种产品和工具链,但 Docker 的全部能力不等于 OCI。Docker CLI、Docker daemon、Compose、Docker Hub、构建缓存管理等都属于 Docker 体系;OCI 负责其中的开放格式和运行时接口。

因此,下面两句话都不准确:

  • “Docker 容器就是 Docker 私有格式,不能被其他工具运行。”
  • “只要是 OCI 镜像,所有 Docker 行为都完全相同。”

更准确的说法是:Docker 生成和使用符合 OCI 生态的镜像与运行时结构,但上层管理能力、默认安全策略、网络实现和用户体验仍由具体工具决定。

3. Linux 容器的隔离边界

容器不是虚拟机。虚拟机通常运行独立的 guest kernel;Linux 容器中的进程使用宿主机 Linux 内核,只是被内核机制限制了视图和资源。

核心机制包括:

Namespace:隔离“看见什么”

常见 namespace 包括:

  • PID:进程编号视图;
  • Mount:挂载点视图;
  • Network:网络设备、路由和端口空间;
  • UTS:主机名;
  • IPC:进程间通信对象;
  • User:用户和 UID/GID 映射。

容器中的进程通常看到自己是 PID 1,但宿主机可以看到它对应的真实 PID。这是视图隔离,而不是创建了另一个内核。

Cgroup:限制“能用多少”

cgroup 用于限制和统计资源,例如:

  • CPU 配额和权重;
  • 内存上限;
  • 进程数上限;
  • 块设备 I/O;
  • PIDs 数量。

如果设置:

docker run --rm --memory=128m --cpus=0.5 alpine:3.20 sh -c '
  echo "container started"
  sleep 10
'

容器最多可使用约 128 MiB 的内存和半个 CPU 的配额。这里的“约”很重要:内核记账、文件页、运行时开销和版本差异会影响实际表现。内存超限时,可能发生 cgroup OOM,容器状态通常可通过以下命令检查:

docker inspect CONTAINER_ID \
  --format '{{.State.OOMKilled}} exit={{.State.ExitCode}}'

Capability:缩小 root 权限

Linux root 不是一个不可分割的权限。Docker 默认会去除一部分 capability,但仍会保留若干常见能力。可以显式降低权限:

docker run --rm \
  --cap-drop=ALL \
  --security-opt=no-new-privileges:true \
  alpine:3.20 id

--cap-drop=ALL 会使进程缺少很多需要内核特权的操作;no-new-privileges 防止进程通过 setuid 或文件 capability 获得更多权限。它们不是绝对安全保证,因为应用仍可能利用内核漏洞或已有权限。

Seccomp、AppArmor 和 SELinux:限制系统调用与访问

Docker 通常使用默认 seccomp 配置限制一部分危险系统调用。AppArmor 或 SELinux 则可以进一步限制进程访问路径、对象和权限。

这些机制依赖宿主机内核与安全模块配置。--privileged 会显著放宽容器权限,通常应视为高风险例外,而不是普通故障修复方式。它可能让容器获得更多设备访问、capability 和安全策略豁免,不能简单等同于“加一个调试参数”。

4. 容器边界的反例

以下命令展示了容器并不会自动成为安全边界:

docker run --rm -v /:/host alpine:3.20 ls /host

这个命令将宿主机根目录以读写方式挂载到容器的 /host。即使容器进程没有完整 root 权限,挂载本身也可能暴露大量敏感内容;如果使用读写挂载,则可能修改宿主机文件。

因此,隔离边界由多个因素共同决定:

实际风险=f(namespace,cgroup,capability,seccomp,LSM,挂载,内核漏洞,运行用户)\text{实际风险} = f(\text{namespace}, \text{cgroup}, \text{capability}, \text{seccomp}, \text{LSM}, \text{挂载}, \text{内核漏洞}, \text{运行用户})

Docker 的默认配置降低了风险,但不承诺容器可以承受恶意宿主机级攻击。多租户、强对抗场景需要进一步评估 rootless、专用节点、虚拟化运行时或其他隔离方案。


三、命令行、镜像和容器的基本操作

1. 用一个可验证的例子开始

docker run --rm --name web \
  -p 8080:80 \
  nginx:alpine

执行后:

  • Docker 若本地没有镜像,会先拉取;
  • 容器内 nginx 监听 80
  • -p 8080:80 将宿主机 TCP 8080 映射到容器网络命名空间中的 80;
  • 前台进程保持 CLI 附着;
  • Ctrl-C 会触发停止流程;
  • --rm 使容器退出后自动删除。

另开终端验证:

curl -i http://127.0.0.1:8080/
docker ps
docker logs web

docker logs 读取的是容器标准输出和标准错误。若 nginx 被配置为把日志写入容器内部文件,而不是 stdout/stderr,Docker 日志驱动可能看不到这些日志。

2. 端口声明和端口发布不同

Dockerfile 中的:

EXPOSE 8080

只是镜像元数据,表示应用预期使用该端口,不会自动把端口发布到宿主机。运行时必须显式发布:

docker run --rm -p 127.0.0.1:8080:8080 image

绑定 127.0.0.1 只允许本机访问;绑定 0.0.0.0 通常意味着监听宿主机所有接口。生产环境应结合主机防火墙、云安全组和反向代理确认实际暴露范围。

3. 环境变量不是秘密存储

docker run --rm \
  -e APP_MODE=production \
  -e DATABASE_PASSWORD='example-password' \
  image

-e 适合普通配置,但密码可能出现在:

  • shell 历史;
  • 进程环境;
  • Docker inspect 输出;
  • 调试信息;
  • 崩溃转储或日志。

因此,环境变量不应被当作自动加密的秘密管理系统。运行时秘密应根据部署环境使用 Docker secrets、编排系统 Secret、云密钥服务或文件权限控制。单机 Docker Compose 的 secrets 机制可以将秘密以文件形式挂载,但其安全性仍取决于主机权限、daemon 权限和 Compose 部署方式。


四、Dockerfile 与 BuildKit:从“能构建”到“可复现”

1. 构建上下文是什么

执行:

docker build -t example-api:dev .

最后的 .构建上下文。客户端或 BuildKit 会将上下文中的文件提供给构建过程,Dockerfile 中的:

COPY . /app

只能复制上下文内的路径,不能引用上下文父目录中的文件。

.dockerignore 用于减少上下文:

.git
node_modules
dist
*.log
.env

它有三项作用:

  1. 减少上传和扫描内容;
  2. 提高构建速度;
  3. 避免把本地秘密、依赖缓存和无关文件复制进镜像。

它不是秘密保护的唯一边界。若秘密已经被 COPY 进某个镜像层,即使后续删除,旧层仍可能保留内容。

2. Dockerfile 指令的语义

一个适合编译型应用的多阶段 Dockerfile:

# syntax=docker/dockerfile:1

FROM golang:1.23-alpine AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
    -o /out/server ./cmd/server

FROM alpine:3.20
RUN adduser -D -H -u 10001 app
WORKDIR /app
COPY --from=build /out/server /app/server
USER 10001
EXPOSE 8080
ENTRYPOINT ["/app/server"]

关键点如下:

  • FROM ... AS build 创建构建阶段;
  • COPY go.mod go.sum 先复制依赖声明,使源代码修改不必重新下载依赖;
  • RUN --mount=type=cache 使用 BuildKit 的构建缓存挂载;
  • COPY --from=build 只把编译产物复制到运行阶段;
  • 最终镜像不包含 Go 编译器和源代码;
  • USER 10001 让应用不以 root 身份运行;
  • ENTRYPOINT 使用 exec form,应用能更直接地接收信号。

ENTRYPOINT ["..."]ENTRYPOINT ... 不同。前者不会经过 shell,适合让应用成为容器主进程;后者可能经过 shell,信号转发、参数处理和退出码可能出现额外行为。

3. 缓存命中不是“源码没变”

BuildKit 会根据指令、输入文件和相关参数决定是否复用缓存。下面的顺序通常比直接 COPY . . 更容易复用依赖缓存:

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

但是缓存并不等于正确性证明。以下因素可能使缓存结果与期望不一致:

  • 构建脚本依赖当前时间;
  • 远程依赖使用浮动版本;
  • 基础镜像 tag 被重新指向;
  • 构建读取了未声明的外部状态;
  • 缓存挂载目录被错误地当作最终产物;
  • 不同架构下复用不兼容内容。

RUN --mount=type=cache 的目录是构建缓存,不会自动进入最终镜像,也不应被当作可靠持久化存储。缓存丢失后,构建必须仍然能够从声明的依赖重新完成。

4. Secret 不应写入镜像层

BuildKit 支持将构建秘密临时挂载到单个 RUN

# syntax=docker/dockerfile:1
FROM alpine:3.20

RUN --mount=type=secret,id=token \
    token="$(cat /run/secrets/token)" && \
    wget --header="Authorization: Bearer $token" \
         -O /tmp/private.tar.gz \
         https://example.invalid/private.tar.gz && \
    tar -xzf /tmp/private.tar.gz -C /opt && \
    rm /tmp/private.tar.gz

构建命令:

printf '%s' "$PRIVATE_TOKEN" > /tmp/build-token

DOCKER_BUILDKIT=1 docker build \
  --secret id=token,src=/tmp/build-token \
  -t private-client:dev .

rm -f /tmp/build-token

秘密挂载只在该 RUN 步骤可见,不会像:

ARG TOKEN
RUN wget -H "Authorization: Bearer $TOKEN" ...

那样容易进入构建参数、历史记录或层内容。即使使用 secret mount,也必须避免把秘密复制到 /opt、日志或生成文件中。

5. 多架构构建不是“换一个 tag”

构建 ARM64 和 AMD64 镜像时,编译器、基础镜像和二进制架构都必须匹配。例如:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/team/api:1.0.0 \
  --push .

这会生成一个多平台 manifest,客户端根据节点架构选择对应镜像。交叉编译时还要考虑:

  • CGO 是否依赖目标架构的系统库;
  • 构建过程是否执行目标架构二进制;
  • 是否通过 emulation 执行,导致行为或速度变化;
  • 本地缓存是否能安全复用。

“构建命令成功”不代表每个平台的运行时行为都一致,必须在目标架构或兼容环境中测试。

6. 可重复构建的条件与边界

若希望相同源码得到相同镜像内容,需要控制:

  1. 基础镜像使用 digest,而不是只使用可变 tag;
  2. 依赖版本锁定;
  3. 构建工具版本固定;
  4. 生成文件不嵌入当前时间、随机数和主机路径;
  5. 构建上下文明确且不包含未跟踪的外部输入;
  6. 不依赖不可控的网络内容;
  7. 对构建结果进行 digest 比较。

形式上,若构建函数表示为:

I=F(S,B,D,E)I = F(S, B, D, E)

其中:

  • SS:源代码和构建上下文;
  • BB:基础镜像内容;
  • DD:依赖和工具链;
  • EE:构建环境和外部状态;

只有当这些输入在语义上固定时,才有希望得到相同的镜像 II。固定 Dockerfile 本身不够;若 FROM alpine:latest 每天变化,BB 仍然是不稳定输入。


五、数据、网络和 Compose

1. 三类常见挂载

Bind mount

docker run --rm \
  -v "$PWD/config:/etc/app:ro" \
  image

宿主机路径直接映射到容器。它适合开发时挂载源代码或生产时挂载明确的配置文件,但强依赖宿主机目录结构,并可能造成容器修改宿主机文件。

Named volume

docker volume create app-data
docker run -d --name db \
  -v app-data:/var/lib/data \
  image

Docker 管理 volume 的位置和生命周期,适合需要持久化但不想绑定具体宿主路径的数据。volume 仍然存储在宿主机上,不等于跨节点复制或备份。

tmpfs

docker run --rm \
  --tmpfs /run:rw,noexec,nosuid,size=64m \
  image

数据存于内存,容器停止后消失。它适合临时文件、运行时 socket 等场景,但会消耗宿主机内存。

2. 容器网络

默认情况下,容器拥有自己的 network namespace。Docker bridge 网络通过虚拟网卡、网桥和 NAT 将容器连接到宿主机网络。

创建自定义网络:

docker network create app-net

docker run -d --name backend --network app-net \
  alpine:3.20 sleep 3600

docker run --rm --network app-net \
  alpine:3.20 getent hosts backend

在同一用户自定义网络中,容器通常可以通过服务名或容器名进行 DNS 解析。不要把容器 IP 写死,因为容器重建后 IP 可能变化。

EXPOSE、容器内部监听地址和宿主机端口发布是三个不同概念:

  • 应用监听 127.0.0.1:只接受容器自身回环接口连接;
  • 应用监听 0.0.0.0:接受容器网络接口连接;
  • -p host:container:将宿主机端口转发到容器端口;
  • --network host:在 Linux 上让容器使用宿主机网络 namespace,隔离性和端口行为都会改变。

3. Compose 的职责

Compose 用一个 YAML 文件描述多容器应用的配置、网络、volume 和依赖关系。示例:

services:
  web:
    build:
      context: .
      target: runtime
    image: example/web:dev
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DATABASE_HOST: db
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 5

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: local-only-password
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  db-data:

启动并验证:

docker compose up -d --build
docker compose ps
docker compose logs -f web
docker compose down

这里的 depends_on 只能表达 Compose 层面的启动顺序或健康条件,不能保证数据库初始化完成、迁移完成或业务真正可用。应用仍应具备连接重试、超时和错误恢复能力。

Compose 适合本地开发、测试和单机部署;它不是完整的跨节点调度器。它通常不负责:

  • 多节点故障转移;
  • 全局副本调度;
  • 跨节点服务发现的一致性控制;
  • 自动容量扩展;
  • 完整的灰度流量治理。

生产是否使用 Compose,取决于部署平台和运维能力,不能仅由文件格式决定。


六、安全:从镜像内容到运行时边界

1. 镜像安全不是只扫描 CVE

镜像安全至少包含四个层面:

  1. 来源:基础镜像和依赖是否来自可信来源;
  2. 内容:是否包含高风险漏洞、调试工具、秘密和多余文件;
  3. 构建链:构建过程是否可审计、可重复、可追溯;
  4. 运行时:进程是否以非 root 运行、权限是否最小化、网络是否最小暴露。

镜像扫描可以发现已知漏洞,但不能证明:

  • 应用业务逻辑安全;
  • 所有漏洞都已被数据库收录;
  • 容器一定不会越权;
  • 配置和秘密没有泄露;
  • 依赖版本在当前业务中一定可接受。

2. 一个更小权限的运行示例

docker run --rm \
  --user 10001:10001 \
  --cap-drop=ALL \
  --security-opt=no-new-privileges:true \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  image

这些选项的作用分别是:

  • 指定非 root 用户;
  • 删除 Linux capabilities;
  • 禁止通过特定机制获得新增权限;
  • 将根文件系统设为只读;
  • 为确实需要写入的 /tmp 提供临时文件系统。

但这组参数可能使应用启动失败。诊断顺序应是:

docker run ... image
docker logs CONTAINER_ID
docker inspect CONTAINER_ID

如果应用需要写 /var/cache/app,应明确挂载一个临时目录或 volume,而不是直接取消只读根文件系统。安全限制必须结合应用实际写路径验证。

3. rootless 的边界

Rootless Docker 或 rootless containerd 让 daemon 和容器进程以普通用户身份运行,降低 daemon 被攻破后直接获得宿主机 root 的风险。但它不是“无权限容器”的同义词,也可能受到:

  • 网络端口限制;
  • cgroup 管理能力;
  • 文件系统和 id mapping;
  • 某些存储驱动;
  • 宿主机内核配置

的影响。是否采用 rootless,需要在目标发行版、内核和部署方式上做实际验证。

4. Docker daemon 本身是高价值权限边界

能够访问 Docker daemon 的用户,通常可以请求 daemon 创建挂载宿主机目录、访问镜像和启动高权限容器。因此,把用户加入 docker 组不应轻率视为“普通开发权限”。远程暴露 Docker API 时必须使用认证和加密,并配合网络访问控制;不应直接将未保护的 daemon socket 暴露到公网。


七、可观测性:知道容器“为什么死”和“为什么慢”

可观测性通常分为:

  • 日志(logs):记录离散事件和上下文;
  • 指标(metrics):记录可聚合的时间序列;
  • 追踪(traces):记录一次请求跨服务的调用路径;
  • 事件(events):记录容器、镜像、网络等 Docker 对象状态变化。

1. 先区分应用状态和容器状态

下面的容器可能处于 Up,但应用实际上不可用:

docker run -d --name fake-service alpine:3.20 \
  sh -c 'sleep 3600'

Docker 只能知道 sleep 还在运行,不知道它是否实现了 HTTP API。HEALTHCHECK 可以增加应用级探测:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -q -O - http://127.0.0.1:8080/health || exit 1

健康检查的退出码通常有这些意义:

  • 0:健康;
  • 1:不健康;
  • 启动期间可能显示 starting

健康检查不是自动修复机制,也不是完整的 readiness 系统。探测接口若依赖数据库,数据库短暂抖动可能导致实例被判定不健康;探测过重则会反过来增加负载。

2. 诊断一条失败路径

假设服务退出:

docker ps -a --filter name=api
docker inspect api --format '
status={{.State.Status}}
exit={{.State.ExitCode}}
oom={{.State.OOMKilled}}
error={{.State.Error}}
started={{.State.StartedAt}}
finished={{.State.FinishedAt}}'
docker logs --timestamps --tail=200 api
docker events --since 10m

分析顺序是:

  1. Status 判断是运行中、退出还是重启;
  2. ExitCode 判断进程自身退出还是被信号终止;
  3. OOMKilled 判断是否触发内存限制;
  4. Error 查看 daemon 或运行时创建失败;
  5. 日志确认应用在退出前做了什么;
  6. events 确认是否发生了 kill、restart、health_status 等对象事件。

退出码只能缩小范围,不能单独证明根因。例如 137 常见于进程收到 SIGKILL,但可能来自 OOM,也可能来自人工 kill -9;必须结合 OOMKilled、内核日志和 Docker 事件判断。

3. CPU、内存和 I/O 指标

docker stats --no-stream api
docker top api

docker stats 适合快速观察容器的 CPU、内存、网络和块 I/O;它不是长期监控系统。生产环境应将应用指标和容器/主机指标发送到监控系统,并记录:

  • 请求量、延迟分位数、错误率;
  • 工作队列长度;
  • 内存工作集和 OOM 次数;
  • CPU throttling;
  • 文件描述符和进程数;
  • 网络连接数;
  • 磁盘空间与 inode;
  • 重启次数和健康检查失败次数。

容器层指标无法替代主机层指标。容器磁盘写入失败,可能是容器可写层、volume、宿主机文件系统或 inode 耗尽;必须分别检查。

4. 日志的工程约束

推荐应用将结构化日志写到 stdout/stderr,例如:

{"level":"error","request_id":"r-123","path":"/orders","error":"timeout"}

这样 Docker 日志驱动和上层采集系统可以统一收集。日志中不应写入密码、token、完整 Cookie 或不必要的个人数据。

日志轮转必须明确配置。若使用 json-file 且没有合理轮转,高流量服务可能让宿主机磁盘被日志填满。磁盘耗尽不仅影响日志,还可能阻止容器写入、数据库刷盘和系统正常运行。


八、生产交付:从提交到可回滚版本

1. 生产交付的基本对象

生产交付应围绕不可变制品展开:

源码提交
  -> CI 测试
  -> BuildKit 构建
  -> 镜像扫描与签名/证明
  -> 推送 registry
  -> 记录镜像 digest
  -> 部署 digest
  -> 健康验证
  -> 灰度扩大

不要把“构建”和“部署”绑定成每台机器现场编译。推荐由 CI 生成一次镜像,然后所有环境部署同一个 digest。环境差异通过显式配置注入,而不是重新构建不同内容。

例如:

docker buildx build \
  --platform linux/amd64 \
  -t registry.example.com/team/api:git-8f31c2a \
  --push .

docker buildx imagetools inspect \
  registry.example.com/team/api:git-8f31c2a

部署记录应保存:

  • Git commit;
  • Dockerfile 和构建器版本;
  • 基础镜像 digest;
  • 最终镜像 digest;
  • 配置版本;
  • 数据库迁移版本;
  • 发布人和时间;
  • 验证结果。

2. 为什么不能只用 latest

假设:

10:00  latest -> sha256:A
10:30  latest -> sha256:B

节点甲在 10:05 拉取得到 A,节点乙在 10:35 拉取得到 B。此时同一个服务名下运行了两个不同版本,问题难以复现,回滚也无法根据 tag 唯一确定内容。

更可靠的部署方式是:

registry.example.com/team/api@sha256:A

回滚就是把服务定义恢复到旧 digest,而不是猜测某个 tag 现在指向什么。

3. 灰度和回滚的逻辑

灰度发布的核心不是“先启动一个容器”,而是控制流量和观测范围。可以把版本流量表示为:

pnew=RnewRnew+Roldp_{\text{new}} = \frac{R_{\text{new}}}{R_{\text{new}} + R_{\text{old}}}

其中 RnewR_{\text{new}}RoldR_{\text{old}} 是新旧版本承载的请求量。灰度期间至少比较:

  • 新旧版本错误率;
  • 延迟分位数;
  • 业务成功率;
  • 资源消耗;
  • 下游依赖错误;
  • 日志中的特定异常。

若新版本错误率显著升高,回滚动作应是:

  1. 停止增加新版本流量;
  2. 将流量切回旧版本;
  3. 确认旧版本实例健康;
  4. 保留新版本日志、事件和镜像 digest;
  5. 评估是否需要回滚数据库迁移或执行补偿操作。

数据库变更是回滚的难点。应用回滚不等于数据库自动回滚。更安全的迁移通常采用向后兼容步骤:

先增加可选字段
 -> 发布同时兼容旧字段和新字段的代码
 -> 回填数据
 -> 切换读写路径
 -> 在确认无旧代码后再删除旧字段

直接删除旧字段后,代码回滚可能因查询失败而无法恢复。

4. 容量规划:限制不是容量

容器设置 --cpus--memory 是资源边界,不是容量规划本身。容量估算可从需求反推:

设:

  • QQ:峰值请求率;
  • LL:单实例稳定处理能力;
  • uu:期望利用率;
  • NN:实例数量。

则一个粗略下界是:

NQLuN \ge \left\lceil \frac{Q}{L \cdot u} \right\rceil

例如峰值为 900 req/s,单实例在目标延迟下稳定处理 120 req/s,期望利用率为 0.75:

N900120×0.75=10N \ge \left\lceil \frac{900}{120 \times 0.75} \right\rceil = 10

这只是计算下界,还要考虑:

  • 单实例故障后的剩余容量;
  • 发布期间新旧版本并存;
  • 节点故障;
  • 下游数据库连接上限;
  • 内存峰值和 GC;
  • 突发流量;
  • 日志和临时文件增长。

如果每个实例占用 100 个数据库连接,10 个实例并不代表数据库只需要支持 10 个连接;还要加上连接池上限、迁移任务、管理连接和重试放大效应。

5. 重启策略不能替代故障修复

restart: always 或类似自动重启配置可以处理短暂故障,但会掩盖配置错误并形成重启风暴。启动失败时应查看:

docker inspect api --format '{{json .HostConfig.RestartPolicy}}'
docker logs api
docker events --since 30m

若进程每秒崩溃一次,自动重启会持续消耗 CPU、写日志并制造告警噪声。生产系统应配合退避、启动失败阈值、健康检查和明确的人工介入流程。


九、故障诊断的分层方法

1. 创建阶段失败

表现:

pull access denied
no matching manifest
failed to solve
permission denied

优先检查:

docker pull image:tag
docker image inspect image:tag
docker version
docker info

常见原因包括:

  • registry 认证失败;
  • 当前架构没有对应 manifest;
  • 构建上下文缺少文件;
  • daemon 无法访问网络;
  • 文件权限或 SELinux 标签不允许访问;
  • BuildKit 缓存或磁盘空间不足。

2. 启动阶段失败

表现为容器迅速 Exited,或 docker run 直接返回错误。

检查:

docker ps -a
docker logs CONTAINER
docker inspect CONTAINER

重点查看:

  • 入口文件是否存在;
  • 工作目录是否正确;
  • 非 root 用户是否有权限读写;
  • 环境变量是否完整;
  • 端口是否已被占用;
  • CPU 架构是否匹配;
  • 动态链接库是否缺失。

3. 运行阶段失败

容器仍然 Up,但请求超时或错误。此时应分开检查:

docker exec CONTAINER ps
docker exec CONTAINER sh -c 'cat /proc/1/status'
docker inspect CONTAINER
docker stats --no-stream CONTAINER

再从网络路径逐段验证:

客户端
 -> 宿主机监听端口
 -> Docker 端口转发
 -> 容器网络接口
 -> 应用监听地址
 -> 应用线程/事件循环
 -> 下游服务

例如应用只监听 127.0.0.1:8080,而外部连接通过容器网卡访问 172.x.x.x:8080,即使端口发布正确,也可能连接失败。应用应根据网络模型监听合适地址,通常是容器内的 0.0.0.0,同时由宿主机或代理控制外部暴露。

4. 存储阶段失败

表现可能包括:

  • 数据重启后消失;
  • no space left on device
  • 数据库无法写入;
  • 新容器读不到旧数据。

判断路径:

docker inspect CONTAINER --format '{{json .Mounts}}'
docker volume ls
docker system df
df -h
df -i

必须确认实际写入路径是否位于 volume。仅仅“启动了一个带 volume 的容器”不代表应用写入的数据都在该 volume 中;应用可能写到了另一个目录,或启动参数覆盖了默认数据路径。


十、建议的完整学习路线

阶段一:Linux 与进程基础

先掌握:

  • 文件权限、UID/GID、signals;
  • PID 1、进程组、标准输入输出;
  • mount、网络接口、DNS;
  • cgroup、namespace 的基本观察方式;
  • systemd、iptables/nftables 的基本概念。

验证容器问题时,如果不了解 Linux 进程和信号,就很难解释 docker stop、僵尸进程、信号转发和优雅退出。

阶段二:Docker 基本对象

完成以下练习:

docker pull alpine:3.20
docker image ls
docker image inspect alpine:3.20
docker run --name shell -it alpine:3.20 sh
docker inspect shell
docker diff shell
docker commit shell shell:experimental
docker rm -f shell

重点理解:

  • 镜像配置和容器配置的差异;
  • 容器可写层;
  • docker diff 展示了哪些文件变化;
  • commit 为什么不适合作为可审计的生产构建流程。

阶段三:Dockerfile 与 BuildKit

练习:

  • 构建上下文和 .dockerignore
  • 层缓存顺序;
  • 多阶段构建;
  • cache mount;
  • secret mount;
  • 非 root 运行;
  • 固定依赖和基础镜像 digest;
  • 多架构构建。

每次构建都检查:

docker history image:tag
docker image inspect image:tag
docker run --rm image:tag

不要只关注“构建成功”,还要确认秘密没有进入层、最终镜像是否包含不必要的编译器和包管理器、入口进程是否能接收信号。

阶段四:网络、存储和 Compose

完成一个包含 Web、API 和数据库的 Compose 项目,要求:

  • 服务通过名称通信;
  • 数据库使用 named volume;
  • Web 仅发布到本机;
  • API 有健康检查;
  • 数据库凭据不写入 Git;
  • docker compose down 后确认哪些数据保留;
  • 删除 volume 后确认数据确实丢失。

这组实验能直接揭示“容器删除”和“数据删除”并不总是同一个动作。

阶段五:安全与可观测性

为同一个镜像分别运行:

docker run --rm image
docker run --rm --user 10001:10001 image
docker run --rm --cap-drop=ALL image
docker run --rm --read-only --tmpfs /tmp image

记录每次失败的原因和需要开放的最小目录或 capability。再为应用增加:

  • 结构化 stdout 日志;
  • /health/ready 端点;
  • 请求延迟、错误率、活动连接数指标;
  • request ID;
  • graceful shutdown;
  • 退出码和异常分类。

阶段六:生产交付

建立一个最小 CI 流程:

提交代码
 -> 单元测试
 -> Dockerfile 构建
 -> 镜像扫描
 -> 生成 digest
 -> 推送 registry
 -> 测试环境部署
 -> 冒烟测试
 -> 生产灰度
 -> 观测
 -> 扩大或回滚

最后形成运行手册,至少包含:

  • 如何确认当前运行的 image digest;
  • 如何查看最近 30 分钟事件和日志;
  • 如何判断 OOM、磁盘耗尽和端口冲突;
  • 如何停止流量并回滚;
  • 回滚后如何验证业务;
  • 数据库迁移失败时谁负责决策;
  • 哪些操作需要 root 或 daemon 管理权限;
  • 故障证据如何保留。

Docker 的完整能力最终体现在这条闭环中:用可追溯的输入构建不可变镜像,用明确的隔离边界运行它,用日志、指标、追踪和事件观察它,再用可验证、可回滚的流程把它交付到真实环境。

完整学习目录

一、容器基础

  1. Docker 与 OCI 架构:CLI、Daemon、containerd、runc 和隔离边界
  2. Docker 镜像与分层:内容寻址、OverlayFS、缓存和镜像体积
  3. Docker 容器生命周期:创建、启动、信号、退出、重启与清理

二、镜像构建

  1. Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
  2. Docker 多阶段构建:最小运行时、依赖固定、调试层和体积优化
  3. Docker 多架构构建:Buildx、QEMU、Manifest 与跨平台发布

三、运行与编排

  1. Docker 网络完整指南:Bridge、端口映射、DNS、Overlay 和排障
  2. Docker 存储:Volume、Bind Mount、tmpfs、权限和备份恢复
  3. Docker Compose 完整指南:服务、网络、卷、依赖、Profile 和生产边界
  4. Docker 资源治理:CPU、内存、PID、I/O、cgroups 与 OOM

四、安全与供应链

  1. Docker 配置与 Secret:环境变量、文件注入、轮换和泄漏防护
  2. Docker 容器安全:非 root、Capabilities、Seccomp、只读根和隔离
  3. Docker 软件供应链:SBOM、签名、扫描、可信基础镜像和策略门禁
  4. Docker Registry 与镜像分发:Tag、Digest、认证、缓存和清理

五、生产运维

  1. Docker 日志与监控:Logging Driver、Metrics、事件和容量告警
  2. Docker 健康检查与优雅关闭:PID 1、Probe、Stop Signal 和超时
  3. Docker 数据备份与主机迁移:卷、数据库、一致性和恢复演练
  4. Docker 故障排查:启动失败、网络、磁盘、OOM、构建和 Daemon
  5. Docker Engine API 与自动化:SDK、事件、权限和幂等操作
  6. Docker 生产交付体系:CI、灰度、回滚、容量和运行手册

六、开发与命令行

  1. Docker CLI 与对象模型:Container、Image、Network、Volume 和 Filter
  2. Docker Inspect、Exec 与调试:Namespace、环境、进程和最小镜像
  3. Docker Context 与远程 Daemon:SSH、TLS、权限和环境隔离
  4. Rootless Docker:User Namespace、网络、存储、限制和迁移
  5. Docker Desktop 架构:Linux VM、文件共享、网络、资源和企业边界
  6. Dev Containers 开发环境:配置、Feature、缓存、Secret 和复现
  7. Docker Compose 开发工作流:Bind、Watch、Override、Profile 和调试
  8. Docker 容器化测试:一次性依赖、健康等待、Fixture 和资源清理

七、构建深入

  1. Docker 构建上下文与 .dockerignore:传输边界、缓存和 Secret 泄漏
  2. BuildKit 缓存深入:Layer、Cache Mount、远程缓存和失效诊断
  3. Docker Build Secret 与 SSH Mount:凭据注入、缓存和泄漏防护
  4. Docker ARG、ENV、LABEL 与 Metadata:作用域、继承和敏感边界
  5. Docker 可重复依赖安装:锁文件、镜像源、缓存、校验和离线
  6. Docker Alpine、Distroless 与 Scratch:libc、证书、时区和调试取舍
  7. Go 应用 Docker 镜像:静态链接、CA、时区、非 root 和优雅退出
  8. Java 应用 Docker 镜像:JLink、Class Data Sharing、内存和 JVM 参数
  9. Node.js 应用 Docker 镜像:依赖、构建产物、PID 1、信号和安全
  10. Vue 与 React 静态站点镜像:构建、Nginx、缓存、路由和运行时配置
  11. Docker Buildx Bake:多目标、矩阵、缓存、平台和 CI 编排
  12. Docker 构建证明:Provenance、SBOM、Attestation 和 SLSA

八、运行时深入

  1. Docker 运行时隔离:namespaces、cgroups、Mount、PID 和网络
  2. Docker Storage Driver:overlay2、Copy-on-Write、Inode 和磁盘诊断
  3. Docker Volume 权限:UID/GID、umask、Rootless、SELinux 和共享
  4. Docker Bind Mount 深入:传播、递归、只读、符号链接和平台差异
  5. Docker Bridge 与 iptables/nftables:NAT、端口发布、转发和冲突
  6. Docker 容器 DNS:嵌入式解析、Service Name、Search Domain 和故障
  7. Docker IPv6、Macvlan 与 Host 网络:场景、隔离和路由边界
  8. Docker GPU 容器:NVIDIA Runtime、设备、驱动、资源和可观测
  9. Docker PID 1 与 Init:信号、子进程回收、Shell Form 和 Tini
  10. Docker 时区、Locale 与 CA 证书:最小镜像中的运行时基础
  11. 数据库运行在 Docker:持久化、初始化、备份、资源和生产边界

九、Compose 与交付

  1. Compose 服务发现与依赖:DNS、Healthcheck、启动顺序和重连
  2. Compose 配置合并:Override、include、extends、变量插值和验证
  3. Compose 多环境治理:开发、测试、生产差异、Secret 和不可变制品
  4. Compose 扩容与并发:Replica、端口、负载均衡、共享状态和边界
  5. Compose 反向代理架构:Nginx、TLS、服务发现、路由和零停机
  6. Docker CI 构建流水线:缓存、并行、扫描、签名、推送和晋级
  7. Docker 不可变制品晋级:Digest、环境配置、验收、灰度和回滚
  8. Docker Swarm 基础与边界:Service、Task、Overlay、Secret 和滚动更新

十、Daemon 与运维深入

  1. Docker Daemon 配置:data-root、日志、代理、镜像源、TLS 和验证
  2. Docker User Namespace Remap:UID 映射、Volume、权限和迁移
  3. Docker Live Restore 与重启恢复:Daemon 故障、容器存活和限制
  4. Docker Engine 升级:兼容、存储驱动、API、灰度、回滚和验证
  5. Docker 离线与受限网络:镜像同步、依赖缓存、签名和补丁
  6. Docker 磁盘治理:Layer、Build Cache、Volume、日志和安全清理
  7. Docker 网络故障诊断:Namespace、Bridge、DNS、NAT、MTU 和抓包
  8. Docker OOM 与性能诊断:cgroups、PSI、CPU Throttle、I/O 和内存
  9. Docker 在 Linux、macOS 与 Windows 的差异:VM、路径、网络和性能
  10. Docker 安全事件响应:隔离容器、保全证据、镜像追溯和恢复

系列导航与关联阅读

官方资料

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