备选标题
- 机器没重启,代理先死了:给桌面自动化加三层守护的踩坑实录
- 注册了不等于在跑:定时任务"悄悄死"的三种形态与判活设计
- 从一次无痕失联说起:开机自启、进程兜底、看门狗判活的分工边界
定时自动化任务的故障就三种形态:注册了没跑、进程活着但逻辑死了、重启后行为不符合预期。任何单层的守护——只配自启、只加重启、只靠人肉巡检——都盖不全这三种形态。能盖全的架构底线是三层:开机自启管冷启动、进程内异常兜底管自己崩、周期看门狗管一切其它死法,而且每一层都必须幂等。
先交代来源:我运维着若干桌面自动化服务——两个本地代理进程(一批长任务经它们出网调用模型)、几路 cron 风格的定时任务(其中一路是心跳看护)。9 月 19 日傍晚,一条"跑了八小时的会话连不上了"的用户反馈,把整条守护链的窟窿一次性照了出来。这篇把事故、架构、每个设计取舍和交过学费的位置完整记一遍。
一、那晚的事故:机器三天没重启,代理先死了
把时间线摊开,故障的诡异之处在于"所有预防动作都做对了,还是死了":
9/16 17:11 机器开机,此后再未重启过
9/18 白天 按惯例把自启快捷方式放进启动文件夹
9/19 10:40 代理服务日志停在一条上游 504 —— 无崩溃栈,系统事件日志无记录
9/19 白天 全天无人察觉(当时还没有任何报警手段)
9/19 18:20 用户反馈"会话连不上",开始排查
9/19 18:35 坐实:进程没了,端口无人监听;自启项在,但机器没重启,
它压根没轮到出场
头一个反应是"是不是谁重启了机器"——自启快捷方式前天刚配,时间点太巧了。但 uptime 给出相反答案:这台机器从 9/16 起连续运行,从未重启。真正的死法是运行中无痕死亡:日志末行停在上游超时那一行,没有异常栈,没有崩溃报告,怀疑被某个外部清理动作直接杀掉了。
同晚发现第二个代理端口也是这么死的,当时挂着 9 个在跑的任务。那一路只配了自启、没配看门狗,死了八个小时,没有任何人知道——直到用户的会话连不上才被动暴露。
八小时无人察觉的原因,复盘时得到一个不太中听的结论:故障感知完全建立在"有人用到"之上。代理挂掉不抛异常、不出告警,它只是让依赖它的那些长对话悄悄连不上;客户端的重试逻辑又把瞬时失败和永久失败搅在一起,日志里两者长得一模一样。“没人投诉不等于没死”,这条后来写进了所有交接文档。
这条事故链里有一个值得写进排障手册开头的判断:自启机制防的是"重启",而这台机器三天没重启——自启项的覆盖率是零。 你配了它,不代表它在为你工作。
二、三种死法,各自都"看起来没事"
把后来复盘出的故障形态排成一张表,每种形态都有一个善于迷惑人的表象:
| A. 注册了没跑 | 调度日志里干脆没有记录,像"还没到点" | 任务挂靠的宿主实体被清理,派发永久失败;cron 时间在验证工具链里被时区回落带偏 | 判活 + 执行流水(查"该有而没有") |
| B. 跑了但结果不对 | 任务确实触发了,日志也在滚动 | 某段代码从没被调度真正执行过,首次排到才暴露真 bug;HTTP 客户端不认系统代理环境变量 | 首轮战报逐条看 + 结果留痕 |
| C. 进程悄悄死 | 端口曾经监听、PID 曾经存在,事后什么痕迹都没有 | 被外部动作静默杀掉;未捕获异常拖垮事件循环 | L2 减少自崩 + L3 判活拉起 |
形态 A 的迷惑性在于它是慢性死亡:那次心跳看护任务绑定在一个宿主会话上,宿主后来被清理,之后每一轮派发都报同一个"实体不存在"的错——调度器没有停,错误一直在发生,只是没人看调度日志。看门狗如果只盯"我自己的端口",对这类"依赖丢了"的失败完全无感。
形态 B 的两个案例都发生在主调度任务的首次自动执行。一次是下游接口瞬时超时,按事先立的铁律不重试(重试会覆盖当日快照,次日自愈),如实记入日志了事;另一次是审计脚本返回"抽样 0 条"——它用某个脚本运行时自带的 fetch 抓线上站点,而那个 fetch 不认 HTTPS_PROXY 环境变量(curl 认),直连拿到一个空响应。这段代码写了好几个星期,直到头一回真正被排到才暴露:此前它从没活到过被调度的那一天。
三种形态摊开看有个共同点:观测信号与事实脱钩。A 脱在调度器与宿主之间,B 脱在触发成功与链路完整之间,C 脱在进程存在与服务可用之间。所以三层架构的设计原则不是"多装几层",而是每一层只拿与自己职责匹配的证据说话:L1 拿"重新登录后的进程"说话,L3 拿"健康检查"说话,流水表拿"每轮结果"说话。拿错证据的层,装再多也是漏。
三、L1 与 L2:一层管重启,一层管自崩
这一层的实现朴素到不值得讨论:快捷方式进启动文件夹,或者注册一个登录触发的计划任务。它解决的问题只有一个——机器重启、用户重新登录之后,服务要自己回来。
容易被忽略的是它的触发条件。登录触发只在下一次登录时兑现;机器不重启,这个入口就永远躺着。所以 L1 的验收方式不能是"看一眼启动文件夹里有没有那个文件",必须是人为制造一次登录:注销再登录(或者干脆演练一次重启),确认进程在、端口通、版本对。配了不等于能触发,触发过才等于配了。L1 的验收证据是重新登录后服务自报的启动时间与版本,落进启动日志——不是看快捷文件在不在文件夹里。文件在,不等于入口触发过;入口触发过,不等于服务可用。
L2 进程内兜底:只兜"自己崩",兜不了"被杀"
第二层是进程内的异常兜底,实现只有几行:
// safety-net.js —— L2:兜住"自己崩",只记日志、不退出
process.on('uncaughtException', (err) => {
logger.fatal({ err, where: 'uncaughtException' }); // 完整栈落盘
// 不 process.exit(),理由见正文
});
process.on('unhandledRejection', (reason) => {
logger.error({ reason, where: 'unhandledRejection' });
});
“记完日志就退出、让外部监管器拉起来"是教科书做法,我们这次选了"活着继续服务”,是个有意识的取舍:这个进程同时挂着多路长连接的会话,一次重启会把所有在途交互打断重连,代价比"带伤服务到下一次健康检查"更高。这个选择成立的前提恰恰是 L3 存在——看门狗保证就算兜底之后进程处于不健康状态,十分钟内也会被换一个新的。两层是配套设计,单独抄哪一层都会歪。
更要清醒的是 L2 的边界:它只兜"自己抛异常崩掉"这一种死法。被外部直接杀进程、被系统回收、事件循环被同步代码挂死——这些场景里连异常处理器都没有执行机会。L2 的定位是"降低自崩概率",不是"杜绝死亡",杜绝不了的部分正是 L3 的存在理由。
四、L3 看门狗:三条铁律——周期、幂等、判活看健康检查
第三层是这晚事故后新加的:一个每 10 分钟跑一次的计划任务,执行一个静默脚本,脚本调统一控制入口的 start 命令。三条铁律写死在设计里:周期固定、动作幂等、判活必须探服务本体。
' watchdog.vbs —— 计划任务的执行体:静默、无窗口
Set sh = CreateObject("WScript.Shell")
sh.Run "node C:\\srv\\bin\\svc-ctl.js start", 0, False
:: 注册每 10 分钟一次的看门狗任务(分钟级周期需要管理员提权)
schtasks /Create /TN "svc-watchdog" /SC MINUTE /MO 10 ^
/TR "wscript.exe \\"C:\\srv\\bin\\watchdog.vbs\\"" /F
:: 核验不是查任务列表,而是让任务真跑一轮再看结果文件
schtasks /Run /TN "svc-watchdog"
type C:\\srv\\var\\watchdog.last.txt
注册这段有个细节:分钟级周期的计划任务要求提权。在某些权限管控关闭的机器上它会静默通过,于是你容易把"注册成功"误读成"确实在跑"——所以注册完立刻手动 Run 一次,检查结果文件有没有更新。计划任务的验收物永远是产物,不是任务列表里那一行。
start 命令本身是幂等的,而且经历了一次重要的加固。原始实现判活看的是 PID 文件里的进程还活着没有,看起来没毛病,直到意识到:机器重启后 PID 会被回收复用,一个毫不相干的进程可能顶上了旧 PID——start 一查"进程还在",判定服务"已在运行",于是真正死了的服务永远等不到下一次拉起。判活看 PID 是在问"那个号还有没有人",判活看健康检查才是问"那个人还在不在"。 加固后的骨架:
// svc-ctl.js —— start():健康检查优先,幂等,拉活而非确认
async function start() {
if (await probe()) {
return report('already-running'); // 判活=探测本体,不看 PID 文件
}
const child = spawn(process.execPath, ['C:\\\\srv\\\\app\\\\proxy.js'], {
detached: true, stdio: 'ignore',
});
child.unref();
writePidOnlyForReference(child.pid); // PID 留作日志参考,不进判据
const up = await waitFor(probe, 8000); // 至多等 8 秒
report(up ? 'started' : 'start-failed'); // 失败也如实落结果文件
}
function probe() {
// TCP 连接 127.0.0.1:8789 + GET /health,整体 2s 超时
// 任何一步失败 => false;端口通但 /health 超时 => 同样 false
}
/health 端点里放什么,决定了看门狗的判活质量。只回 200 的静态端点约等于没有——我们让它顺手做一次上游连通性的轻量试探,端口通但出不了网的半死状态也能被判出来。判活标准要贴着故障形态定,那晚代理的死法恰恰是"活着的时候一切正常,死了之后什么都不说",判活的底线就是"问它问题它得答"。
把三层的分工钉死在一张表上,避免以后有人拿单层方案来省另外两层:
| L1 自启 | 重启/重登后行为变化 | 登录或开机 | 验收靠演练,不靠存在 | 启动文件夹快捷方式 |
| L2 进程内兜底 | 未捕获异常导致的自崩 | 异常抛出瞬间 | 完整栈落盘;有 L3 配套才敢不退出 | uncaughtException 处理器 |
| L3 看门狗 | 被杀、挂死、外部清理等一切 L1/L2 接不住的 | 固定每 10 分钟 | 幂等;判活=健康检查;失败留痕 | 计划任务 → vbs → ctl start |
五、时间这一层:TZ 静默回落与 cron 首跑语义
心跳看护任务注册的是每天 09:30 本地时间。注册完要核验下一次执行时刻,我在 Git Bash 里核对 epoch 转换时踩到一个很阴的坑:TZ=Asia/Shanghai date -d @<epoch> 的时区变量会静默失效,回落到 UTC,不报错、不提示,输出一个差 8 小时的时间戳,让你怀疑任务配错了。
# 露馅方法:拿两个不同 TZ 对比,输出相同即 TZ 根本没生效
$ TZ=Asia/Shanghai date -d @1789781400 '+%F %T %Z'
2026-09-19 01:30:00 GMTDT # 本地应为 09:30
$ TZ=Asia/Tokyo date -d @1789781400 '+%F %T %Z'
2026-09-19 01:30:00 GMTDT # 与上海输出一致 => 两个都没生效
# 可靠做法:固定用 UTC 基准,加偏移用算术完成,不赌环境变量
$ EPOCH=1789781400
$ date -u -d "@$((EPOCH + 8 * 3600))" '+%F %T (+0800)'
2026-09-19 09:30:00 (+0800)
这类坑的共同结构是:工具的默认环境和你的心智模型不一致,而工具不吭声。防守动作两条:一切时间判断用 UTC epoch 加手动偏移来算;涉及时区的输出,永远拿两个不同时区做交叉对比,相同即露馅。再补一条通用规矩:cron 的五个字段写的是墙钟时间,墙钟背后必须有明确的时区归属。注册任务时,把"调度器按哪个时区解释这个表达式"当成必确认项——跨时区协作、夏令时地区、容器里跑调度,这三类环境都是高发故障点。归属拿不准时,宁可把表达式按 UTC 写死、在注释里换算本地钟点,也别留一个"看起来是 09:30"的悬念。
首跑语义是另一个容易想错的点。当时文档里写"nextRun 应为明天 09:30",而任务实际是凌晨 00:05 创建的——按文档预期去核验会得出"调度错了"的错误结论,实际当天 09:30 就是首跑,语义正确。文档措辞有自己的前提(写在 09:30 之后创建),核验调度永远以注册时刻直接推算触发点,不要拿文档的示例当判据。
主调度当天 09:00 的自动触发全链约两分钟跑完,产出物正常入库。首轮战报里那条"下游接口瞬时超时、按铁律不重试、次日自愈"和第二节说的"审计脚本首跑才暴露的真 bug",都逐条记进了调度日志。这里有一条经验值得单独强调:定时任务往往是那段代码头一回被真实调度执行的场合,人肉调试永远跑不到"凌晨、无终端、带代理环境变量、依赖上一次产物"的组合路径。首轮战报要逐条读,绿灯不算读完。
六、现场保全:日志轮转与自愈前快照
看门狗上线后冒出一个新问题,属于那种"防故障的机制自己制造故障":原来的实现里,服务启动时会删除上一轮的运行时日志。删除动作在人工重启时无所谓,但加上每十分钟一轮的自愈重启之后,每一次拉起都在销毁自己要找的现场——服务为什么死,永远查不到了,因为判死到重启之间没有任何东西留下来。
修复分两半。头一半,启动时不删日志,改成轮转保留上一轮:
// log-rotate.js —— 启动时轮转而非删除:给下一次排查留现场
function rotateRuntimeLog(dir) {
const cur = path.join(dir, 'runtime.log');
if (!fs.existsSync(cur)) return;
// 只保留上一轮:prev 被覆盖是可接受的——我们要找的从来是"出事那一轮"
fs.renameSync(cur, path.join(dir, 'runtime.prev.log'));
}
轮转策略别贪多。单文件 prev 在这个场景够用(关心的只有出事前的那一轮),要留多轮就上编号或时间戳,但必须事先想清楚哪些文件是"证据"、哪些是"垃圾"——证据文件不参与启动清理,垃圾文件才有容量上限。
第二半,把快照动作挪到重启之前:看门狗判定不健康、执行拉起动作前,先 dump 一份现场:
// snapshot.js —— 自愈动作之前先留证:回答"它死之前正在干嘛"
function dumpWatchdogSnapshot(state, reason) {
const lines = [
`at=${new Date().toISOString()} reason=${reason}`,
`phase=${state.phase} no_progress_ms=${Date.now() – state.lastProgressAt}`,
…state.transitions.slice(–12) // 近期 12 条状态迁移
.map((t) => ` ${t.at} ${t.from} -> ${t.to}`),
'',
];
fs.appendFileSync(SNAPSHOT_FILE, lines.join('\\n'), 'utf8');
// 快照文件不参与任何启动轮转——它是给死人写的证词
}
两个文件分工明确:轮转后的运行日志回答"死之前正在处理什么",自愈前快照回答"当时处于哪个阶段、卡了多久、近期状态怎么变的"。共同的纪律只有一条:现场保全动作必须发生在拉起动作之前,落盘完成才允许 spawn 新进程。顺序反了,这套机制的全部取证能力归零。
七、让"没跑"成为可查询的事实:执行流水表
形态 A 那种慢性死亡,靠进程级守护是抓不住的——任务本体没崩,是派发断了。终结它的办法是把每一轮执行的结果写进一张表,成功也写,跳过也写,失败更写,跳过和失败必须带原因码:
— run_trace:执行流水。每轮一行,"该有而没有"本身就是告警信号
CREATE TABLE run_trace (
id INTEGER PRIMARY KEY,
job_id TEXT NOT NULL, — 哪个任务
started_at TEXT NOT NULL,
finished_at TEXT,
outcome TEXT NOT NULL CHECK (outcome IN ('ok','skipped','failed')),
reason TEXT, — skipped/failed 必填:
— host-missing / lock-held / timeout
— / upstream-4xx / transient-downstream
duration_ms INTEGER
);
— 对账:用户说"没收到结果",先查这张表,别先查代码
SELECT outcome, reason, started_at FROM run_trace
WHERE job_id = 'daily-pull' ORDER BY started_at DESC LIMIT 20;
流水表的价值在三种查询结果的对比里,它把过去全靠经验猜的排障变成查表:
| 那个时间点根本没有行 | 从未触发 | 查调度注册与 L3 的 last 结果文件 |
| 有行,outcome=skipped | 触发了,但前置检查没放行 | 查 reason 码对应的锁/依赖/窗口 |
| 有行,outcome=failed | 触发了,链路中间断了 | 查下游接口与代理配置 |
| 有行,outcome=ok,用户仍说没有 | 我方完成,断在投递 | 查发送与对账链路 |
注意第三列的区分:skipped 和 failed 是两种事实。那次主调度的瞬时超时如果记成 failed 并触发重试,反而会污染当日快照;它被记成 skipped 加原因码,次日自愈,对账时一眼看清。流水要配一个定期裁剪(保留窗口按"能对账的用户反馈周期"定,我们留了四十天),不然表本身会变成新的故障源。
八、四个交过学费的地方
一,自启项的"已配置"错觉。 快捷方式放进去就以为高枕无忧,实际它要等一次永远不会到来的重启。验收动作必须是注销重登演练一次,看进程回不回得来。
二,PID 复用造成的"已在运行"误判。 判活看 PID 文件,在重启后的机器上是掷骰子。改成健康检查优先之后,PID 只作为日志参考字段保留,任何判断都不引用它。
三,看门狗自带的测试工具说谎。 控制入口有个 test 子命令,它跑"非流式请求"那一步时报 JSON 解析错——查下去发现是测试脚本自己没剥掉 SSE 的注释行(: ping 开头的行不是数据),把工具毛病误读成服务坏了。教训:看门狗给出的"坏了/活着"结论,动手拉起重启之前,要用独立手段交叉验证一次(直接 curl 端口、手工发一笔真实请求)。判活体系自己也要被验收。
四,定时任务的隐藏依赖不判活。 心跳看护绑定的宿主实体被清理后派发慢性失败,而当时的监控只看服务端口。后来把"任务依赖是否完整"(宿主存在、凭据未过期、上游可达)也纳入 L3 的健康检查项——判活的边界要贴着故障形态画,故障出在哪,检查就做到哪。
九、落地清单
| 1 | L1 自启入口配好并做一次注销重登演练 | 重登后进程在、端口通、版本对 | 必须 |
| 2 | L2 注册 uncaughtException / unhandledRejection | 完整异常栈落盘,进程行为符合所选策略 | 必须 |
| 3 | L3 周期看门狗,动作幂等(健康检查不过才拉起) | 连跑三轮,活着的进程不被误重启 | 必须 |
| 4 | 判活=TCP+HTTP 健康端点,禁用 PID 判活 | 手工杀进程后一个周期内恢复 | 必须 |
| 5 | 计划任务注册后手动 Run 一次,查结果文件 | 结果文件时间戳更新 | 必须 |
| 6 | 启动日志轮转 prev,不自删现场 | 自愈重启后仍能读到上一轮日志 | 必须 |
| 7 | 自愈拉起前 dump 状态快照,追加写不轮转 | 快照含阶段、无推进时长、状态迁移 | 必须 |
| 8 | 每轮执行写 run_trace,成功也写,skipped 带原因 | 对一次"没跑"投诉十分钟内定档 | 应当 |
| 9 | 时间核验用 UTC+手动偏移,双 TZ 交叉验证 | 两个不同时区命令输出确实不同 | 应当 |
| 10 | 首轮自动战报逐条读,不只看绿灯 | 每条 skipped/failed 有归因 | 应当 |
十、常见问题
Q:直接用现成的进程守护器(注册成系统服务),还需要这三层吗?
A:守护器能覆盖 L2 和部分 L3(崩了拉起),但两件事它替不了:健康检查仍然是你的事——端口能连不等于任务还能派发,半死状态它判不出来;L1 的触发条件仍然要演练验收——服务设成自动启动不等于登录会话里一切就绪。三层是职责划分,不是工具数量。
Q:10 分钟周期是不是太迟钝?
A:周期是业务容忍度和误判成本的折中。我们场景里任务粒度是天级和小时级,10 分钟的失联窗口没人疼;秒级场景不该用看门狗凑,把进程挂到服务管理器下、配合就绪探针,才有秒级恢复的底座。
Q:维护时故意停服务,看门狗会不会十分钟就把它拉回来?
A:会,所以显式停要有显式语义:给 ctl 加 pause,落一个哨兵文件,看门狗见到哨兵只记日志不动作。预期状态必须写进代码,不能停在口头上——"我待会儿会停它"不算防护。
Q:机器睡眠把任务窗错过,是不是就永久漏跑了?
A:注册时勾选错过后补跑(StartWhenAvailable),醒来会补一次;更兜底的是流水表——漏跑那一轮"该有而没有"的行,对账查询会把它暴露出来,而不是等用户反馈。
Q:uncaughtException 里不退出,进程会不会带着坏状态一直跑?
A:有这个风险,所以这个策略只在两个条件同时成立时才敢用:L3 的判活是真健康检查(坏状态会在下个周期被换掉),以及关键状态可从磁盘重建。两个条件缺一个,就退回"记日志、退出、等拉起"的教科书路径。
写在末尾
这三层架构没有一个新技术:快捷方式、异常处理器、计划任务、一次 HTTP 探测。让它们起作用的是三条纪律,每条都是事故教的。
验收永远看产物,不看配置。 自启看登录演练,计划任务看结果文件,判活看健康检查的答复——配置存在不等于机制工作,那晚死掉的代理就是"全配对了但只配对了一层"的样子。
现场必须在死亡和拉起之间活下来。 轮转不自删、快照先于重启,顺序错了取证能力归零。
"没跑"必须是可查询的事实。 流水表把相当玄学的故障形态——没有任何记录的安静——变成了四行查询结果里的一种。
定时任务不会告诉你它死了。三层守护的意义,就是让它死的那一刻,替它把讣告写好。


