我们团队的 Monorepo 项目现在塞了十几个应用和几十个内部 package,每次 CI 打包发布,Docker 镜像构建都慢得像蜗牛。尤其是拉取依赖这一步,只要任何一个无关紧要的子包改了一行代码,或者新增了一个临时依赖,Docker 构建缓存就会直接失效,然后重新把上千个 npm 包全部下载一遍。这简直是灾难,不仅把 CI 机器的带宽占满,还让发布流程的心智负担极大。
折腾了好几天,我发现绝大多数人写的 Dockerfile 在处理 pnpm monorepo 时都有问题。常见套路是直接把所有 package.json、pnpm-workspace.yaml 和 pnpm-lock.yaml 都 COPY 进去,然后执行 pnpm install。但这根本没有解决本质问题:只要任何一个子包的 package.json 发生微小变动(比如修改了版本号或者加了个描述字段),Docker 缓存层就判定失效,导致依赖重新下载。
实际上,pnpm 提供了一个非常强大的命令:pnpm fetch。它允许我们只依赖 pnpm-lock.yaml 这一个文件,就可以把整个 monorepo 所需的依赖项全部下载到 pnpm 的 store 缓存中。只要 pnpm-lock.yaml 没有变,这一层构建缓存就是绝对安全的。
这里直接给出我们在线上跑的完美 Dockerfile 配置,核心思想是利用多阶段构建和 pnpm fetch 来实现完美的缓存分离:
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS fetch-stage
WORKDIR /app
# 仅仅复制 lockfile 即可,哪怕子包的 package.json 修改了,只要依赖版本没变,这一步就完美命中缓存
COPY pnpm-lock.yaml ./
# 设置 pnpm store 目录,并使用 pnpm fetch 离线拉取所有依赖到 store 中
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch --prod
FROM base AS installer
WORKDIR /app
# 此时复制所有文件来准备构建
COPY . .
# 再次利用 store 缓存并运行离线安装,pnpm 会从本地 store 中硬链接依赖,速度近乎瞬时
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --prod
FROM base AS builder
WORKDIR /app
COPY --from=installer /app .
# 编译指定的子应用,比如我们的后台管理端 apps/admin-dashboard
RUN pnpm --filter admin-dashboard run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# 拷贝编译好的产物和 node_modules
COPY --from=builder /app/apps/admin-dashboard/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/admin-dashboard/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/main.js"]
要用好这个方案,有几个细节大坑必须避开。首先,必须在 Dockerfile 头部启用 syntax=docker/dockerfile:1.4,否则 --mount=type=cache 缓存挂载可能无法生效。其次,pnpm fetch --prod 只会拉取生产依赖,如果你在构建阶段(例如 pnpm run build)需要某些 devDependencies,那干脆去掉 --prod 参数,拉取全量依赖。
另外,如果子包之间存在大量的内部 workspace:* 互相依赖,pnpm install --offline 时可能会因为找不到本地 workspace 包的依赖关系而报错。这时候可以考虑在 installer 阶段先用一个脚本把所有 package.json 中的 workspace: 依赖版本重写成实际版本,或者使用 pnpm deploy 命令。不过对于绝大多数中大型项目,采用上述 fetch-stage 配合全局 lockfile 的方式,就已经能把镜像构建时间从之前的 15 分钟直接压缩到 2 分钟以内。
目前这个方案已经在我们的生产 CI/CD 中平稳运行了三个月,再也没有遇到因为拉取 npm 依赖超时而导致部署流产的问题。下阶段打算尝试把 store 挂载到宿主机的共享目录中,进一步榨干 CI 编译机的磁盘 I/O 性能。