本节目标:把 TypeScript 服务从「本地能跑」推进到「可重复交付」。读完后你能写出一个多阶段 Dockerfile,说清镜像为什么能从 1.2GB 降到 180MB;能搭起一条分层清晰的 CI/CD 流水线,覆盖类型检查、测试、构建、镜像签名与制品晋级;并知道缓存、非 root 运行、健康检查、供应链这四处最容易踩的坑。
18.1 Docker 与 CI/CD 流水线
前面十七章我们一直在造零件:脚手架、类型、测试、HTTP 服务、数据库、缓存、队列、前端、契约、可观测性。这一章要回答最后一个问题——这些东西怎么变成线上跑着的服务,并且能安全地换掉旧版本。
很多团队的 TypeScript 项目恰恰在这里失守:pnpm build 在本地是绿的,上了 CI 就挂;镜像 1.2GB,每次发布推 5 分钟;容器用 root 跑;回滚靠 git revert 再等一整条流水线跑完。这一节按交付链路的顺序逐个解决。
交付链路的三个阶段
先把「发布」这个词拆开,否则讨论会一直串味:
| 阶段 | 输入 | 输出 | 关键约束 |
|---|---|---|---|
| 构建 Build | 源码 + 锁文件 | 不可变制品(镜像 / bundle) | 可重复、可追溯 |
| 流水线 CI | 一次提交 | 通过门禁的制品 | 快、确定性 |
| 交付 CD | 制品 | 线上流量 | 可灰度、可回滚 |
三者共享同一条前提:制品不可变。同一个 commit 构建出的镜像,在测试环境与生产环境必须是同一个 digest,而不是「到生产再重新构建一次」。这条前提一旦破了,后面的灰度与回滚都会失真——你回滚的其实是另一个你没测过的东西。
多阶段构建:把镜像拆成三层
容器化的第一课不是「写 Dockerfile」,而是「别把所有东西塞进一个镜像」。一个可维护的 Node 镜像应该分成三层:依赖层(只随锁文件变化)、构建层(随源码变化)、运行层(只保留运行时需要的文件)。
FROM node:22-alpine AS base
ENV PNPM_HOME=/pnpm
ENV PATH=${PNPM_HOME}:$PATH
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
RUN --mount=type=cache,id=pnpm,target=${PNPM_HOME}/store \
pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm run build && pnpm prune --prod
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.js"]
这个文件里有四个决定成败的细节:
--frozen-lockfile:锁文件与package.json不一致时直接失败。CI 里绝不能出现「顺手升级了一个小版本」的隐式变更。--mount=type=cache:pnpm store 挂到 BuildKit 缓存,跨构建复用下载物。这是把 CI 从 8 分钟压到 90 秒的主要贡献者,展开做法见 远程构建缓存 。pnpm prune --prod:装依赖时为了构建必须带devDependencies,但运行时不需要。裁剪后再COPY --from,运行层体积直接腰斩。COPY --chown=node:node+USER node:以非 root 身份运行。这一条不是「最佳实践清单上的装饰」,而是容器逃逸的最后一道闸门。
层顺序决定缓存命中率
Docker 的层缓存是前缀失效:某一层变了,它之后的所有层全部重算。所以 COPY 的顺序比内容更重要:
# 反例:任何一次改代码都会让依赖层缓存失效,每次都重装 node_modules
COPY . .
RUN pnpm install --frozen-lockfile && pnpm run build
# 正例:先锁文件后源码,只改业务代码时不重装依赖
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
这两段的差别在本地感受不到(本地有卷缓存),但在 CI 上意味着「每次提交都多花 3 分钟」。多阶段构建与层优化的完整拆解见 BuildKit 多阶段构建 。
镜像体积账:别凭感觉瘦身
瘦身前先算清账,否则很容易优化了不痛的地方:
| 做法 | 镜像体积 | 主要构成 |
|---|---|---|
node:22 单阶段,全量 node_modules | ~1.2 GB | 编译工具链 + devDependencies |
node:22-alpine 单阶段 | ~480 MB | 仍带 devDependencies |
多阶段 + prune --prod | ~180 MB | 只留运行依赖 |
| 多阶段 + tsup 打成单文件 | ~90 MB | 无 node_modules |
distroless/nodejs22 运行层 | ~110 MB | 无 shell,攻击面最小 |
三条可量化的结论:
- 基础镜像选型贡献约 40% 的体积差,
node:22→node:22-alpine一次省 700MB。 - devDependencies 裁剪贡献约 60%,
prune --prod比任何换基础镜像都划算。 .dockerignore不是可选项。忘了它,COPY . .会把.git、node_modules、coverage、本地.env一起塞进构建上下文,既拖慢构建又有泄密风险。
# .dockerignore
.git
.github
node_modules
dist
coverage
*.log
.env*
!.env.example
注意 !.env.example 这一行:白名单放行示例配置,其余 .env* 全部挡住。环境变量的类型化与校验见 2.2 环境变量与配置的类型化
。
运行时安全基线
镜像跑起来之后,还有一组开关决定它有多难被攻破:
| 措施 | 做法 | 挡住的攻击 |
|---|---|---|
| 非 root | USER node | 容器内提权 |
| 只读根文件系统 | readOnlyRootFilesystem: true | 落盘木马、篡改二进制 |
| 丢弃能力 | drop: ["ALL"] | 原始套接字、挂载、ptrace |
| 禁止提权 | allowPrivilegeEscalation: false | setuid 提权 |
| 密钥不落镜像 | 运行时注入 / 挂载 | 镜像被拉走即泄密 |
最后一条最常被违反。把 DATABASE_URL、JWT_SECRET 写进 Dockerfile 的 ENV,等于把生产密码提交进了镜像仓库——即使后来删掉,它仍然留在层历史里,docker history 一查就出来:
docker history --no-trunc ghcr.io/acme/api:1.4.2 | grep -i secret
健康检查与优雅停机
容器编排系统判断一个实例「能不能收流量」,靠的是探针,不是进程是否存在。两者必须分开:
- 存活探针(liveness):进程死了就重启。它不应该检查数据库——数据库抖动会让所有实例同时重启,把一次小故障放大成雪崩。
- 就绪探针(readiness):依赖没准备好就摘掉流量,但不重启。
livenessProbe:
httpGet: { path: /healthz, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
initialDelaySeconds: 3
periodSeconds: 5
terminationGracePeriodSeconds: 30
terminationGracePeriodSeconds 与应用的优雅关闭必须成对出现:编排系统发 SIGTERM 后最多等 30 秒,应用要在收到信号后停止接受新连接、排空在途请求与连接池,然后退出。这一对的写法见 5.3 优雅关闭与健康检查
,Docker 侧的探针行为见 健康检查与自愈
。
CI 流水线的阶段划分
流水线的核心设计原则是便宜的检查排在前面,失败得越早越好。类型检查 20 秒,集成测试 3 分钟,镜像构建 2 分钟——顺序反了,一个拼写错误要等 5 分钟才知道。
| 阶段 | 内容 | 典型耗时 | 失败含义 |
|---|---|---|---|
| 静态检查 | tsc --noEmit、ESLint、格式 | 20–60s | 代码不合规,不该进主干 |
| 单元测试 | Vitest,覆盖率门禁 | 1–3min | 逻辑错了 |
| 集成测试 | Testcontainers 起真实依赖 | 2–5min | 契约或 SQL 错了 |
| 构建 | 前端 bundle + 服务端 dist | 1–3min | 构建配置错了 |
| 镜像 | 多阶段构建、推送、签名 | 1–2min | 制品不可用 |
| 晋级 | 部署到 staging / 生产 | 秒级 | 环境或权限问题 |
对应的 GitHub Actions 骨架:
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
- run: pnpm run lint
- run: pnpm run test -- --coverage
image:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
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@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
三个细节值得单独说:
concurrency.cancel-in-progress:同一分支连续推送时取消旧运行。不做这一条,高峰期 CI 队列会互相堵死,账单也会翻倍。needs: verify:镜像只在静态检查与测试全绿后才构建。反过来(先构建再测试)会让坏镜像提前占满仓库。tags: ...:${{ github.sha }}:用 commit SHA 而不是latest。SHA 是唯一标识,latest是可变引用;用latest部署,你永远说不清线上跑的是哪份代码,也回滚不了。
门禁的细节(提交信息规范、husky 钩子、覆盖率阈值)见 1.3 代码规范与提交门禁 与 4.3 类型测试与覆盖率门禁 ,容器内跑 CI 的写法见 CI 容器作业 。
缓存:把 8 分钟压到 90 秒
CI 慢的根因几乎永远是「重复下载」。四层缓存按收益从大到小排列:
| 缓存对象 | 键 | 收益 | 失效时机 |
|---|---|---|---|
| pnpm store | pnpm-lock.yaml 哈希 | 最大 | 锁文件变化 |
| Docker 层 | 基础镜像 + 锁文件 + 源码前缀 | 大 | 上游层变化 |
| 构建产物 | dist / .vite 哈希 | 中 | 源码变化 |
| 测试结果 | 源文件哈希 | 小 | 文件变化 |
前两层是必做项,第三层视项目而定。有一条反模式要警惕:把缓存当成正确性依赖。如果 CI 只在有缓存时通过,那说明你测的是缓存而不是代码。验证方法很简单——定期在无缓存分支上跑一次完整流水线。
远程缓存与多架构构建的进阶玩法见 远程构建缓存 、多架构构建 与 CI 性能与缓存 。
制品与供应链
最后一步是让制品「可证明」。三件事缺一不可:
- 签名:镜像推送到仓库后立即签名,部署时校验。没有签名的镜像不允许上生产。
- SBOM:随镜像生成软件物料清单,出事时能回答「这个 CVE 影响我们哪些服务」。
- 来源证明(provenance):记录这个镜像由哪个仓库、哪个 commit、哪条流水线构建。
上面 YAML 里的 provenance: true 与 sbom: true 就是干这个的,输出会作为 OCI 制品附在镜像旁。签名与供应链加固的完整链路见 镜像签名
、镜像供应链
、制品仓库与来源证明
,以及本系列上一节的 17.3 依赖供应链与应用安全加固
。
一个容易忽略的配套项是制品晋级:同一份镜像从 staging 提升到生产,只改「部署指向哪个 digest」,不重新构建。这就是本节开头「制品不可变」的落地形式。
常见坑
按出现频率排序:
Error: Cannot find module '/app/dist/server.js':tsconfig.json的outDir与CMD路径不一致,或include漏了入口文件。构建层能看到dist不代表运行层 COPY 到了。EACCES: permission denied, open '/app/...':COPY后文件属主是 root,而进程以node用户运行。用--chown或统一USER。pnpm install报ERR_PNPM_OUTDATED_LOCKFILE:--frozen-lockfile的正常拦截,说明有人改了package.json没提交锁文件。这是好事,不要用--no-frozen-lockfile绕过。- CI 里通过、本地失败(或反之):
NODE_ENV或时区不同。流水线里显式设置TZ=UTC,避免本地Asia/Shanghai下日期测试随机失败。 - 镜像里带走了
.env:.dockerignore没写或写错了模式(.env*要配合!.env.example使用)。 - 健康检查写成检查数据库:数据库抖动时全部实例被重启,一次小故障被放大成大面积不可用。
小结
这一节把「交付」拆成了三层,每层各有一条不可退让的底线:
- 构建层用多阶段 Dockerfile 分离依赖、构建、运行,层顺序决定缓存命中率;体积靠「基础镜像 + devDependencies 裁剪 +
.dockerignore」三件事解决,其中裁剪贡献最大。 - 运行层以非 root、只读根文件系统、丢弃能力为基线,密钥只在运行时注入;存活探针不查数据库,就绪探针才管流量,且必须与
terminationGracePeriodSeconds成对配置。 - 流水线层按「便宜的检查在前」排序,用 commit SHA 而非
latest打标签,用concurrency取消过期运行,用 pnpm store 与 BuildKit 两层缓存把耗时压下来。 - 制品层坚持不可变:签名、SBOM、来源证明三件套齐全,晋级只改 digest 不重新构建。
到这里,一个版本已经能被打包成可追溯、可运行的镜像了。但「能构建」不等于「能安全上线」——真正危险的一步是动数据库结构,以及把新版本暴露给一部分真实用户。下一节我们就讲数据库迁移与灰度发布。
阅读导航:上一节:17.3 依赖供应链与应用安全加固 · 下一节:18.2 数据库迁移与灰度发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。