KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 现场排障:让客户先能自己查出答案 — keel 龙骨

这一章要回答的是:客户打电话说「不对」的时候,谁先能查出答案? 在一个没有我们、也可能没有外网的机房里,排障的瓶颈不是「查不出来」, 而是每一步都要等人。这一章的设计目标只有一个:把「能自己查的那部分」交出去。

这一章要回答的是:客户打电话说「不对」的时候,谁先能查出答案?
在一个没有我们、也可能没有外网的机房里,排障的瓶颈不是「查不出来」,
而是每一步都要等人。这一章的设计目标只有一个:把「能自己查的那部分」交出去。


一、自检脚本的定位:只陈述事实

现场自检脚本的定位写在它的注释里,只有一句话:只陈述事实,不表达意见。

这句话决定了它的输出形状。它不打印「一切正常」,而是把每一条判断的依据摆出来:

它报什么 为什么这是「事实」而不是「意见」
镜像/代码版本号 可核对
三个服务的 running / healthy 可核对
GET /healthz 与 GET /readyz 的状态码 可核对
库结构版本、任务条数 可核对
本实例的在跑项目与数据卷个数 可核对

「看起来正常」这种句子在自检里一个都不该出现。 原因不是措辞洁癖——是因为判断留给读的人,而读的人可能比我们更懂这个业务。把「正常」写进输出,等于替客户下了一个他没法反驳的结论。

还有两处设计值得说清:

退出码 = 失败条数(0 表示全过)。 这一行的用处是让自检能被监控系统直接接走。打印在屏幕上的结论只有当时站在那台机器前的人看得到;一个数字能被采集、能被告警、能进趋势图。

它明确不对「制品有没有被改过」做失败判定。 装好之后,安装目录里一定会多出新东西:升级放进来的新镜像、现场生成的 .env、备份目录,脚本也可能被就地修过。MANIFEST 的时效是安装之前——它的职责是「包在搬运途中有没有坏」,不是「装完之后有没有被人改过」。所以自检只报事实,要复核完整性就重跑一次 install.sh——它在任何有副作用的事之前就会先校验。

二、board doctor:三条能自己查的事实

自检脚本查的是「这套系统整体」,而 board doctor 查的是**「这个镜像在当前现场能不能正常工作」**。它的输出是三条:

[ok]   镜像里的代码版本:1.0.0
[ok]   工作目录可写:/var/lib/board
{"event": "db.ready", ...}
[FAIL] 库结构版本:3,镜像要求 2 —— 先跑 `board migrate`

自检结束:1 项失败

三条各自对应一类现场事故:

其一,代码版本。 客户说「我升过级了」的时候,这一行是唯一能确认「跑的是哪一版」的地方。注意它报的是镜像里的版本——容器里的代码是镜像带的,和宿主机上任何一个目录都无关。

其二,工作目录可写。 这是最常见的一类现场事故:客户挂进来的卷可能只读,或者属主不对(记得 02 章那个写死的 10001:10001 吗)。这一类故障的形态特别难查——容器起来了、端口通、页面能打开,只有在需要落盘的那一刻才炸。所以自检的做法是主动去写一个探针文件再删掉,而不是「检查一下权限位」。

其三,结构版本。 这一条是唯一会失败的一条,而它失败时的措辞是动作导向的:不是「结构版本不匹配」,而是「先跑 board migrate」。给读者的下一步,比给读者一个诊断更有用。

顺带一个细节:那行 {"event": "db.ready", ...} 会插在自检的几行之间。这不是 bug——日志和自检都写 stdout,所以它们会交错。读自检输出时要能一眼把 JSON 日志行和自检行分开:前者是过程记录,后者是结论。

三、两个端点,回答两个不同的问题

web 角色有两个健康端点,它们故意不是同一件事:

端点 它证明什么 数据库不可用时
GET /healthz 进程还活着、能接连接 200
GET /readyz 我真的能干活(连得上库 + 结构版本对得上) 503

