演练结束,故事才刚刚开始
游戏日当天跑了四个实验:进程、网络、依赖、区域各一次。现场很热闹,群里发了截图,结论是“发现了两个问题,整体可控”。
两周后,审计要一份材料:当时到底坏了什么、谁负责修、修完没有、怎么证明修好了。团队翻出聊天记录、几张过期的大盘截图和一份手工填写的表格。截图没有采集时间,表格里的“已修复”没有对应提交,两个问题也没有回归用例。这次演练无法证明任何事。
问题不在演练本身,而在演练结束后的那半天没有被设计。一次游戏日真正的产出不是“跑过”,而是三样可复核的东西:
- Finding:发现了什么,严重级别、责任人、到期日、当前状态;
- Regression:修复是否有测试证明“修前失败、修后通过”;
- Evidence:当时的假设、时间线、指标和异常栈是否被完整留存,且之后没有被改写。
本节把这三样东西做成一个可审计的 GameDay 结构。它不替代 事件复盘与改进项
的流程,而是把复盘产出的承诺变成机器能检查的字段;证据采集的口径则沿用 可观测性契约
,避免每个团队各拍各的截图。
Finding 不是待办列表
Finding 和普通工单的区别在于它必须能回答“为什么这次演练不算白做”。本文要求五个字段:严重级别、症状、根因、责任人、到期日,再加一个状态。
| 级别 | 含义 | 允许状态 | 是否必须绑定回归 |
|---|---|---|---|
| P1 | 关键状态转换丢失或数据不可恢复 | open / fixed | 是 |
| P2 | 主要降级路径失效,需人工兜底 | open / fixed | 是 |
| P3 | 观测、文档或体验缺口 | open / fixed / accepted | 否 |
两条规则值得单独强调。
第一,P1/P2 不允许 accepted。可以接受一个观测缺口,不能接受“支付转换会丢,但我们决定接受”。如果业务真的决定接受,那应该修改系统假设或降级方案,而不是把 Finding 状态改成 accepted 了事。
第二,P1/P2 必须绑定回归用例。没有回归的修复,下一次演练还会以同样的方式失败;审计时也无法区分“真的修了”和“当时看起来好了”。
P3 可以接受,但仍要有责任人和到期日。允许接受不等于允许悬空,否则“低优先级”会变成“永远不做”的同义词。
回归用例必须证明两件事
一个测试被称为回归用例,至少要满足:
- 修复前运行会失败;
- 修复后运行会通过;
- 能指回具体的 Finding;
- 有证据说明它确实运行过。
第一条最容易被跳过。很多团队在修复后补一个测试,测试当然是绿的,但它从未在旧代码上跑过,因此不能证明它捕获了原来的缺陷。本文用两个布尔字段 FailsBeforeFix 与 PassesAfterFix 显式记录,缺一不可。
演练发现的往往是“组合条件”下的缺陷:特定超时、特定重试次数、特定实例分布。修复后补的测试如果只覆盖单点调用,可能永远抓不到原问题。要求“修前失败”迫使团队先复现,再修复。
证据要封存,而不是截图
证据留存最常见的做法是往共享盘扔截图。截图的问题是:没有采集时间、没有字节边界、可以被覆盖,也无法证明评审后没有被修改。
本文用 Evidence 记录四个字段:名称、类型、SHA-256、字节数、采集时间。内容本身可以放在对象存储或制品库,清单里只保留摘要。这样审计分两步:
Audit检查清单结构:记录是否完整、证据类型是否齐全、Finding 是否有主、回归是否绑定、覆盖矩阵是否缺层;Verify重新计算内容摘要:字节是否仍与清单一致,是否缺失。
Go 实现:把闭环写成可审计结构
下面的实现覆盖三类检查:实验记录与证据、Finding 与回归、层与状态转换的覆盖。Audit 返回所有错误而不是第一个,便于一次修完;Verify 只做摘要比对,不重复结构校验。
// file: internal/platform/chaos/gameday.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
type Severity string
const (
SeverityP1 Severity = "P1"
SeverityP2 Severity = "P2"
SeverityP3 Severity = "P3"
)
func (s Severity) mustFix() bool { return s == SeverityP1 || s == SeverityP2 }
// Evidence is an immutable artifact. Content lives elsewhere; this record
// carries the digest that proves it did not change after the game day.
type Evidence struct {
Name string
Kind string
SHA256 string
Bytes int64
CapturedAt time.Time
}
func NewEvidence(name, kind string, at time.Time, body []byte) Evidence {
sum := sha256.Sum256(body)
return Evidence{
Name: name, Kind: kind, SHA256: hex.EncodeToString(sum[:]),
Bytes: int64(len(body)), CapturedAt: at,
}
}
type ExperimentRecord struct {
ID string
Layers []Layer
Transitions []string
StartedAt time.Time
EndedAt time.Time
Aborted bool
Verdict string // passed, failed, aborted
Evidence []Evidence
}
type Finding struct {
ID string
ExperimentID string
Severity Severity
Symptom string
RootCause string
Owner string
Due time.Time
Status string // open, fixed, accepted
RegressionID string
}
type Regression struct {
ID string
FindingID string
FailsBeforeFix bool
PassesAfterFix bool
EvidenceName string
}
type GameDay struct {
Date time.Time
Records []ExperimentRecord
Findings []Finding
Regressions []Regression
RequiredLayers []Layer
RequiredTransitions []string
}
func validDigest(s string) bool {
body, err := hex.DecodeString(s)
return err == nil && len(body) == sha256.Size && hex.EncodeToString(body) == s
}
// Audit returns every reason the game day is not reviewable.
func (g GameDay) Audit(now time.Time) []error {
var errs []error
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if g.Date.IsZero() {
add("chaos: game day has no date")
}
records, evidenceNames := map[string]bool{}, map[string]bool{}
layers, transitions := map[Layer]bool{}, map[string]bool{}
for _, r := range g.Records {
if r.ID == "" || records[r.ID] {
add("chaos: empty or duplicate record %q", r.ID)
continue
}
records[r.ID] = true
if !r.EndedAt.After(r.StartedAt) {
add("chaos: record %s ends before it starts", r.ID)
}
kinds := map[string]bool{}
for _, ev := range r.Evidence {
if ev.Name == "" || evidenceNames[ev.Name] {
add("chaos: record %s has an empty or duplicate evidence name", r.ID)
}
evidenceNames[ev.Name] = true
if !validDigest(ev.SHA256) || ev.Bytes <= 0 {
add("chaos: evidence %q has an invalid digest or size", ev.Name)
}
if ev.CapturedAt.Before(r.StartedAt) || ev.CapturedAt.After(r.EndedAt) {
add("chaos: evidence %q was captured outside %s", ev.Name, r.ID)
}
kinds[ev.Kind] = true
}
for _, kind := range []string{"hypothesis", "timeline", "metrics"} {
if !kinds[kind] {
add("chaos: record %s has no %s evidence", r.ID, kind)
}
}
if r.Aborted && !kinds["abort"] {
add("chaos: aborted record %s has no abort evidence", r.ID)
}
if r.Verdict == "failed" && !kinds["trace"] {
add("chaos: failed record %s has no trace evidence", r.ID)
}
for _, l := range r.Layers {
layers[l] = true
}
for _, tr := range r.Transitions {
transitions[tr] = true
}
}
findings := map[string]Finding{}
for _, f := range g.Findings {
if f.ID == "" || findings[f.ID].ID != "" {
add("chaos: empty or duplicate finding %q", f.ID)
continue
}
findings[f.ID] = f
switch f.Status {
case "open", "fixed", "accepted":
default:
add("chaos: finding %s has status %q", f.ID, f.Status)
}
if f.Owner == "" || f.Due.IsZero() {
add("chaos: finding %s is missing an owner or due date", f.ID)
}
if f.Status == "open" && f.Due.Before(now) {
add("chaos: finding %s is open past its due date", f.ID)
}
if f.Status == "accepted" && f.Severity.mustFix() {
add("chaos: finding %s cannot accept %s", f.ID, f.Severity)
}
}
regressions := map[string]Regression{}
for _, r := range g.Regressions {
regressions[r.ID] = r
if !r.FailsBeforeFix || !r.PassesAfterFix {
add("chaos: regression %s must fail before and pass after the fix", r.ID)
}
if !evidenceNames[r.EvidenceName] {
add("chaos: regression %s references unknown evidence", r.ID)
}
}
for _, f := range g.Findings {
if !f.Severity.mustFix() {
continue
}
r, ok := regressions[f.RegressionID]
if !ok || r.FindingID != f.ID {
add("chaos: finding %s (%s) has no bound regression", f.ID, f.Severity)
}
}
if len(g.RequiredLayers) == 0 && len(g.RequiredTransitions) == 0 {
add("chaos: game day declares no required coverage")
}
for _, l := range g.RequiredLayers {
if !layers[l] {
add("chaos: required layer %s has no record", l)
}
}
for _, tr := range g.RequiredTransitions {
if !transitions[tr] {
add("chaos: required transition %s has no record", tr)
}
}
return errs
}
// Verify recomputes every digest against the stored bodies.
func (g GameDay) Verify(contents map[string][]byte) []error {
var errs []error
for _, r := range g.Records {
for _, ev := range r.Evidence {
body, ok := contents[ev.Name]
if !ok {
errs = append(errs, fmt.Errorf("chaos: missing content for %q", ev.Name))
continue
}
sum := sha256.Sum256(body)
if hex.EncodeToString(sum[:]) != ev.SHA256 {
errs = append(errs, fmt.Errorf("chaos: digest mismatch for %q", ev.Name))
}
if int64(len(body)) != ev.Bytes {
errs = append(errs, fmt.Errorf("chaos: size mismatch for %q", ev.Name))
}
}
}
return errs
}
Audit 的错误信息刻意带上记录或 Finding 的 ID。审计失败时,人看到的不应该只是“有 3 个错误”,而是“gd-dependency 缺少 abort 证据”“F-1 是 P2 但没有绑定回归”。错误文本也是治理接口的一部分。
Verify 只遍历清单里已登记的证据,不扫描目录。这是有意的:目录里多出来的文件不能自动变成证据,只有经过评审、写进清单的内容才算数。
测试:用变异用例证明门禁真的会拦
审计代码最容易写成“看起来什么都能过”。测试的办法不是只跑一个合法样本,而是逐条破坏规则,确认每一种破坏都被拒绝。
// file: internal/platform/chaos/gameday_test.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos
import (
"strings"
"testing"
)
func joined(errs []error) string {
parts := make([]string, len(errs))
for i, err := range errs {
parts[i] = err.Error()
}
return strings.Join(parts, " | ")
}
func TestGameDayVerifyDetectsTampering(t *testing.T) {
g, contents := validGameDay()
if errs := g.Verify(contents); len(errs) != 0 {
t.Fatalf("valid evidence rejected: %s", joined(errs))
}
contents["gd-zone-metrics"] = []byte("success=0.999 rewritten after review")
errs := g.Verify(contents)
if len(errs) == 0 || !strings.Contains(joined(errs), "digest mismatch") {
t.Fatalf("tamper not detected: %s", joined(errs))
}
t.Logf("tamper: %s", joined(errs))
delete(contents, "gd-zone-metrics")
errs = g.Verify(contents)
if len(errs) == 0 || !strings.Contains(joined(errs), "missing content") {
t.Fatalf("missing content not detected: %s", joined(errs))
}
t.Logf("missing: %s", joined(errs))
}
validGameDay() 构造四条记录:进程与区域实验通过,网络实验失败并留下 trace,依赖实验中止并留下 abort;两个 Finding 中一个 P2 已修复并绑定 R-1,一个 P3 仍开放;必选覆盖包含四层与三条状态转换。完整测试还逐条变异这些条件:
| 变异 | 期望错误 |
|---|---|
删掉 metrics 证据 | no metrics evidence |
中止记录删掉 abort 证据 | no abort evidence |
失败记录删掉 trace 证据 | no trace evidence |
| P2 解绑回归 | no bound regression |
| 回归只记录“修后通过” | must fail before |
| 删除区域实验 | required layer zone |
| P3 过期仍为 open | open past its due date |
本机运行 go test ./internal/platform/chaos/ -count=1 -v,相关原始输出如下(固定清单与固定时钟,数值仅用于演示门禁行为):
=== RUN TestGameDayAudit
gameday_test.go:90: valid: records=4 findings=2 regressions=1
gameday_test.go:115: missing_metrics: chaos: record gd-process has no metrics evidence
gameday_test.go:115: aborted_without_abort: chaos: aborted record gd-dependency has no abort evidence
gameday_test.go:115: failed_without_trace: chaos: failed record gd-network has no trace evidence
gameday_test.go:115: p2_without_regression: chaos: finding F-1 (P2) has no bound regression
gameday_test.go:115: regression_passes_only_after: chaos: regression R-1 must fail before and pass after the fix
gameday_test.go:115: missing_layer: chaos: required layer zone has no record
gameday_test.go:115: open_past_due: chaos: finding F-3 is open past its due date
--- PASS: TestGameDayAudit (0.00s)
=== RUN TestGameDayVerifyDetectsTampering
gameday_test.go:129: tamper: chaos: digest mismatch for "gd-zone-metrics" | chaos: size mismatch for "gd-zone-metrics"
gameday_test.go:135: missing: chaos: missing content for "gd-zone-metrics"
--- PASS: TestGameDayVerifyDetectsTampering (0.00s)
覆盖矩阵:证明“四层都练过”不能靠嘴
游戏日最容易注水的地方是覆盖面。团队说“四层都覆盖了”,但记录里只有四个通过的实验,没有一条落在关键状态转换上。
本文要求 GameDay 显式声明 RequiredLayers 与 RequiredTransitions,审计时逐项检查是否至少有一条记录命中。这样做有两个后果:
- 计划阶段就要写清楚“这次必须覆盖哪些层、哪些转换”,不能跑完再总结;
- 缺项会直接导致审计失败,而不是在汇报 PPT 里被解释掉。
矩阵的行是层,列是关键状态转换,格子里填实验 ID。对于 arch-lab,最低要求是四层各一次,且 CREATED → INVENTORY_RESERVED、INVENTORY_RESERVED → PAID、INVENTORY_RESERVED → CANCELLED 三条转换至少各被一次实验触及。
常见错误与边界
- 先修后补测试。 补出来的测试没有在旧代码上失败过,无法证明它捕获了原缺陷;
FailsBeforeFix必须来自真实运行。 - 证据只存截图。 截图没有字节边界和摘要,无法验证是否被覆盖;至少应保存原始文本或制品,并记录 SHA-256。
- 把 accepted 当万能出口。 P1/P2 不接受“已接受”;如果确实不修,应修改系统假设或降级方案,并留下决策记录。
边界也要说明:这套结构只校验“材料是否自洽”,不判断根因分析是否深刻、修复方案是否最优。它是一道门禁,不是评审的替代品。真正的技术判断仍需要人来看,尤其是 P1 的根因与回归用例是否覆盖了组合条件。
小结
游戏日的产出不是“跑过几次”,而是 Finding、Regression、Evidence 三件套,加上一张覆盖矩阵。Finding 用严重级别和到期日约束责任,回归用“修前失败、修后通过”约束有效性,证据用摘要约束不可篡改性,覆盖矩阵约束演练范围。
本节实现的 Audit 与 Verify 把其中可机械检查的部分固定下来,并用变异测试证明门禁真的会拦。剩下的部分——根因是否成立、修复是否过度、回归是否覆盖组合条件——仍然需要评审。工具的价值不是替人做判断,而是让判断建立在完整、未被改写的材料上。
阅读导航:上一节:8.2 演练设计:稳态假设、爆炸半径与止损 · 下一节:9.1 制品、环境与不可变部署 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。