# 系统架构:外网网关 + 高性能后端(worker) > 本文档描述「旷视五接口」的部署架构拆分。**接口文档(对外契约)完全不变**——客户端看到的 URL、请求/响应结构、错误码 1001–1008 全部保持原样。本文只改变内部如何处理这些请求。 --- ## 1. 背景与目标 外网服务器(`hair.xiangsilian.com`)性能不足以跑 MediaPipe + BiSeNet + 标注图生成等重逻辑。因此拆分为两层: - **外网网关(gateway)**:保持 HTTPS 对外接口不变,**自身不跑算法**,只做反向代理、健康检查、负载分发、鉴权、标注图托管。资源占用极小,可继续跑在现有外网机。 - **高性能后端(worker)**:独立机器,**GPU + 32G 内存**,跑真正的算法逻辑。通过 `http://hair.xiangsilian.com:28187` 这类 `host:port` 暴露。 **核心诉求**: 1. 对外接口与文档零变化。 2. 网关可配置**多个 worker URL**,周期探测可用性,只把请求发给健康的 worker。 3. **每个 worker 并发 = 1**(一次处理一个请求);多 worker 即可并发,**总并发 = 健康 worker 数**。 --- ## 2. 拓扑总览 ``` HTTPS (对外,接口文档不变) ┌────────┐ :443 ┌───────────────────────────┐ │ 客户端 │ ───────▶ │ 外网网关 gateway │ └────────┘ │ hair.xiangsilian.com │ │ - 反向代理 5 个接口 │ │ - 健康检查 worker 池 │ │ - 空闲 worker 派发(并发=worker数)│ │ - 共享密码鉴权 │ │ - 标注图 base64→落盘→URL │ │ - 无可用后端→1007 │ └───────┬───────────┬─────────┘ HTTP + 密码头 │ │ ┌───────────────────────┘ └─────────────┐ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ worker #1 │ ...(可配置多个)... │ worker #N │ │ :28187 GPU/32G │ │ :xxxxx GPU/32G │ │ 跑完整 app.py │ │ 跑完整 app.py │ │ + face_analysis │ │ + face_analysis │ │ 并发=1 │ │ 并发=1 │ └──────────────────┘ └──────────────────┘ ``` --- ## 3. 职责划分 | 能力 | 网关 gateway | worker | |------|:---:|:---:| | 对外 HTTPS、接口契约 | ✅ | ✗ | | 反向代理 5 个接口 | ✅ | ✗ | | 算法(MediaPipe/BiSeNet/测量/标注图) | ✗ | ✅ | | 入参校验(大小/格式/分辨率/人脸) | ✗(透传) | ✅ | | worker 健康检查 + 池管理 | ✅ | 提供 `/health` | | 负载分发(挑空闲 worker) | ✅ | ✗ | | 鉴权(共享密码) | 发送密码 | 校验密码 | | 标注 PNG 落盘 + 对外 URL | ✅ | 返回 base64 | | `/static/*` 静态托管 | ✅ | ✗ | | GPU | 不需要 | ✅ | > **原则**:所有业务逻辑只在 worker 实现一份(worker 跑的就是完整 `app.py` + `face_analysis`)。网关是无状态的薄层,除了"健康池 + worker 忙闲状态"外不持有业务状态。 --- ## 4. 网关配置文件 后端 URL 列表、共享密码、各项参数集中在一个配置文件,运维手动维护(密码定期轮换)。 `gateway/config.json`(示例): ```json { "workers": [ "http://hair.xiangsilian.com:28187", "http://10.0.0.12:28187" ], "shared_password": "REPLACE_ME_ROTATE_PERIODICALLY", "accept_passwords": ["REPLACE_ME_ROTATE_PERIODICALLY"], "health_check": { "path": "/health", "interval_seconds": 8, "timeout_seconds": 3, "unhealthy_threshold": 2, "healthy_threshold": 1 }, "dispatch": { "per_worker_concurrency": 1, "queue_wait_seconds": 30, "request_timeout_seconds": 60, "retry_on_failure": true, "max_retries": 1 } } ``` 字段说明: - `workers`:worker 基址列表,手动增删。**增加一个就多一路并发**。 - `shared_password`:网关调用 worker 时发送的密码。 - `accept_passwords`:worker 端用(见 §7)——允许的密码列表,**轮换期可同时放新旧两个**,实现不停机改密码。网关侧也可放在 worker 的独立配置里。 - `health_check`:探测周期、超时、连续失败几次判定下线、连续成功几次判定上线。 - `dispatch.per_worker_concurrency`:**固定为 1**(当前约束)。 - `dispatch.queue_wait_seconds`:所有 worker 都忙时请求最多排队多久,超时返回 1007。 - `request_timeout_seconds`:单次转发到 worker 的超时。 - `retry_on_failure` / `max_retries`:worker 转发失败时是否换一个 worker 重试。 > 配置变更后网关需 reload(可做成监听文件变更热加载,或重启网关进程)。 --- ## 5. 健康检查 网关后台**周期轮询**每个 worker 的 `/health`(已存在于 `app.py`,排除在 OpenAPI 之外): - 间隔 `interval_seconds`,每次超时 `timeout_seconds`。 - 连续失败 `unhealthy_threshold` 次 → 标记**下线**,停止派发。 - 重新连续成功 `healthy_threshold` 次 → 标记**上线**,恢复派发。 - 健康池 = 当前在线的 worker 集合。 **主动检查 + 被动剔除**双保险:除周期探测外,转发请求时若 worker 连接失败/超时,立即把它标记为不健康并(按配置)换一个 worker 重试。 > worker 的 `/health` 建议返回 **模型就绪状态**:只有 MediaPipe / BiSeNet 权重都加载完成才返回 200,否则返回 503——避免请求被派发到尚未热好的 worker。 --- ## 6. 负载分发与并发 **每个 worker 并发 = 1**,所以分发逻辑很简单: 1. 网关维护每个健康 worker 的**忙/闲**状态。 2. 新请求到来:从健康池里选**一个空闲 worker**,标记为忙,转发;收到响应(或失败)后标记为闲。 3. 若当前**无空闲 worker**(全忙):请求进入**队列等待**,直到有 worker 空闲或等待超过 `queue_wait_seconds`(超时返回 1007)。 4. 若**健康池为空**(无可用后端):直接返回 1007。 - **总并发能力 = 健康 worker 数量**。加机器即扩并发。 - 选空闲 worker 的策略:任意(如先到先得 / 轮转空闲),并发=1 下无需最少连接数算法。 --- ## 7. 鉴权(共享密码) `:28187` 在公网可直接访问,必须鉴权防止他人直接打 worker。 - **网关 → worker**:每个转发请求带密码头,如 `X-Internal-Token: `。 - **worker 校验**:worker 侧配置文件持有 `accept_passwords` 列表,收到请求校验 `X-Internal-Token` 是否在列表内;不匹配返回 HTTP 401,**不进入业务逻辑**。 - **轮换**:运维改密码时,先把新密码加进 worker 的 `accept_passwords`(此时新旧都接受)→ 再把网关 `shared_password` 切到新值 → 确认无旧密码流量后从 worker 移除旧密码。全程不停机。 - 密码**仅存配置文件**,不写日志、不进 git(配置文件加入 `.gitignore`,仓库只放 `config.example.json`)。 > 建议叠加防火墙:worker 防火墙只放行网关来源 IP(纵深防御)。密码是应用层兜底。 --- ## 8. 单次请求处理流程 ``` 客户端 ──(HTTPS, multipart/json, 接口文档原样)──▶ 网关 │ ├─ 1. 选一个空闲健康 worker(无则排队/1007) ├─ 2. 原样转发请求体 + 加 X-Internal-Token 头 │ ──(HTTP)──▶ worker │ ├─ 校验密码(失败→401,网关视为该 worker 异常) │ ├─ 跑完整业务(校验/MediaPipe/BiSeNet/测量/标注图) │ └─ 返回标准信封;图片字段以 base64 形式(见 §9) ├─ 3. 收到 worker 响应,标记该 worker 空闲 ├─ 4. 若响应含 base64 图片 → 落盘到网关 /static/annotations/{uuid}.png │ → 把字段改写成对外 URL(见 §9) └─ 5. 把最终(符合接口文档的)响应返回客户端 ``` - 网关**不解析也不校验**业务入参,原样透传(worker 负责全部校验与错误码)。worker 返回的 1001–1008 由网关**透传**给客户端。 - 网关只在「无后端/排队超时」时**自行**返回 1007。 --- ## 9. 标注图处理(base64 → 落盘 → URL 改写)★关键 接口文档里多个接口返回图片 URL(接口1 标注图、接口3 标记图、接口5 发际线图)。拆分后: 1. **worker 不落盘、不拼 URL**,而是把生成的 PNG 以 **base64** 放进响应的约定字段返回给网关。 - 约定:worker 用 `*_base64` 字段承载图片,例如接口1 返回 `annotated_image_base64` 而非 `annotated_image_url`。 2. **网关收到后**:对每个 `*_base64` 字段—— - 解码 → 保存到网关本地 `static/annotations/{uuid}.png`; - 删除该 base64 字段,新增对应的 `*_url` 字段,值为 `https://hair.xiangsilian.com/static/annotations/{uuid}.png`; 3. 客户端最终看到的字段名/URL **与接口文档完全一致**(如 `annotated_image_url`)。 > **完整字段映射表见 [`网关-开发任务书.md`](网关-开发任务书.md) §6**(接口1/2/3/5 全部 `*_base64`→`*_url`,以 `接口文档.md` 为准)。 > ⚠️ 两个易漏点: > 1. **数组里的图片字段**:接口2 `results[].image_base64`/`results[].grown_image_base64`、接口5 > `hairline_images[].image_base64` 在数组元素内——改写逻辑要**递归进数组**(建议「凡 key 以 `_base64` > 结尾就改写」的通用递归,自动覆盖嵌套与未来新增字段)。 > 2. **可空字段**:接口2/3 的生发图(ComfyUI 未起/失败时)`*_base64` 为 **null** → 保留 null,不落盘。 > > 代价:内部 HTTP 多传一份 base64(图片放大约 1.33×)。worker↔网关若跨网络,单图 ~100KB–1MB 量级,可接受。 > ⚠️ 生发接口(2/3)经 ComfyUI 同步出图较慢(接口2 ~18s、接口3 ~6s),网关转发**超时要调大(≥120s)**。 > > 静态文件清理:网关 `static/annotations/` 会持续增长,需加定期清理(按时间或容量,定时任务),与拆分前同样的问题,由网关侧负责。 --- ## 10. 错误处理 | 场景 | 返回 | 由谁 | |------|------|------| | 业务错误(无人脸/分辨率/非正面/超大/格式…) | 1001–1008(原样) | worker 产生,网关透传 | | **无可用 worker / 全忙排队超时** | **1007 系统错误** | 网关 | | worker 转发失败(连接/超时/5xx/401) | 按配置换 worker 重试;重试耗尽 → 1007 | 网关 | - 复用 **1007**(系统错误)表示「基础设施层不可用」,**不新增错误码**,接口文档不动。 - 局限:调用方无法从错误码区分"算法失败"与"后端全挂"(都是 1007)。如需区分,可在 `message` 文案上体现(如"后端服务暂不可用,请稍后重试"),但 `code` 保持 1007。 --- ## 11. worker 侧相对单机方案的变化 worker 跑的就是「接口1 技术实现方案」里描述的完整逻辑,但有几处因拆分/硬件而变: 1. **GPU 加速**:worker 有独立 GPU,BiSeNet 改用 **CUDA** 推理(torch GPU 版),比 CPU 快很多;MediaPipe 仍 CPU(Python solutions API 仅 CPU)。 2. **不落盘、返回 base64**:标注图相关接口的 handler 改为返回 `*_base64`,不再保存到本地 `/static`、不拼 URL(改由网关做,见 §9)。 3. **`/health` 反映就绪**:模型加载完才返回 200。 4. **鉴权中间件**:worker 增加一个校验 `X-Internal-Token` 的中间件/依赖(见 §7)。 5. **资源宽裕**:32G 内存 + GPU,无需单机方案里 2核4G 的并发限制与降级开关;但 worker 自身仍是**并发=1**(由网关保证,不向 worker 发并发请求;worker 可不做内部并发控制,但建议 uvicorn 单 worker 进程以省显存)。 > 单机方案文档(接口1 技术实现方案)描述的「方案A/B、虹膜标定、标注图、误差验证」全部不变,只是运行位置从外网机挪到 GPU worker,并启用 GPU。 --- ## 12. 部署 ### 网关(外网机,hair.xiangsilian.com) - 新增 gateway 应用(轻量 FastAPI/asgi 代理)。 - nginx 把 443 → 网关进程;网关再转发到 worker 池。 - 托管 `/static/*`(标注图落盘目录)。 - 配置文件 `gateway/config.json`(含 worker 列表 + 密码)。 - systemd 管理网关进程。 ### worker(GPU 机,:28187) - 部署完整 `app.py` + `face_analysis` + 模型权重(见 `OFFLINE_ASSETS.md`)。 - torch 用 **GPU 版**(CUDA),其余依赖同单机方案。 - uvicorn 监听 `0.0.0.0:28187`(仅经防火墙放行网关)。 - 配置文件持有 `accept_passwords`。 - systemd 管理 worker 进程;`/health` 供网关探测。 --- ## 13. 安全注意 - `:28187` **公网可达 + HTTP 明文**:密码头会明文走公网。**强烈建议**给 worker 也套一层 TLS(worker 前置 nginx 终止 HTTPS,或网关↔worker 走内网/VPN/IP 白名单),否则密码可被中间人嗅探。 - 若短期内只能 HTTP,务必靠**防火墙 IP 白名单**把 worker 限制为只接受网关来源,密码作为应用层兜底。 - 密码不入 git、不写日志。 - 网关对转发的请求体大小设上限(如 ≤2MB),防止被超大 body 拖垮。 --- ## 14. 待确认 / 后续 1. **worker↔网关传输是否加 TLS**:当前 §13 标为强烈建议。若运维能给 worker 配 HTTPS 或限定内网,安全性更好。请确认部署条件。 2. **worker host:port 形态**:`hair.xiangsilian.com:28187` 是端口转发到 GPU 机,还是 GPU 机直接持有该域名?影响防火墙与 TLS 方案。 3. **多 worker 的物理分布**:是否都在同一内网?若跨公网,base64 图片传输与密码明文风险都需重新评估。 4. **静态图清理策略**:网关 `static/annotations/` 的保留时长 / 清理触发条件。 --- > **文档版本**: v1.0 | **创建日期**: 2026-06-14 | 配套:接口1 技术方案 v2.0 / 开发任务书 / OFFLINE_ASSETS.md > **关键约束**: 接口文档不变;每 worker 并发=1;无后端→1007;标注图 base64→网关落盘→URL