Docker 多阶段构建与镜像优化
一个 Node 应用镜像从 1.2GB 瘦到 150MB,靠的不是黑魔法,是几条基本规则。本文讲清 Docker 镜像瘦身:多阶段构建(构建层 vs 运行层分离)、基础镜像选型(distroless/alpine)、.dockerignore、层缓存复用、利用 Next.js standalone,把镜像做小、构建做快、攻击面做小。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
我接手过一个 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 . .一股脑塞了进去
瘦身的收益是三件事一起来的,不是一个:
- 拉取快:从仓库 pull 一个 150MB 的镜像,比拉 1.2GB 快近 10 倍。在 K8s 里弹性扩容、滚动更新,这是分钟级和秒级的差别。
- 启动快:镜像小、层数少、磁盘 I/O 少,容器冷启动肉眼可见地变快。
- 攻击面小:镜像里没有 shell、没有
curl、没有apt-get,攻击者就算 RCE 了也无处下脚。CVE 扫描器的红叉直接少一半。
记住这三点,后面每一条优化都能对得上号。
二、多阶段构建:把"构建"和"运行"分家
多阶段构建(Multi-stage build)是镜像瘦身的第一性原理。核心思想:Dockerfile 里写多个 FROM,每个 FROM 是一个独立阶段,最后只把你要的东西 COPY --from=... 到最终阶段。
# ---- 阶段 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 | ~120MB | musl 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:
# 依赖与构建产物(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。这意味着你改一行业务代码,重新构建要重装一遍依赖,几十秒到几分钟白等。
正确姿势:把不变的层放前面,频繁变的层放后面。依赖锁文件比业务代码稳定,所以先拷它:
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 里开:
const nextConfig = {
output: 'standalone',
};
构建后 .next/standalone/ 目录里是一个自包含的最小 Node server——它已经把运行时需要的 node_modules 子集(只留实际被引用的依赖)打包进去了。你不需要再 COPY node_modules,直接:
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。加一个系统用户:
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 里卡一发:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
高危 CVE 直接挂构建。镜像小了,扫描器也快,这是连锁收益。
3. buildx 缓存。本地开发和 CI 用 BuildKit 缓存层,跨分支、跨 PR 复用:
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
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。