结论先说:双机方案换来的不是更高的性能,而是三件事:域名不需要备案、算力和成本按需分配、静态站与业务后端可以独立重启。代价是跨机网络的每一层都要显式配置——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 standalone | Chroma 持久化 | 语料仅 1700–2000 chunk |
| 业务库 | MySQL 8 | SQLite + 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 机器能不能稳定运行的前提。