#DevOps

Docker 多阶段构建与镜像优化

一个 Node 应用镜像从 1.2GB 瘦到 150MB,靠的不是黑魔法,是几条基本规则。本文讲清 Docker 镜像瘦身:多阶段构建(构建层 vs 运行层分离)、基础镜像选型(distroless/alpine)、.dockerignore、层缓存复用、利用 Next.js standalone,把镜像做小、构建做快、攻击面做小。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年7月4日·更新于 2026年7月23日

我接手过一个 Node 项目,docker images 一看,镜像 1.2GB。打开 Dockerfile,通篇一个 FROM node:22,然后 COPY . .、npm install、npm run build、CMD ["node", "server.js"],一气呵成。构建是能构建,问题也一眼可见:它把整个构建工具链、源码、devDependencies、.git 历史全部打进了运行镜像。

这种镜像跑起来没问题,但拉一次几十秒、启动一次几秒、漏洞扫描器一跑红一片。这篇文章讲清怎么把镜像从 1.2GB 做到 150MB 以内,顺带把构建速度和安全性也提一截。没有黑魔法,就几条基本规则。

一、镜像为什么那么大:先把锅甩对

一个典型的"胖镜像"里,运行时根本用不到的东西占了 80% 以上:

  • 基础镜像本身:node:22(完整 Debian)解压后约 900MB,光 shells、apt、glibc 文档就占一大半
  • devDependencies:typescript、eslint、vitest、prisma 这些只在构建期用,运行时一行代码都没碰
  • 源码与构建中间产物:src/、.next/cache/、*.map、测试快照
  • 构建上下文污染:.git、本地 node_modules、.env、npm-debug.log,这些被 COPY . . 一股脑塞了进去

瘦身的收益是三件事一起来的,不是一个:

  1. 拉取快:从仓库 pull 一个 150MB 的镜像,比拉 1.2GB 快近 10 倍。在 K8s 里弹性扩容、滚动更新,这是分钟级和秒级的差别。
  2. 启动快:镜像小、层数少、磁盘 I/O 少,容器冷启动肉眼可见地变快。
  3. 攻击面小:镜像里没有 shell、没有 curl、没有 apt-get,攻击者就算 RCE 了也无处下脚。CVE 扫描器的红叉直接少一半。

记住这三点,后面每一条优化都能对得上号。

二、多阶段构建:把"构建"和"运行"分家

多阶段构建(Multi-stage build)是镜像瘦身的第一性原理。核心思想:Dockerfile 里写多个 FROM,每个 FROM 是一个独立阶段,最后只把你要的东西 COPY --from=... 到最终阶段。

dockerfile
# ---- 阶段 1:builder,装依赖 + 构建 ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# ---- 阶段 2:runner,只拷产物 ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]

关键在第二阶段:它只 COPY --from=builder 了构建产物,没拷源码、没拷 node_modules、没拷 devDependencies。builder 阶段那几百兆的垃圾,在最终镜像里一行都不留。

builder 用完即弃,这就是多阶段的精髓。docker build 结束后,中间阶段会被清掉(除非你 --target 停在某一阶段调试),最终 push 到仓库的只有最后那个 FROM。

三、基础镜像选型:node:22 / alpine / slim / distroless 怎么选

这一步直接决定镜像下限。同样是 Node 22,四个选择体积差出一个数量级:

基础镜像体积特点
node:22~900MB完整 Debian,啥都有,也啥都多余
node:22-slim~200MB精简 Debian,保留 glibc,兼容性好
node:22-alpine~120MBmusl libc,极小,部分原生模块有坑
gcr.io/distroless/nodejs22-debian12~150MB无 shell、无包管理器,最安全

我的建议很直接:

  • 默认选 node:22-slim。兼容性最好,绝大多数 npm 包不带原生绑定,体积也能接受。团队刚上手多阶段,从 slim 起步最稳。
  • 追求极致选 alpine。但要有心理准备:sharp、bcrypt、node-canvas、better-sqlite3 这类依赖原生编译的包,在 musl 上可能要重新编译甚至跑不起来。Prisma 在新版已经做了 alpine 适配,但老版本踩过坑的不少。上了 alpine 就把镜像在 CI 里真的跑一遍,别只看构建通过。
  • 安全要求高选 distroless。它连 /bin/sh 都没有,攻击者 exec 出来一个 shell 都做不到。代价是调试困难——docker exec -it <c> sh 直接报错,排查问题得靠日志和 kubectl cp。生产环境这个代价值得。

我的实战选择:builder 用 node:22-alpine(快、装编译工具链方便),runner 用 node:22-alpine 或 distroless。builder 阶段不需要多安全,它又不上线。

四、.dockerignore:第一道闸门,90% 的人漏掉

docker build 第一步是把**整个构建上下文(Build Context)**打包发给 daemon。没有 .dockerignore,COPY . . 之前,.git(可能几百 MB)、本地 node_modules(如果你装过)、.next(上一次的构建产物)、node_modules、.env、IDE 配置、*.log 全部会被压缩、传输、解压一遍。

后果不只是镜像大,更狠的是每次构建都慢——因为构建上下文一变,缓存命中率就掉。

一个像样的 .dockerignore:

gitignore
# 依赖与构建产物(builder 会重新 install,不要把宿主的带进去)
node_modules
.next
out
build
dist

