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

Docker 生产交付体系:CI、灰度、回滚、容量和运行手册

生产交付不是“把镜像构建出来并执行 docker run”。它至少包含一条可追踪的数据链:

源代码
  ↓
可复现构建与测试
  ↓
镜像、SBOM、签名、漏洞结果
  ↓
不可变版本发布
  ↓
灰度部署与健康验证
  ↓
流量切换
  ↓
指标、日志、事件和容量观察
  ↓
回滚或继续扩大范围

本文以现代 Docker Engine、BuildKit 和 Compose 规范为基础,重点讨论 Linux 主机上的容器交付。Docker Compose 适合单机、多容器应用编排;它不是跨主机调度器,也不会自动提供 Kubernetes 一类的滚动发布、服务发现或故障迁移能力。跨主机生产系统需要额外的编排器、负载均衡器、镜像仓库和监控系统。

一、先定义交付对象:不可变镜像和可验证版本

1.1 镜像标签不是版本身份

镜像引用通常有两种形式:

registry.example.com/payments/api:2025.03.08-abc123
registry.example.com/payments/api@sha256:...

标签是人可读的别名,可以被重新推送;摘要是镜像内容的哈希身份。生产部署若只记录:

image: registry.example.com/payments/api:latest

就无法回答“当前运行的到底是哪一份内容”,也无法保证回滚时取得的仍然是原版本。

更可靠的流程是:

  1. CI 根据提交构建镜像;
  2. 推送带有提交标识的标签;
  3. 读取推送后的 digest;
  4. 将 digest 写入发布清单;
  5. 生产只按 digest 拉取和启动;
  6. 记录提交 ID、构建器版本、基础镜像 digest 和部署时间。

例如:

services:
  api:
    image: registry.example.com/payments/api@sha256:0123456789abcdef...

这里的摘要必须是真实仓库返回的完整 digest,不能使用示例值。使用 digest 不代表镜像永久存在:仓库仍可能执行删除或保留策略,所以生产环境还应保证已发布 digest 的保留周期。

1.2 Linux 容器的边界

Linux 容器通过 namespace 隔离进程、网络、挂载点等视图,通过 cgroup 限制或统计资源,并与宿主机共享 Linux 内核。因此:

  • 容器不是虚拟机,不能隔离不同内核;
  • 宿主机内核漏洞可能影响所有容器;
  • --privileged、挂载 /var/run/docker.sock、挂载宿主机根目录都会显著扩大权限边界;
  • 文件权限、信号、cgroup 和网络行为以 Linux 为主要语义;
  • Windows 容器和 Linux 容器的镜像格式、内核接口、隔离机制不同,不能直接套用本文全部命令。

生产主机还需要明确 Docker Engine 的 rootful 或 rootless 模式。rootless 可以降低 Docker daemon 对宿主机的权限,但会影响端口绑定、存储驱动和 cgroup 能力;不能把“使用 rootless”误认为自动解决了容器内的所有权限问题。

二、CI:从源代码到可部署制品

2.1 一个可审计的流水线

典型流水线可以分为以下状态:

flowchart LR
    A[提交代码] --> B[静态检查与单元测试]
    B --> C[BuildKit 构建]
    C --> D[镜像集成测试]
    D --> E[生成 SBOM 与 provenance]
    E --> F[漏洞与策略门禁]
    F --> G[推送镜像]
    G --> H[按 digest 生成发布清单]
    H --> I[部署灰度环境]
    I --> J[健康检查与业务验证]
    J --> K[扩大流量或停止发布]

每个状态都应有明确的输入、输出和失败处理。例如,漏洞扫描失败时不能继续部署;部署后健康检查通过,也不能替代业务指标验证,因为进程存活并不等于请求正确。

CI 至少应执行:

  • 代码格式和静态检查;
  • 单元测试;
  • 使用生产近似依赖的集成测试;
  • Dockerfile 构建;
  • 容器启动测试;
  • HTTP 或 RPC 健康测试;
  • SBOM 生成;
  • 镜像漏洞扫描;
  • 签名或可信来源校验;
  • 发布清单生成。

