为什么部署里有一个 build 容器还有一个 api 容器
为什么部署里有一个 build 容器还有一个 api 容器
项目:SGCC 智能服务控制台 · 学习记录
起因
我重构完部署后,看着 docker-compose.yaml 里的服务列表犯嘀咕:
1 | services: |
「我不就是部署个前端嘛,为什么还要有一个 api 容器?前端应该 build 完用 nginx 反代 index.html 就够了啊?」
——这是我那天的原话。后面被纠正了我才意识到:「前端」这个词在我脑子里和实际意义不一致。
下面把这件事彻底理清楚。
概念修正:「前端」≠「整个网站」
浏览器里能跑什么、不能跑什么
能跑的:
- HTML / CSS / JS(这些都是死的文本文件)
- 渲染界面、响应点击
- 调
fetch()给某个 URL 发请求
不能跑的:
- 连数据库
- 读服务器硬盘
- 解密只能放在服务端的私钥
- 用服务器的 SMTP 凭据发邮件
后面四件事每一件都需要一个跑代码的进程在服务器上。这个进程就是常说的「后端」。
一次「点击通话列表」实际发生什么
把项目里的真实流程拆开看:
1 | 浏览器 nginx api 容器 db |
两次请求,两个职责:
- 第一次
GET /→ 浏览器拿 HTML(静态,nginx 直接发文件) - 第二次
GET /api/calls→ 浏览器要数据(动态,必须查 DB)
第一次没 nginx 也行(甚至放 GitHub Pages 都行)。第二次没后端根本不可能完成——nginx 不会查 SQL。
我们项目里 api 容器到底跑什么
打开 frontend/server/index.js(注意:虽然在 frontend/ 目录下,但它就是后端 Express):
| 接口 | 干什么 | 为什么必须有进程 |
|---|---|---|
POST /api/auth/send-code |
调 SMTP 发验证码 | 要执行代码 + 连邮件服务器 |
POST /api/auth/login |
用 private.pem RSA 解密上传的 auth.key |
要执行 RSA 解密 + 写 cookie |
GET /api/calls |
查 Postgres | 要执行 SQL |
GET /api/calls/:id/recording |
流式发录音文件 | 要先验登录、再读硬盘文件 |
GET /api/dashboard |
算今日/本周/本月统计 | 要执行 SQL 聚合 |
这些都跑不了 nginx。所以 api 容器是必须的。
命名误导:frontend/ 这个目录名很坑
仓库目录长这样:
1 | frontend/ |
历史命名不规范,结果就是「frontend」这个词在我脑子里=整个站,但其实是 React 部分 + Express 部分混居。看到 api 容器才会觉得「为什么前端还要再来一个?」。
实际上:「frontend 容器」这个概念在新架构里被拆成了 frontend-build(编译前端 React)+ api(后端 Express)。
那为什么是「frontend-build + api」两个容器,不是一个?
这是另一个常见困惑。下面单独说。
frontend-build 是个一次性容器
1 | frontend-build: |
它的工作:
npm ci(装包,包括 vite、typescript 这些 devDeps)npm run build(vite build 把 React 源码编译成dist/index.html、dist/assets/*.js)- 退出(状态 0)
退出之后这个容器就死了,不占 CPU 不占内存。它存在的唯一目的就是干活那 30 秒。
api 是常驻服务
1 | api: |
它的工作:
- 启动 Express,绑
:3001 - 接 nginx 转发过来的
/api/*请求 - 永远不退出
为什么不合并?
两个容器,两套使命:
| frontend-build | api | |
|---|---|---|
| 生命周期 | 一次性,编译完就退出 | 常驻 |
restart 策略 |
no(重启反而出问题) |
always(挂了得自愈) |
| 是否进入运行时 | 否,产物给 nginx | 是,浏览器一直在调 |
| 失败处理 | 失败 → compose up 失败 → 用户看到 |
失败 → 自动拉起来再试 |
如果合并成一个容器:
- 要么把
npm run build && node server/index.js串起来——那容器永远卡在编译那一步,每次重启都得重编一次(30 秒) - 要么
node server/index.js起来后再后台npm run build——一旦 build 失败 nginx 就拿不到 dist,但 api 容器还活着,状态混乱
「单一职责 + 各自合适的生命周期管理」是分开的最大理由。
完整的启动时序
把 5 个服务排到时间轴上:
1 | T=0 docker compose up -d |
这条链路里每一步的依赖都是非空的:
frontend-build必须先完成,否则 nginx 没有dist/可发api必须先起,否则 nginx 反代/api直接 502db必须 healthy,否则 api 一查就失败
docker compose 的 depends_on + condition 把这些前后关系编排好了,省心。
另一个曾经踩过的坑:vite: not found
第一版我把 api 服务写成:
1 | api: |
启动后 frontend-build 报错:
1 | > vite build |
为什么?
frontend-build 和 api 都挂了同一份 ./frontend:/app/frontend,而且没有先后依赖时它们并发执行 npm ci:
frontend-build:npm ci(含 devDeps,把 vite 装进node_modules)api:npm ci --omit=dev(不装 devDeps,把 vite 从node_modules清掉)
时序:
1 | T0 两个并发开始 npm ci |
修复方案有两个层面:
1. 让 api 不再自己装包
1 | api: |
api 启动时 frontend-build 已经退出,node_modules 完好。两边不再竞争。
2. frontend-build 显式 --include=dev
1 | frontend-build: |
即使容器外部 shell 偶然带了 NODE_ENV=production,build 也一定能拿到 vite。
教训
当多个容器共享同一个挂载卷时,要么明确串行,要么各管各的目录。 共享 node_modules 是个雷,最好让一个容器负责装包,其他人只读。
还有一个收获:backend/.conda 映射代替容器内 pip install
第一版我给 backend 服务用了 python:3.11-slim + 容器内 pip install -r requirements.txt。问题:
- 镜像启动每次都要装包(依赖几十兆,包括
cryptography这种带 native 编译的) - 第一次启动可能要 5 分钟
- 网络抖一下就装失败
后来意识到:宿主机 backend/.conda/ 里已经有完整的 Python + 依赖,直接挂进容器就行。
1 | backend: |
但要注意几个细节:
glibc 版本兼容
conda env 里的 binaries 是宿主机编译的(Ubuntu 25 / glibc 2.42)。容器的 glibc 必须够新:
1 | # 检查 conda env 二进制最高需要的 glibc 版本 |
最高 2.34,那 debian:bookworm(glibc 2.36)刚好够,debian:bullseye(2.31)就太老。
启动脚本要能找到 python
backend/run_pipeline_every_3min.sh 原来硬编码:
1 | PYTHON_EXEC="${SCRIPT_DIR}/.conda/bin/python3.12" |
但 conda env 实际是 python 3.13,于是 fallback 到 PATH 里的 python3——slim 容器里没有这玩意儿,挂掉。
修法:
1 | PYTHON_EXEC="${SCRIPT_DIR}/.conda/bin/python" # 用 symlink,不写死小版本 |
env 加载策略:根 .env 软链 frontend/.env
docker-compose.yaml 里有大量 ${X:?...} 插值:
1 | environment: |
compose 只会自动读取根目录的 .env——不是 frontend/.env,也不是 backend/.env。
但项目的密钥都在 frontend/.env(npm run dev 也读这份)。两份一样的 .env 维护起来易出错。
解决:软链。
1 | ln -sf frontend/.env .env |
这样:
- 根
.env与frontend/.env是同一个文件 - compose 自动加载根
.env→ 插值${ENCRYPTION_KEY}取到值 npm run dev(本地开发)读frontend/.env- 单一来源,零冗余
总览:最终架构
1 | ┌─────────────────────────────┐ |
对外只开一个端口(NGINX_HOST_PORT,默认 3000)。其他全部内部网络通信。
未来加 HTTPS 也只动 nginx:加 443 + 证书 + 把 80 重定向过去,其他服务一行代码不用改。
收获
- 「前端 / 后端」不是「写哪种代码」,而是「在哪里执行」:在浏览器执行的就是前端,在服务器执行的就是后端。哪怕都是 JavaScript,跑在 Node 里就是后端。
- 静态托管能做的事极有限:能 SPA + 调外部 API 就到顶了。涉及鉴权、私钥、DB、文件流,必须有自己的后端。
- 每个容器只做一件事 + 用合适的生命周期策略:一次性的就
restart: no,常驻的就restart: always。混着写一定会出问题。 - 共享挂载卷要小心写竞争:
node_modules、临时目录这些被两个容器同时操作的位置,要明确谁是 writer、其他人 read-only。 - 能复用的环境就别在容器里重装:宿主机已经准备好的 conda env 直接挂进去,又快又一致。前提是 glibc 兼容。
参考索引
| 主题 | 文件 |
|---|---|
| 完整 compose | docker-compose.yaml |
| nginx 配置 | nginx/nginx.conf |
| Express 后端 | frontend/server/index.js |
| 前端 build 配置 | frontend/vite.config.ts |
| pipeline 启动脚本 | backend/run_pipeline_every_3min.sh |
| 部署文档 | docs/DEPLOYMENT.md |