# 版本控制与 CI 报告
.git
.github
.lighthouseci

# 本地环境与日志
.env*
*.log
npm-debug.log*
yarn-error.log*
.pnpm-debug.log*

# 编辑器与系统
.vscode
.idea
.DS_Store
*.tsbuildinfo

# 部署本身不需要进镜像
Dockerfile
.dockerignore
docker-compose*.yml
README.md
docs

一句话:.dockerignore 是 COPY . . 的安全带。没系就别上路。

五、层缓存复用:先拷 package.json,再拷源码

Docker 每条指令是一层,缓存按"指令 + 输入"命中。COPY . . 一改任意源码,后面所有层全部失效——包括 RUN pnpm install。这意味着你改一行业务代码,重新构建要重装一遍依赖,几十秒到几分钟白等。

正确姿势:把不变的层放前面,频繁变的层放后面。依赖锁文件比业务代码稳定,所以先拷它:

dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
# 第 1 步:只拷锁文件,这一层只有 package.json 变了才会失效
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# 第 2 步:现在才拷源码。源码变了,只让 builder 的 COPY 失效,
# deps 阶段的 pnpm install 命中缓存,秒级完成
COPY . .
RUN pnpm build

把依赖单独抽成 deps 阶段是个进阶技巧:它让"装依赖"这一层不仅能在多次构建间复用,还能干净地 COPY 到任意下游阶段。改一行代码,deps 阶段直接命中缓存,构建从 2 分钟掉到 15 秒。

另一个常被忽略的点:pnpm 比 npm/yarn 更适合 Docker。pnpm 用硬链接 + content-addressable store,装出来的 node_modules 体积小、速度快,而且 --frozen-lockfile 能严格保证 CI 和本地的依赖完全一致。

六、实战:Next.js standalone,把镜像做到 150MB

这是 ROI 最高的一招。Next.js 13+ 支持 output: 'standalone',在 next.config.ts 里开:

ts
const nextConfig = {
  output: 'standalone',
};

构建后 .next/standalone/ 目录里是一个自包含的最小 Node server——它已经把运行时需要的 node_modules 子集(只留实际被引用的依赖)打包进去了。你不需要再 COPY node_modules,直接:

dockerfile
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# 三样东西:standalone server + 静态资源 + public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# 如果用了 i18n / Prisma,需要额外补这些运行时文件
COPY --from=builder /app/src/messages ./src/messages
COPY --from=builder /app/src/generated ./src/generated

CMD ["node", "server.js"]

注意 standalone 不包含 public/ 和 .next/static/(静态资源要单独拷),也不包含你运行时动态读取的目录(messages、Prisma client、配置文件等)。漏拷运行时会报 MODULE_NOT_FOUND,这是 standalone 最常见的坑——对着报错逐个补就行。

最终镜像:node:22-alpine 基础(~50MB)+ standalone server + 少量静态资源,总计 120–160MB。和最初 1.2GB 的胖镜像比,瘦了 87%。

七、最小权限、镜像扫描、构建缓存:收尾三件套

镜像做小了,最后补三件事,把它做"稳"。

1. 非 root 用户运行。默认容器以 root 跑,一旦逃逸就是宿主机 root。加一个系统用户:

dockerfile
RUN addgroup --system --gid 1001 nodejs \
 && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
USER nextjs

USER nextjs 放在 CMD 之前,确保进程以非 root 启动。K8s 里再用 runAsNonRoot: true 兜底。

2. 镜像扫描。Trivy / Grype / Snyk 都行,CI 里卡一发:

bash
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest

高危 CVE 直接挂构建。镜像小了,扫描器也快,这是连锁收益。

3. buildx 缓存。本地开发和 CI 用 BuildKit 缓存层,跨分支、跨 PR 复用:

bash
docker buildx build \
  --cache-from type=registry,ref=myorg/cache \
  --cache-to type=registry,ref=myorg/cache,mode=max \
  -t myapp:latest .

mode=max 把中间阶段也缓存进去(默认只缓存最终阶段),配合 deps 阶段,构建基本是秒级。CI 上把缓存放在 registry 或 S3,所有开发者共享。

小结

  • 镜像大的根因是把构建工具链、源码、devDependencies 全打进了运行镜像。瘦身的收益是三件事一起来的:拉取快、启动快、攻击面小。
  • 多阶段构建是第一性原理:builder 阶段装依赖+构建,runner 阶段只 COPY --from=builder 产物。builder 用完即弃,最终镜像不染一尘。
  • 基础镜像按需选:默认 node:22-slim(兼容好),追极致用 alpine(注意原生模块坑),安全优先用 distroless(无 shell,最稳)。
  • .dockerignore + 层缓存顺序是构建速度的两道闸:别让 .git / node_modules 进上下文;先拷 package.json 装依赖,再拷源码,让依赖层稳如老狗。
  • Next.js output: standalone 是 ROI 最高的一招,直接把镜像做到 150MB 量级,顺带带走一份最小依赖的 node_modules。

一句话收尾:镜像优化不是炫技,是把"构建产物"和"运行环境"分清楚——上线的是产物,不是工地。 一个 150MB、无 shell、非 root 的镜像,是你给运维和安全的最好交付物。

Related

相关推荐