把数据库容器停掉,同一次调用会得到:

GET /healthz  → 200
GET /readyz   → 503  {"ok": false, "stage": "db", "detail": "[Errno -2] Name or service not known"}
POST /api/tasks → 503 {"error": "database unavailable", "detail": "[Errno -2] Name or service not known"}

为什么必须分成两个,而不是一个? 因为在 restart: unless-stopped 的策略下,「把数据库抖动算成自己不健康」是一个会自我放大的错误:数据库短暂不可用 → 探针失败 → 容器重启 → 重启期间数据库还是不可用 → 再失败。一个探针写得不对,能让一次数据库抖动升级成全站反复重启。

healthz 刻意不碰数据库,就是为了打断这条链:数据库再抖,web 进程本身没坏,它就该继续活着。而这个「不健康」的信号由 readyz 承担——它送给的是负载均衡或运维的人,而不是「杀掉这个容器」这个动作。探针回答的问题不同,它触发的动作也该不同。

再看 readyz 报错里的那句话:[Errno -2] Name or service not known。这不是「连接被拒绝」,是「这个名字解析不出来」。 原因是 Docker 内置 DNS 在容器停止后就不再解析 db 这个名字。这个区别决定了下一步该查什么:看到「拒绝」要去查数据库进程,看到「名字解析不出来」要先查那个容器还在不在。

四、排障顺序:从「哪一层断了」往下分

客户说「不对」的时候,最贵的不是查不出,是每一步都要等人。所以排障的顺序应该是一棵树,而不是一张清单:

flowchart TD
    Q0["客户说:不对"] --> Q1{"docker compose ps 里三个服务是什么状态"}
    Q1 -->|"有容器不在 running"| A1["看它退出码与最后几行日志"]
    A1 --> A1a["Exited 0:一次性进程正常跑完(migrate 就是这样)"]
    A1 --> A1b["Exited 非 0:报错在它的日志最后几行"]
    A1 --> A1c["unhealthy:跑 board doctor,看它报的是哪一条"]
    Q1 -->|"三个都在 running"| Q2{"GET /healthz 是不是 200"}
    Q2 -->|"不是"| A2["进程层面就没起来:查端口占用与 web 日志"]
    Q2 -->|"是"| Q3{"GET /readyz 是不是 200"}
    Q3 -->|"不是,stage=db"| A3["库连不上:查 db 容器在不在、db 这个名字能不能解析"]
    Q3 -->|"不是,stage=schema"| A4["结构版本对不上:跑 board migrate"]
    Q3 -->|"是"| A5["这一层没问题:往业务数据与配置上查"]

这棵树有三条值得记住的分支线:

五、两处最容易被忽略的读法

其一,日志的时间戳。 容器里的 date 报的是 +0000,也就是 UTC;而现场机器多半是本机时区。所以客户把容器日志和宿主机上的操作记录并排看时,时间戳对不上是排障里常见的第一道坎。它不是故障,但它会让你把两件不相干的事连成因果。

其二,实例名。 一台机器上跑了两套实例时,docker compose ps 会给出六个容器。这时「哪几个是这套实例的」这个问题,答案不是靠人眼从前缀里挑,而是每个实例自己的配置里就有——把实例名读出来、再按它去数挂在自己名下的卷:

docker compose ls -q
docker volume ls --format '{{.Name}}' | grep -c "^${INSTANCE}_"

这里有一个具体的坑:docker compose ls --format '{{.Name}}' 会直接报错(ls 只接受 table 与 json,不支持 Go 模板)。而它如果被写在命令替换里、stderr 又被丢掉,你会得到一个静默的空值——于是「有两套实例」和「一套都没有」看起来一模一样。要名字得用 -q。

六、这套东西不解决什么

