KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 演练:单机发布与一次完整的回滚 — keel 龙骨

这一章回答:前六章的判断落到"单机 Docker Compose + Nginx"这个最常见的中小团队形态上,发布日到底执行哪些命令、每一步失败的出口是什么、回滚命令具体长什么样。目标很具体:发布 12 分钟内完成,且任意时刻一条命令退回上一个可用版本。

这一章回答:前六章的判断落到"单机 Docker Compose + Nginx"这个最常见的中小团队形态上,发布日到底执行哪些命令、每一步失败的出口是什么、回滚命令具体长什么样。目标很具体:发布 12 分钟内完成,且任意时刻一条命令退回上一个可用版本。

诚实边界先行:本章的编排文件产出自 docker compose config 的纯客户端解析(本机未启动容器守护进程),镜像构建与容器运行未在本机执行;命令序列是教学替身,生产 Linux 下的细节(systemd、日志路径、用户权限)以你自己的环境为准。但序列的结构与失败出口设计,是可以照搬的判断。

一、发布单元先固化:compose 文件里的六个必备件

发布之前先回答"发布单元是什么"。单机形态下它是一份引用固定 digest 的 compose 文件,六个必备件缺一个都会在发布日或回滚日找你收账:

name: myapp
services:
  app:
    image: ghcr.io/example/myapp@sha256:9f2c4b8e1d3a7f605c8e2b4d9a1f7c3e5b8d2a6f4e9c1b7d3a5f8e2c6b9d4a1f
    env_file: .env.production
    ports:
      - "127.0.0.1:8000:8000"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 15s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512m
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
必备件 缺了会怎样
digest 引用(不是 tag) 回滚时"旧版本"可能已被同名 tag 覆盖——第 01 章"发出去的是不是你以为的那个"直接失守
healthcheck up -d 返回成功≠服务活着;没有它,"等待健康"无从判断
资源上限 内存泄漏会拖垮宿主机上所有服务,包括 Nginx
日志轮转 json-file 默认不轮转,磁盘写满那天全线挂
端口绑定 127.0.0.1 应用直接暴露公网,绕过 Nginx 的 TLS 与限流
env_file 外置配置 配置烧进镜像 = 换环境要重打镜像,铁律③破

把这份文件喂给 docker compose config(纯客户端解析,不需要守护进程),看归一化输出里的三处细节——这些就是"声明会被平台解释"的直观证据:

$ docker compose -f compose.yml config
name: myapp
services:
  app:
    deploy:
      resources:
        limits:
          memory: "536870912"        # ← 512m 被归一化成字节数字符串
    image: ghcr.io/example/myapp@sha256:9f2c4b8e...d4a1f
    networks:
      default: null                  # ← 没写 networks,平台自动补 default 网络
    ports:
      - mode: ingress                # ← 短语法被展开成长语法,host_ip 显式化
        host_ip: 127.0.0.1
        target: 8000
        published: "8000"
        protocol: tcp
networks:
  default:
    name: myapp_default

注意 memory: "536870912"——你写的 512m 进入平台后是字节数。编排文件是声明,运行的解释权在平台;排查"内存限制没生效"类问题时,第一步永远是看 config 归一化后的实际值,而不是你写的那个数。

二、发布序列:七步与各自的失败出口

整个发布序列一张图(编号与正文一致,虚线外框是回滚出口):

flowchart TD
    A["0 预检:last-good-digest 存在"] --> B["1 pull 指定 digest"]
    B --> C["2 记录 prev-digest(回滚资源)"]
    C --> D["3 compose up -d"]
    D --> E{"4 健康检查:60s 内 healthy?"}
    E -->|否| R["回滚出口:up 旧 digest"]
    E -->|是| F{"5 版本端点 == 预期版本?"}
    F -->|否| R
    F -->|是| G["6 观测窗口 10 分钟"]
    G --> H{"observe 脚本退出码"}
    H -->|非 0| R
    H -->|0| I["7 写 last-good-digest,归档发布记录"]
    style A fill:#f4f1e8,stroke:#8a815c
    style R fill:#f9e8e8,stroke:#a05252

