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
两个设计点:
- 第 2 步在第 3 步之前。顺序反了,坏版本替换实例后你就读不到旧 digest 了——回滚资源必须在变更发生前采集。
- 第 7 步在最后。
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 章"发了个寂寞"的自动化 |
自测题
- 发布序列里,记录 prev-digest 为什么必须发生在替换实例之前?last-good-digest 为什么必须发生在观测窗口之后?
docker compose config把512m归一化成"536870912",这说明了声明式文件的什么性质?- 版本回滚为什么要"先改 compose 文件再 up",而不是直接 stop + run 旧镜像?
- 判断"这次能不能版本回滚"的那条 psql 命令在检查什么?两种结果分别对应哪种回滚路径?
- observe.sh 的两个代理指标是什么?它们替代不了指标系统里的哪两类信号?
现在能解释什么
- 一条"单机 + Compose"形态的完整发布与回滚链路,每一步的失败出口与资源从哪来。
- 面试被问"你们的发布流程"时,能从预检讲到归档、报出回滚演练的实际耗时,而不是"写个脚本重启一下"。
- 什么时候该从这条单机路径升级到 K8s / GitOps(服务数、团队数、审计要求),以及升级时哪些资产(digest 纪律、冒烟、退出码约定)是直接平移的。