docs: 网关摘除隔离期实施方案(摘除后 60s 隔离期,防探测复活横跳)

复盘 9·19 worker 磁盘满事故网关未切换的三层原因;给出本仓库 gateway/
pool.py 的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 +
探测复活守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。
worker 侧深检(/health 磁盘水位+依赖探测)已在 ubuntu3090_debug_4090
分支 14562d4 上线。
This commit is contained in:
xsl
2026-09-20 21:49:36 +08:00
parent a0f53ad6ad
commit 88fee6474d
+234
View File
@@ -0,0 +1,234 @@
# 网关摘除隔离期实施方案(方案 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 段即可。**