对应的命令序列(deploy.sh 的内部,全部通过 ssh 在生产机执行):

# 0. 预检:回滚资源必须先确认存在,否则本次发布不具备可回滚前提
cat /srv/myapp/last-good-digest          # 应输出上一个可用 digest

# 1. 只拉 digest 引用的镜像——tag 可能变,digest 不会
docker pull ghcr.io/example/myapp@sha256:9f2c4b8e...

# 2. 记录当前运行版本的 digest(回滚资源),这一步在第 3 步之前
docker inspect --format '{{.Image}}' myapp-app-1 | cut -d: -f2 > /srv/myapp/prev-digest

# 3. 新 compose 文件已写好新 digest,一条命令替换实例
docker compose -f /srv/myapp/compose.yml up -d

# 4. 等健康:start_period 15s + retries 3 × interval 10s ≈ 最长 45s 的判定窗口
docker compose -f /srv/myapp/compose.yml ps   # 等 STATUS 出现 (healthy)

# 5. 版本确认(第 01 课 05 章的第二项冒烟):必须返回预期版本号
curl -fsS http://127.0.0.1:8000/version  # 期望: v1.7.2 (abc1234)

# 6. 观测窗口(脚本见下节),失败退出码非 0
./observe.sh "v1.7.2 (abc1234)" 600

# 7. 全部通过后,才允许把本次 digest 写成"上一个可用版本"
grep -o '@sha256:[a-f0-9]*' /srv/myapp/compose.yml | head -1 > /srv/myapp/last-good-digest

两个设计点:

三、观测脚本:把"盯十分钟"变成一个退出码

第 01 课 05 章的观测四件套,在单机形态下的最小实现是一个 20 行的脚本——它的核心设计是退出码约定:0 通过,非 0 触发回滚。这让"发布后观察"可以被 CI 调用,也让回滚判断不需要人盯着屏幕:

#!/usr/bin/env bash
# observe.sh <期望版本号> <观测秒数> [基础URL]
# 退出码 0 = 观测通过;非 0 = 触发回滚
set -uo pipefail
BASE="${3:-http://127.0.0.1:8000}"
EXPECTED="${1:?用法: observe.sh <期望版本号> <观测秒数> [基础URL]}"
DURATION="${2:-600}"
END=$((SECONDS + DURATION))
TOTAL=0; FAILS=0
while [ $SECONDS -lt $END ]; do
  TOTAL=$((TOTAL + 1))
  CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$BASE/healthz") || CODE=000
  [ "$CODE" = "200" ] || FAILS=$((FAILS + 1))
  VER=$(curl -s --max-time 3 "$BASE/version" || echo unreachable)
  if [ "$VER" != "$EXPECTED" ]; then
    echo "[abort] 版本漂移:期望 $EXPECTED,实得 $VER"; exit 2
  fi
  sleep 10
done
PCT=$((FAILS * 100 / TOTAL))
echo "healthz 失败率 ${PCT}%($FAILS/$TOTAL)"
[ "$PCT" -le 1 ]     # 失败率超过 1% → 非零退出 → 回滚

它的局限也要说清楚:这是健康失败率 + 版本漂移两个代理指标,不是真正的业务错误率与延迟分布。它能在单机、无监控系统的场景下兜住最粗的一层底;有了指标系统之后,退出依据应换成错误率与 P99(第 01 课 05 章的数字化触发条件),脚本骨架不变。

四、回滚:命令与迁移纠缠的实操判断

第 01 课 05 章给过回滚的四种形态与决策图;落到命令层:

# 配置回滚(坏在配置/开关上):退 env,一条命令
cp /srv/myapp/.env.production.bak /srv/myapp/.env.production
docker compose -f /srv/myapp/compose.yml up -d

# 版本回滚(代码缺陷、无迁移纠缠):换回旧 digest,一条命令
OLD=$(cat /srv/myapp/last-good-digest)
sed -i "s|@sha256:[a-f0-9]*|${OLD}|g" /srv/myapp/compose.yml   # 先改声明
docker compose -f /srv/myapp/compose.yml up -d                  # 再对齐现实
curl -s http://127.0.0.1:8000/version                           # 必须回到旧版本号

