跳转到主要内容

crayonxiaoxin

Docker 镜像瘦身实战:从 489 MB 到 388 MB

前言

一个 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.jsoneslint.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 或更小的基础镜像收益不大,还可能引入兼容问题。

讨论

还没有留言,来留下第一条评论吧!

留下足迹