结论先说:双机方案换来的不是更高的性能,而是三件事:域名不需要备案、算力和成本按需分配、静态站与业务后端可以独立重启。代价是跨机网络的每一层都要显式配置——Host 头、SSE 缓冲、防火墙、跨 worker 事件流,没有一个能靠默认值。

为什么不是一台机器

把全部服务塞进一台 4C4G 当然更简单:同源、零反代、零跨机延迟。但有两个约束绕不过去:

  • 备案。域名 `hulkai.cn` 指向香港服务器,不需要 ICP 备案;广州的 4C4G 只承担 API,不需要直接对公网暴露。
  • 算力与预算。静态站只需要一个 Nginx 和几十 MB 内存,2C2G 足够;后端要跑索引、SQLite、Chroma 和 SSE,4C4G 才勉强够用。

于是分工定死:香港只做静态托管、TLS 和反向代理,不装 Python、不落业务数据;广州只做 FastAPI + 数据 + 向量索引,业务端口只对香港开放。浏览器始终只和香港握手,跨机部分对用户不可见。

浏览器只打香港,广州只信香港

这样做的直接好处是同源:前端用相对路径请求 `/api/*`,Nginx 反代到广州,浏览器眼里只有一个域名。不用开 CORS,不用处理 Cookie 的 SameSite,也不会出现 HTTPS 页面请求 HTTP 接口的混合内容问题。

广州的 8004 端口则必须挡住公网。这里有个容易踩的坑:

Docker 的 `-p 8004:8004` 由 iptables 直接下发规则,会绕过 ufw / firewalld。在宿主机上执行 `ufw deny 8004` 是无效的,端口依然能从公网访问。

正确做法是在云控制台的安全组里限制来源:8004 只放行香港的 `43.132.214.124/32`,80/443 放行全网,SSH 单独限源。上线后用另一台外部机器验证:

curl --connect-timeout 3 http://129.204.108.45:8004/api/v2/health
# 期望:超时。返回 JSON 说明安全组配错了。

跨机反代:Host 头不能想当然

最初的反代写法是常见的 `proxy_set_header Host $host;`,结果广州侧按域名做了拦截,请求被拒。`$host` 传过去的是 `hulkai.cn`,而广州期望的是它自己的地址。改用 upstream 名之后恢复:

upstream gz_backend {
    server 129.204.108.45:8004;
    keepalive 32;
}

location /api/ {
    proxy_pass http://gz_backend;
    proxy_http_version 1.1;
    proxy_set_header Host              $proxy_host;   # 不是 $host
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection        "";
}

`Connection ""` 配合 upstream 的 `keepalive 32` 让长连接复用;否则每个请求都重新握手,跨境链路的 RTT 会直接叠加到每次 API 调用上。

SSE:三条缓冲开关和 300 秒超时

这个系统的事件流(`/api/v2/events`)是 SSE。Nginx 默认会缓冲上游响应,结果所有事件攒到最后一次性吐出——前端看起来就是「转圈很久,然后瞬间结束」。必须显式关掉:

proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;

proxy_connect_timeout 10s;
proxy_send_timeout    300s;
proxy_read_timeout    300s;

只关缓冲还不够。事件流本身可能持续几十秒,默认 60s 的读超时会把连接掐断,所以三个超时都放宽到 300s。

另一个问题是多 worker。uvicorn 起两个 worker 时,POST 查询和 GET 事件流可能落在不同进程,进程内队列根本收不到对方的事件。解决办法是把事件写进 Redis Stream,两个 worker 都从同一个流读取;Redis 不可用时自动回落到进程内队列。SSE 的可靠性因此从「靠进程亲和」变成了「靠一个共享的流」。

4C4G 上做减法

这台机器的内存只有 4GB,而原方案里 BGE-M3 权重就 4.6GB,Milvus standalone 官方建议至少 8GB。不减掉这些,容器根本起不来。最终的选择是:

组件原方案现方案理由
Embedding本地 BGE-M3(4.6GB)云端 API,1024 维内存归零,延迟 20–50ms
Reranker本地 BGE-Reranker不部署详见 另一篇复盘
向量库Milvus standaloneChroma 持久化语料仅 1700–2000 chunk
业务库MySQL 8SQLite + volume单机写入,够用且免运维
镜像数 GB(含 torch)1.2–1.5GB去掉 torch / sentence-transformers

Chroma 用 `PersistentClient` 以文件锁方式独占目录,索引构建必须是单进程。这里的处理是把「建索引」和「查索引」分开:启动时由 entrypoint 单进程完成 bootstrap,之后只读查询。容器内存上限设 2800m,留出系统与 Redis 的空间;首次启动要建 2594 条向量索引,健康检查的启动窗口给到 300s,否则会被误判成启动失败而反复重启。

数据只放在卷里

SQLite 数据库、Chroma 目录、演示种子全部落在 named volume `mfg_data`,容器内路径固定为 `/app/data`。镜像升级不碰卷,回滚也不会丢数据;反过来,任何写进镜像层的数据都是一次性的。

演示种子(2093 条历史工单 + 案例库)随镜像分发,首次启动由 entrypoint 复制到卷里,已存在就跳过。这样新环境一启动就有数据可看,又不会覆盖线上已经产生的记录。

`stop_grace_period: 30s` 不是摆设:SQLite 正在写的时候收到 SIGKILL,可能留下 journal 文件甚至损坏。给足退出时间比事后修复便宜得多。

结果与代价

项单机方案双机方案
域名备案需要不需要(香港)
静态站内存与后端共享< 100MB,独立重启
后端内存与前端共享 4GB独占 2800m 上限
跨机延迟0每次 API 多一次 RTT
运维面1 台2 台 + 安全组 + 证书
故障域一起挂静态站与 API 互不影响

最后一行是最实际的收益:后端重启、重建索引、升级镜像的时候,主页和博客仍然在线,只是 Demo 接口短暂不可用。静态站和业务后端的生命周期本来就不同,分开部署让这个差异变成了可操作的自由度。

如果重来一次

  • 先把「广州只信香港」写进部署清单第一项。端口暴露错了不会报错,只会安静地对全网开放。
  • SSE 的三项配置当成接口契约的一部分。换反代、加 CDN、上 WAF 时都要重新确认,否则事件流会以最隐蔽的方式坏掉。
  • 单仓、双 compose,而不是两条发布分支。两台机器拉同一个仓库,各自 `docker compose up -d --build`;目录路径写清楚,比维护两份快照可靠。
  • 把启动窗口和内存上限写进 compose。它们不是调优参数,是这台 4GB 机器能不能稳定运行的前提。