From 88fee6474d752a22e4920842bf2e4e1e6ff7fe58 Mon Sep 17 00:00:00 2001 From: xsl Date: Sun, 20 Sep 2026 21:49:36 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=BD=91=E5=85=B3=E6=91=98=E9=99=A4?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=9C=9F=E5=AE=9E=E6=96=BD=E6=96=B9=E6=A1=88?= =?UTF-8?q?=EF=BC=88=E6=91=98=E9=99=A4=E5=90=8E=2060s=20=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=9C=9F=EF=BC=8C=E9=98=B2=E6=8E=A2=E6=B5=8B=E5=A4=8D=E6=B4=BB?= =?UTF-8?q?=E6=A8=AA=E8=B7=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复盘 9·19 worker 磁盘满事故网关未切换的三层原因;给出本仓库 gateway/ pool.py 的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 + 探测复活守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。 worker 侧深检(/health 磁盘水位+依赖探测)已在 ubuntu3090_debug_4090 分支 14562d4 上线。 --- docs/网关摘除隔离期实施方案.md | 234 +++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/网关摘除隔离期实施方案.md diff --git a/docs/网关摘除隔离期实施方案.md b/docs/网关摘除隔离期实施方案.md new file mode 100644 index 0000000..841aa00 --- /dev/null +++ b/docs/网关摘除隔离期实施方案.md @@ -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 段即可。**