退出码为0不等于任务成功:可观测性验收设计
三个真实的"假成功"现场
第一个现场来自一次日志采集任务。运维同学写了个 shell 脚本,把应用日志压缩后上传到对象存储,最后一行是 gzip -c app.log | curl -T - -X PUT "$URL"。任务跑了三个月一直显示成功,直到有下游同事抱怨"昨天的日志查不到"。翻出来一看:curl 上传的是一段空流,对象大小 0 字节。为什么会返回 0?因为 gzip 打开文件失败时——权限被改过——退出码是 1,但管道里 curl 读到空 stdin,自己正常退出并返回 0。shell 默认 pipefail 是关闭的,整条管道的退出码取的是最后一个命令的退出码,于是失败被吞掉了。修复很简单:脚本头加 set -euo pipefail,并且上传后校验 Content-Length 不小于预期。
第二个现场是前端发布。CI 流水线执行构建、上传静态资源、调用刷新 CDN 的接口,全部返回 200,流水线绿灯。但验收脚本用 curl -s https://site.example.com/health 去检查时拿到 302,脚本判定"非 200 即失败",直接把人叫起来。实际上站点是健康的,只是健康检查路径被一条新加的鉴权规则重定向到了登录确认页。这次是成功被误判为失败,和上一次正好相反,但根因相同:判据盯的是自己脚本看到的中间形态,而不是业务的真实终态。
第三个现场最隐蔽。用户充值后积分没涨,客服查后台,活动服务的日志里明明白白写着 grant points success, uid=..., amount=100。日志没撒谎——服务确实把发放请求发出去了,只是下游积分账户服务在写数据库时因为唯一键冲突把这条记录当成重复请求忽略了,而上游没有校验写入结果,只打印了"已发送"。这类问题的共同特征是:日志和退出码描述的是"我执行了什么",不是"系统变成了什么样"。
为什么退出码天然不可信
退出码的语义是"进程有没有异常终止",它的信息量只有 8 位,且规则由程序自己定义。以下这些情况都会给出 0:
- 管道中最后一个命令成功,前面命令失败(未开
pipefail); - 命令失败但被
|| true、2>/dev/null掩盖; - HTTP 请求拿到了 4xx/5xx,但
curl不加-f时仍返回 0; - 脚本调用的接口返回了业务错误码,脚本只判断 HTTP 状态不判断响应体;
- 异步任务只是"提交成功",实际执行还在队列里,甚至已经失败。
再看日志。一行 success 只能证明代码执行到了这一行,不能证明下游接受、数据库提交、用户可感知。要让它可信,必须让日志携带可核验的结果值,而这本身就是另一套工程。
验收设计:只认远端状态
核心原则一句话:任务的成功判据不来自执行者,而来自被改变的系统的可观测状态。执行者的输出只能作为诊断线索,不能作为验收依据。落地成三层:
第一层,任务提交与任务完成分离。凡是异步的(对象存储上传完成、CDN 刷新、消息投递、批量任务),本地只负责提交并记录一个可用于回查的标识,比如对象 key、刷新任务 ID、消息 ID。验收阶段拿这个标识去查状态,而不是等本地命令返回。
第二层,判据必须是一次独立读取。验收动作要在任务执行完成之后,通过另一条链路读取真实状态。上传文件就 HEAD 一下远端对象,看 Content-Length 与本地一致、ETag 或校验和匹配:
curl -sI "$URL" | grep -i content-length
发布就抓取一个已知应包含新版本标记的页面,匹配版本号字符串,而不是判断是否 200;同时显式处理 3xx,用 curl -sL -o /dev/null -w '%{http_code}' 跟随跳转后再判断,或者干脆比对页面内容里的构建号。
数据类任务就查数据库。积分发放验的是 select count(*) from point_record where uid=? and biz_no=? 返回 1 且金额正确,而不是看服务日志里有没有 success。对账类任务验的是两端条数差为 0。
第三层,判据要能表达"不该发生的事"。除了正向检查,还要有负向断言:对象存储里不出现 0 字节文件、发布后旧版本资源返回 404、积分流水不出现同一 biz_no 的两条记录。正向判据防漏,负向判据防重、防脏。
给每类任务写可核验的判据
判据要满足三个条件:可自动执行、结果是离散的通过/失败、失败时能指出差在哪。按任务类型列:
- 文件/备份类:远端对象存在;字节数与源一致;校验和一致;无 0 字节对象。缺一不可,只比对大小会漏掉内容损坏。
- 发布/部署类:目标版本号在页面或接口响应中可见;实例健康检查通过数等于期望副本数;旧版本特征消失。
- 数据变更类:以业务唯一键查询,记录数为 1;关键字段值与预期相等;关联表状态同步变更成功。
- 消息/通知类:消息 ID 在投递记录中状态为已送达;消费端有对应的处理记录;重复投递不产生重复副作用。
- 批量任务类:逐项结果记录完整,成功数 + 失败数 = 总数,失败项有原因,且失败率不超过阈值。
这些判据写进流水线的最后一步,和任务本身解耦。任务重跑不影响判据,判据也可以独立重跑——这一点很关键,出问题时能单独验证而不必重跑整个任务。
两个容易被忽略的配套动作
一是空结果的显式处理。"产出为空"是最常见的假成功。判据里要写死期望下限,比如期望条数大于 0、期望文件数等于 N。如果业务上确实允许为空,那就必须显式建模成"技能上的允许为空"并单独告警,不能默认放行。二是验收超时与重试。远端状态可能有传播延迟,例如 CDN 刷新需要几十秒,数据库主从同步有毫秒级滞后。判据应设定最大等待时间与轮询间隔,超时即判失败,而不是立即判失败或无限等下去。建议轮询间隔 5 到 10 秒,总超时按业务 SLA 设 2 到 5 分钟,并记录最后一次读到的实际状态供排查。
最后一条经验:把"执行成功"和"目标达成"做成两个独立的指标上报。前者衡量脚本健壮性,后者衡量业务正确性。当两者的比值长期偏离 1,就说明验收设计本身有盲区,或者判据已经跟不上业务变化了。这个比值,比任何一行 success 日志都更值得盯着。
- 点赞
- 收藏
- 关注作者
评论(0)