2.2 使用 BuildKit 构建,而不是把密钥写进镜像

下面是一个基础 Dockerfile:

# syntax=docker/dockerfile:1

FROM golang:1.23-bookworm AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
    -o /out/api ./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]

关键点如下:

  • 多阶段构建把编译器和源码留在构建阶段;
  • ENTRYPOINT 使用 JSON 数组形式,避免额外 shell 成为 PID 1;
  • 使用非 root 用户运行;
  • GOOS=linux 明确目标平台;
  • -trimpath 减少构建路径泄露,但不等同于完整可复现构建;
  • 基础镜像仍然应按 digest 固定,而不是仅使用 debian:bookworm 这类可变标签。

构建命令:

docker buildx build \
  --platform linux/amd64 \
  --tag registry.example.com/payments/api:2025.03.08-abc123 \
  --provenance=true \
  --sbom=true \
  --push \
  .

这些 --provenance--sbom 选项依赖较新的 Buildx、BuildKit 和仓库支持;CI 中应先用 docker buildx versiondocker buildx inspect 确认能力。它们生成的证明和 SBOM 是供应链元数据,不能替代漏洞扫描和签名。

不要这样处理构建凭据:

ARG NPM_TOKEN
RUN npm config set //registry.example.com/:_authToken=$NPM_TOKEN

因为构建参数可能进入历史或元数据。应使用 BuildKit secret mount,且让凭据只在需要的 RUN 步骤存在:

# syntax=docker/dockerfile:1
FROM node:22-bookworm AS build
WORKDIR /src

COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

COPY . .
RUN npm run build

构建时:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  --tag registry.example.com/web:2025.03.08-abc123 \
  --push .

前置条件是本机或 CI 工作空间存在 .npmrc。命令成功时,secret 不应被复制到最终镜像;仍需检查 Dockerfile 和构建产物,防止应用主动把凭据写入输出目录。

2.3 SBOM、签名和策略门禁各自解决什么问题

SBOM 是软件物料清单,描述镜像内包含哪些包及其版本。它回答“里面有什么”,但不直接回答“是否安全”。

漏洞扫描 将 SBOM 或镜像文件系统与漏洞数据库匹配。扫描结果受数据库更新时间、发行版修复回移和误报影响。例如,发行版可能将安全修复回移到旧版本号,因此不能仅按上游软件版本字符串机械判断。

签名 证明某个身份对某个镜像摘要执行过签署。签名不保证代码没有漏洞,也不保证构建过程本身可信。

provenance 记录构建来源、构建器和输入等信息,可帮助回答“这个 digest 是由什么源代码和流水线构建的”。

因此,一个门禁的逻辑通常是:

digest 存在
∧ SBOM 可生成
∧ provenance 满足要求
∧ 漏洞结果满足阈值
∧ 签名身份属于允许发布者
∧ 基础镜像属于允许来源
→ 允许进入部署仓库

签名工具可以使用 Cosign 等 OCI 生态工具;Docker Engine 本身不会替你设计完整的组织身份和策略体系。策略必须明确:

  • 阻断哪些严重等级;
  • 是否允许有修复版本的漏洞;
  • 是否允许开发依赖存在于最终镜像;
  • 签名身份与环境的对应关系;
  • 例外由谁批准、何时过期;
  • 基础镜像和仓库是否在允许列表中。

“扫描通过”也不是永久事实。镜像 digest 不变,但漏洞数据库会更新,所以应定期重新扫描已部署 digest。

三、健康检查、PID 1 和优雅关闭

3.1 PID 1 的实际作用

容器内由 Docker 启动的第一个进程通常是 PID 1。它负责接收停止信号,并承担孤儿进程回收等职责。常见错误是:

ENTRYPOINT ["sh", "-c", "python app.py"]

