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["这一层没问题:往业务数据与配置上查"]
这棵树有三条值得记住的分支线:
Exited 0是好事。migrate跑完就退,它的成功形态就是「退出码 0 且不在运行」。把「有容器 exited」当成故障,是现场最常见的一次误判(04 章那行Container board-migrate-1 Exited就是这个陷阱)。healthz与readyz把「进程没起来」和「进程起来了但干不了活」分开了。 这两个状态在客户眼里是同一句「打不开」,但排查方向相反。- 最后一条线才是业务。 前面三条都过了,说明基础设施这一层是通的——这时候才该去看数据、看配置、看那个具体操作。
五、两处最容易被忽略的读法
其一,日志的时间戳。 容器里的 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。
六、这套东西不解决什么
一份排障清单只写「查什么」是不够的,还要写清它的边界。这套交付形态明确不做四件事:
- 它不做高可用。 单机、单实例,机器坏了就是坏了。要做高可用,形态本身就变了(那是《Kubernetes 应用课》讨论的范围)。
- 它不管理数据库的运维。 它只保证数据库被正确地初始化、备份与回滚;容量、参数调优、慢查询不在里面。
- 它不假设现场有 K8s。 这一点必须点明:私有化现场多数不装 K8s,所以整课用的都是 compose 这一层的原语。把 K8s 那套探针与滚动更新的经验直接搬过来,会发现现场根本没有承接它们的那个东西。
- 它不能替客户做业务判断。 自检报的是事实,
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
要观察三件事,而不是「看到两个数字就算跑过」:
healthz仍然是 200。 数据库停了,web进程本身没坏——这一条就是「不把数据库抖动升级成重启」的实现。readyz是 503,而它的detail说的是「名字解析不出来」,不是「连接被拒绝」。
这两种措辞指向完全不同的下一步。- 容器有没有被重启。 回到
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 输出做成标准的交接格式 |
每次报障都要从头问一遍「你那边现在是什么状态」 |
| 不承担数据库运维 | 明确写清责任边界与升级窗口 | 客户以为是我们的问题,我们以为在客户责任内,中间空着 |
自测题
- 现场自检脚本为什么「只陈述事实,不表达意见」?举出两条它报告的内容,
说明它们各自为什么是可核对的。 board doctor报「工作目录不可写」时,最可能的根因是什么?为什么这一类故障
在容器正常启动时看不出来?- 为什么
healthz与readyz必须分开?如果只有一个端点并且它检查数据库,
数据库抖动时会发生什么? Name or service not known与Connection refused各自指向什么?
为什么现场第一步该问的是「那个容器还在不在」?- 私有化现场多数不装 K8s 这件事,会怎样影响你在这里使用「探针」这类概念的方式?
现在能解释什么
- 为什么现场自检的定位是「只陈述事实」,以及退出码为什么比一行结论更有用;
doctor那三条检查各自在防哪类现场事故,以及「给下一步动作」为什么比「给诊断」更有用;healthz与readyz的分工,以及一个探针能把一次数据库抖动放大成全站反复重启的机制;- 一条从「哪一层断了」往下分的排障顺序,以及
Exited 0为什么常常是好消息; - 这套交付形态明确不解决的几件事——知道它不解决什么,和知道它解决什么一样重要。