后台清扫器容错统一与 Server 进程生命周期
后台清扫器容错统一与 Server 进程生命周期
- 标题:清扫器一轮失败不再永久停摆,Server 入口接管信号与退出码
- 目标分支:
main - 相关记录:实施计划
摘要
稳定性复查里确认了两个缺陷,都属于「故障不会自己喊人」这一类。
其一,后台清扫器会静默永久停摆。 Server 进程内有五个周期清理任务,其中三个
(客户端元数据、WebSSH 会话、客户端连接租约)的循环把第一轮数据库错误当致命错误 return err,
运行它的 goroutine 随即结束;这个错误只在 ServerRuntime.Close() 时才被读出来,而一个长期运行的
Server 永远不会走到那里。后果是一条瞬时故障(数据库重启、连接池抖动、单条语句超时)之后,清理能力
永久消失且没有任何信号,client_connection_leases 退回本次要修的无界增长。仓库里另外两个清扫器
(审计保留、身份维护)原先就是「记录并继续」——正确的策略早已存在,只是被抄了五份而漏掉了三份。
其二,tunnelmesh-server 是唯一没走 cli.Main 的入口。 它调用
cli.Execute(context.Background(), …) 再 log.Fatal,而全仓只有 cli.Main 安装 SIGINT/SIGTERM
处理器。于是优雅停机(ServeListener 里那条 proxy entry → Agent 流 → relay → HTTP drain → VPN 排空
的链路)早已实现却永远不会被触发:systemctl stop 直接以信号带走进程,实测退出码 143。同时
log.Fatal 把 78(配置错误)与 77(凭据被永久拒绝)压成 1,而这两个码正是交付单元要排除的,
结果坏配置在 Restart=on-failure 下每 5 秒重来一次、永不自愈。Server 单元也是全仓唯一还在
Restart=on-failure 的常驻单元——macOS 三个角色共用 KeepAlive=true。
修法分四层,各自可独立验证:
- 新增
internal/server/sweep_loop.go:唯一的周期清扫策略runSweepLoop。立即跑一轮、按 tick 补跑、 一轮失败只意味着这一轮没做成(按<前缀>_sweep_failed记日志后等下一轮)、只有上下文取消才返回; 已取消的上下文不开工(关停日志不被误报污染),非正间隔直接报错而不是让time.NewTickerpanic—— goroutine 里的 panic 会带走整个 Server 进程,一个清理任务不该有这种爆炸半径。 - 五个清扫器的
Run全部委托该循环。仓储未接线是配置错误而非可重试的一轮,因此每个Run保留进循环 之前的 nil 守卫并原样返回,避免退化成每 tick 刷一条日志的僵尸任务。SQL、间隔、批量、保留窗口、Sweep/SweepOnce签名、ServerRuntime.Close()的错误汇聚全部未动。 cmd/tunnelmesh-server/main.go改为os.Exit(cli.Main(cli.NewServerRoot())),两个 Server systemd 单元 与其余角色对齐为Restart=always+RestartPreventExitStatus=78 77,并显式写出TimeoutStopSec=60s、KillSignal=SIGTERM。同时加两个结构性守卫测试(scripts/service_lifecycle_test.go):每个无构建约束的cmd/*/main.go必须委托cli.Main;每个常驻单元必须Restart=always+RestartPreventExitStatus=78 77。 同类漂移下次会在 CI 红灯,而不是在下一次停机时才被发现。deploy/prometheus/alert-rules.yaml补两条规则:TunnelMeshLeaseSweepErrors(15 分钟内多于 3 轮失败) 与TunnelMeshLeaseSweepStalled(10 分钟内 0 轮)。这两条必须排在第 1、2 层之后:循环会在首轮失败 后死亡时,result="error"只产生一个样本,increase永远够不到阈值,指标形同虚设。
无 DDL、无 SchemaVersion、无 API、无新增配置项——属于 PATCH。
用户影响
- Server 的停止行为变了:
systemctl stop/restart现在会先执行优雅下线再退出,实测 0.03 秒内以 退出码 0 结束;改动前是被SIGTERM直接杀死(143),在途请求被硬切断。 - 坏配置不再无限重启:
run与check-config以 78 退出(改动前是 1),被RestartPreventExitStatus=78 77挡住;崩溃、监听器被意外关掉、入口返回 0 等情况一律由Restart=always拉起。 - 后台清理任务对数据库抖动的表现从「永久停摆且无信号」变成「本轮失败、下一轮重试、日志与指标都有痕迹」。
反过来,运维判据也要跟着变:进程存活不再是后台任务健康的证据,要看两条新告警或
*_sweep_failed日志事件。 - 五个清扫任务的失败日志事件名统一为
<前缀>_sweep_failed:client_metadata、webssh_session、client_connection_lease、audit_retention、auth_maintenance。审计与身份维护原有的成功日志 事件名与字段逐字未变。 - 容器部署是这次改动收益最大的一类:官方镜像以 exec 形式把
cmd/tunnelmesh-server编成/usr/local/bin/tunnelmesh作为 PID 1(distroless,无 init),而内核不会把「默认行为是终止」的信号 投递给 PID 1——没有安装处理器的旧入口因此完全收不到SIGTERM,docker stop/滚动更新必定等满stop_grace_period(默认 10 秒)再被SIGKILL,drain 一行都没执行。现在处理器已安装,宽限期内能干净 退出;启用 VPN 数据面且server.vpn.shutdown_timeout大于 10 秒时,把 compose 的stop_grace_period相应调大。docker-compose.{local,cluster}.yml早已是restart: unless-stopped(0 退出也会拉起),这反过来印证了 systemd 的Restart=on-failure是全仓唯一的例外。 WinSW(onfailure action="restart")与 launchd 模板本次未改。
API / Schema / 配置影响
- 无 HTTP 接口、无
docs/api/openapi.yaml、无migrations/变更。 - 无新增或改名的配置键;文档里出现的
server.vpn.shutdown_timeout是既有键,用于解释TimeoutStopSec=60s的预算构成。 - 部署产物:
deploy/systemd/tunnelmesh-server.service与deploy/systemd-user/tunnelmesh-server.service的[Service]段变化,一键安装脚本按模板渲染,无需改脚本;已用手工 drop-in 覆盖过单元的环境,systemctl edit生成的覆盖仍然优先,不受本次影响。 - 指标:不新增序列,只是让既有的
tunnelmesh_client_connection_lease_sweep_total在故障期间持续出样本。 - 告警:新增 2 条,
deploy/prometheus/alert-rules.yaml由 9 条增至 11 条。 - 文档同步:
docs/deployment/linux-systemd.md(三角色统一重启策略与TimeoutStopSec的理由)、docs/operations/configuration.md(退出码表与「server 仍用 on-failure」那段)、docs/operations/observability.md(新增「后台清扫任务」一节 + 两条告警口径与值班归属)、docs/operations/troubleshooting.md(新增「Server 停止后没有执行优雅下线」条目,并把租约回收的 「确认清扫是否在跑」改成指标口径)。
安全与授权影响
- 无新增暴露面、无鉴权变化。清理任务的错误日志只含错误文本与计数,不含行标识符、DSN、Token;
sweep_loop_test.go里有一条断言专门盯这一点(失败日志中不得出现dsn/token)。 - 关停期间不再产生新的错误日志:已取消的上下文不开工,避免 shutdown 日志里混进必然失败的数据库错误。
- 入口改动不影响
ExecStartPre的密钥注入约定(EnvironmentFile=-语义未变)。
测试证据
TDD 红灯(实现前实测输出):
go test ./internal/server -run TestRunSweepLoop -count=1→internal/server/sweep_loop_test.go:82:22: undefined: runSweepLoop(6 处)。go test ./internal/server -run 'SweeperSurvivesFailed' -count=1→ 三条 FAIL,均为Run error = database is not reachable, want context canceled after 1 rounds,即旧实现在首轮失败就返回。go test ./scripts -run TestEveryBinaryEntrypoint -count=1→../cmd/tunnelmesh-server/main.go does not call cli.Main, so it cannot handle SIGTERM or map exit codes与calls log.Fatal inside main, which collapses every exit code to 1。go test ./scripts -run TestLongLivedUnits -count=1→ 两个 server 单元各报缺Restart=always、 缺RestartPreventExitStatus=78 77、缺TimeoutStopSec=。TestServerMainReturnsConfigExitCode在实现前即为绿(cli.Main的映射一直正确,缺的是入口没有使用它), 本任务的实际红灯来自上面的入口守卫,已在计划的执行记录里如实标注。
新增测试(13 个):
internal/server/sweep_loop_test.go:TestRunSweepLoopSweepsBeforeTheFirstTick、TestRunSweepLoopSurvivesFailedPasses、TestRunSweepLoopLogsFailuresWithoutNamingRows、TestRunSweepLoopStopsWhenCancellationLooksLikeAShutdown、TestRunSweepLoopSkipsPassesAfterCancellation、TestRunSweepLoopRejectsNonPositiveInterval。- 三个停摆回归:
TestClientMetadataSweeperSurvivesFailedRounds、TestWebSSHSweeperSurvivesFailedRounds、TestClientConnectionSweeperSurvivesFailedRounds(最后一个还断言result="error"样本数 ≥ 3,即告警能工作的直接证据)。 internal/cli/exit_test.go:TestServerMainReturnsConfigExitCode。scripts/service_lifecycle_test.go:TestEveryBinaryEntrypointUsesCLIMain、TestLongLivedUnitsAlwaysRestart。
既有测试保持绿色、未被放宽:TestClientConnectionSweeperWithoutGaugesOrRepository(仓储缺失必须立即返回)、
TestClientMetadataSweeperMarksExpiredAndStops、TestWebSSHSweeperRunsUntilCancel、
TestServerRuntimeCloseStopsWebSSHSweeper、TestAuthMaintenanceSweeperRunStopsOnCancel、
TestNewServerRuntimeStartsAuditRetentionOnlyWhenConfigured。
门禁实际输出:
| 命令 | 结果 |
|---|---|
go build ./... |
OK |
go test ./... -count=1 |
全部 ok(无 FAIL 行) |
go vet ./... |
OK |
gofmt -l internal/ cmd/ scripts/ |
无输出 |
git diff --check |
无输出 |
go test -race ./internal/cli ./scripts -count=1 |
ok 73.5s / ok 1.9s |
go test -race ./internal/server -run 'Sweep|Sweeper|AuditRetention|AuthMaintenance' -count=1 |
ok 4.4s |
go test ./deploy/install/oneclick -run Server -count=1(含 TM_ONECLICK_E2E=1) |
TestOneClickServerUserModeInstall PASS 5.1s |
promtool check rules … |
未执行:本机无 promtool 且 docker daemon 未运行。回退 ruby -ryaml 解析通过,rules=11,两条新规则 expr 已回读核对 |
真实进程对照取证(本机无 systemd,用 kill -TERM 直接验证入口语义;「改动前」二进制来自 main 的
git worktree,两者都先确认 /health/ready 返回 200):
| 场景 | 改动前(238857d) |
改动后 |
|---|---|---|
run 收到 SIGTERM |
exit=143(被信号杀死,drain 未执行) |
exit=0,0.03s 内结束 |
check-config 遇坏配置 |
exit=1 |
exit=78 |
run 遇坏配置 |
exit=1 |
exit=78 |
顺带修复的既有缺陷(测试基建,非生产代码):TestClientConnectionSweeperRunSweepsBeforeTheFirstTick
的两个测试替身在改动前的 main 上就会被 -race 判为 data race(Run 的 goroutine 与测试协程无同步地读写
同一切片)。因为本次要在同一文件里新增按 tick 调用的夹具,顺手给它们加了互斥量与访问器。CI 从不跑
go test -race ./internal/server,所以它长期未暴露。
已知无关失败:TestServerNodeAPIListDetailLifecycleAndAudit 的既有 flake(已在 PR #88 记录),本次全量
go test ./... -count=1 未复现。
发布步骤
- 合并后按常规灰度:先发一个 Server 节点,观察
TunnelMeshLeaseSweepStalled/TunnelMeshLeaseSweepErrors为绿、*_sweep_failed事件不出现,再全量。 - 已用一键脚本安装的环境执行
systemctl daemon-reload后重启 Server,让新单元生效;只换二进制不 reload 时,Restart=always/RestartPreventExitStatus不会更新,但优雅停机与真实退出码已经生效。 - 手工安装的环境需要把两个 Server 单元的改动同步过去,或直接
curl | bash重跑安装脚本。 - Prometheus 侧
promtool check rules后 reload;Grafana 无需改动(两条告警是 warning 级)。 - 遗留死行不需要手工清理:新 Server 启动后第一轮清扫即按每分钟 1000 行收敛。
回滚步骤
- 应用为 PATCH 且无数据动作:回滚 = 换回旧二进制 + 旧单元 +
daemon-reload,5 分钟内可完成。 - 代码与单元必须一起回滚:若保留
Restart=always而退回log.Fatal入口,坏配置会重新变成 5 秒周期的 无限重启(这正是旧行为里最坏的一条)。只想退单元时,systemctl edit写Restart=on-failure即可先行止损。 - 分层可独立回退:
git revert任务 2/3 的提交即恢复「失败即退出」,sweep_loop.go留着无副作用; 两条告警规则删掉即可,指标本身由 PR #88 引入,不受影响。 - 回滚后需要重新确认的观测口径:
*_sweep_failed不再出现,租约回收健康与否只能看result="error"是否有过样本(这也是为什么回滚后要一并撤掉告警,避免误判为「没告警就是好的」)。
Reviewer 关注点
- 「一轮失败只算一轮」是否是所有清理任务都该遵循的策略:有没有哪个清扫器的失败其实是致命的? 逐个看完后的结论是没有——五个任务的语句都以时间戳为边界且幂等,重试无副作用;但这值得独立确认。
Restart=always对 Server 是否安全。这是本案推翻前一个 PR 遗留结论的地方(见docs/pull-requests/2026-11-05-client-session-pool-silent-exit.md的 Reviewer 关注点):判据是ServeListener在http.ErrServerClosed时也返回nil,即 0 退出确实可能意味着「监听器没了」。- 仓储缺失保留「进循环之前就返回」的不对称语义(配置错误 vs 可重试的一轮),确认这个边界划得对。
- 跨节点
sum(...)的告警语义:它表达「没有任何节点在回收」而不是「某个节点停了」,依据是清扫语句的 幂等性;如果将来给清扫任务加分布式锁或按节点分区,这条口径必须重审。 TimeoutStopSec=60s与server.vpn.shutdown_timeout(可配,默认 15s)的关系:把后者调到 60 秒以上时, 单元里的预算需要一并上调,否则 systemd 会先SIGKILL。- 入口守卫只检查无构建约束的
cmd/*/main.go:tunnelmesh-client-tray是//go:build tray的桌面程序, 在主线程驱动 AppKit/WebView2 并自带信号处理,属有意排除,理由写在测试注释里。
集成状态
- 分支:
codex/sweeper-resilience-and-server-lifecycle,基线238857d(含 PR #88)。 - 本地门禁:上表全部执行,
promtool一项未执行并已在 Test Evidence 标注原因。 - 待办:CI(build-test / packaging / windows-tray / mysql56)与
promtool check rules需在合并前由 CI 或 reviewer 环境补上。