Docker 多阶段构建实战:把 1.2GB 的 Node.js 镜像压到 50MB
前言:你的镜像为什么这么胖?
上周帮团队 review 一个前端项目的 Dockerfile,docker images 一看——1.2GB。一个静态站点,打包产物加起来不到 20MB,结果镜像比操作系统还大。
这不是个例。我见过太多项目的 Dockerfile 长这样:
FROM node:20WORKDIR /appCOPY . .RUN npm installRUN npm run buildEXPOSE 3000CMD ["npm", "start"]看起来没毛病,能跑。但问题是:生产环境里你需要 gcc、python3、npm 这些构建工具吗? 你需要 node_modules 里几百 MB 的 devDependencies 吗?
答案显然是不需要。今天这篇文章,我们用 Docker 的多阶段构建(Multi-stage Build),把这个 1.2GB 的怪物压到 50MB 以下——而且不牺牲任何功能。
一、理解镜像臃肿的根源
在动手优化之前,先搞清楚”肉”长在哪。
1.1 Docker 镜像的分层机制
Docker 镜像是由多个只读层(Layer)叠加而成的。每一条 RUN、COPY、ADD 指令都会创建一个新层:
Layer 5: CMD ["npm", "start"] ~0MBLayer 4: RUN npm run build ~50MBLayer 3: RUN npm install ~400MBLayer 2: COPY . . ~100MBLayer 1: FROM node:20 ~700MB─────────────────────────────────Total: ~1.25GB注意,即使你在后面的层里删除了文件,前面层里的文件仍然存在。这是很多人踩的坑:
RUN npm installRUN npm run buildRUN rm -rf node_modules # 没用!前面的层里 node_modules 还在这条 rm 只是在新层里标记”删除”,但镜像总大小不会减少。
1.2 基础镜像的体积对比
选错基础镜像,起点就输了:
| 镜像 | 大小 | 适用场景 |
|---|---|---|
node:20 | ~700MB | 开发调试(别用在生产) |
node:20-slim | ~200MB | 轻量开发,不含编译工具 |
node:20-alpine | ~50MB | 生产部署首选 |
nginx:alpine | ~20MB | 纯静态站点 |
python:3.12 | ~900MB | Python 开发(臃肿) |
python:3.12-slim | ~150MB | Python 生产(推荐) |
python:3.12-alpine | ~50MB | 极致精简(注意兼容性) |
一个选择就能砍掉 600MB,这是最容易拿到的优化收益。
二、多阶段构建:核心原理
多阶段构建的思路很简单:用一个”胖”容器来构建,用一个”瘦”容器来运行,只把构建产物拷过去。
# ===== 阶段 1:构建 =====FROM node:20 AS builderWORKDIR /appCOPY package*.json ./RUN npm ci --production=falseCOPY . .RUN npm run build
# ===== 阶段 2:运行 =====FROM nginx:alpine AS productionCOPY --from=builder /app/dist /usr/share/nginx/htmlEXPOSE 80CMD ["nginx", "-g", "daemon off;"]关键在 COPY --from=builder——它从第一阶段的文件系统里,只把 dist/ 目录拷到最终镜像。Node.js 运行时、node_modules、源代码,全部丢掉。
构建前后对比
# 优化前(单阶段)myboke:latest 1.24GB
# 优化后(多阶段)myboke:latest 23.4MB # 🔥 缩小 98%不是魔法,就是不把不需要的东西带进生产镜像。
三、实战案例:Vue/Astro 前端项目
以一个 Astro 博客项目为例(对 Vue 项目同理),完整的生产级 Dockerfile:
# ===== 阶段 1:依赖安装 =====FROM node:20-alpine AS depsWORKDIR /app
# 只拷 lockfile,利用缓存层COPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfile
# ===== 阶段 2:构建 =====FROM node:20-alpine AS builderWORKDIR /app
COPY --from=deps /app/node_modules ./node_modulesCOPY . .
# 构建参数注入ARG SITE_URL=https://example.comENV SITE_URL=${SITE_URL}
RUN corepack enable && pnpm run build
# ===== 阶段 3:生产运行 =====FROM nginx:1.27-alpine AS production
# 自定义 nginx 配置COPY nginx.conf /etc/nginx/conf.d/default.conf
# 只拷构建产物COPY --from=builder /app/dist /usr/share/nginx/html
# 非 root 用户运行(安全加固)RUN chown -R nginx:nginx /usr/share/nginx/html
EXPOSE 80HEALTHCHECK --interval=30s --timeout=3s \ CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]为什么分了三个阶段?
把”依赖安装”单独抽出来是一个高级技巧。原因是 Docker 的层缓存机制:
- 如果
package.json和pnpm-lock.yaml没变,deps阶段会命中缓存,跳过pnpm install - 源代码改了只会触发
builder阶段重新构建 - 依赖不变时,构建速度从 2 分钟降到 20 秒
这个优化在 CI/CD 里效果尤其明显。
配套的 nginx.conf
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html;
# 静态资源缓存策略 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 1y; add_header Cache-Control "public, immutable"; }
# SPA 路由回退(如果是 SPA 模式) location / { try_files $uri $uri/ /index.html; }
# gzip 压缩 gzip on; gzip_types text/plain text/css application/json application/javascript text/xml; gzip_min_length 1000;}.dockerignore 别忘了
很多人写了完美的 Dockerfile,却忘了 .dockerignore,导致 COPY . . 把一堆垃圾拷进去:
node_modulesdist.git.env**.log.DS_Store.vscodecoverage.git 目录动辄几十 MB,加上 node_modules——你的构建上下文就先胖了 500MB。
四、实战案例:Python FastAPI 项目
Python 项目的容器化有自己的坑。来看一个 FastAPI 服务的优化:
# ===== 阶段 1:构建依赖 =====FROM python:3.12-slim AS builderWORKDIR /app
# 安装编译工具(部分库需要)RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ===== 阶段 2:生产运行 =====FROM python:3.12-slim AS productionWORKDIR /app
# 只拷编译好的 Python 包COPY --from=builder /install /usr/local
# 拷应用代码COPY ./app ./app
# 非 root 用户RUN useradd --create-home appuserUSER appuser
EXPOSE 8000CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Python 容器化的三个坑
坑 1:pip 缓存吃掉几百 MB
# ❌ 错误:pip 会缓存下载的包RUN pip install -r requirements.txt
# ✅ 正确:禁用缓存RUN pip install --no-cache-dir -r requirements.txt坑 2:Alpine 与 Python 的兼容性问题
很多人看到 Alpine 镜像小就直接用,但 Python 生态的很多库(如 pandas、numpy)依赖 C 扩展,在 Alpine 上需要从源码编译,构建时间从 30 秒暴增到 15 分钟,而且最终镜像也不一定更小。
我的建议:
python:3.12-slim → 150MB,兼容性好,构建快 ✅python:3.12-alpine → 50MB,但编译慢,兼容性差 ⚠️除非你的项目只用纯 Python 库(没有 C 扩展),否则 slim > alpine。
坑 3:requirements.txt 的锁定
# ❌ 模糊版本,每次构建可能装不同版本flask>=2.0
# ✅ 精确锁定flask==3.1.1werkzeug==3.1.3推荐用 pip-compile(pip-tools)或 poetry export 生成精确的依赖文件。
五、进阶技巧:榨干最后的空间
5.1 合并 RUN 指令减少层数
# ❌ 三个层RUN apt-get updateRUN apt-get install -y curlRUN rm -rf /var/lib/apt/lists/*
# ✅ 一个层,且清理在同一层生效RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*5.2 用 dive 分析镜像层
dive 是一个神器,可以逐层分析镜像,找出哪一层最胖:
# 安装brew install dive # macOS# 或docker run --rm -it \ -v /var/run/docker.sock:/var/run/docker.sock \ wagoodman/dive:latest <your-image>它会用交互式界面展示每一层的文件变化,精确到字节。我靠这个工具发现过一个项目里有人把 .git 目录打进了镜像——300MB 就这么浪费了。
5.3 利用 BuildKit 缓存挂载
Docker BuildKit 提供了 --mount=type=cache,可以在不增加镜像层的情况下缓存构建中间产物:
# syntax=docker/dockerfile:1FROM node:20-alpine AS builderWORKDIR /appCOPY package.json pnpm-lock.yaml ./
# 缓存 pnpm store,跨构建复用RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ corepack enable && pnpm install --frozen-lockfile
COPY . .RUN pnpm run build这个缓存不会进入最终镜像,但能大幅加速重复构建。
5.4 安全扫描不能少
镜像瘦了不代表安全了。生产镜像必须做安全扫描:
# Trivy:开源、快、准docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ aquasec/trivy:latest image your-image:latest
# Docker Scout(Docker 官方)docker scout cves your-image:latestAlpine 基础镜像的一个隐藏优势:包少 = 攻击面小 = 漏洞少。
六、完整的 CI/CD 集成示例
把多阶段构建接入 GitHub Actions,实现推送即部署:
name: Build & Deploy
on: push: branches: [main]
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
- name: Login to Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push uses: docker/build-push-action@v5 with: context: . push: true tags: ghcr.io/${{ github.repository }}:latest cache-from: type=gha cache-to: type=gha,mode=max build-args: | SITE_URL=https://your-site.com注意 cache-from 和 cache-to 使用了 GitHub Actions 缓存,CI 构建速度能提升 3-5 倍。
七、优化效果汇总
做完全部优化后的对比数据:
| 优化措施 | 镜像大小 | 构建时间 | 说明 |
|---|---|---|---|
原始 FROM node:20 | 1.24GB | 3min 20s | 什么都没做 |
换 node:20-slim | 580MB | 3min 10s | 基础镜像瘦身 |
多阶段 + nginx:alpine | 23.4MB | 2min 50s | 只保留产物 |
| + 依赖缓存分层 | 23.4MB | 22s* | *依赖未变时 |
| + BuildKit 缓存挂载 | 23.4MB | 18s* | 极致缓存 |
| + .dockerignore | 23.1MB | 15s* | 构建上下文变小 |
从 1.24GB 到 23MB,缩小了 98%。构建时间从 3 分 20 秒到 15 秒(缓存命中时),缩短了 93%。
Python FastAPI 项目的数据:
| 优化措施 | 镜像大小 |
|---|---|
FROM python:3.12 单阶段 | 1.1GB |
多阶段 + python:3.12-slim | 180MB |
+ --no-cache-dir + 清理 | 155MB |
八、总结与最佳实践清单
Docker 镜像优化不是什么黑魔法,核心就三件事:
- 选对基础镜像:生产用
alpine或slim,别用完整版 - 多阶段构建:构建和运行分开,只带走产物
- 利用缓存:依赖分层 + BuildKit 缓存挂载
最后给一个 checklist,每次写 Dockerfile 前过一遍:
- 基础镜像用了
slim或alpine版本? - 使用了多阶段构建?
-
.dockerignore排除了.git、node_modules等? -
RUN指令合并了?安装后清理了? - 依赖安装和代码拷贝分层了?(利用缓存)
- 用非 root 用户运行?
- 加了
HEALTHCHECK? - 做过安全扫描?
容器化不是把 npm start 包一层壳就完事。写好 Dockerfile 是每个现代开发者的基本功——不管你是前端、后端还是全栈。
💡 动手试试:拿你手头的项目,跑一下
docker images看看镜像多大。然后用本文的方法优化一遍,欢迎在评论区晒出你的优化数据!
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!