为什么部署里有一个 build 容器还有一个 api 容器

项目:SGCC 智能服务控制台 · 学习记录

起因

我重构完部署后,看着 docker-compose.yaml 里的服务列表犯嘀咕:

1
2
3
4
5
6
services:
db # 数据库
frontend-build # ?
api # ?
nginx # 反向代理 + 静态托管
backend # Python 数据管线

「我不就是部署个前端嘛,为什么还要有一个 api 容器?前端应该 build 完用 nginx 反代 index.html 就够了啊?」

——这是我那天的原话。后面被纠正了我才意识到:「前端」这个词在我脑子里和实际意义不一致

下面把这件事彻底理清楚。


概念修正:「前端」≠「整个网站」

浏览器里能跑什么、不能跑什么

能跑的

  • HTML / CSS / JS(这些都是死的文本文件)
  • 渲染界面、响应点击
  • fetch() 给某个 URL 发请求

不能跑的

  • 连数据库
  • 读服务器硬盘
  • 解密只能放在服务端的私钥
  • 用服务器的 SMTP 凭据发邮件

后面四件事每一件都需要一个跑代码的进程在服务器上。这个进程就是常说的「后端」。

一次「点击通话列表」实际发生什么

把项目里的真实流程拆开看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
浏览器                 nginx              api 容器              db
│ │ │ │
│ 用户访问 /work-order │ │ │
│ ──────────────────► │ │ │
│ ◄── dist/index.html │ │ │
│ │ │ │
│ React 跑起来了,需要数据 │ │ │
│ ── GET /api/calls ─► │ │ │
│ │ ── 转发 ─────────►│ │
│ │ │ ── SELECT * ────►│
│ │ │ ◄── 数据 ────────│
│ │ ◄── JSON ─────────│ │
│ ◄── JSON ───────────│ │ │
│ │ │ │
│ React 把数据渲染成表格 │ │ │

两次请求,两个职责

  1. 第一次 GET / → 浏览器拿 HTML(静态,nginx 直接发文件)
  2. 第二次 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
2
3
4
5
frontend/
├── server/index.js ← 这是 Express 后端
├── pages/, components/ ← 这才是真正的前端 React
├── package.json ← 两边的依赖混在一起
└── dist/ ← React 编译产物

历史命名不规范,结果就是「frontend」这个词在我脑子里=整个站,但其实是 React 部分 + Express 部分混居。看到 api 容器才会觉得「为什么前端还要再来一个?」。

实际上:「frontend 容器」这个概念在新架构里被拆成了 frontend-build(编译前端 React)+ api(后端 Express)。


那为什么是「frontend-build + api」两个容器,不是一个?

这是另一个常见困惑。下面单独说。

frontend-build 是个一次性容器

1
2
3
4
frontend-build:
image: node:22-alpine
command: ["sh", "-lc", "npm ci --include=dev && npm run build"]
restart: "no" # 干完就退出,不重启

