
备选标题机器没重启代理先死了给桌面自动化加三层守护的踩坑实录注册了不等于在跑定时任务悄悄死的三种形态与判活设计从一次无痕失联说起开机自启、进程兜底、看门狗判活的分工边界定时自动化任务的故障就三种形态注册了没跑、进程活着但逻辑死了、重启后行为不符合预期。任何单层的守护——只配自启、只加重启、只靠人肉巡检——都盖不全这三种形态。能盖全的架构底线是三层开机自启管冷启动、进程内异常兜底管自己崩、周期看门狗管一切其它死法而且每一层都必须幂等。先交代来源我运维着若干桌面自动化服务——两个本地代理进程一批长任务经它们出网调用模型、几路 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. 跑了但结果不对任务确实触发了日志也在滚动某段代码从没被调度真正执行过首次排到才暴露真 bugHTTP 客户端不认系统代理环境变量首轮战报逐条看 结果留痕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()健康检查优先幂等拉活而非确认asyncfunctionstart(){if(awaitprobe()){returnreport(already-running);// 判活探测本体不看 PID 文件}constchildspawn(process.execPath,[C:\\srv\\app\\proxy.js],{detached:true,stdio:ignore,});child.unref();writePidOnlyForReference(child.pid);// PID 留作日志参考不进判据constupawaitwaitFor(probe,8000);// 至多等 8 秒report(up?started:start-failed);// 失败也如实落结果文件}functionprobe(){// 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 转换时踩到一个很阴的坑TZAsia/Shanghai date -d epoch的时区变量会静默失效回落到 UTC不报错、不提示输出一个差 8 小时的时间戳让你怀疑任务配错了。# 露馅方法拿两个不同 TZ 对比输出相同即 TZ 根本没生效$TZAsia/Shanghaidate-d1789781400%F %T %Z2026-09-19 01:30:00 GMTDT# 本地应为 09:30$TZAsia/Tokyodate-d1789781400%F %T %Z2026-09-19 01:30:00 GMTDT# 与上海输出一致 两个都没生效# 可靠做法固定用 UTC 基准加偏移用算术完成不赌环境变量$EPOCH1789781400$date-u-d$((EPOCH8*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 —— 启动时轮转而非删除给下一次排查留现场functionrotateRuntimeLog(dir){constcurpath.join(dir,runtime.log);if(!fs.existsSync(cur))return;// 只保留上一轮prev 被覆盖是可接受的——我们要找的从来是出事那一轮fs.renameSync(cur,path.join(dir,runtime.prev.log));}轮转策略别贪多。单文件 prev 在这个场景够用关心的只有出事前的那一轮要留多轮就上编号或时间戳但必须事先想清楚哪些文件是证据、哪些是垃圾——证据文件不参与启动清理垃圾文件才有容量上限。第二半把快照动作挪到重启之前看门狗判定不健康、执行拉起动作前先 dump 一份现场// snapshot.js —— 自愈动作之前先留证回答它死之前正在干嘛functiondumpWatchdogSnapshot(state,reason){constlines[at${newDate().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执行流水。每轮一行该有而没有本身就是告警信号CREATETABLErun_trace(idINTEGERPRIMARYKEY,job_idTEXTNOTNULL,-- 哪个任务started_atTEXTNOTNULL,finished_atTEXT,outcomeTEXTNOTNULLCHECK(outcomeIN(ok,skipped,failed)),reasonTEXT,-- skipped/failed 必填-- host-missing / lock-held / timeout-- / upstream-4xx / transient-downstreamduration_msINTEGER);-- 对账用户说没收到结果先查这张表别先查代码SELECToutcome,reason,started_atFROMrun_traceWHEREjob_iddaily-pullORDERBYstarted_atDESCLIMIT20;流水表的价值在三种查询结果的对比里它把过去全靠经验猜的排障变成查表查询看到什么结论下一步去哪查那个时间点根本没有行从未触发查调度注册与 L3 的 last 结果文件有行outcomeskipped触发了但前置检查没放行查 reason 码对应的锁/依赖/窗口有行outcomefailed触发了链路中间断了查下游接口与代理配置有行outcomeok用户仍说没有我方完成断在投递查发送与对账链路注意第三列的区分skipped 和 failed 是两种事实。那次主调度的瞬时超时如果记成 failed 并触发重试反而会污染当日快照它被记成 skipped 加原因码次日自愈对账时一眼看清。流水要配一个定期裁剪保留窗口按能对账的用户反馈周期定我们留了四十天不然表本身会变成新的故障源。八、四个交过学费的地方一自启项的已配置错觉。快捷方式放进去就以为高枕无忧实际它要等一次永远不会到来的重启。验收动作必须是注销重登演练一次看进程回不回得来。二PID 复用造成的已在运行误判。判活看 PID 文件在重启后的机器上是掷骰子。改成健康检查优先之后PID 只作为日志参考字段保留任何判断都不引用它。三看门狗自带的测试工具说谎。控制入口有个 test 子命令它跑非流式请求那一步时报 JSON 解析错——查下去发现是测试脚本自己没剥掉 SSE 的注释行: ping开头的行不是数据把工具毛病误读成服务坏了。教训看门狗给出的坏了/活着结论动手拉起重启之前要用独立手段交叉验证一次直接 curl 端口、手工发一笔真实请求。判活体系自己也要被验收。四定时任务的隐藏依赖不判活。心跳看护绑定的宿主实体被清理后派发慢性失败而当时的监控只看服务端口。后来把任务依赖是否完整宿主存在、凭据未过期、上游可达也纳入 L3 的健康检查项——判活的边界要贴着故障形态画故障出在哪检查就做到哪。九、落地清单序号事项验收标准优先级1L1 自启入口配好并做一次注销重登演练重登后进程在、端口通、版本对必须2L2 注册 uncaughtException / unhandledRejection完整异常栈落盘进程行为符合所选策略必须3L3 周期看门狗动作幂等健康检查不过才拉起连跑三轮活着的进程不被误重启必须4判活TCPHTTP 健康端点禁用 PID 判活手工杀进程后一个周期内恢复必须5计划任务注册后手动 Run 一次查结果文件结果文件时间戳更新必须6启动日志轮转 prev不自删现场自愈重启后仍能读到上一轮日志必须7自愈拉起前 dump 状态快照追加写不轮转快照含阶段、无推进时长、状态迁移必须8每轮执行写 run_trace成功也写skipped 带原因对一次没跑投诉十分钟内定档应当9时间核验用 UTC手动偏移双 TZ 交叉验证两个不同时区命令输出确实不同应当10首轮自动战报逐条读不只看绿灯每条 skipped/failed 有归因应当十、常见问题Q直接用现成的进程守护器注册成系统服务还需要这三层吗A守护器能覆盖 L2 和部分 L3崩了拉起但两件事它替不了健康检查仍然是你的事——端口能连不等于任务还能派发半死状态它判不出来L1 的触发条件仍然要演练验收——服务设成自动启动不等于登录会话里一切就绪。三层是职责划分不是工具数量。Q10 分钟周期是不是太迟钝A周期是业务容忍度和误判成本的折中。我们场景里任务粒度是天级和小时级10 分钟的失联窗口没人疼秒级场景不该用看门狗凑把进程挂到服务管理器下、配合就绪探针才有秒级恢复的底座。Q维护时故意停服务看门狗会不会十分钟就把它拉回来A会所以显式停要有显式语义给 ctl 加 pause落一个哨兵文件看门狗见到哨兵只记日志不动作。预期状态必须写进代码不能停在口头上——我待会儿会停它不算防护。Q机器睡眠把任务窗错过是不是就永久漏跑了A注册时勾选错过后补跑StartWhenAvailable醒来会补一次更兜底的是流水表——漏跑那一轮该有而没有的行对账查询会把它暴露出来而不是等用户反馈。QuncaughtException 里不退出进程会不会带着坏状态一直跑A有这个风险所以这个策略只在两个条件同时成立时才敢用L3 的判活是真健康检查坏状态会在下个周期被换掉以及关键状态可从磁盘重建。两个条件缺一个就退回记日志、退出、等拉起的教科书路径。写在末尾这三层架构没有一个新技术快捷方式、异常处理器、计划任务、一次 HTTP 探测。让它们起作用的是三条纪律每条都是事故教的。验收永远看产物不看配置。自启看登录演练计划任务看结果文件判活看健康检查的答复——配置存在不等于机制工作那晚死掉的代理就是全配对了但只配对了一层的样子。现场必须在死亡和拉起之间活下来。轮转不自删、快照先于重启顺序错了取证能力归零。没跑必须是可查询的事实。流水表把相当玄学的故障形态——没有任何记录的安静——变成了四行查询结果里的一种。定时任务不会告诉你它死了。三层守护的意义就是让它死的那一刻替它把讣告写好。