Files
hair/docs/网关摘除隔离期实施方案.md
T
xsl 8554850d55 docs: 网关摘除隔离期实施方案(供网关机器参考实现,暂不在本仓库改 gateway 代码)
复盘 9·19 磁盘满事故网关未切换的三层原因;给出 main 分支 gateway/pool.py
的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 60s + 探测复活
守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。worker 侧深检
(方案 A)已在本分支 14562d4 上线。
2026-09-20 22:07:48 +08:00

10 KiB
Raw Blame History

网关摘除隔离期实施方案(方案 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. 部署步骤(网关机器)

  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 段即可。