它的工作:

  1. npm ci(装包,包括 vite、typescript 这些 devDeps)
  2. npm run build(vite build 把 React 源码编译成 dist/index.htmldist/assets/*.js
  3. 退出(状态 0)

退出之后这个容器就死了,不占 CPU 不占内存。它存在的唯一目的就是干活那 30 秒

api 是常驻服务

1
2
3
4
api:
image: node:22-alpine
command: ["node", "server/index.js"]
restart: always # 挂了自动重启

它的工作:

  1. 启动 Express,绑 :3001
  2. 接 nginx 转发过来的 /api/* 请求
  3. 永远不退出

为什么不合并?

两个容器,两套使命:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
T=0    docker compose up -d

T=1 db 启动 → 进入 healthcheck 轮询

T=20 db healthy
├─► frontend-build 启动(npm ci + npm run build,约 30s)
├─► backend 启动(apt-get install bash + 启动 Python 管线)

T=50 frontend-build 退出(exit 0)
└─► api 启动(depends_on: frontend-build completed_successfully)

T=52 api 监听 :3001
└─► nginx 启动(depends_on: api started)

T=53 nginx 监听 :80 → 用户能访问

这条链路里每一步的依赖都是非空的:

  • frontend-build 必须先完成,否则 nginx 没有 dist/ 可发
  • api 必须先起,否则 nginx 反代 /api 直接 502
  • db 必须 healthy,否则 api 一查就失败

docker composedepends_on + condition 把这些前后关系编排好了,省心。


另一个曾经踩过的坑:vite: not found

第一版我把 api 服务写成:

1
2
api:
command: ["sh", "-lc", "npm ci --omit=dev && node server/index.js"]

启动后 frontend-build 报错:

1
2
> vite build
sh: vite: not found

为什么?

frontend-buildapi 都挂了同一份 ./frontend:/app/frontend,而且没有先后依赖时它们并发执行 npm ci

  • frontend-buildnpm ci(含 devDeps,把 vite 装进 node_modules
  • apinpm ci --omit=dev不装 devDeps,把 vite 从 node_modules 清掉)

时序:

1
2
3
4
T0   两个并发开始 npm ci
T12 frontend-build npm ci 完成 → node_modules/.bin/vite 在
T13 api npm ci --omit=dev 完成 → 把 vite 清掉了!
T14 frontend-build 继续执行 npm run build → vite: not found

修复方案有两个层面:

1. 让 api 不再自己装包

1
2
3
4
5
6
7
api:
command: ["node", "server/index.js"] # 不 npm ci,直接跑
depends_on:
db:
condition: service_healthy
frontend-build:
condition: service_completed_successfully # ← 关键

api 启动时 frontend-build 已经退出,node_modules 完好。两边不再竞争。

2. frontend-build 显式 --include=dev

1
2
3
4
5
6
7
8
frontend-build:
command:
- sh
- -lc
- |
npm ci --include=dev && npm run build
environment:
NODE_ENV: development # ← 防止外部 NODE_ENV=production 让 npm 跳过 devDeps

即使容器外部 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
2
3
4
5
6
7
backend:
image: debian:bookworm-slim
volumes:
- ./backend:/app/backend # .conda 在 backend/ 下,跟着一起进去
command: >
bash -lc "apt-get update -qq && apt-get install -y bash tzdata >/dev/null &&
exec bash /app/backend/run_pipeline_every_3min.sh"

但要注意几个细节:

glibc 版本兼容

conda env 里的 binaries 是宿主机编译的(Ubuntu 25 / glibc 2.42)。容器的 glibc 必须够新:

1
2
3
4
5
6
# 检查 conda env 二进制最高需要的 glibc 版本
$ find backend/.conda -name "*.so" -exec objdump -T {} \; 2>/dev/null \
| grep -oE 'GLIBC_[0-9.]+' | sort -V -u | tail -3
GLIBC_2.28
GLIBC_2.33
GLIBC_2.34

最高 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
2
3
4
5
PYTHON_EXEC="${SCRIPT_DIR}/.conda/bin/python"   # 用 symlink,不写死小版本
if [[ ! -x "${PYTHON_EXEC}" ]]; then
CONDA_PY="$(ls "${SCRIPT_DIR}/.conda/bin/"python3.* 2>/dev/null | head -n1 || true)"
[[ -n "${CONDA_PY}" ]] && PYTHON_EXEC="${CONDA_PY}" || PYTHON_EXEC="python3"
fi

env 加载策略:根 .env 软链 frontend/.env

docker-compose.yaml 里有大量 ${X:?...} 插值:

1
2
environment:
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY 未在根 .env 中提供}

compose 只会自动读取根目录的 .env——不是 frontend/.env,也不是 backend/.env

但项目的密钥都在 frontend/.env(npm run dev 也读这份)。两份一样的 .env 维护起来易出错。

解决:软链

1
ln -sf frontend/.env .env

这样:

  • .envfrontend/.env 是同一个文件
  • compose 自动加载根 .env → 插值 ${ENCRYPTION_KEY} 取到值
  • npm run dev(本地开发)读 frontend/.env
  • 单一来源,零冗余

总览:最终架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                  ┌─────────────────────────────┐
外网 ──► nginx:80 │ │
│ │ / → /usr/share/nginx/html/
│ │ (= frontend/dist/,由 frontend-build 产出)
│ │ /api/* → http://api:3001
│ └─────────────────────────────┘


sgcc-internal (docker bridge)
├── db Postgres,**不对宿主机映射**
├── frontend-build 一次性容器,编译完就退出
├── api Express 常驻
├── backend Python 数据管线常驻(用宿主机 .conda)
└── nginx 唯一对外端口

对外只开一个端口NGINX_HOST_PORT,默认 3000)。其他全部内部网络通信。

未来加 HTTPS 也只动 nginx:加 443 + 证书 + 把 80 重定向过去,其他服务一行代码不用改。


收获

  1. 「前端 / 后端」不是「写哪种代码」,而是「在哪里执行」:在浏览器执行的就是前端,在服务器执行的就是后端。哪怕都是 JavaScript,跑在 Node 里就是后端。
  2. 静态托管能做的事极有限:能 SPA + 调外部 API 就到顶了。涉及鉴权、私钥、DB、文件流,必须有自己的后端。
  3. 每个容器只做一件事 + 用合适的生命周期策略:一次性的就 restart: no,常驻的就 restart: always。混着写一定会出问题。
  4. 共享挂载卷要小心写竞争node_modules、临时目录这些被两个容器同时操作的位置,要明确谁是 writer、其他人 read-only。
  5. 能复用的环境就别在容器里重装:宿主机已经准备好的 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