一份排障清单只写「查什么」是不够的,还要写清它的边界。这套交付形态明确不做四件事:

  1. 它不做高可用。 单机、单实例,机器坏了就是坏了。要做高可用,形态本身就变了(那是《Kubernetes 应用课》讨论的范围)。
  2. 它不管理数据库的运维。 它只保证数据库被正确地初始化、备份与回滚;容量、参数调优、慢查询不在里面。
  3. 它不假设现场有 K8s。 这一点必须点明:私有化现场多数不装 K8s,所以整课用的都是 compose 这一层的原语。把 K8s 那套探针与滚动更新的经验直接搬过来,会发现现场根本没有承接它们的那个东西。
  4. 它不能替客户做业务判断。 自检报的是事实,doctor 给的是下一步动作;「这个结果对不对」要由读过业务的人来答。

动手:可观察结果

在数据库不可用的情况下,把两个端点各调一次:

docker compose stop db
docker compose exec web python -c "import urllib.request as u; print(u.urlopen('http://127.0.0.1:8080/healthz').status)"
docker compose exec web python -c "import urllib.request as u; print(u.urlopen('http://127.0.0.1:8080/readyz').status)"
docker compose start db

要观察三件事,而不是「看到两个数字就算跑过」:

  1. healthz 仍然是 200。 数据库停了,web 进程本身没坏——这一条就是「不把数据库抖动升级成重启」的实现。
  2. readyz 是 503,而它的 detail 说的是「名字解析不出来」,不是「连接被拒绝」。
    这两种措辞指向完全不同的下一步。
  3. 容器有没有被重启。 回到 docker compose ps 看 web 的启动时间——它不该因为数据库停了而被重启。

完成标志:能不看笔记说出「为什么 healthz 和 readyz 必须分开,以及合在一起会导致什么」。

故障注入

把 db 容器停掉,然后按第四节的顺序完整走一遍:

docker compose stop db
docker compose ps
docker compose exec db pg_isready        # 预期:连不上,因为是它自己停了
docker compose start db

预期结果是:docker compose ps 里 db 显示 Exited,而 web 与 worker 仍然是 running;
web 的 readyz 返回 503 且 stage 是 db。把 db 起回来之后,readyz 自己恢复成 200——
不需要重启 web。

这个「自己恢复」的性质值得记住:只有把「能连上库」这种可恢复状态和「进程活着」这种不可恢复状态分开,系统才有自愈的余地。 如果两个判断合在一起,db 一停 web 就会被反复杀掉重启,等 db 回来时 web 还在一轮一轮地重启中。

生产边界

教学里这样 真实交付要换成 不换会出什么事
客户手工跑 selfcheck.sh 看结果 退出码接进监控,自检按周期跑 故障只在有人手动跑脚本时才被发现
日志直接写 stdout 接进客户的日志平台,并按实例名打标签 两套实例的日志混在一起,无法分出是谁的
单机单实例 按现场评估高可用需求,形态随之改变 机器一坏整套停摆,且没有任何接管路径
时区靠容器内 UTC 统一时区口径,或在文档里写明日志是 UTC 「时间对不上」这类假因果反复消耗双方的排障时间
排障靠电话沟通 把自检结果、doctor 输出做成标准的交接格式 每次报障都要从头问一遍「你那边现在是什么状态」
不承担数据库运维 明确写清责任边界与升级窗口 客户以为是我们的问题,我们以为在客户责任内,中间空着

自测题

  1. 现场自检脚本为什么「只陈述事实,不表达意见」?举出两条它报告的内容,
    说明它们各自为什么是可核对的。
  2. board doctor 报「工作目录不可写」时,最可能的根因是什么?为什么这一类故障
    在容器正常启动时看不出来?
  3. 为什么 healthz 与 readyz 必须分开?如果只有一个端点并且它检查数据库,
    数据库抖动时会发生什么?
  4. Name or service not known 与 Connection refused 各自指向什么?
    为什么现场第一步该问的是「那个容器还在不在」?
  5. 私有化现场多数不装 K8s 这件事,会怎样影响你在这里使用「探针」这类概念的方式?

现在能解释什么

进入 keel 阅读