Docker 容器化部署实操方法(GitHub 文档翻译工具项目)

企业客户的一句“数据不出内网”,直接关上了云托管的大门。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 参数后演练才通过。

十、安全建议

容器安全在私有化场景下权重很高,因为镜像直接部署在客户内网,一旦有问题影响的是对方的整个网络。我按风险从高到低列了几条落地措施,每一条都对应一个真实发生过的风险点:

  1. 基础镜像用 node:20-alpine,体积小、漏洞少;
  2. 定期 docker scan 检查 CVE;
  3. 容器以非 root 运行;
  4. 关键路径只读文件系统;
  5. 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。排查方向是看容器日志里有没有对应的报错,再对症处理。

十二、给同类项目的建议

这套方案沉淀下来后,我又把它复用到另一个文档处理项目里,改造成本很低,因为核心原则是通用的。总结成四条建议,都是踩过坑换来的:

  1. 多阶段构建,减小最终镜像;
  2. 镜像标签带 git sha,方便回滚;
  3. 配置与镜像分离,密钥不进镜像;
  4. 健康检查必须存在,编排系统靠它判断。

镜像标签带 git sha 这个建议,是客户有一次升级后出问题想回滚,结果镜像 tag 只有 v1.0 没有对应记录,费了半天才定位到版本。改成 sha 之后就再没遇到过这种问题。到这里,Docker 容器化部署的落地链路就完整了:从进程划分、镜像构建,到编排、健康检查、CI 推送,再到日志、备份、安全,每一环都有明确取舍,照着这套走,私有化交付会顺很多。

常见问题(FAQ)

Q1:Docker 部署和 Vercel 部署差异大吗?

业务代码一致,主要是环境变量与启动命令差异。

Q2:能否用单镜像跑多个进程?

能,但不推荐。分离后扩缩容更灵活。

Q3:私钥进镜像怎么办?

立刻 rotate 并重新部署,旧镜像作废。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部