《TypeScript编程实战》18.1 Docker 与 CI/CD 流水线

本节把 TypeScript 服务从「本地能跑」推进到「可重复交付」:先用多阶段 Dockerfile 拆开依赖层、构建层与运行层,讲清镜像为何能从 1.2GB 降到 180MB;再给出分层清晰的 CI/CD 流水线,覆盖类型检查、测试、镜像构建、签名与制品晋级;最后落到缓存、非 root 运行、健康检查与供应链加固这些易踩的坑。读完后你能独立写出从提交到上线的自动化流水线。

本节目标:把 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"]

这个文件里有四个决定成败的细节:

  1. --frozen-lockfile:锁文件与 package.json 不一致时直接失败。CI 里绝不能出现「顺手升级了一个小版本」的隐式变更。
  2. --mount=type=cache:pnpm store 挂到 BuildKit 缓存,跨构建复用下载物。这是把 CI 从 8 分钟压到 90 秒的主要贡献者,展开做法见 远程构建缓存 。
  3. pnpm prune --prod:装依赖时为了构建必须带 devDependencies,但运行时不需要。裁剪后再 COPY --from,运行层体积直接腰斩。
  4. 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 环境变量与配置的类型化 。

运行时安全基线

镜像跑起来之后,还有一组开关决定它有多难被攻破:

措施做法挡住的攻击
非 rootUSER node容器内提权
只读根文件系统readOnlyRootFilesystem: true落盘木马、篡改二进制
丢弃能力drop: ["ALL"]原始套接字、挂载、ptrace
禁止提权allowPrivilegeEscalation: falsesetuid 提权
密钥不落镜像运行时注入 / 挂载镜像被拉走即泄密

最后一条最常被违反。把 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 + 服务端 dist1–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 storepnpm-lock.yaml 哈希最大锁文件变化
Docker 层基础镜像 + 锁文件 + 源码前缀大上游层变化
构建产物dist / .vite 哈希中源码变化
测试结果源文件哈希小文件变化

前两层是必做项,第三层视项目而定。有一条反模式要警惕:把缓存当成正确性依赖。如果 CI 只在有缓存时通过,那说明你测的是缓存而不是代码。验证方法很简单——定期在无缓存分支上跑一次完整流水线。

远程缓存与多架构构建的进阶玩法见 远程构建缓存 、多架构构建 与 CI 性能与缓存 。

制品与供应链

最后一步是让制品「可证明」。三件事缺一不可:

  1. 签名:镜像推送到仓库后立即签名,部署时校验。没有签名的镜像不允许上生产。
  2. SBOM:随镜像生成软件物料清单,出事时能回答「这个 CVE 影响我们哪些服务」。
  3. 来源证明(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 数据库迁移与灰度发布 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes