# 网关摘除隔离期实施方案(方案 B) > 目标读者:网关机器的部署/维护者。本文档给出基于 **main 分支** `gateway/` 代码的 > 完整改动说明,可直接照做。**不需要改 worker 侧任何代码。** ## 1. 背景与事故复盘 2026-09-19 晚,GPU worker 机器(ubuntu3090_debug_4090 所在主机)磁盘写满,业务接口 全部失败,但网关**持续把请求打到该机器近 16 个小时**(21:30 故障发生 → 次日 13:36 人工重启),没有切换到其他 worker。复盘结论是三层防线同时失效: | 层 | 问题 | 后果 | |---|---|---| | 1. worker `/health` 太浅 | 只返回内存里的"模型就绪"标志,磁盘满/下游 ComfyUI 挂掉不影响它 | 探测一直 200 | | 2. 业务错误不触发摘除 | 下游出错经包装后以 HTTP 200/400 + 错误码返回,`forward.py` 只对 连接异常/超时/≥500/401 摘除 | 请求失败但 worker 被视为健康 | | 3. 摘除后 8 秒即复活(**本方案要修的**) | `mark_worker_unhealthy()` 下线后,下一轮探测(8s 后)`/health` 返回 200,`healthy_threshold=1` 使其**一次成功立即重新上线** | 即使被摘除也会反复横跳,流量持续打入 | worker 侧的第 1 层已经修复(方案 A:`/health` 深检磁盘水位 + 下游依赖 TCP 探测, 异常返回 503,代码在分支 `ubuntu3090_debug_4090`,提交 `14562d4`)。本方案修复 第 3 层:**被动摘除后进入隔离期,隔离期内即使探测成功也不复活**。两层配合后: worker 磁盘满 → `/health` 503 → 网关 2 次探测失败摘除 → 隔离期内不再接流量。 > 第 2 层(错误码语义化,涉及调用方兼容)暂不处理,单独立项。 ## 2. 现状代码行为(main 分支 gateway/pool.py) - `mark_worker_unhealthy(w)`:被 `forward.py` 在转发失败(连接异常/超时/5xx/401)时 调用,置 `w.online = False`。**没有任何冷却机制**。 - `_health_check_loop(cfg)`:每 `interval_seconds`(默认 8s)探测所有 worker 的 `/health`;返回 200 时 `consecutive_successes += 1`,达到 `healthy_threshold` (默认 **1**)且当前 offline → 立即 `online = True`。 - 因此被动摘除最多 8 秒后就被一次探测成功拉回——如果 worker 是"浅健康"或故障 间歇性表现,就会陷入 摘除→复活→失败→摘除 的横跳。 - 另外 58-96 行有一段**从未被调用的死代码** `_check_worker_health()` / `_mark_failure()`(写了一半的旧实现),本次顺带删除,避免后人改错地方。 ## 3. 代码改动(共 2 个文件 + 1 个配置) ### 3.1 `gateway/pool.py` **(1) `WorkerState` 增加一个字段:** ```python @dataclass class WorkerState: """单个 worker 的运行时状态。""" url: str online: bool = False # 初始 offline,等健康检查通过后上线 busy: bool = False consecutive_failures: int = 0 consecutive_successes: int = 0 offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准) ``` **(2) 模块顶部(`logger = ...` 之后)加一个配置读取 helper:** ```python def _cooldown_seconds() -> float: """被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。""" try: from gateway.config import get_config return float(get_config()["health_check"].get("cooldown_seconds", 60)) except Exception: # noqa: BLE001 return 60.0 ``` (函数内 import 是为了不引入模块级循环依赖;`gateway/config.py` 不依赖 pool,顶层 import 也可以。) **(3) `mark_worker_unhealthy()` 摘除时同时设置隔离期:** ```python async def mark_worker_unhealthy(w: WorkerState) -> None: """被动标记:转发失败时立即将该 worker 下线,并进入隔离期。 隔离期内即使 /health 探测成功也不复活(由 _health_check_loop 守卫), 避免"摘除→8 秒复活→再失败"的横跳。 """ async with _pool_condition: if w.online: w.online = False w.consecutive_failures += 1 w.offline_until = time.monotonic() + _cooldown_seconds() logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)", w.url, _cooldown_seconds()) ``` **(4) `_health_check_loop()` 的探测成功分支加隔离期守卫。** 找到这段: ```python if resp.status_code == 200: w.consecutive_failures = 0 w.consecutive_successes += 1 if w.consecutive_successes >= healthy_threshold and not w.online: w.online = True logger.info("✅ Worker 上线: %s", w.url) ``` 改为: ```python if resp.status_code == 200: w.consecutive_failures = 0 w.consecutive_successes += 1 if w.consecutive_successes >= healthy_threshold and not w.online: if time.monotonic() < w.offline_until: logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url) else: w.online = True logger.info("✅ Worker 上线: %s", w.url) ``` 注意:`except`(连接异常)分支只累计失败、不涉及复活,无需改动; `init_pool()` 启动时的首轮探测同理无需改动(进程刚启动时 `offline_until` 为 0, 不构成隔离)。 **(5) 删除死代码**:`_check_worker_health()` 与 `_mark_failure()` 两个函数整体删除(约 58-96 行,以函数名为准,别删到 `_health_check_loop`)。删除前可确认无调用方: `grep -rn "_check_worker_health\|_mark_failure" .` 应只剩定义处。 ### 3.2 `gateway/config.py` `DEFAULTS["health_check"]` 增加一个 key: ```python "health_check": { "path": "/health", "interval_seconds": 8, "timeout_seconds": 3, "unhealthy_threshold": 2, "healthy_threshold": 1, "cooldown_seconds": 60, # ← 新增:被动摘除后的隔离期(秒) }, ``` `config.example.json` 同步加这个 key。网关机器的实际 `config.json` **不加也不会报错** (代码里有 60 的默认兜底),但建议显式加上,便于运维感知。 ### 3.3 建议单测:`tests/test_gateway_pool.py`(新文件) gateway 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例 (不依赖真实网络): ```python """gateway/pool.py 隔离期逻辑单测。""" import asyncio import time from unittest.mock import patch from gateway import pool from gateway.pool import WorkerState def _drain(coro): return asyncio.run(coro) def test_passive_ejection_sets_cooldown(): w = WorkerState(url="http://127.0.0.1:9000", online=True) async def run(): pool._pool_condition = asyncio.Condition() with patch.object(pool, "_cooldown_seconds", return_value=60.0): await pool.mark_worker_unhealthy(w) _drain(run()) assert w.online is False assert w.offline_until > time.monotonic() def test_probe_success_revives_after_cooldown(): w = WorkerState(url="http://127.0.0.1:9000") # offline w.offline_until = time.monotonic() - 1 # 隔离期已过 # 直接验证状态迁移逻辑(若按 3.1(4) 内联实现,可复制该分支逻辑做断言, # 或将判断抽成 _try_revive(w, healthy_threshold) 小函数后单测) ... def test_probe_success_blocked_during_cooldown(): w = WorkerState(url="http://127.0.0.1:9000") w.offline_until = time.monotonic() + 60 ... # 断言保持 offline ``` > 提示:如果按 3.1(4) 原样内联修改,后两个用例不好直接驱动,可以把"是否允许复活" > 抽成一个 6 行的纯函数 `_try_revive(w, healthy_threshold) -> bool` 再测,主循环 > 调用它即可——实现方式自定,覆盖语义即可。 ## 4. 部署步骤(网关机器) 1. `git pull` main 分支(含本方案代码改动)。 2. (可选但建议)`config.json` 的 `health_check` 段加 `"cooldown_seconds": 60`。 3. 重启网关服务(`hair-gateway.service` 或等价的 uvicorn 进程)。重启期间网关 本身短暂不可用,选低峰执行。 4. 观察启动日志:`健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N` 正常出现。 ## 5. 验证 **功能验证(低峰演练):** 1. 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器 `systemctl stop hair-worker`(或临时设 `HEALTH_MIN_FREE_GB=999` 重启)。 2. 观察网关日志: - 连续 2 次探测失败 → `⚠ Worker 下线`(原有行为); - **转发失败触发的** `⚠ Worker 被动下线: ...(转发失败,隔离 60s)`(新日志); - 恢复 worker 后,若刚被被动摘除过,即使 `/health` 恢复 200,也应看到 60 秒内 **不出现** `✅ Worker 上线`,隔离期满后下一次探测成功才上线。 3. `GET /gateway-health` 观察 `workers_healthy` 的变化节奏。 **回归确认:** 正常场景 worker 上下线不受影响(隔离期只对"被动摘除"生效; 探测失败导致的下线,恢复后仍按 `healthy_threshold` 立即上线)。 ## 6. 回滚 - 代码回滚:`git revert` 对应提交即可,无数据/配置迁移。 - 紧急关闭隔离期:网关机器 `config.json` 设 `"cooldown_seconds": 0` (等价于旧行为——摘除后下一次探测成功即复活),无需回代码。 ## 7. 附:worker 侧方案 A 的行为(已上线,供联调参考) worker(分支 `ubuntu3090_debug_4090`,提交 `14562d4`)的 `/health` 现在会在以下 情况返回 **503**(网关 2 次探测失败即摘除): - 模型加载中:`503 {"status":"starting"}`(原有行为不变) - 磁盘剩余低于阈值:`503 {"status":"degraded","reason":"disk_free_below_5.0g","disk_free_gb":...}` - 下游依赖(ComfyUI 8188 / change_hair 8801)TCP 不通: `503 {"status":"degraded","reason":"dep_down:comfyui,...","deps":{...}}` 正常时返回 `200 {"status":"ok","disk_free_gb":24.6,"deps":{"comfyui":true,"change_hair":true}}`。 worker 侧配置(`worker_config.json` 的 `health` 段,环境变量可覆盖): `min_free_gb`(默认 5,0=关闭)、`check_deps`(默认 true)、`dep_comfyui`(默认 127.0.0.1:8188)、`deps_cache_seconds`(默认 5)。**其他 worker 机器如果也想启用 深检,cherry-pick 该提交并补一份 worker_config.json 的 health 段即可。**