此时 PID 1 是 sh。如果 shell 没有正确转发 SIGTERM,应用可能直到超时后才收到 SIGKILL,造成连接中断或事务未完成。

更稳妥的是:

ENTRYPOINT ["python", "app.py"]

如果必须使用 shell,应显式使用 exec

ENTRYPOINT ["sh", "-c", "exec python app.py"]

也可以在镜像中使用专门的 init 进程处理信号和僵尸进程。docker run --init 使用 Docker 提供的 init 机制,但是否采用应结合应用本身是否已经正确处理子进程。

3.2 Stop signal 和超时的因果链

容器停止的大致过程是:

docker stop
  ↓
向容器 PID 1 发送停止信号,默认通常为 SIGTERM
  ↓
等待 stop grace period
  ↓
仍未退出则发送 SIGKILL

Compose 中可以明确声明:

services:
  api:
    image: registry.example.com/payments/api@sha256:...
    stop_signal: SIGTERM
    stop_grace_period: 30s

stop_signal 指定应用应处理的信号,stop_grace_period 指定等待时间。它们不是“保证优雅关闭”的开关:应用必须实现收到信号后的停止逻辑,例如:

  1. 标记实例不再接收新请求;
  2. 从服务发现或负载均衡中摘除;
  3. 等待进行中的请求;
  4. 停止消费新消息;
  5. 提交或回滚当前事务;
  6. 关闭连接池;
  7. 在期限内退出。

如果应用需要 90 秒才能完成最长请求,而 grace period 只有 10 秒,那么第 10 秒会被强制杀死。反过来,盲目设置很长的超时也会拖慢发布和故障恢复。

3.3 Probe 的状态和限制

Compose 的 healthcheck 是容器内部探针,不是完整的业务监控:

services:
  api:
    image: registry.example.com/payments/api@sha256:...
    healthcheck:
      test: ["CMD", "/bin/sh", "-c", "wget -q -O- http://127.0.0.1:8080/readyz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s

如果基础镜像没有 wget,该检查会永远失败;精简镜像通常需要把探针程序编译进镜像,或者使用 CMD-SHELL 调用已有工具。检查命令退出码为 0 才表示成功。

容器健康状态通常经历:

starting → healthy
starting → unhealthy
healthy  → unhealthy
unhealthy → healthy

start_period 允许启动初始化阶段的失败不立即计入重试;interval 是检查间隔;timeout 是单次检查超时;retries 是连续失败次数阈值。具体状态可查看:

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

健康检查失败不会自动重启普通 Docker Compose 服务。它会改变健康状态;是否重启取决于外部编排、监控自动化或人工操作。Compose 的:

depends_on:
  db:
    condition: service_healthy

主要用于启动顺序:启动 api 前等待 db 健康。它不等于运行期间持续监督,也不解决数据库迁移锁、连接重试和网络瞬断。

应区分两个端点:

  • livez:进程是否仍然能工作,通常不检查所有依赖;
  • readyz:是否应该接收流量,可能检查数据库连接、配置加载和关键依赖。

把数据库短暂故障直接映射为 liveness 失败,可能造成重启风暴;把所有故障都报告为 ready,则会让流量进入无法正确处理请求的实例。

四、Compose 生产部署的边界和基本模型

一个最小 Compose 文件可以是:

services:
  api:
    image: ${API_IMAGE}
    restart: unless-stopped
    init: true
    read_only: true
    tmpfs:
      - /tmp
    user: "10001:10001"
    ports:
      - "127.0.0.1:18080:8080"
    environment:
      PORT: "8080"
    healthcheck:
      test: ["CMD", "/bin/sh", "-c", "wget -q -O- http://127.0.0.1:8080/readyz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s

read_only 会把根文件系统设为只读;应用确实需要写入时应显式挂载卷或 tmpfs,否则启动可能失败。restart: unless-stopped 是本机 Docker 的重启策略,不是高可用机制:宿主机损坏、Docker daemon 故障或整机断电仍可能导致服务不可用。

部署前先展开并校验配置:

export API_IMAGE='registry.example.com/payments/api@sha256:...'

docker compose config
docker compose pull
docker compose up -d --wait
docker compose ps

docker compose config 可以发现变量未替换、缩进和合并结果问题;pull 拉取清单中的镜像;up -d 创建或重建容器;较新的 Compose 支持 --wait,它会等待服务达到运行或健康状态,具体行为和可用选项应以 docker compose up --help 为准。不能把命令返回成功理解为业务发布成功,还要验证 HTTP、关键业务操作和指标。

五、灰度发布:先验证新版本,再改变流量

5.1 灰度的定义

灰度发布是让新版本只接收一部分可控流量或一部分实例,然后依据观测结果逐步扩大范围。它与“启动第二个容器”不同:

启动新版本 ≠ 新版本收到流量
收到少量流量 ≠ 已验证全部场景
健康检查通过 ≠ 业务指标正常

单机 Compose 不负责按百分比分配外部请求。通常需要反向代理、服务网格或云负载均衡器执行流量切换。Compose 只负责运行蓝、绿两个版本。

一种结构如下:

flowchart LR
    U[客户端] --> P[反向代理/负载均衡器]
    P --> B[blue: 旧版本]
    P --> G[green: 新版本]
    B --> DB[(数据库)]
    G --> DB
    M[指标与日志系统] --> P
    M --> B
    M --> G

部署顺序是:

  1. 保持 blue 接收 100% 流量;
  2. 启动 green;
  3. 等待 green 的 readiness;
  4. 对 green 执行版本、配置和关键业务验证;
  5. 将少量流量切到 green;
  6. 比较错误率、延迟、业务成功率、资源使用和日志异常;
  7. 逐步增加比例;
  8. 观察完整窗口后完成发布;
  9. 保留 blue,直到回滚窗口结束。

若代理配置按服务名路由,必须验证 DNS 缓存、连接复用和长连接行为。已经建立的 WebSocket、HTTP/2 或长轮询连接不一定会因配置切换立即迁移。

5.2 数据库迁移决定了回滚能力

应用镜像通常可以快速回滚,数据库 schema 却可能已经向前演进。安全的迁移一般采用扩展—迁移—收缩模式:

  1. 先增加向后兼容的字段或表;
  2. 部署同时兼容旧 schema 和新 schema 的应用;
  3. 回填数据;
  4. 切换读写路径;
  5. 确认旧版本不再运行后,再删除旧字段或约束。

反例是新版本先删除旧字段,然后发现应用错误并回滚镜像。旧版本恢复后仍访问已删除字段,回滚反而扩大故障。

六、回滚:恢复旧制品,不是重新构建“差不多的版本”

6.1 何时触发回滚

回滚条件应在发布前定义,例如:

  • 5 分钟错误率超过基线和绝对阈值;
  • p95/p99 延迟持续超过服务目标;
  • 关键业务成功率下降;
  • 数据库连接池耗尽或队列持续增长;
  • 新版本出现不可接受的日志错误;
  • 内存或 CPU 进入持续饱和状态。

不能只看平均延迟。少量慢请求可能被平均值隐藏,而 p99 可能已经破坏用户体验。

6.2 Compose 中的回滚过程

如果部署文件使用环境变量:

services:
  api:
    image: ${API_IMAGE}

回滚时设置上一个已批准的 digest:

export API_IMAGE='registry.example.com/payments/api@sha256:previous...'

docker compose config
docker compose pull
docker compose up -d --no-deps --wait api
docker compose ps
docker inspect --format '{{.Config.Image}} {{.Image}}' "$(docker compose ps -q api)"

这里:

  • .Config.Image 显示容器配置使用的引用;
  • .Image 显示实际镜像 ID;
  • digest 引用可以避免标签漂移;
  • --no-deps 防止回滚应用时无意重启依赖服务;
  • --wait 只验证启动/健康状态,仍需要执行业务验证。

回滚后应检查:

docker compose logs --since=10m api
docker events --since=10m --filter container="$(docker compose ps -q api)"

若新版本已经写入不可逆数据,先停止继续扩大损害,再判断是应用回滚、前向修复还是数据恢复。数据库回滚不能仅凭“旧镜像还能启动”决定。

6.3 回滚的常见失败表现

  • 容器启动成功但请求 500:健康检查只检查端口,未覆盖依赖或业务逻辑;
  • 镜像拉取失败:旧 digest 未被仓库保留,或生产主机无访问凭据;
  • 回滚后仍有新版本流量:代理缓存、连接复用或另一台主机仍运行新版本;
  • 旧版本启动后数据库报错:迁移不向后兼容;
  • 容器反复重启:入口进程退出、配置不完整、权限错误或 OOM;
  • 回滚无法降低延迟:根因可能是数据库、外部依赖或容量,而非应用代码。

因此发布记录必须保存镜像 digest、Compose 配置、环境变量版本、迁移版本、代理配置和执行人,而不是只保存一个 Git 分支名。

七、容量:从请求量推导实例数,而不是凭感觉扩容

7.1 Little 定律和并发容量

对稳定系统,Little 定律为:

L=λWL = \lambda W

其中:

  • LL 是系统内平均并发请求数;
  • λ\lambda 是吞吐率,单位为请求/秒;
  • WW 是请求在系统内的平均停留时间,单位为秒。

例如,流量为 120 请求/秒,平均响应时间为 80 毫秒:

L=120×0.08=9.6L = 120 \times 0.08 = 9.6

这意味着系统平均约有 10 个并发请求,但不意味着只需要 10 个线程或 1 个容器,因为 p99 延迟、CPU、内存、连接池和突发流量还会增加需求。

如果每个实例安全承载 40 个并发请求,按目标利用率 70% 计算:

N9.640×0.7=1N \ge \left\lceil \frac{9.6}{40 \times 0.7} \right\rceil = 1

数学上得到 1,但生产还要考虑实例故障、部署期间双版本并存和流量突发。若要求任意丢失一个实例后仍能承载目标流量,至少需要 2 个;若灰度期间 blue 和 green 各保留实例,还要计算总主机资源。

7.2 CPU 容量算例

假设压测得到:

  • 峰值流量:120 请求/秒;
  • 每个请求平均消耗 12 毫秒 CPU;
  • 目标 CPU 利用率:不超过 60%;
  • 每个实例提供 1 个 CPU 核的有效配额。

所需 CPU 核数为:

C=120×0.0120.60=2.4C = \frac{120 \times 0.012}{0.60} = 2.4

因此至少需要 3 个实例的 CPU 容量。若要求 N-1 容错,则 3 个实例中损失 1 个后只剩 2 个,不足 2.4 个核,应至少配置 4 个实例。若灰度期间新旧版本同时运行,主机必须能容纳旧版本和新版本的总 CPU、内存以及代理开销。

CPU 配额可以限制容器:

services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 768M

在 Compose 的不同运行模式和版本中,deploy 资源字段的实际执行能力需要验证;不要仅凭文件写入就假定主机已经施加限制。可在运行后检查:

docker inspect api --format '{{json .HostConfig.NanoCpus}} {{json .HostConfig.Memory}}'
docker stats --no-stream api

docker stats 显示的是观测值,不是压测替代品。内存限制触发后可能发生 OOM kill;查看:

docker inspect api --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
dmesg -T | grep -i -E 'oom|killed process'

7.3 内存、连接和磁盘是不同的容量问题

内存容量不能只看应用堆:

MhostN×Mcontainer-peak+Mdaemon+Mproxy+Mlog+Msystem+MheadroomM_{\text{host}} \ge N \times M_{\text{container-peak}} + M_{\text{daemon}} + M_{\text{proxy}} + M_{\text{log}} + M_{\text{system}} + M_{\text{headroom}}

M_container-peak 应使用压测和生产观测中的峰值,而非启动后的瞬时值。还要考虑 page cache、线程栈、运行时元数据和突发分配。

连接容量也可能先于 CPU 饱和。例如:

实例数 × 每实例数据库连接池上限
    > 数据库允许的最大连接数

扩容应用可能因此压垮数据库。队列消费者则要同时观察队列深度、消费速率和消息处理时间;单纯增加容器数量不一定提高吞吐,可能只是增加下游争用。

磁盘容量需要包括:

  • 可写层;
  • volume 数据;
  • 镜像层;
  • 容器日志;
  • 构建缓存;
  • 临时文件;
  • 删除但仍被进程打开的文件。

查看 Docker 空间:

docker system df
df -h
docker ps --size

不要在故障中直接执行:

docker system prune -a --volumes

它可能删除未使用镜像、网络、容器和卷,卷删除尤其可能造成数据丢失。生产清理必须先确认对象归属、备份状态和保留策略。

八、日志、指标和事件:为发布与容量提供证据

8.1 Logging Driver 和日志流转

Docker logging driver 决定容器标准输出和标准错误如何被 Docker 接收、存储或转发。常见的 json-file 便于本机查看,但若不设置轮转,日志可能填满磁盘:

services:
  api:
    logging:
      driver: local
      options:
        max-size: "20m"
        max-file: "5"

local 驱动使用 Docker 的本地存储格式并支持轮转;实际集中采集方案也可以使用 syslogjournald、Fluentd 等驱动,取决于主机和日志平台。不要同时让应用写大量文件日志又依赖 Docker 标准输出,否则会造成重复采集和磁盘不可见增长。

检查实际配置:

docker inspect api --format '{{json .HostConfig.LogConfig}}'
docker logs --since=15m --timestamps api

docker logs 只能查看 Docker 能捕获的标准输出/错误,不会自动显示应用写入任意文件的内容。生产日志至少要包含时间、级别、服务、版本 digest、请求 ID 和错误上下文,但密码、令牌和个人敏感信息不得写入日志。

8.2 指标与事件的不同职责

指标适合回答“系统现在是否恶化”:

  • 请求速率;
  • 错误率;
  • p50、p95、p99 延迟;
  • CPU、内存、网络和文件系统;
  • 重启次数;
  • OOM 次数;
  • 队列深度;
  • 数据库连接池使用率。

事件适合回答“Docker 刚刚做了什么”:

docker events --since=30m \
  --filter type=container \
  --filter event=die \
  --filter event=restart \
  --filter event=health_status

事件是实时流,不是长期审计数据库;应转发到持久化系统。docker inspect 适合检查单个对象当前状态,但不是历史变化记录。

Docker daemon 的指标接口、容器指标采集器和应用指标端点在不同部署中有所不同,不能把 docker stats 当作完整监控系统。生产应将应用指标、主机指标、Docker/容器指标和日志关联到同一个版本与实例标识。

九、运行手册:把诊断和恢复写成可执行路径

运行手册不是“重启服务”四个字,而是包含触发条件、证据、操作、验证和退出条件的故障流程。

9.1 发布前检查

set -eu

docker version
docker compose version
docker compose config
docker image inspect "$API_IMAGE"
docker compose pull

应确认:

  • Docker Engine 和 Compose 版本满足流水线要求;
  • 目标平台与镜像平台匹配;
  • digest 可从生产仓库拉取;
  • 所需 secret、网络、卷和环境变量存在;
  • 数据库迁移版本与应用版本兼容;
  • 旧版本镜像仍在保留期内;
  • 监控和回滚权限可用。

如果 docker image inspect 失败,通常表示本地没有该镜像或引用格式错误;如果 pull 失败,应先区分 DNS、仓库认证、网络策略和 digest 不存在,不要立即改回 latest

9.2 容器启动失败

按以下顺序收集证据:

docker compose ps
docker compose logs --tail=200 api
docker inspect "$(docker compose ps -q api)" \
  --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}'

常见判断路径:

  1. Exited 且退出码为 0:入口程序可能正常结束,但服务本应常驻;
  2. 退出码为 126:通常涉及不可执行权限;
  3. 退出码为 127:命令不存在;
  4. OOMKilled=true:先调查内存峰值、限制和泄漏;
  5. unhealthy 但进程运行:检查探针命令是否存在、端口是否正确、依赖是否可用;
  6. 重启次数持续增加:检查 restart、入口异常和外部依赖。

不要为了“让容器保持运行”把入口改成 sleep infinity。这会掩盖真实启动失败,并让健康检查和监控失去意义。

9.3 延迟和错误率升高

先区分新版本特有问题和全局基础设施问题:

docker stats --no-stream
docker compose logs --since=10m api
docker events --since=10m
ss -s
df -h

然后按版本、实例和依赖拆分指标:

新版本错误率高、旧版本正常
→ 暂停扩大灰度,切回旧版本

所有版本同时变慢
→ 检查数据库、外部依赖、主机 CPU/IO 和网络

应用无明显错误但 p99 上升
→ 检查连接池、线程池、队列、下游超时和资源争用

磁盘接近满
→ 先确认日志、容器层、volume 和删除打开文件,再按保留策略清理

回滚前后都要保存证据。没有证据的回滚可能恢复可用性,却丢失定位根因所需的信息。

9.4 优雅停止验证

可以使用一个临时容器验证信号路径:

docker run --name signal-test --rm \
  --stop-signal SIGTERM \
  alpine:3.20 \
  sh -c 'trap "echo got-term; exit 0" TERM; while :; do sleep 1; done'

另一个终端执行:

docker stop --time 5 signal-test

预期容器输出 got-term 并在 5 秒内退出。这个例子只验证 shell 进程的信号处理,不代表真实应用的请求排空逻辑已经正确。真实服务还应在压测下验证:停止实例后,新请求不再进入该实例,进行中的请求在期限内完成,超时请求不会无限阻塞。

十、一次完整交付示例

假设 CI 已得到镜像 digest:

registry.example.com/payments/api@sha256:aaa...

Compose 使用环境变量:

services:
  api:
    image: ${API_IMAGE}
    init: true
    restart: unless-stopped
    stop_signal: SIGTERM
    stop_grace_period: 30s
    healthcheck:
      test: ["CMD", "/bin/sh", "-c", "wget -q -O- http://127.0.0.1:8080/readyz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s

部署过程为:

export API_IMAGE='registry.example.com/payments/api@sha256:aaa...'

docker compose config > rendered-compose.yaml
docker compose pull
docker compose up -d --wait
docker compose ps
curl --fail http://127.0.0.1:18080/readyz

其中 rendered-compose.yaml 应进入发布审计记录,但要先确认其中没有明文 secret。健康检查和 curl 都成功后,再让代理把少量流量转到该实例。若业务错误率、p99 延迟或下游连接数异常,恢复代理流量到旧版本,并将 API_IMAGE 改回旧 digest:

export API_IMAGE='registry.example.com/payments/api@sha256:old...'
docker compose pull
docker compose up -d --wait --no-deps api
curl --fail http://127.0.0.1:18080/readyz

最后检查运行中的镜像身份、日志、事件和业务指标,确认回滚不是只恢复了容器进程,而是恢复了对用户可用的服务。

生产交付的核心不是某个 Compose 参数,而是让“构建的内容、部署的版本、实际接收的流量、观测到的行为和执行的恢复动作”能够互相对应。只有这样,灰度才有验证意义,回滚才有确定对象,容量计算才有观测依据,运行手册才不会退化为未经验证的命令集合。


系列导航与关联阅读

官方资料

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