注意版本回滚是先改声明文件、再应用,不是 docker stop + docker run 手拼命令——因为回滚后 compose 文件里必须描述的就是生产正在运行的东西,否则下一次正常发布会在不知情之间把刚回滚的版本又顶掉。

迁移纠缠的实操判断(是否允许版本回滚)只需要回答一个问题:这次的迁移有没有执行 contract 步骤。两个检查命令:

# 本次发布带了哪些迁移?
docker compose exec app ls /app/migrations/ | tail -3

# 关键:contract 目标(比如被删的旧列)还在不在?
docker compose exec db psql -U app -c '\d orders' | grep -c legacy_column
#   计数 > 0 → 只做了 expand → 版本回滚安全
#   计数 = 0 → contract 已执行 → 版本回滚不可用,走前滚修复

这就是第 01 课 05 章那张决策图里唯一红框的实操判据。也是为什么第 03 章反复强调迁移拆成 expand / contract 两步、中间隔一次发布——拆开了,才有"任何时候都允许退版本"的窗口。

五、演练记录:做过才算会

发布与回滚都是肌肉记忆,纸上谈兵不算数。每次演练留一张记录表:

项目 记录
演练日期 / 触发方式(真实发布 or 计划演练)
发布总耗时(第 0 步到第 7 步) ______ 分钟(目标 ≤ 12)
卡在哪一步最久
回滚演练耗时(从决定回滚到版本端点返回旧版本号) ______ 秒
发现的问题(缺失的预检、含糊的输出、没备份的资源)

"回滚耗时"是这张表里最重要的数字:它是你的 MTTR 下限,也是"我们随时能退"这句话的底气来源。第一次演练 8 分钟、第二次 3 分钟,这个收敛过程本身就是交付能力在变好的证据。

动手:可观察结果

动作 产出物 判断"做完了"的标准
给自己的服务写六必备件齐了的 compose 文件 一份文件 + config 输出 归一化输出里 healthcheck / 资源 / 轮转逐项可指认
跑通一次完整七步发布 一次发布记录 版本端点返回预期值,last-good-digest 更新
跑一次回滚演练并计时 演练记录 版本端点回到旧版本号,耗时被记录
故意把观测窗口砍到 60 秒 前后对比 能说出砍短的代价(慢任务/低频错误漏检)
把 observe.sh 接进 CI 的部署 job 一条自动回滚的流水线 脚本非零退出时,部署 job 标红且触发回滚

故障注入

注入方式 观察什么 说明的现象
去掉 healthcheck 后发布一个起不来的版本 多久被发现 up -d 秒回成功,坏实例静默运行——健康检查就是为这一刻存在的
发布到第 3 步时人为中断 实例处于什么状态 旧版仍健康或新版半起,取决于时机——所以预检要先确认回滚资源
抹掉 prev-digest 与 last-good-digest 再触发回滚 回滚是否失败 回滚资源不备份 = 没有回滚
版本回滚时先 up 再改 compose 文件 下一次发布发生什么 文件与现实不一致,刚回滚的版本被静默顶掉
observe 只测 /healthz 不测 /version 缓存导致的部署失效能否发现 版本漂移检测正是第 01 课 05 章"发了个寂寞"的自动化

自测题

  1. 发布序列里,记录 prev-digest 为什么必须发生在替换实例之前?last-good-digest 为什么必须发生在观测窗口之后?
  2. docker compose config 把 512m 归一化成 "536870912",这说明了声明式文件的什么性质?
  3. 版本回滚为什么要"先改 compose 文件再 up",而不是直接 stop + run 旧镜像?
  4. 判断"这次能不能版本回滚"的那条 psql 命令在检查什么?两种结果分别对应哪种回滚路径?
  5. observe.sh 的两个代理指标是什么?它们替代不了指标系统里的哪两类信号?

现在能解释什么

进入 keel 阅读