企业客户的一句“数据不出内网”,直接关上了云托管的大门。Vercel 部署很方便,但对方的安全合规要求让外部托管这条路直接走不通,只能退回去找私有化方案。对比了单机裸跑、K8s 和 Docker Compose 之后,我选了 Docker 这条折中路线:镜像能推到内网 registry,部署脚本简单,运维同学半小时就能上手,不需要专门招一个 K8s 工程师。所以这个项目在 Vercel 之外保留了一套完整的 Docker 部署方案。下面把这套方案怎么搭、踩过哪些坑、每一步为什么这么做完整拆开讲一遍。
一、整体结构
动手之前先把进程边界划清楚,这是我第一批改动里花时间最多的一件事。翻译工具不是单一进程:Web 端要响应请求,后台要跑翻译任务,两者节奏完全不同,硬塞进一个容器里只会互相拖累。翻译任务偶发吃满 CPU,会把 Web 的响应时间也拉下去;反过来 Web 高峰期把内存吃光,worker 就 OOM 了。拆开之后各自限流、各自扩缩容,问题就好处理得多。
最终定下来的拓扑是这样,四个服务各管一摊:
Dockerfile
docker-compose.yml
├─ app # Next.js 服务
├─ worker # 翻译任务 worker
├─ postgres # 数据库
└─ redis # 缓存
app 负责接收 GitHub Webhook、展示管理后台;worker 负责排队拉翻译任务、调模型、回写结果;postgres 存任务状态和用户数据;redis 既做队列又做幂等去重的存储。第一次部署时我偷懒想省一个 redis,用 postgres 轮询代替队列,结果翻译任务一多,数据库连接被轮询占满,Web 请求全部排队。后来老实换回 Redis Stream 做队列,问题当场消失。队列这种东西还是别自己造。
二、Dockerfile
Web 服务用的是 Next.js,跑在 pnpm monorepo 里。最初我直接照搬官方模板写了个单阶段 Dockerfile,构建出来的镜像快 1.8GB,推镜像都要几分钟,内网带宽小一点的客户直接卡在拉取环节。后来改成多阶段构建,体积掉到几百兆,这里贴的就是当时沉淀下来的版本。
这段构建逻辑要解决的是”构建环境大、运行环境小”的矛盾:第一阶段带全部开发依赖去编译,第二阶段只保留产物和运行期必要的依赖。
# ---- build ----
FROM node:20-alpine AS build
WORKDIR /repo
COPY pnpm-lock.yaml package.json ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm --filter web build
# ---- runtime ----
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /repo/apps/web/.next ./.next
COPY --from=build /repo/apps/web/public ./public
COPY --from=build /repo/apps/web/package.json ./
COPY --from=build /repo/node_modules ./node_modules
USER app
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "3000"]
几个细节值得单独说。--frozen-lockfile 是硬性要求,pnpm 发现 lockfile 和 package.json 不一致会直接报错而不是悄悄升级依赖,这保证了每次构建环境一致,避免”本地能跑、容器里崩”的玄学问题。corepack enable 这行也是踩过坑换来的:新版 Node 默认不带 pnpm,不先启用 corepack,构建阶段就会报 pnpm: not found。非 root 用户 USER app 同样重要,容器里以 root 跑业务进程,一旦应用被攻破,攻击者拿到的是宿主机 root 权限,这是安全基线里明确不让过的。
要点整理成清单,方便对照检查:
- 多阶段构建,减小最终镜像;
--frozen-lockfile锁版本;- 非 root 用户运行;
- 暴露 3000 端口。
三、Worker 镜像
worker 和 Web 服务镜像共享同一个基础镜像,但内容更薄,不需要 Next.js 那套运行产物,只要编译后的 JS 和依赖就能跑。拆出独立镜像还有一个实际原因:worker 的发布节奏和 Web 不一样。模型供应商 API 调整、翻译策略改版,往往只动 worker 不动 Web,合并成一个镜像会让每次部署都白白重建整个 Web 端。
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
CMD ["node", "apps/worker/dist/main.js"]
worker 处理翻译任务、轮询 GitHub API、定时清理。这个镜像我故意没做多阶段,因为 worker 启动时需要现场加载各类资源,依赖分离反而引入额外复杂度,收益不明显。等它体积真的影响部署速度了,再回来优化也不迟。
四、docker-compose 示例
单机私有化场景我统一用 docker-compose 编排,不用 K8s。客户那边往往就一台 4 核 16G 的服务器,K8s 装下来先吃掉一半资源,运维成本完全不成比例。compose 把四个服务、网络、卷一次性拉起,一条 docker compose up -d 就完成部署,对交付团队来说足够简单。
version: "3.9"
services:
app:
build: .
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/app
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
depends_on: [db, redis]
worker:
build: .
command: node apps/worker/dist/main.js
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/app
REDIS_URL: redis://redis:6379
depends_on: [db, redis]
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgres
redis:
image: redis:7
volumes:
pgdata:
这里有个坑要提醒:depends_on 只保证启动顺序,不保证依赖就绪。postgres 容器起来了不等于端口能接受连接,app 启动时如果连不上数据库就直接崩溃退出。我最初只配了 depends_on,结果 app 容器反复重启,日志里全是连接拒绝。解决办法是 app 入口加一个等待重试逻辑,或者给 db、redis 配健康检查后再让 app 依赖它们的 healthy 状态。
五、健康检查
私有化部署没有 PaaS 帮你看进程死活,容器退出、端口不响应只能靠编排系统自己发现。健康检查就是干这个的:定期探测一个约定接口,返回正常才认为容器活着,探测失败就重启或摘流量。没有这层,线上出问题往往要等用户报障才发现,体验很差。
services:
app:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
/api/health 返回 200 表示正常。这个接口我要求它只做浅层检查,返回 200 就行,不要去连数据库、redis 做全链路探测。理由是健康检查每 30 秒打一次,如果每次都查数据库,高峰期会白白增加数据库压力;深层依赖的状态交给业务告警去覆盖,健康检查只回答”进程是否还活着”这一个问题。
六、私有化部署的环境变量
环境变量是私有化交付里最容易被忽略又最容易出事故的一环。项目里有 GitHub App 凭据、数据库密码、模型 API Key,这类敏感信息必须跟镜像分离:镜像可以随便复制分发,但密钥一旦打进去,镜像在哪密钥就在哪。设计上我把配置全部外置,部署时通过环境变量或 Docker secret 注入。
下面从配置项、用途两个维度列一下这套部署需要的环境变量:
| 变量 | 说明 |
|---|---|
GITHUB_APP_ID |
GitHub App 数字 ID |
GITHUB_APP_PRIVATE_KEY |
PEM 私钥 |
GITHUB_APP_WEBHOOK_SECRET |
Webhook 签名密钥 |
NEXTAUTH_SECRET |
NextAuth 会话密钥 |
DATABASE_URL |
Postgres URL |
REDIS_URL |
Redis URL |
OPENROUTER_API_KEY |
模型 API Key |
密钥通过 Docker secret 或环境变量注入,不入镜像。具体到做法:Compose 里用 secrets: 声明敏感项,容器内以文件形式挂载;非敏感的如 DATABASE_URL 走 environment 即可。注意 compose 文件本身也会入库,里面的变量值不要写死明文,用 ${VAR} 引用外部 .env,.env 再进 gitignore。
七、构建与推送 CI
镜像要落到内网 registry,靠人肉 docker build 再手动打 tag 推上去,版本一定乱。我直接在 GitHub Actions 里挂了构建推送流水线,打 tag 时自动触发,产物带版本号推到 GHCR。这套流程跑起来之后,客户拿到的每个镜像都能追溯到对应的 git 提交,排查问题省了大量时间。
name: docker
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/your-org/docs-translator:${{ github.ref_name }}
一个补充:build-push-action 默认不启用缓存,每次构建都从零开始,构建时间会明显变长。给这个 action 加 cache-from 和 cache-to,走 GitHub Actions 缓存,依赖没变的情况下构建能省一半以上时间。另外 tag 用 github.ref_name 直接映射 git 标签,客户部署时版本号一眼能对上,回滚也知道该拉哪个。
八、日志与监控
私有化环境没有现成的日志采集平台,很多团队就放任容器日志堆在宿主机的 docker 里,等出问题才去翻,翻半天还找不到有用的信息。我从一开始就定了两条规矩:日志全部走 stdout/stderr,不写本地文件;格式用结构化 JSON,方便任何日志平台直接解析。
- 日志走 stdout/stderr,被 Docker/K8s 收集;
- 用
pino输出结构化日志; - 集成 OpenTelemetry,导出到 Prometheus / Tempo。
用 pino 而不是 console.log,是因为它能序列化错误对象、带 request id,排查”这条翻译任务到底卡在哪个请求上”的时候,靠着 request id 能把日志串起来。OpenTelemetry 的接入一开始没做,后来有一次翻译任务整体变慢,找不出是哪一段拖的,才补上 trace 埋点。有了 trace,每个任务的各阶段耗时一目了然。
九、备份与恢复
翻译工具跑的是真实业务数据,任务记录、用户配置、模型调用日志都在 postgres 里,丢了没法重建。之前有一次客户误删容器,数据卷还在所以虚惊一场,但那次之后我把备份当成独立议题对待,不再指望”数据卷应该还在”。
- Postgres 用
pg_dump定时备份; - 备份文件上传到 S3 / OSS;
- 恢复演练每月一次。
pg_dump 挂在 cron 里每天全量备份,备份文件压缩后上传到对象存储,保留 30 天。这里重点说恢复演练:光备份不演练等于没备份。第一次演练我们就发现备份文件无法直接恢复,原因是备份时没用 --no-owner,换机器恢复时角色不存在直接报错。改成加 --no-owner --no-privileges 参数后演练才通过。
十、安全建议
容器安全在私有化场景下权重很高,因为镜像直接部署在客户内网,一旦有问题影响的是对方的整个网络。我按风险从高到低列了几条落地措施,每一条都对应一个真实发生过的风险点:
- 基础镜像用
node:20-alpine,体积小、漏洞少; - 定期
docker scan检查 CVE; - 容器以非 root 运行;
- 关键路径只读文件系统;
- seccomp / AppArmor 加固。
alpine 基础镜像比 debian 系小很多,攻击面也小;但要注意 alpine 用的是 musl libc,个别依赖如果强依赖 glibc 会跑不起来,选镜像前先验证一遍。docker scan 挂在 CI 里跑,高危 CVE 出现直接让流水线失败,逼着构建侧及时处理。只读文件系统这条,对 Web 服务比较难直接套用(Next.js 运行时可能要写缓存目录),我的做法是只把代码目录挂成只读,把真正需要写的目录单独挂出来。
十一、常见问题
整理一下交付过程中客户和团队问得比较多的几个问题。这些问题单独看都不难,但都真实导致过线上故障或部署失败,值得提前预防。
| 现象 | 排查 |
|---|---|
| 启动慢 | 镜像大、构建缓存失效 |
| 健康检查失败 | 监听端口、依赖服务未就绪 |
| 内存溢出 | Node 默认 2GB,需要 --max-old-space-size |
| 私钥泄露 | 立刻 rotate 私钥,删旧镜像 |
“启动慢”最常见的原因不是代码慢,而是镜像里塞了构建缓存或没用的文件,或者构建时没挂缓存层每次全量重来。”内存溢出”这条要特别留意:Node 默认堆上限约 2GB,翻译任务吃内存时不够用会直接崩溃,需要按机器配置调 --max-old-space-size。排查方向是看容器日志里有没有对应的报错,再对症处理。
十二、给同类项目的建议
这套方案沉淀下来后,我又把它复用到另一个文档处理项目里,改造成本很低,因为核心原则是通用的。总结成四条建议,都是踩过坑换来的:
- 多阶段构建,减小最终镜像;
- 镜像标签带 git sha,方便回滚;
- 配置与镜像分离,密钥不进镜像;
- 健康检查必须存在,编排系统靠它判断。
镜像标签带 git sha 这个建议,是客户有一次升级后出问题想回滚,结果镜像 tag 只有 v1.0 没有对应记录,费了半天才定位到版本。改成 sha 之后就再没遇到过这种问题。到这里,Docker 容器化部署的落地链路就完整了:从进程划分、镜像构建,到编排、健康检查、CI 推送,再到日志、备份、安全,每一环都有明确取舍,照着这套走,私有化交付会顺很多。
常见问题(FAQ)
Q1:Docker 部署和 Vercel 部署差异大吗?
业务代码一致,主要是环境变量与启动命令差异。
Q2:能否用单镜像跑多个进程?
能,但不推荐。分离后扩缩容更灵活。
Q3:私钥进镜像怎么办?
立刻 rotate 并重新部署,旧镜像作废。