Client 缩容误终止会话池导致服务静默退出
Client 缩容误终止会话池导致服务静默退出
- 标题:修复连接池缩容被当作链路终止故障导致的 client 静默 0 退出,并收紧 systemd 拉起策略
- 目标分支:
main - 相关记录:实施计划
摘要
一台生产 Client 的四条隧道在 12:03 全部消失,systemd 显示 inactive (dead)、Result=success、NRestarts=0:进程以 0 退出,
Restart=on-failure 因此不认为需要拉起,而 enabled 只管开机自启。根因分三层:
internal/client/session_pool.go的evaluate缩容时只取消被选中的 slot,而 slot 的 runner 无法区分“缩容取消”和“链路故障” (两条路径都会取消它的上下文),于是把context.Canceled投递给连接池的终止通道,cancelAll()连带拆掉本应存活的会话。internal/client/runtime.go的runRoles用isContextErr丢弃这类错误后返回ctx.Err();此时运行上下文并未取消,于是整个 run 上报成功。internal/supervisor见 workerExitOK即“跟随正常结束”,主进程 0 退出,systemd 无失败可依据。
修复同样分三层:slot 记录“主动缩容”标记,只有非缩容的 runner 错误才能终止连接池;runRoles 在无人要求停止而角色已退出时返回新的
ErrRunEndedUnexpectedly,使进程非 0 退出并让 supervisor 走既有退避重启分支;交付的 systemd 单元对 client/agent 改用 Restart=always,
不再把退出码 0 当作“自愿结束”的证据。触发需要公网扫描造成 burst→idle 抖动,现场日志中两次同签名退出都与批量
client session closed 相隔数十秒内,且 supervisor 从未记录 worker exited; restarting。
用户影响
- 配置了
client.connections.max > min的 Client/Agent 不再因为缩容而整体失联:隧道在扩缩容后继续服务,进程在异常结束时非 0 退出并被自动拉起。 - 默认配置(
connections.max=1)行为不变,永不缩容,因此不受该缺陷影响,也不会感知本次改动。 - 运维侧:
systemctl stop/restart仍是停止任务,Restart=always不会把它们顶回去;78(配置错误)与77(凭据被永久拒绝)依旧不重启。 - 无接口、Web 管理后台与数据库变更。
API / Schema / 配置影响
- 无 API 与 OpenAPI 变更,无 Schema 版本变更,无新增或废弃配置项。
- 新增导出错误
client.ErrRunEndedUnexpectedly,仅用于日志与退出码推导,不进入协议载荷。 - 单元模板变更:
deploy/systemd/tunnelmesh-{client,agent}.service、deploy/systemd-user/tunnelmesh-{client,agent}.service的Restart由on-failure改为always;server 单元保持on-failure。 - 活文档同步:
docs/operations/configuration.md退出码表及其说明、docs/deployment/linux-systemd.md重启策略段、docs/operations/troubleshooting.md新增“Client 隧道突然全部不可用,但服务显示正常停止”一节。
安全与授权影响
- 不涉及认证、授权、密钥或凭据处理;日志与退出码不新增任何敏感字段。
- 故障排查文档明确建议收紧
allow_remote与来源 ACL:长期暴露的 SSH 端口被公网扫描是本问题的外部触发源,降低抖动频率同时缩小爆破面。 - 未改变“不实现任意远程命令执行”的边界,也未触及 ADR 0005 的脚本执行能力。
测试证据
红灯(实现前实测输出):
--- FAIL: TestSessionPoolScaleDownDoesNotStopPool
session_pool_test.go:414: Run() returned after a scale-down: context canceled
--- FAIL: TestRuntimeWaitFailsWhenSessionRoleEndsUnexpectedly
runtime_test.go:495: Wait() = <nil>, want ErrRunEndedUnexpectedly
--- FAIL: TestClientRunExitsNonZeroWhenSessionRoleEndsUnexpectedly
client_runtime_test.go:66: client run exit code = 0 (err = <nil>), want 1 so a supervisor restarts it
绿灯与门禁:
go test ./internal/client ./internal/cli -count=1通过;包级-race通过,覆盖新增标记的并发取消路径。go test ./... -count=1:38 个包全部ok,无失败。go test -race ./... -count=1:见下方集成状态。go vet ./...无输出;gofmt -l internal/client internal/cli无输出;git diff --check干净。- 既有契约未被削弱:
TestRuntime...Stop(internal/client/runtime_test.go:451,Stop后为nil或context.Canceled)与TestExitCodeFor(internal/cli/exit_test.go:88,“未被告知停止的取消错误”仍是ExitGeneric)继续通过。 - 无前端改动,
web/的npm test/npm run build与嵌入产物校验不在本次范围。
发布步骤
- 先合入代码,按既有灰度顺序发布 Client/Agent:只读配置目录、
ExecStartPre仍执行check-config,二进制替换即可,无 DB 与配置迁移。 - 灰度批次选择配置了
connections.max > min的节点(本次事故的暴露面),观察一个evaluation_interval周期内不再出现Deactivated successfully,并确认journalctl中出现的是 supervisor 重启而不是进程静默结束。 - 核心指标:Client 隧道可用率与
client session closed计数;升级期间 worker 崩溃重启次数应上升后收敛(异常结束从静默变为可见的重启)。 - 单元模板变更只影响新装与显式重装;存量机器由运维 drop-in 覆盖,本仓库发布不远程改动生产机器。
回滚步骤
- 回滚即还原二进制与四个单元模板,无数据、无 Schema、无配置补偿,5 分钟内可完成。
- 若
Restart=always引发重启风暴:还原为Restart=on-failure即可;风暴本身由RestartPreventExitStatus=78 77与 supervisor 的GiveUpAfter(退出码 70)双重限制。 - 若 Task 2 的退出语义影响到复用
Runtime的系统托盘:优先回退该改动并保留连接池标记修复,根因层修复单独生效且不改变停止语义。 - 现场止损 drop-in
/etc/systemd/system/tunnelmesh-client.service.d/50-restart-always.conf与本 PR 解耦,删除后systemctl daemon-reload即回到旧行为。
Reviewer 关注点
- 缩容与真实故障的区分依赖
evaluate中“先标记再取消”的顺序,请重点看这条 happens-before 是否可接受,以及是否应改为在p.mu下读取标记。 runRoles现在会在“所有角色返回而运行上下文未取消”时统一上报ErrRunEndedUnexpectedly。请确认可是否存在合法的多角色自然结束场景(当前实现里连接池与 agent 角色都只在被取消或失败时返回,没有自然结束)。- 连接池没有“补回
Min”的路径:slot 因真实故障消失后只能靠扩缩容判定恢复。本次不改这个语义,但值得单独立项。 - Agent 侧连接池(
internal/agent/connection_pool.go)未审计是否存在同类“意外结束上报成功”,本 PR 不宣称已修复,只由单元模板兜底。 Restart=always对 Server 与容器编排不适用的理由已在文档中说明,若 reviewer 认为应统一,需要另案评估 Server 的退出语义。
集成状态
- 实施计划已获用户确认后执行,三个任务全部完成。
go test ./... -count=1、go vet ./...、gofmt、git diff --check通过;go test -race ./...结果见补充。- 尚未 commit、push 或创建远端 PR,等待用户授权。