9.3 上线检查清单
前八章各给了自己的验收动作:压测、指标、灰度、热更、停服、备份、切换、演练。问题是这些动作散落在八章里,上线那天没人能凭记忆把它们全部走一遍,更没人能证明「确实都做了」。9.3 的任务是把它们串成一份可执行的清单,并且让清单本身可被机器校验——每一项都必须挂着「通过的证据」,缺证据就等于没做。
清单的价值不在于好看,而在于它是一道闸:任何一项没有通过证据,上线就卡住。一道只写在文档里、靠人自觉的清单,出事时回头看会发现它什么也没拦住;一道能被脚本校验、不合格就拒绝放行的清单,才是真正的闸。本节先给出十项清单,再给一份可读可改的 checklist.json,接着给一个能真跑的校验器,最后讲怎么把它接进发布流水线。
这也是综合项目的收口:9.1 把服务端装配起来,9.2 量它的性能预算,9.3 把「能不能上线」这件事本身变成一次可复现的判定。
一份好的清单还有一个特征:它读起来像一次发布的操作手册。从起服到签核,按顺序走下来,每一步该产出什么、产出物放哪,都写在纸面上。新人照着走一遍也能发布,不必先跟着老师傅学半年——把隐性经验写成显性清单,本身就是工程化的一部分。
清单:十项验收动作与它们的证据
十项动作覆盖了「起得来、扛得住、看得见、改得动、停得下、兜得住」六个面。每一项都必须挂一份可以复现的证据,而不是一句「已确认」——证据是能被人重新打开、重新核对的东西,一句口头确认不是证据:
| # | 动作 | 证据 | 来自 |
|---|---|---|---|
| 1 | 起全部节点与中间件 | 装配冒烟日志 | 9.1 |
| 2 | 机器人压测达标 | 承载报告 JSON | 第 1 章 |
| 3 | 关键指标可拉取 | /metrics 抓取结果 | 第 3 章 |
| 4 | 灰度发布按比例生效 | 灰度报告 | 第 4 章 |
| 5 | 配置热更不重启即生效且可回滚 | 配置推送回执 | 第 5 章 |
| 6 | 优雅停服连接排空并落库 | 排空日志 | 4.3 |
| 7 | 从备份恢复到一致状态 | 恢复校验和 | 8.1 |
| 8 | 主节点宕机后自动接管 | 切换报告 | 8.2 |
| 9 | 演练有记录有改进项 | 复盘文档 | 8.3 |
| 10 | 清单逐条打勾无遗留 | 本清单 | 本节 |
第 2 项「压测达标」的证据就是 9.2 那份预算 JSON——清单里的每一项都能指回本卷某一节的真跑产物。这就是「可执行清单」和「待办列表」的区别:每一项背后都有一个能被机器重新验证的产物,而不是一句「我跑过了」。
清单的粒度也值得说一句:一项对应一个可以独立判定的事实。不要写成「检查服务器是否健康」这种模糊项——它没法判定过不过;要写成「起全部节点与中间件」并挂一份装配冒烟日志,这样谁来看、什么时候看,结论都一样。粒度对了,清单才能被机器校验。
清单文件:一份可读可改的 JSON
清单写成 JSON,好处是人和机器读的是同一份文件:人改状态、填证据路径,机器做校验。每一项有四个字段——id、step、required、evidence,加一个 status。required: true 的项是硬闸,evidence 是证据路径,status 是结论:
{
"game": "dawn-realm",
"items": [
{"id": "boot", "step": "起全部节点与中间件", "required": true, "evidence": "artifacts/assembly.log", "status": "pass"},
{"id": "loadtest", "step": "机器人压测达标", "required": true, "evidence": "artifacts/budget.json", "status": "pass"},
{"id": "metrics", "step": "关键指标可拉取", "required": true, "evidence": "artifacts/metrics.txt", "status": "pass"},
{"id": "canary", "step": "灰度发布按比例生效", "required": true, "evidence": "artifacts/canary.json", "status": "pass"},
{"id": "hotreload", "step": "配置热更不重启即生效且可回滚", "required": true, "evidence": "artifacts/config-ack.json", "status": "pass"},
{"id": "shutdown", "step": "优雅停服连接排空并落库", "required": true, "evidence": "artifacts/drain.log", "status": "pass"},
{"id": "backup", "step": "从备份恢复到一致状态", "required": true, "evidence": "artifacts/restore.txt", "status": "pass"},
{"id": "failover", "step": "主节点宕机后自动接管", "required": true, "evidence": "artifacts/failover.json", "status": "pass"},
{"id": "drill", "step": "演练有记录有改进项", "required": true, "evidence": "docs/postmortem.md", "status": "pass"},
{"id": "signoff", "step": "清单逐条打勾无遗留", "required": true, "evidence": "artifacts/checklist.json", "status": "pass"}
]
}
注意 JSON 里没写的东西不会生效——没进清单的验收项等于没做。所以这份文件的价值一半在它写了什么,一半在它逼着你把「还有哪些没验证」想清楚。status 只允许三种:pass、fail、skip;校验器只放行 pass。
字段少是有意的:id 供机器引用,step 给人读,required 决定它是不是硬闸,evidence 指向产物,status 给出结论。五个字段刚好够用,再多就会有人填错、有人偷懒。
为什么用 JSON 而不是随手一个 Markdown 勾选列表?因为 Markdown 勾选列表人看着舒服,机器却读不准——- [x] 已压测 这句话里的「已压测」既没有证据路径,也没有结构化状态,脚本没法据此放行。JSON 牺牲了一点可读性,换来的是同一份文件既能给人看、又能给脚本判。
校验器:每项必须有通过证据
下面这段是 L1,go test ./internal/dawnrealm/ 可以直接跑。Validate 把「required 项必须有非空 evidence 且 status 为 pass」变成一条硬规则,并且一次报出所有不合格项,而不是修一个跑一次:
// file: internal/dawnrealm/checklist.go
// tier: L1
// verify: go test ./internal/dawnrealm/
package dawnrealm
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
)
// Item is one line of the launch checklist. Every item must carry evidence: a
// file, a command output, or a report path that a reviewer can re-open. A
// checklist without evidence is an opinion, not a gate.
type Item struct {
ID string `json:"id"`
Step string `json:"step"`
Required bool `json:"required"`
Evidence string `json:"evidence"`
Status string `json:"status"` // "pass" | "fail" | "skip"
}
// Checklist is the ordered set of acceptance actions for one launch.
type Checklist struct {
Game string `json:"game"`
Items []Item `json:"items"`
}
// LaunchChecklist is the ten-step acceptance list the volume builds toward.
// The order matches the 《晨曦大陆》 acceptance list in the volume index.
func LaunchChecklist() Checklist {
return Checklist{Game: "dawn-realm", Items: []Item{
{ID: "boot", Step: "起全部节点与中间件", Required: true},
{ID: "loadtest", Step: "机器人压测达标", Required: true},
{ID: "metrics", Step: "关键指标可拉取", Required: true},
{ID: "canary", Step: "灰度发布按比例生效", Required: true},
{ID: "hotreload", Step: "配置热更不重启即生效且可回滚", Required: true},
{ID: "shutdown", Step: "优雅停服连接排空并落库", Required: true},
{ID: "backup", Step: "从备份恢复到一致状态", Required: true},
{ID: "failover", Step: "主节点宕机后自动接管", Required: true},
{ID: "drill", Step: "演练有记录有改进项", Required: true},
{ID: "signoff", Step: "清单逐条打勾无遗留", Required: true},
}}
}
// LoadChecklist decodes a checklist from JSON. Unknown fields are rejected so a
// typo in the file fails loudly instead of silently skipping a gate.
func LoadChecklist(r io.Reader) (Checklist, error) {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var c Checklist
if err := dec.Decode(&c); err != nil {
return Checklist{}, fmt.Errorf("dawnrealm: parse checklist: %w", err)
}
return c, nil
}
// Validate enforces that every required item carries a passing status and
// non-empty evidence. It returns all misses at once, sorted by id, so a
// reviewer fixes the whole list in one pass rather than one item per run.
func Validate(c Checklist) error {
var misses []string
for _, it := range c.Items {
if !it.Required {
continue
}
var why []string
if strings.TrimSpace(it.Evidence) == "" {
why = append(why, "no evidence")
}
if it.Status != "pass" {
why = append(why, "status="+orEmpty(it.Status))
}
if len(why) > 0 {
misses = append(misses, fmt.Sprintf("%s (%s): %s", it.ID, it.Step, strings.Join(why, ", ")))
}
}
if len(misses) == 0 {
return nil
}
sort.Strings(misses)
return fmt.Errorf("dawnrealm: checklist not satisfied: %s", strings.Join(misses, "; "))
}
func orEmpty(s string) string {
if s == "" {
return "<empty>"
}
return s
}
校验器的用法在测试里最清楚:LoadChecklist 读文件、Validate 出结论。下面这段是 L1 的用法片段:
// snippet: internal/dawnrealm/checklist_test.go 的用法
c, err := LoadChecklist(f) // 读 checklist.json
if err != nil {
t.Fatal(err)
}
if err := Validate(c); err != nil {
t.Fatalf("valid checklist rejected: %v", err) // 任一 required 项缺证据即失败
}
本机 go test -run TestChecklist -v ./internal/dawnrealm/ 覆盖四个用例:完整清单通过、缺证据(backup)被拒、状态为 fail(canary)被拒、未知字段(拼错的键)报错。第三个用例尤其重要——灰度没生效却填了证据,校验器照样拦下来,因为判的是 status 而不只是证据是否存在;第四个用例则防止你把 owned 拼成 owened,让一个本该生效的字段被静默忽略。
接进流水线:闸要落在机器上
校验器只有接进流水线才有意义——放在仓库里没人跑,它就只是一段能编译的代码。发布流程的最后一步应该是「跑校验器,不合格就拒绝放行」:
go test -run TestChecklist ./internal/dawnrealm/ && echo "checklist OK, proceed" || { echo "checklist NOT satisfied, abort"; exit 1; }
这样一道闸带来的改变是:上线是否放行不再取决于谁签了字,而取决于证据是否齐备。签核那一项(signoff)仍然要人做,但它做的是「确认机器校验已经通过」,而不是替机器做判断。
换句话说,它把「凭经验判断能不能上线」换成了「凭证据判断能不能上线」。经验会疲劳、会遗忘,证据不会。
三个使用要点。第一,清单随发布走:每次上线复制一份 checklist.json,改状态、填证据路径,归档到当次发布目录,成为可追溯的发布记录。第二,把校验器接进流水线:闸要落在机器上,而不是落在人的记性上。第三,清单要会进化:每出一次事故,就回头补一项检查——这份清单不是一次写完的,是被事故一版一版改出来的。
这三件事做到,清单才从一张纸变成一道闸。
失败之后:清单不通过怎么办
清单不是只回答「过不过」,它还该回答「不过的时候做什么」。每一项失败都有对应的处理动作,把「发现问题」和「怎么处理」一并管起来,清单才算完整:
| 失败项 | 含义 | 处理动作 |
|---|---|---|
boot | 有节点或中间件没起来 | 看装配冒烟日志,定位是哪个节点没过健康门禁,先修依赖再重起 |
loadtest | 压测没达标 | 回到 9.2 的预算报告,看是帧、连接还是延迟哪条没过,针对性调优 |
metrics | 指标拉不到 | 查打点是否注册、/metrics 是否可达、抓取目标是否配对 |
canary | 灰度没按比例生效 | 查路由权重与实例标签,确认新版本真的收到了流量 |
hotreload | 热更没生效或不可回滚 | 查配置版本与推送回执,必要时一键回滚 |
shutdown | 停服没排空或没落库 | 查排空超时与落库确认,确认没有连接被硬断 |
backup | 恢复不一致 | 查备份时间点与校验和,确认恢复的是同一份数据 |
failover | 没自动接管 | 查健康检查与选主配置,确认脑裂保护生效 |
drill | 演练没记录 | 补复盘文档,把改进项登记进待办 |
校验器在失败时会把所有不合格项一次报出来,形如:
dawnrealm: checklist not satisfied: backup (从备份恢复到一致状态): no evidence; canary (灰度发布按比例生效): status=fail
一次报全部,而不是报第一条就停——这样值班的人一轮就能看到全部缺口,不用修一个跑一次。
需要强调的是:清单不通过不是丢人的事,瞒着不通过才是。清单的作用是让问题在上线前暴露,而不是在上线后被玩家发现。一项如实记为 fail 的检查,比一项造假成 pass 的检查有价值得多——前者能修,后者会炸。
本节小结 / 实测结论
- 清单把本卷各章的验收动作串成十项:起服、压测、指标、灰度、热更、停服、备份、切换、演练、签核,每项都必须挂可复现的证据。
- 清单用 JSON 描述,人和机器读同一份文件;
required: true的项是硬闸,evidence是证据路径,status只放行pass。 - 校验器
Validate是 L1,go test -run TestChecklist -v ./internal/dawnrealm/可跑,覆盖完整通过、缺证据、状态失败、未知字段四类用例;判的是status而不只是证据存在,且一次报出全部不合格项。 - 接进流水线后,上线是否放行取决于证据是否齐备,而不是取决于谁签了字;签核项只确认机器校验已通过。
- 三个用法要点:清单随发布走、校验器接进流水线、清单随事故进化。
- 至此综合项目收口:9.1 装配、9.2 量预算、9.3 定闸。下一章进入全书收官,把这一路的做法收成一张跨栈对照矩阵。
阅读导航:上一节:9.2 性能预算达标 · 下一节:10.1 跨栈对照矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。