复盘 9·19 磁盘满事故网关未切换的三层原因;给出 main 分支 gateway/pool.py
的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 60s + 探测复活
守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。worker 侧深检
(方案 A)已在本分支 14562d4 上线。
10 KiB
网关摘除隔离期实施方案(方案 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 增加一个字段:
@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:
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() 摘除时同时设置隔离期:
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() 的探测成功分支加隔离期守卫。 找到这段:
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)
改为:
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:
"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 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例 (不依赖真实网络):
"""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. 部署步骤(网关机器)
git pullmain 分支(含本方案代码改动)。- (可选但建议)
config.json的health_check段加"cooldown_seconds": 60。 - 重启网关服务(
hair-gateway.service或等价的 uvicorn 进程)。重启期间网关 本身短暂不可用,选低峰执行。 - 观察启动日志:
健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N正常出现。
5. 验证
功能验证(低峰演练):
- 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器
systemctl stop hair-worker(或临时设HEALTH_MIN_FREE_GB=999重启)。 - 观察网关日志:
- 连续 2 次探测失败 →
⚠ Worker 下线(原有行为); - 转发失败触发的
⚠ Worker 被动下线: ...(转发失败,隔离 60s)(新日志); - 恢复 worker 后,若刚被被动摘除过,即使
/health恢复 200,也应看到 60 秒内 不出现✅ Worker 上线,隔离期满后下一次探测成功才上线。
- 连续 2 次探测失败 →
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 段即可。