30 · 日志、健康与关闭:进程活着,为什么任务还会失败#
小周刚部署完,curl 返回 200,任务页面也显示服务运行中,但小陈的一份导出 FAILED。另一台 Worker 已异常,页面却仍是绿色。健康检查是不是写错了?先不要把三个不同问题混为一个:“能接数据库请求”“近期有投递活动”“这一笔业务成功”。
本章继续使用第 29 章的 teamdesk-stage6 / .env.stage6 学习部署,沿日志字段、两类探针和关闭顺序逐层查证。命令目标明确限定这套学习环境,不拿别人的服务做故障实验。
1. 从一条任务日志追踪到一轮执行#
从 worker/index.ts 的 runWorkerProcess 进入 生命周期,再沿 createTaskWorker 进入 Worker 工厂 搜索 context、task.started、task.attempt_failed,再看 logger.ts:Worker 用 Pino 输出一行一条 JSON,附 service、pid 和 ISO 时间。
| 字段 | 追踪什么 | 常见误读 |
|---|---|---|
| taskId | 业务任务 | 不区分任务的不同执行轮次 |
| run | 用户手动开启的轮次,从 0 开始 | 误当自动重试次数 |
| attempt | 此次尝试,attemptsMade+1 | 误当全部历史累计次数 |
| event | 发生了哪类处理动作 | 只搜中文“失败”混淆所有错误 |
| durationMs | 本次处理包装函数经历的时间 | 当作用户提交到完成的总耗时 |
例如同一 T / run=0 可能先有 attempt=1 的 started 和 attempt_failed,再有 attempt=2 的 started、completed;这里是同轮自动重试。用户手动重试后 run 才变化。这个时间线是读法示意,不是本次已执行日志。
2. 事件名称也要结合数据库解释#
processTask 返回后,包装函数重新查任务的 status / run:当前轮且 COMPLETED 就记录 task.completed,否则 task.skipped。它没有读取 resultSummary 判断是否真正生成通知。
因此,正常取消的旧提醒也可能对应已完成账本,不能把 task.completed 直接翻译成“已通知负责人”。 要回查摘要与实际通知。旧轮次消息被跳过,也不表示新一轮失败。
失去租约的执行者现在直接返回;若 B 仍 RUNNING,包装函数记录 task.skipped。若 B 已完成,A 的包装函数也可能读到 COMPLETED 并记录 task.completed,所以该日志不证明是哪名执行者保存了文件。数据库已完成但最后进度汇报失败也会保留结果并返回,见第 25、26 章。
task.attempt_failed 是本次处理抛错;task.failed 是 BullMQ 的 failed 事件记录,是否耗尽看 exhausted 和账本。failed 回调只记日志,没有 executionId 就不代写 FAILED;正常失败由处理器按租约保存,崩溃遗留由投递扫描恢复后核对队列终态。exhausted 结合尝试预算与 UnrecoverableError 判断,包括永久拒绝和 stalled 超限,不靠错误字符串猜测。task.stalled 表示失去队列执行锁,不是发现某张工单业务超时。dispatcher.failed / recovered 则表示投递扫描的失败与恢复。
真实 Worker 测试 已验证六种消费 / 恢复路径,详见第 26、27 章;它们不启动本章的心跳和信号退出循环;本章另用八条真实子进程用例补上这些证据,容器和 Web 实验仍需单独运行。
投递故障日志按约 30 秒节制输出,没每秒刷错不代表依赖已恢复;确认 recovered、成功扫描和当前账本,而不是仅凭一段安静日志下结论。
3. 日志级别和脱敏分别管什么#
LOG_LEVEL 默认 info,可用 fatal/error/warn/info/debug/trace/silent;非法值在初始化时抛错,不悄悄退回默认。设置 warn 后正常 task.started / completed 的 info 日志不会出现,但任务仍可能正常运行。
logger 的 redact 覆盖 password、secret、token、cookie、authorization、部分一层字段及特定环境变量路径,censor 为“[已隐藏]”。它不是任意深度、任意字段名的敏感内容识别器;一条自由文本里拼进连接串,也不会因为字段名叫 msg 就自动安全。
当前调用只传必要任务字段、固定提示与错误分类,避免传原始 Error、job.data 全文、SQL、Cookie、工单正文或 CSV。排错时不应为“看清楚”而临时打印全部环境变量。
部署 Compose 的 json-file 配置 max-size=10m、max-file=3 是每容器日志轮转,不是集中检索、告警或永久归档平台。换容器和保留策略也会影响可查看的历史。
4. Web 探针只测试了哪一条路#
打开 /api/health:
// 源码节选:检查主线
await connection();
try {
await getDb().$queryRaw`SELECT 1`;
return Response.json(
{ status: 'ok' },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch {
return Response.json(
{ status: 'unavailable' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
);
}已对照安装版本的 connection 文档:这里明确等待真实请求后执行检查,不在构建时把缺数据库配置的结果固定成就绪响应。它不是数据库 connect 方法,也不是缓存清理方法。
SELECT 1 证明当前连接可执行简单查询,不访问 Ticket、Membership 等业务表。所以数据库有连接但某个迁移缺失时,健康可能是 200,业务仍会缺列失败。这个端点不测 Redis、登录、团队权限、Worker 或下载内容。
db.ts 复用连接池;公开响应只返回 ok / unavailable,不回传原始地址和异常。Dockerfile 的 Web 探针请求容器内 3000 端口;主机 curl 则走映射后的 3100,两者位置不同。
5. 多进程心跳怎样避免互相代签到或误删除#
小周在同一台机器开 A、B 两个 Worker。旧实现用 hostname 作实例键,两个进程会写同一个字符串键;A 退出时删除整键,B 明明正常却可能被误判。另一个问题是共享键只代表最后一次写入,最后一个进程退出后,页面还要等旧 TTL 到期才变红。
现在 heartbeat.ts 把心跳改为“作用域 + 独立进程登记”:
<QUEUE_PREFIX>:worker-heartbeat:active → 所有近期可接工作的进程登记
<QUEUE_PREFIX>:worker-heartbeat:active:<scope> → 这个作用域内的进程登记
member = <scope>/<随机 UUID>
score = Redis 当前时间 + 20 秒scope 默认 hostname。在一个容器一个 Worker 的部署中,它对应当前容器;在同一宿主机运行多个需单独检查的实例,可分别用 WORKER_INSTANCE_ID=worker-a、WORKER_INSTANCE_ID=worker-b 启动,探针也传入相同配置。同作用域探针表示该组至少一个进程健康,不保证组内每个进程都正常。 想单独观察 A,就不能故意让它与 B 使用同一 scope。
跟着源码读 createHeartbeat → renew / remove → hasHeartbeat:
- 每个进程生成独立随机 member,即使 scope 相同也不会覆盖。
- 只有投递扫描成功,且没有开始退出,才调用 renew。Lua 用 Redis TIME 计算过期分数,清理旧成员,再登记自己的下一次到期时间;避免不同机器时钟偏差直接影响判断。
- 全局与作用域有序集合写在同一个 Lua 操作里。键本身设置两倍 TTL,用于回收不再访问的集合;健康判断看成员分数,不是看键还存在。
- hasHeartbeat 先按 Redis 当前时间清过期成员,再判断集合中是否仍有人。B 不能让作用域 A 的过期成员变新。
- 正常退出只 ZREM 自己的 member,保留同伴;最后一个成员退出后,查询立即得到无有效登记,不必等旧字符串键过期。
queueHealth 默认读全局集合,供任务页面判断近期是否有可接工作者;worker-health.ts 读指定作用域,成功 exit 0,缺心跳或依赖检查失败 exit 1。共享 queueHealth 的 2 秒上限覆盖连接与读取全程,短脚本另有 4 秒兜底。Redis 已 ready、随后不响应 EVAL 时,也不能把页面查询一直挂住;redis=false 表示本次完整检查未成功,并不专指 TCP 从未连上。
协议变更操作: 更新 Web、Worker 与探针时一起重启到这版代码。旧的字符串心跳键不再参与判断,会自然过期;新集合使用 :active 后缀,不把旧字符串误当有序集合读取。若在宿主机检查容器,scope 默认 hostname 不同,仍应在容器内执行探针或明确指定目标作用域。
心跳只说明近期成功扫描且没有开始退出,不证明每笔任务正确。异常冻结的进程需要等有效期过去,页面还受轮询影响;SIGTERM 则主动撤销登记,表示“正在排空,不接新工作”。在途任务仍可以持有租约并正确完成。
6. 用一个矩阵排查“健康但业务失败”#
| Web 探针 | Worker 证据 | 任务现象 | 应优先查什么 |
|---|---|---|---|
| 200 | 心跳正常 | 导出 FAILED | 权限变化、筛选、上限、taskId/run 错误 |
| 200 | 心跳过期 | 任务 WAITING | Worker、Redis、投递扫描和配置 |
| 503 | 可能异常 | 业务请求失败 | 数据库 / Web 配置,先恢复基础依赖 |
| 200 | 共享正常、某实例失败 | 其他任务仍完成 | 当前实例日志,别让别人的心跳代替它 |
restart: unless-stopped 针对进程退出的重启策略,不会仅因仍存活的进程变成 unhealthy 就自动重启它。需要自动处理“活着但不健康”,还要外部编排或明确退出策略,仓库不能据此宣称具备完整自愈。
7. SIGTERM 怎样兼顾停止接单与完成在途任务#
从 runWorkerProcess 的 shutdown 往下追。正式入口只创建数据库与 Redis 连接,再进入这套生命周期;子进程测试也使用同一个函数,避免只测试一个模拟版关闭流程。
| 时点 | 代码做什么 | 业务含义 |
|---|---|---|
| 收到 SIGTERM | stopping=true,清扫描定时器,只记录一次 stopping | 不再启动新扫描;重复 SIGTERM 不重复清理 |
| 开始关闭 | 立即调用 worker.close,同时撤销自己的心跳登记 | 不再接新任务;健康查询不把排空进程算作可接工作者 |
| 等待阶段 | 等 worker.close 和当前 tickPromise 都结束 | 在途消费仍续 Redis 执行锁;已开始的扫描可以完成,但不能再续心跳 |
| 排空结束 | 关闭 Queue、Redis、数据库,记录 stopped,退出码 0 | 结果已提交,连接已释放 |
| 超过 75 秒 | 记录 shutdown_timeout,真实 process.exit(1) | 不伪造成功,也不把仍在使用的租约随意清掉 |
退出与扫描可能交错:tick 等待数据库时收到 SIGTERM,关机先撤销登记;tick 后来返回,如果不重查 stopping,就会又写一份“我还健康”的心跳。现在检查位于 dispatchTasks 返回后、renew 之前,测试专门卡在这处等待,验证不会复活登记。
为什么不能一收到信号就断 Redis?在途任务可能仍需要更新队列进度,而且 worker.close 在排空期间继续续执行锁。先断开会让其他 Worker 误以为任务 stalled,更换 executionId,原任务的提交就可能被拒绝。正确顺序是先停止接单、继续为已接单工作提供依赖,再释放连接。
正式 pnpm worker 与 Docker CMD 使用 node --import tsx src/worker/index.ts,让接收信号的 Node 就是执行业务的进程,减少一层 CLI 子进程转发。测试同样直接 fork Node 加载 tsx;开发用 worker:dev 仍走 watch,开发重启不等同正式容器的退出预算。
正常退出不主动清任务的 executionId;处理器自己提交 COMPLETED 时才清租约。如果截止时间到了,进程退出会断开数据库连接,未提交的事务回滚,账本可能仍为 RUNNING / E1。重启后 Redis 锁自然过期,真实 stalled 检测让 B 接手 E2;B 按新租约提交,旧 E1 不再有资格写结果。若队列已终止,则按第 26 章的扫描流程收敛。
三个正式预算仍是:导出读取事务最长 60 秒、程序关闭期限 75 秒、Compose stop_grace_period=90s。它们不是无限保证,超时必须交给恢复流程。SIGINT / SIGTERM 正常关闭;unhandledRejection 尝试有界关闭并保持失败退出码,已经开始排空后再遇到这类错误也不能被最后的退出码 0 掩盖;uncaughtException 仍记录 fatal 后立即 exit(1)。
8. 在隔离部署中观察一次停止与恢复#
先确认第 29 章的配置与项目,再运行:
curl --fail http://localhost:3100/api/health
docker compose -p teamdesk-stage6 --env-file .env.stage6 -f compose.deploy.yaml exec -T worker node --import tsx scripts/worker-health.ts
docker compose -p teamdesk-stage6 --env-file .env.stage6 -f compose.deploy.yaml logs --tail 100 worker记录探针退出码、worker.started、任务定位字段。然后仅停止本章 Worker:
docker compose -p teamdesk-stage6 --env-file .env.stage6 -f compose.deploy.yaml stop worker
docker compose -p teamdesk-stage6 --env-file .env.stage6 -f compose.deploy.yaml logs --tail 100 worker
docker compose -p teamdesk-stage6 --env-file .env.stage6 -f compose.deploy.yaml start worker应找 stopping → stopped 的正常顺序与重启后的 started;停止期间 Web 应仍可用,新任务可受理但等待。若刚好有小任务,它可能先完成再退出,这不证明模拟了长任务中途被杀;若没等到正常 stopped,查超时和退出结果。
本地无需容器也能运行两项日志测试:
pnpm exec vitest run tests/worker-logger.test.ts --reporter=verbose现有测试 用内存流检查任务字段、指定凭据隐藏;另一用例检查 warn 过滤 info、非法级别拒绝。它不启动 Worker,不验证真实 Redis 心跳、多实例故障或 SIGTERM 关闭,不能把两项 passed 写成运行维护全链路通过。
八条真实子进程测试:先看边界,再运行#
准备并迁移独立测试库,方法见第 27 章。运行:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
TEST_REDIS_URL=redis://127.0.0.1:6380 \
pnpm exec vitest run tests/worker-process.integration.test.ts --reporter=verbose打开 进程测试 与 子进程夹具:父进程 fork 真正 Node 进程,用 --import tsx 加载入口。夹具只增加团队范围和数据库调用处的 IPC 关卡,心跳、SIGTERM、续锁、期限、资源关闭都执行正式 runWorkerProcess;健康探针则直接运行正式 scripts/worker-health.ts。
| 场景 | 怎样控制现场 | 必须得到什么 |
|---|---|---|
| 导出正常排空 | 暂停真实读取,SIGTERM 后继续暂停超过 1 秒锁时长,再放行 | 心跳主动撤销,Redis 锁仍有效,E1 没被换掉,exit 0,CSV / 摘要 / 有效期正确 |
| 提醒正常排空 | 同样发送真实信号,延后放行提醒事务 | exit 0,通知和系统事件各 1,version 恰好加 1;重复 SIGTERM 只产生一次 stopping |
| 导出超时退出 | 读取不放行,将测试关闭预算设为 1000ms | 真实 exit 1,仍为 RUNNING / E1 且无文件;新进程收到 stalled 并认领不同 E2,最终两边完成 |
| 提醒超时退出 | 提醒事务不放行,达到同一真实期限退出 | 未提交事务回滚,无通知、version 未变;B 接手后只提交一份通知与事件 |
| 同作用域双进程 | 两个独立 member;暂停 B 续期后退出 A | A 只移除自己,B 登记仍在,探针仍成功;B 最后退出后不健康 |
| 单实例冻结 | 向 A 发 SIGSTOP,B 属于另一作用域并继续运行 | A 的有效登记过期、A 探针失败;B 和全局仍健康;SIGCONT 后 A 可正常退出 |
| 扫描与退出交错 | 第二轮扫描在数据库调用前暂停,SIGTERM 后才放行 | 退出扫描不复活心跳;旧进程不消费新工作,新进程完成遗留排队任务 |
| 连接成功后读取挂起 | 本例 TCP 代理转发 Redis 握手,只截住完整 EVAL 命令 | 正式探针在共享读取上限内 exit 1,不靠 4 秒兜底才结束 |
测试不通过 sleep 猜谁先执行:IPC、日志和 Redis / PostgreSQL 状态决定继续时刻;20ms 只是状态轮询间隔。超过原始 1 秒锁期限仍看到正 TTL,才证明排空期间真实续锁;在途任务必须保持相同 E1,不只是检查最终绿了。正常排空之后还检查待投递任务没有被旧进程接走,并由新进程完成,原任务的完整结果行保持不变。
心跳用 100ms 扫描、600ms 有效期加速冻结实验;验证主动撤销时改用 5000ms 有效期,要求两秒内消失,防止只靠 TTL 到期也通过。同伴删键用例暂停 B 的续期,避免错误删除被下一次写入掩盖。正式的 3000ms 扫描、20 秒有效期、75 秒关闭预算保持原值。
提醒事务会持有团队锁;这时再创建同团队的行,外键检查可能等待该锁。因此“退出后出现的新任务”夹具提前建好,开始时不参与投递,收到停止信号后才改为 WAITING。它是在构造关闭边界,不是测试手动重试接口。
这是 macOS / Linux 上的真实进程、真实操作系统信号与真实数据库 / Redis,不是模拟 Worker.close。它仍不启动完整 Web,也没有执行 Docker PID 1 转发、容器守护、默认 75 秒的完整墙钟等待或 Redis 真正网络断线;对应部署步骤仍要在上面的隔离容器中验收。测试只清理自己生成的前缀、团队和子进程,失败时杀掉自己创建的子进程,保留开发数据。
拓展阅读何时需要更细的探针?
当前 Web 将数据库基本可用作为就绪依据,Worker 用扫描心跳衡量近期活动。若要进一步区分进程存活、可接新请求、队列积压、单任务超时,需要各自定义指标及处理动作;探针越重也越可能给故障中的依赖增压,不是把全部业务塞进一个健康接口就更可靠。
9. 练习与答案#
练习 A:task.completed 后用户说没通知,先查什么#
动手练习查看参考答案
参考答案:查同 taskId/run 的 kind、resultSummary、提醒 revision 和实际接收人 / 通知记录。完成可能是旧提醒跳过;日志包装按账本状态命名,不保证本次新增了通知。
练习 B:LOG_LEVEL=warn 时没有 started,怎样判断 Worker 没运行#
动手练习查看参考答案
参考答案:不能仅凭缺 info 日志判断。核对进程状态、实例探针、账本和 warn/error 输出;当前级别过滤正常启动和执行日志是预期行为,必要时调整自己学习环境的级别并重新加载配置。
练习 C:容器 unhealthy 但进程没退,为什么没被自动重启#
动手练习查看参考答案
参考答案:健康状态与退出重启是两个机制。unless-stopped 不等于 unhealthy 自动替换;先定位不健康原因,再由明确的运维动作或编排处理,不以反复删数据重建容器代替诊断。
练习 D:SIGTERM 后探针失败,在途任务却还在完成,是矛盾吗#
动手练习查看参考答案
参考答案:不矛盾。心跳表示当前还能接新工作;收到停止信号就撤销自己的登记。已接任务仍有 Redis 执行锁与数据库租约,依赖在排空期间继续可用,完成后才关连接。不能把 readiness 变红当作必须立即中断当前事务。
练习 E:超时 exit 1 后,账本为什么不立刻改 FAILED#
动手练习查看参考答案
参考答案:退出进程不能拿一个不完整的关闭过程冒充业务终态。事务内写入会回滚,账本可能保留 RUNNING / E1;新进程通过真实 stalled 接手为 E2 后仍可完成。只有处理器持有租约的业务失败,或扫描核对到真实队列终止,才按各自条件结束账本。
链路:请求 / 任务发生 → 只记录必要定位字段 → 探针检查约定边界 → 结合账本判断业务结果 → 停止接单并有界等待 → 资源清理或失败退出 → 恢复流程接手未完成工作。