交接文档为什么总在凌晨三点出错
一句话总结
运维交接的单位不是文档,而是一条链。服务输出的指标产生告警条件,每个告警挂着一个运行手册条目,每个条目都要有复制过来就能直接运行的检查命令。这条链的哪一环断了,只有通过演练才会暴露出来。
为什么需要它
交付接近尾声时,FDE 要把服务移交给客户运维团队。常见的交接物是一页 wiki:仪表板地址、告警列表,以及“出问题时执行这条命令”的几行文字。这份文档在写成的那天是对的。三个月后的凌晨三点,值班工程师收到呼叫去执行那条命令:端口已经变了,诊断路径的名称不同了,值班人员的笔记本上也没有那个工具。更糟的是,其中一条命令毫无响应地卡住,白白耗掉 30 秒。文档是错的这一事实,恰好在最贵的时刻暴露出来。
Google SRE 书的值班(on-call)一章用数字写出了这种情形的分量。对用户可见的服务,呼叫响应目标通常定为 5 分钟,不那么紧急的系统是 30 分钟。一起事件从原因分析到事后分析平均要耗费 6 小时,所以把 12 小时的一次值班上限定为两起事件。而且它警告说,在压力之下,人会凭直觉和习惯而不是深思熟虑来行动,而这种反应很容易出错。所以需要流程。凌晨的值班工程师需要的不是替他做判断的英雄,而是事先准备好的、让他少动脑也不会出错的检查手段。
工作原理
逐环来看这条链。
1. 指标。Prometheus 文本暴露格式是按行的格式。行以换行符分隔,最后一行也必须以换行符结束,空行会被忽略。# HELP 和 # TYPE 行说明描述与类型(counter、gauge、histogram、summary、untyped),一个指标名称只能有一行 TYPE,而且必须出现在第一个样本之前。样本的形式是 이름{레이블="값"} 값 [타임스탬프](占位符依次为指标名称、标签名、标签值、样本值和时间戳)。在标签值中,只有反斜杠、双引号和换行这三种字符要转义为 \\、\"、\n。因此值中可能出现逗号或花括号,按逗号切分的解析器会悄悄出错。histogram 展开为 _bucket{le="..."}、_sum、_count,而且必须有 le="+Inf" 桶。HTTP 上以 text/plain; version=0.0.4 发出。
# HELP orders_queue_oldest_age_seconds Age of the oldest waiting message in seconds.
# TYPE orders_queue_oldest_age_seconds gauge
orders_queue_oldest_age_seconds{queue="fulfillment"} 2.4
orders_build_info{version="2.4.1",note="handoff \"v2\", see RB"} 1
2. 告警条件。同一本 SRE 书的监控一章要求区分症状和原因。用户遇到的(订单 10 分钟都没有出库)是症状,CPU 偏高只是原因的候选。呼叫应当是紧急的、可以采取行动的、需要人来判断的——如果一个呼叫让人只是机械地敲同一条命令,它就应当被自动化或取消。按这个标准来看,“队列深度超过 1000”是个糟糕的条件。午间高峰时深度即使达到几千,也会在几秒内降下来。“等待时间最长的消息的年龄”才更接近用户感受到的症状。证书也是一样。通常指标不是剩余时间,而是过期时刻,所以必须减去当前时刻。
Prometheus 告警规则中,expr 为真的状态必须持续 for 所指定的时长,才会从 pending 转为 firing,并在 annotations 中放入说明和运行手册链接。本实验没有 Prometheus 服务器,所以把同样的思路转写成一份小型 JSON 规则和脚本。
3. 沉默不等于正常。比较表达式只有在指标存在时才会得出真或假。指标完全不存在时,结果为空,什么也不会响。Prometheus 为此提供了两个机制。每次抓取时,会按目标生成 up 时间序列,成功为 1、失败为 0;absent() 函数在输入为空时返回一个值为 1 的元素。文档把这个函数的用途写作“对时间序列的不存在发出告警时”,原因就在这里。采集器挂掉的夜里,仪表板看起来一片平静,是最危险的。
4. 运行手册条目与可执行的检查。每个条目设四行:检查、判断、处置、升级上报。关键是检查这一行。不要让值班工程师去解读输出的样子,而要让它用退出码作答:正常为 0,确是该故障则为非 0。这样无论是人在凌晨阅读,还是脚本在演练中运行,得到的答案都一样。这里管道是个陷阱。在 bash 中,管道的退出码默认是最后一条命令的退出码,所以 df 없는경로 | awk ...(占位符为不存在的路径)即使 df 失败,结果也是 0。要用 set -o pipefail 来运行,才能看到前面命令的失败。反过来,grep -q 会在第一次匹配时立即结束,前面的 curl 可能收到 SIGPIPE,所以在 pipefail 下,要选择把输入读完的形式。
在现场相遇的样子
把供应商留下的运行手册,对着正常的服务一行行运行,故障的种类会归拢成几种:查看的不是服务而是值班人员的机器的命令(df -h /var/lib/orders 查看的是值班笔记本的磁盘)、名称被改掉的端点(404)、旧端口、值班环境中没有的工具(在没有 ss 的容器里退出码是 127)、没有时间限制而卡住的命令。最后一种还会让检查器也跟着卡住。如果只用 subprocess 的 timeout 杀掉 bash,管道后面的 curl 会抓着输出管道留下来,所以要在新会话中启动,并连同进程组一起切断。
这项检查不是做一次就结束。服务每变一次,交接文档也会过时。所以要把“运行手册检查器”随交接物一并交出,并在定期演练中逐个打开故障模式,看告警 → 运行手册 → 检查命令 → 升级上报是否一路贯通。这是它与记录页(可重现的运行手册)或事后报告的区别。那两者记录的是已经发生的事,而演练则是提前经历尚未到来的夜晚。
实际工作中真正重要的事
- 告警名称、等级、运行手册条目要合在一张表里,让脚本去读这张表。把阈值写死在代码里,表和代码就会各自过时。
- 用语言明确边界值。“低于 10%”和“10% 以下”是不同的告警。要用恰好在边界上的值来测试。
- 把抓取失败(连接失败、500、维护页面 HTML、缺少必需指标)升级为告警。HTTP 200 不是正常的证据。
- 检查命令用退出码作答,并在
bash -o pipefail和时间限制之下测试。 - 升级上报一行里,写值班频道,而不是人名。人会休假。
- 演练结果就是交接的验收标准。以“值班人员用这条链确认了故障”来收尾,而不是“文档交出去了”。
下一项实验要做什么
启动模拟的 orders 服务并切换多种故障模式,亲手构建这条链。抓取指标原文来确认格式,编写能读懂转义的解析器,把交接备忘录中的六个告警转写成 JSON 规则,再用诊断脚本判定。把抓取失败升级为告警,评分器会用多种模式实际运行,检查运行手册的检查命令是否做到正常为 0、故障为非 0。用检查器找出供应商运行手册中已坏的行,最后制作一个从告警到升级上报一次性跑完的值班演练脚本。
参考文档:Prometheus 文本暴露格式、Prometheus 告警规则、Prometheus 函数(absent)、作业与实例(up 时间序列)、Google SRE 书:Being On-Call、Google SRE 书:Monitoring Distributed Systems