Cron 最后一跳总在丢?用 spoold 把 HTTP 回调变成可恢复的本地投递队列
很多自动化任务的主体其实并不脆弱:定时拉取数据、生成报表、跑完构建、导出备份,都可以在本机顺利结束。真正容易被忽略的是最后一步——用 HTTP 通知另一个系统“任务完成了”。一次短暂 DNS 故障、目标服务滚动重启,或者边缘设备刚好断网,都可能让最后那条 curl 消失。脚本退出后,谁还记得再发一次?
给 curl 加 --retry 只能覆盖当前进程仍活着的一小段时间。它解决不了宿主机重启、Cron 已结束、网络长时间不可用这些场景。若业务只需要在一台机器上把出站 HTTP 请求可靠地交出去,spoold 提供了一个很窄、但很清晰的边界:调用方先将请求提交给本地 daemon;只有请求已持久化后,daemon 才确认入队,之后再异步投递和重试。
spoold 是一个 Go 项目,采用 MIT 许可证;本文依据其首个公开版本 v0.1.0 的 README、设计文档与发布材料撰写。它并不是 Kafka、RabbitMQ 的替代品,也不试图提供跨机器高可用队列。更准确的定位是“带持久化责任边界的本地 HTTP 出站器”:适合单机 Cron、家庭服务器、边缘设备或小型自托管服务。
先把失败责任从脚本移到 daemon
假设备份任务完成后需要调用内部 webhook。直接调用时,重试策略与脚本生命周期绑定:脚本超时、被停止或机器重启,未送达的请求就没有状态可追踪。
spoold 的处理方式不同。它用追加式 journal 记录投递状态,并可在重启后重放;投递采用至少一次(at-least-once)语义。这意味着“目标服务实际收到请求,但本地尚未来得及记下成功就崩溃”时,恢复后仍可能再次发送。它不是缺陷,而是可靠投递常见的取舍:宁可重复,也不要悄悄丢失。
因此,接收方必须按稳定投递 ID 去重。spoold 会携带 X-Spoold-Delivery-ID 和 X-Spoold-Attempt:前者可作为业务侧幂等键,后者则适合记录第几次尝试。不要把“HTTP 200”简单等同于“天然恰好一次”;真正的去重需要由消费方保存并判断投递 ID。
用 Docker 先建立一个可持久化的本地出口
项目发布了多架构镜像。下面的示例将管理端口仅发布到本机,并挂载 Docker volume 保存 journal;如果没有这个 volume,容器重建后就会失去尚未投递的状态。
docker run -d \ --name spoold \ --restart unless-stopped \ --publish 127.0.0.1:8080:8080 \ --volume spoold-data:/var/lib/spoold \ ghcr.io/rionlyu/spoold:v0.1.0
这里刻意使用 127.0.0.1:8080:8080,而不是把控制面暴露给整张网络。v0.1 的 HTTP 控制面没有为不可信多租户网络设计认证与租户隔离;把它公开到公网,等于让陌生人有机会要求你的机器向外发请求。若只需本机程序调用,项目也支持 owner-only 的 Unix socket:
spoold \ -unix-socket "$HOME/.spoold/spoold.sock" \ -journal "$HOME/.spoold/spoold.journal" export SPOOLD_URL="unix://$HOME/.spoold/spoold.sock" spoolctl list
Unix socket 不是“更高级的端口”,而是把访问权限交给本地文件权限管理。对于只由 Cron、systemd timer 或同一用户脚本调用的部署,它通常比开放 TCP 端口更容易收紧边界。
将 Cron 回调改成“先入队,后送达”
设想每日构建结束后,要通知一个内部事件接收器。将原先直接 curl 的位置改为 spoolctl send:
spoolctl send \
--idempotency-key "nightly-build-$(date +%F)" \
--header 'X-Event-Type: build.completed' \
--data '{"job":"nightly-build","result":"passed"}' \
--max-attempts 5 \
https://events.example.internal/v1/builds
入队幂等键和投递幂等键解决的是两件事。--idempotency-key 防止调用方因为自身重试而重复创建多条等价任务:同一个 key 且内容相同,应复用既有投递;同 key 但不同内容应当被视为冲突。X-Spoold-Delivery-ID 则由接收端用于处理“网络结果未知”导致的重复投递。
运维时不要只看脚本退出码。应当把队列状态和指标也纳入巡检:
spoolctl list spoolctl getspoolctl retry curl http://127.0.0.1:8080/metrics
list 用于发现积压或失败记录,retry 适合在修复目标服务配置后人工恢复终态失败项;/metrics 可接入现有的 Prometheus 抓取。这样,原本散落在各脚本里的“可能没发出去”变成了可以观察、告警和处理的本地状态。
重试并不等于可以忽略协议设计
spoold 会对传输错误以及 408、425、429、5xx 等响应进行重试;其他非成功响应会直接失败。这个分类提醒我们:权限错误、签名错误、请求体不合规,通常靠重试不会变好。对这类错误,更有效的动作是把状态暴露出来并修正配置,而不是无限循环。
它还默认阻断解析到 loopback、私网、链路本地等地址的目标,并对重定向继续执行检查,以缩小 SSRF 风险。开发本地 receiver 时可以显式使用 -allow-private-targets,但这应只出现在受控测试环境。生产环境若确实需要调用内网服务,应先理解并审查该放宽带来的网络边界变化,不要为了“请求能通”而把安全默认值永久关掉。
另一个常见错误是把 API key 写进 Cron 命令行。更稳妥的做法是由服务管理器或受限环境文件注入变量,再让脚本读取变量构造请求;同时限制 journal、socket 和配置文件的所有者与权限。持久化带来可靠性,也意味着请求体与元数据可能在本地停留更久,需要按敏感数据处理。
上线前,故意演练一次“结果未知”
这种工具最容易在正常网络下看起来毫无价值,因此验证不能只做一次成功请求。建议准备一个只用于测试的接收端和一个可识别的幂等键,依次观察四种状态:请求正常入队并完成;接收端停止后请求留在队列;重启 daemon 后记录仍在;接收端恢复后同一投递继续尝试。最后在接收端日志中检查稳定的 X-Spoold-Delivery-ID,确认重复请求没有被业务逻辑重复入账。
这里的关键是区分两个“成功”。第一层成功是本地已经接受并持久化了任务,调用 Cron 可以安全退出;第二层成功才是远端已响应。前者解决调用方不能长期等待的问题,后者仍需要通过状态、告警和接收端幂等来闭环。若监控只统计 Cron 的 exit code,会把大量“已接管、尚未送达”的真实状态藏起来;至少应针对持续积压、终态失败和指标端点不可达建立告警。
也要为失败请求设计人工处置规则。例如,429 或 5xx 可能代表对方暂时繁忙,等待重试往往合理;持续的 401、403 或请求格式错误,却更可能是凭据、权限或契约变更。此时重复发送不但无效,还可能扩大噪声。将失败 ID、目标服务、最后响应类别和处理责任记录到运维流程中,才能避免队列变成无人查看的“失败墓地”。
对于内容本身,最好让 webhook 表达一个可追踪的事件而非塞入大对象:包含事件类型、业务对象 ID、生成时间和结果摘要即可,完整文件仍放在原有存储位置。这样既降低本地 journal 中敏感负载的暴露面,也方便接收端按 ID 回查。当业务必须保证“数据库记录与发送事件要么同时发生、要么都不发生”时,不要用本地 HTTP 队列掩盖事务边界;应回到 outbox 或数据库事务的设计层面。
它适合什么,又不适合什么
spoold 适合“本机接受请求后,未来负责把它发到 HTTP 目标”的问题,例如备份完成通知、离线设备回传、构建结果 webhook 和单机服务的出站回调。它的价值不在于堆叠功能,而在于把持久化确认、崩溃恢复、重试、投递 ID 与基础指标放到同一个轻量边界中。
反过来,如果业务事件必须与数据库事务原子提交,应优先考虑 transactional outbox;如果要跨节点容灾、多个消费者竞争或承载高吞吐流量,则需要成熟的消息系统。spoold v0.1.0 仍是早期版本,尤其应先在非关键任务中演练“目标不可用—重启 daemon—恢复投递—接收端去重”这条路径,再决定是否放进生产链路。
可靠性不是把重试次数从三次调到十次,而是明确:请求被谁接管、何时算已接受、重复如何处理、失败在哪里可见。对一台机器上的 HTTP 最后一跳而言,把这些问题从临时脚本迁移到一个可检查的本地 spool,往往比继续给 curl 叠参数更可控。