Docker 多阶段构建实战:把 1.2GB 的 Node.js 镜像压到 50MB

2256 字
11 分钟
Docker 多阶段构建实战:把 1.2GB 的 Node.js 镜像压到 50MB

前言:你的镜像为什么这么胖?#

上周帮团队 review 一个前端项目的 Dockerfile,docker images 一看——1.2GB。一个静态站点,打包产物加起来不到 20MB,结果镜像比操作系统还大。

这不是个例。我见过太多项目的 Dockerfile 长这样:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

看起来没毛病,能跑。但问题是:生产环境里你需要 gccpython3npm 这些构建工具吗? 你需要 node_modules 里几百 MB 的 devDependencies 吗?

答案显然是不需要。今天这篇文章,我们用 Docker 的多阶段构建(Multi-stage Build),把这个 1.2GB 的怪物压到 50MB 以下——而且不牺牲任何功能。

一、理解镜像臃肿的根源#

在动手优化之前,先搞清楚”肉”长在哪。

1.1 Docker 镜像的分层机制#

Docker 镜像是由多个只读层(Layer)叠加而成的。每一条 RUNCOPYADD 指令都会创建一个新层:

Layer 5: CMD ["npm", "start"] ~0MB
Layer 4: RUN npm run build ~50MB
Layer 3: RUN npm install ~400MB
Layer 2: COPY . . ~100MB
Layer 1: FROM node:20 ~700MB
─────────────────────────────────
Total: ~1.25GB

注意,即使你在后面的层里删除了文件,前面层里的文件仍然存在。这是很多人踩的坑:

RUN npm install
RUN npm run build
RUN 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~900MBPython 开发(臃肿)
python:3.12-slim~150MBPython 生产(推荐)
python:3.12-alpine~50MB极致精简(注意兼容性)

一个选择就能砍掉 600MB,这是最容易拿到的优化收益。

二、多阶段构建:核心原理#

多阶段构建的思路很简单:用一个”胖”容器来构建,用一个”瘦”容器来运行,只把构建产物拷过去

# ===== 阶段 1:构建 =====
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
# ===== 阶段 2:运行 =====
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

关键在 COPY --from=builder——它从第一阶段的文件系统里,只把 dist/ 目录拷到最终镜像。Node.js 运行时、node_modules、源代码,全部丢掉。

构建前后对比#

Terminal window
# 优化前(单阶段)
myboke:latest 1.24GB
# 优化后(多阶段)
myboke:latest 23.4MB # 🔥 缩小 98%

不是魔法,就是不把不需要的东西带进生产镜像

三、实战案例:Vue/Astro 前端项目#

以一个 Astro 博客项目为例(对 Vue 项目同理),完整的生产级 Dockerfile:

# ===== 阶段 1:依赖安装 =====
FROM node:20-alpine AS deps
WORKDIR /app
# 只拷 lockfile,利用缓存层
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# ===== 阶段 2:构建 =====
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 构建参数注入
ARG SITE_URL=https://example.com
ENV 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 80
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

为什么分了三个阶段?#

把”依赖安装”单独抽出来是一个高级技巧。原因是 Docker 的层缓存机制

  1. 如果 package.jsonpnpm-lock.yaml 没变,deps 阶段会命中缓存,跳过 pnpm install
  2. 源代码改了只会触发 builder 阶段重新构建
  3. 依赖不变时,构建速度从 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_modules
dist
.git
.env*
*.log
.DS_Store
.vscode
coverage

.git 目录动辄几十 MB,加上 node_modules——你的构建上下文就先胖了 500MB。

四、实战案例:Python FastAPI 项目#

Python 项目的容器化有自己的坑。来看一个 FastAPI 服务的优化:

# ===== 阶段 1:构建依赖 =====
FROM python:3.12-slim AS builder
WORKDIR /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 production
WORKDIR /app
# 只拷编译好的 Python 包
COPY --from=builder /install /usr/local
# 拷应用代码
COPY ./app ./app
# 非 root 用户
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["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 生态的很多库(如 pandasnumpy)依赖 C 扩展,在 Alpine 上需要从源码编译,构建时间从 30 秒暴增到 15 分钟,而且最终镜像也不一定更小。

我的建议:

python:3.12-slim → 150MB,兼容性好,构建快 ✅
python:3.12-alpine → 50MB,但编译慢,兼容性差 ⚠️

除非你的项目只用纯 Python 库(没有 C 扩展),否则 slim > alpine

坑 3:requirements.txt 的锁定

Terminal window
# ❌ 模糊版本,每次构建可能装不同版本
flask>=2.0
# ✅ 精确锁定
flask==3.1.1
werkzeug==3.1.3

推荐用 pip-compile(pip-tools)或 poetry export 生成精确的依赖文件。

五、进阶技巧:榨干最后的空间#

5.1 合并 RUN 指令减少层数#

# ❌ 三个层
RUN apt-get update
RUN apt-get install -y curl
RUN 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 是一个神器,可以逐层分析镜像,找出哪一层最胖:

Terminal window
# 安装
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:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY 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 安全扫描不能少#

镜像瘦了不代表安全了。生产镜像必须做安全扫描:

Terminal window
# 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:latest

Alpine 基础镜像的一个隐藏优势:包少 = 攻击面小 = 漏洞少。

六、完整的 CI/CD 集成示例#

把多阶段构建接入 GitHub Actions,实现推送即部署:

.github/workflows/deploy.yml
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-fromcache-to 使用了 GitHub Actions 缓存,CI 构建速度能提升 3-5 倍。

七、优化效果汇总#

做完全部优化后的对比数据:

优化措施镜像大小构建时间说明
原始 FROM node:201.24GB3min 20s什么都没做
node:20-slim580MB3min 10s基础镜像瘦身
多阶段 + nginx:alpine23.4MB2min 50s只保留产物
+ 依赖缓存分层23.4MB22s**依赖未变时
+ BuildKit 缓存挂载23.4MB18s*极致缓存
+ .dockerignore23.1MB15s*构建上下文变小

从 1.24GB 到 23MB,缩小了 98%。构建时间从 3 分 20 秒到 15 秒(缓存命中时),缩短了 93%

Python FastAPI 项目的数据:

优化措施镜像大小
FROM python:3.12 单阶段1.1GB
多阶段 + python:3.12-slim180MB
+ --no-cache-dir + 清理155MB

八、总结与最佳实践清单#

Docker 镜像优化不是什么黑魔法,核心就三件事:

  1. 选对基础镜像:生产用 alpineslim,别用完整版
  2. 多阶段构建:构建和运行分开,只带走产物
  3. 利用缓存:依赖分层 + BuildKit 缓存挂载

最后给一个 checklist,每次写 Dockerfile 前过一遍:

  • 基础镜像用了 slimalpine 版本?
  • 使用了多阶段构建?
  • .dockerignore 排除了 .gitnode_modules 等?
  • RUN 指令合并了?安装后清理了?
  • 依赖安装和代码拷贝分层了?(利用缓存)
  • 用非 root 用户运行?
  • 加了 HEALTHCHECK
  • 做过安全扫描?

容器化不是把 npm start 包一层壳就完事。写好 Dockerfile 是每个现代开发者的基本功——不管你是前端、后端还是全栈。


💡 动手试试:拿你手头的项目,跑一下 docker images 看看镜像多大。然后用本文的方法优化一遍,欢迎在评论区晒出你的优化数据!

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Docker 多阶段构建实战:把 1.2GB 的 Node.js 镜像压到 50MB
https://boke.hackerdream.xyz/posts/docker-multi-stage-build-optimization/
作者
晴天
发布于
2026-04-24
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录