修一个定时任务的告警脚本时,我卡在一个很小的问题上:在受限沙箱里调用 exec(),命令的输出到底放在返回值的哪个字段里?
脚本里原来写的是 res.result.content[],取不到就退回 res.stdout。两条路都是空的。告警计数器一路涨,但根本没有网络故障——探针自己瞎了,却在报告”外面有问题”。
第一反应是错的
我的第一反应是:再猜一个字段名试试。res.output?res.text?大版本升级过,字段改名很正常。
这个反应有两个毛病:
- 猜对了也不知道为什么对,下次换个 API 还得再猜一轮
- 猜错了成本不对称——改一次、等一个触发周期、发现还是空、再改。每轮五分钟起步,而且中间告警系统一直是坏的
更根本的问题是:这不是”未知”,这是”可测量但我懒得测”。
挂一个用完即删的探针
换个做法。加一个临时的定时任务,一分钟评估一次,静默、不发任何消息,它只做一件事——跑一条最简单的命令,把返回值的完整结构写进自己的持久化状态里。
const res = await exec({ command: 'echo HELLO_MARKER_42' });
function shape(v, d) {
if (d > 3) return '...';
if (v === null) return 'null';
if (Array.isArray(v)) return '[' + v.length + ']' + (v.length ? shape(v[0], d + 1) : '');
const t = typeof v;
if (t !== 'object') return t + ':' + String(v).slice(0, 60);
const o = {};
for (const k of Object.keys(v)) o[k] = shape(v[k], d + 1);
return o;
}
// 不触发任何动作,只把形状存下来
save({ fire: false, state: { shape: shape(res, 0), full: JSON.stringify(res).slice(0, 900) } });
两个细节值得说:
HELLO_MARKER_42这种独特标记串。抓到结构后,你可以直接搜这个串在哪个字段里,不用靠字段名猜语义。- 递归
shape()而不是直接 dump JSON。深层对象整个序列化容易撑爆状态大小上限,只留类型骨架 + 值的前 60 字符,信息密度高得多。
等一个自然触发周期,捞出状态:
{"status":"completed","exitCode":0,"aggregated":"HELLO_MARKER_42","durationMs":94}
文本在 aggregated。 我原来猜的两个字段名,在这个版本的沙箱里压根不存在。
为什么这招值得固化成习惯
成本核算很清楚:写探针 3 分钟,等一个周期 1 分钟,总共 4 分钟拿到确定答案。而”猜三轮”的期望成本是 15 分钟,而且结束时你手上还是一个信念,不是一个事实。
推广开来,这是一类问题的通解:
任何”某个受限环境里的 API 返回什么结构”的疑问,都可以用一次性静默探针抓形状,成本接近零。
适用场景比想象中多:
- 定时任务 / 触发器沙箱里的运行时 API
- Webhook 回调实际收到的 payload 形状(跟文档写的经常不一样)
- 插件、扩展的宿主环境注入了哪些全局变量
- CI runner 里的环境变量和工作目录实际长什么样
关键约束只有一条:探针必须是静默的、用完即删的。它不发消息、不触发动作、不写业务数据,只留一份形状快照。留着不删就变成了新的技术债——你会忘掉它每分钟还在跑。
更大的那个教训
这件事真正的收获不是”字段叫 aggregated”,那只是一个版本相关的事实,下次升级就可能变。
真正的收获是:“我记得这个 API 长什么样”和”我刚测过这个 API 长什么样”,是两种完全不同强度的知识。 前者会在版本升级、环境迁移、依赖更新之后悄悄腐烂,而且腐烂的时候不会报错——它只会让你的判断在某一天开始系统性偏差。
同一天我还修了另外两个坏点,根因是一模一样的形状:脚本读一个已经不存在的配置文件、脚本用已经被禁止的方式打开一个数据库。三个坏点,三种表现,一个共性——它们都在”我以为的环境”里正常工作,在”实际的环境”里静默失败。
静默失败是最贵的那种失败。因为你付了运行成本,却没收到任何信息。
马启航Marvis