前言
一个 NestJS + Vue 3 的全栈项目,Docker 镜像接近 500 MB。是 node_modules 太重?还是基础镜像太大?本文记录了 Yam TV 项目 Docker 镜像的瘦身过程和每一步的取舍。
问题背景
Yam TV 是一个视频聚合搜索平台(NestJS API + Vue 3 前端 + Flutter 客户端),通过 Dockerfile.api 多阶段构建。上线前看了一下镜像大小:
yam-tv-api latest 489 MB
对于一个 Node.js 后端来说,500 MB 不能说离谱,但也绝对不算小。直觉上 node_modules 一定是最大的元凶,但具体是运行时依赖重,还是把 devDependencies 也打包了进去?需要验证。
分析
Dockerfile 原有结构
FROM node:22-alpine AS build
# ... 安装全部依赖,编译 ...
RUN pnpm run build:shared && pnpm run build:api && pnpm run build:web
FROM node:22-alpine
COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/apps/api ./apps/api
COPY --from=build /repo/apps/web/dist ./apps/web/dist
final stage 直接复制了整个 node_modules(含所有 devDeps),以及整个 apps/api 目录(包含 ts 源码、tsconfig、eslint 配置等)。
关键问题
- devDependencies 被带到了生产镜像 — TypeScript、ESLint、Jest、Vite、vue-tsc、ts-loader、prettier…… 全部被
COPY --from=build /repo/node_modules带进了最终镜像 - 整目录复制而非只复制产物 —
COPY --from=build /repo/apps/api ./apps/api把源码和构建配置一起复制了进来 - pnpm 的 node_modules 结构 — pnpm 用
.pnpm目录做硬链接 store,COPY会全部展开为实际文件 - 整目录复制而非只复制产物 —
COPY --from=build /repo/apps/api ./apps/api把源码和构建配置一起复制了进来 - pnpm 的 node_modules 结构 — pnpm 用
.pnpm目录做硬链接 store,COPY会全部展开为实际文件 - pnpm 的 node_modules 结构 — pnpm 用
.pnpm目录做硬链接 store,COPY会全部展开为实际文件
优化方案
多阶段构建改造
核心思路:build stage 只负责编译,production stage 从零开始只装 runtime deps。
# ── 构建阶段 ──
FROM node:22-alpine AS build
# ... 安装全部依赖(含 devDeps),编译源码 ...
# ── 生产阶段 ──
FROM node:22-alpine
RUN npm install -g pnpm@10.33.0
WORKDIR /repo
ENV NODE_ENV=production
# 只复制 package.json + lockfile
COPY --from=build /repo/pnpm-lock.yaml /repo/package.json /repo/pnpm-workspace.yaml ./
COPY --from=build /repo/apps/api/package.json ./apps/api/package.json
COPY --from=build /repo/packages/shared/package.json ./packages/shared/package.json
# 只装 runtime deps
RUN pnpm install --frozen-lockfile --prod
# 只复制编译产物,不复制源码
COPY --from=build /repo/apps/api/dist ./apps/api/dist
COPY --from=build /repo/packages/shared/dist ./packages/shared/dist
COPY --from=build /repo/apps/web/dist ./apps/web/dist
两个关键点:
pnpm install --prod — 跳过 devDependencies,只安装 package.json#dependencies 中声明的运行时依赖。TypeScript、ESLint、Jest、Vite 等几百 MB 的构建时依赖不再出现在生产镜像中。
只复制 dist,不复制源码 — apps/api/dist 是 TypeScript 编译后的 JavaScript,apps/web/dist 是 Vite 打包后的静态文件。不需要把 .ts 源文件、tsconfig.json、eslint.config.mjs 带到生产环境。
需要注意的细节
Workspace 包的处理:项目中引用了 @yam-tv/shared(workspace 包)。在 production stage 中,需要确保:
packages/shared/package.json存在(pnpm 需要它来解析"main": "./dist/index.js")packages/shared/dist存在(编译产物)pnpm-workspace.yaml存在(pnpm 需要它来识别 workspace)packages/shared/dist存在(编译产物)pnpm-workspace.yaml存在(pnpm 需要它来识别 workspace)pnpm-workspace.yaml存在(pnpm 需要它来识别 workspace)
pnpm 的 --prod 模式在 monorepo 中会正确处理 workspace 包的 symlink,无需额外配置。
NestJS 是否依赖 reflect-metadata:是的,但它是 @nestjs/core 的 dependency,被 pnpm install --prod 包含,不会遗漏。
静态文件服务:apps/web/dist 由 NestJS 的 express.static 提供,只要求文件系统路径存在,与任何 npm 包无关。
结果
构建后验证:
yam-tv-api latest 489 MB → 388 MB
缩减了 101 MB(约 20%)。
| 内容 | 体积 |
|---|---|
node:22-alpine 基础镜像 | ~125 MB |
| NestJS + TypeORM + sql.js + ioredis + axios 等 runtime deps | ~200 MB |
@yam-tv/shared workspace 包 + zod | ~5 MB |
| 编译产物(dist) | ~10 MB |
| pnpm + Node.js 运行时 | ~45 MB |
devDependencies 贡献了大约 100 MB,与预期一致。
总结
这次瘦身的关键收获:
- 多阶段构建不是银弹 — 关键在于 final stage 从零开始
pnpm install --prod,而不是直接 COPY build stage 的node_modules。这是”多阶段”的正确打开方式。 - pnpm workspace 的
--prod模式在 monorepo 中可用 — 可以正确处理workspace:*协议的包,无需特殊处理。 - 388 MB 对 Node.js 全栈项目来说是合理水平 — 如果你看到 1 GB+ 的镜像,可能是把 devDeps 和源码都打包了;如果看到 150 MB 以下,可能是只放了一个简单的 Express 应用。
- 进一步瘦身的空间有限 — 最重的部分其实是 NestJS + TypeORM + sql.js 这些 runtime 依赖本身,换成
node:22-alpine-slim或更小的基础镜像收益不大,还可能引入兼容问题。
讨论
还没有留言,来留下第一条评论吧!