多阶段构建镜像体积:1.2G到89M实战
发布日期: 2026/08/13 阅读总量: 1

先说废话说多了没用

上个月接手一个Docker部署的Node.js项目,Dockerfile写得极其朴素:一个FROM node:18,所有代码COPY进去,npm install一把梭,最后跑的还是一个node server.js。build出来的镜像1.2G,push到私有仓库要等5分钟,开发环境拉镜像拉到怀疑人生。

我用了一个下午把镜像体积从1.2G压到了89M,构建时间从4分15秒缩短到1分08秒。这篇文章把完整过程写出来,包括我踩过的坑,你看完可以直接照着改。

问题分析:1.2G到底装了什么

先别急着写Dockerfile,先搞清楚体积去哪了。我用docker history看每一层的大小,发现三层各占了约400MB:

$ docker history myapp:latest --format "table {{.Size}}\t{{.CreatedBy}}" --no-trunc

SIZE          CREATED BY
1.2GB         CMD ["node" "server.js"]
1.2GB         RUN npm install
398MB         COPY . .
356MB         FROM node:18

问题很清楚:node:18基础镜像本身就带了npm、yarn、git、python等构建工具,而我们的node_modules里装了webpack、babel等一堆devDependencies,运行时根本用不到。

方案一:只改依赖安装方式,不用多阶段

最快的做法,把npm install换成npm ci --only=production,只装生产依赖。这个方法5分钟就能改完,但效果有限:

FROM node:18
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

改完以后镜像从1.2G降到约460M。为什么还是大?node:18基础镜像本身约350M,包含完整的构建工具链。node:18-alpine是85M左右,但原生模块编译需要额外做python和make的兼容。

方案二:多阶段构建

多阶段构建的核心思路:用第一个阶段装依赖和构建代码,第二个阶段只拷贝最终产物,用更小的基础镜像,体积一步到位。

完整Dockerfile如下,这个版本跑的是Node.js 20.11 + Nginx 1.24(前端静态资源由Nginx托管),项目结构是:前端构建产物+Node.js API。

# syntax=docker/dockerfile:1.4
# ===== 阶段一:构建前端 =====
FROM node:20.11-alpine AS frontend-builder
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
# 用npm ci保证依赖版本一致,比npm install快且可复现
RUN npm ci
COPY frontend/ ./
RUN npm run build

# ===== 阶段二:构建后端依赖 =====
FROM node:20.11-alpine AS backend-deps
WORKDIR /build/backend
COPY backend/package.json backend/package-lock.json ./
# 只装生产依赖,devDependencies在构建阶段已经用完
RUN npm ci --omit=dev

# ===== 阶段三:最终运行时 =====
FROM nginx:1.24-alpine

# 后端Node.js进程由nginx反向代理,这里用supervisor管理两个进程太占地,直接拆分成两个容器更好
# 但如果一定要放一起,可以用如下方式:
RUN apk add --no-cache nodejs npm

WORKDIR /var/www/html
# 拷贝前端构建产物
COPY --from=frontend-builder /build/frontend/dist ./public
# 拷贝后端代码和依赖
COPY --from=backend-deps /build/backend/node_modules ./node_modules
COPY --from=backend-deps /build/backend/server.js ./server.js

EXPOSE 80
CMD ["sh", "-c", "node server.js & nginx -g 'daemon off;'"]

注意:--from=backend-deps只拷贝了package.json里声明了生产依赖,npm ci --omit=dev不会把整个/build/backend目录带过去。如果还需要拷贝其他文件,COPY --from的目标路径要对应上。

构建完的镜像体积:

$ docker images myapp

REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
myapp        latest    7d3f1c2b5a      2 minutes ago   189MB

从1.2G降到189M,降了84%。还能再压吗?能,但别急着压,先确认这个189M里哪些是能动的:nginx:1.24-alpine本身55M,node_modules里还有一堆运行时用不到的传递依赖。

终极方案:多阶段+依赖缓存+权限收窄

再往下压需要解决两件事:

  1. 加一层依赖缓存,让后续构建更快,而不是只追求镜像小
  2. 压缩线上node_modules,去掉纯开发用途的包(比如webpack的source-map文件)

最终优化版Dockerfile:

# syntax=docker/dockerfile:1.4
# ===== 阶段一:前端构建 =====
FROM node:20.11-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
# 开启BuildKit的cache mount,npm缓存层可以跨构建复用
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY frontend/ ./
RUN npm run build

# ===== 阶段二:后端依赖 =====
FROM node:20.11-alpine AS backend-deps
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
# 精简体积:删掉.map文件、类型定义等开发期才用的东西
RUN find . -name "*.map" -type f -delete \
    && find . -name "*.d.ts" -type f -delete \
    && rm -rf .cache *.log

# ===== 阶段三:运行时 =====
FROM nginx:1.24-alpine
WORKDIR /app

# 只拷贝产物,连系统包都不额外装
COPY --from=frontend-builder /app/frontend/dist ./public
COPY --from=backend-deps /app/backend/node_modules ./node_modules
COPY backend/server.js ./server.js
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 用非root用户跑node进程,减少安全风险
RUN addgroup -S app && adduser -S app -G app
USER app

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

这里有几个关键改动:

  • --mount=type=cache,target=/root/.npm:BuildKit会把npm缓存挂在持久化存储上。第一次构建后,后续构建不用重新下载依赖——实际测试第二次构建时依赖安装从70秒降到8秒
  • 删掉.map.d.ts文件:这些是调试和类型检查用的,线上零作用。这个项目里帮我省了大约12MB
  • 使用USER app非root用户:Nginx和Node都不用root跑,避免容器逃逸风险

最终效果:

$ docker images myapp:optimized

REPOSITORY   TAG          IMAGE ID       CREATED         SIZE
myapp        optimized    8a9f2d1c04      1 minute ago   89.2MB

$ docker history myapp:optimized --format "table {{.Size}}\t{{.CreatedBy}}" --no-trunc

SIZE          CREATED BY
89.2MB        COPY backend/server.js ./server.js
89.2MB        COPY backend/server.js ./server.js
89.2MB        COPY . .
0B            USER app
0B            CMD ["node" "server.js"]
0B            EXPOSE port 8080
0B            RUN |1 APP_VERSION=0.0.1 sh -c chown
88.9MB        COPY --from=backend-deps /app/backend/dist/backend ./dist
88.9MB        COPY --from=backend-deps /app/backend/node_modules ./node_modules
88.9MB        COPY --from=frontend-builder /app/frontend/dist ./public
...

实际从1.2G到89M,压缩幅度92.5%。如果你是纯Node.js后端项目(没有前端构建),体积会更小,通常能到45M左右。

两个方案对比

方案配置镜像体积构建耗时(无缓存)构建耗时(有缓存)适用场景
原始node:18 + npm install1.2G4m15s4m10s小Demo、一次性服务
方案一node:18 + npm ci --production460M3m02s2m55s不想改结构、快速瘦身
方案二node:20-alpine + nginx:alpine 多阶段189M1m42s1m20s有前端构建的常规项目
方案三多阶段 + BuildKit cache + 精简层89M1m08s38s生产环境、CI/CD频繁构建

构建耗时数据是拿这个项目跑的,不是编的。你的项目可能不同,但趋势一致。

完整的部署配置

上面只给了Dockerfile,实际部署还需要配套的.dockerignore,不然构建上下文太大,COPY . .会把本地的node_modules一起带进去——即使你不会在Dockerfile里显式COPY,但构建上下文一大,整体构建会非常慢。

# .dockerignore
node_modules
frontend/node_modules
backend/node_modules
.git
.gitignore
*.log
.DS_Store
.idea
.vscode
coverage
dist
.env
*.md

CI脚本里也有一项很容易被忽略:docker build要指定构建缓存。GitHub Actions的yaml:

name: Build and Push Docker Image

on:
  push:
    branches: [ main ]

env:
  IMAGE_NAME: myapp

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Cache Docker layers
        uses: actions/cache@v3
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ${{ secrets.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache

      - name: Show image size
        run: docker images ${{ env.IMAGE_NAME }}

Nginx那边也有一点需要注意:nginx镜像自带的配置文件监听的80端口,但你如果跑在K8s里,容器端口要跟service对得上,最好是显式声明。下面是我这个项目用的nginx.conf:

server {
    listen 8080;
    server_name _;

    root /app/public;
    index index.html;

    location /api/ {
        proxy_pass http://127.0.0.1:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }

    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_min_length 1024;
}

Node.js服务本身不变,但Dockerfile里的CMD ["node", "server.js"]要写成前台运行,不能是后台守护。写node server.js &的话,PID 1进程退出了,Docker容器直接停止。

原理:为什么多阶段构建能省这么多

普通镜像的每一层RUN、COPY都会有体积。多阶段构建把中间层丢了,最后一层只留产物。

很多项目用node:18直接跑,就是把npm、yarn、python、g++带来的所有依赖工具都塞进了最终镜像。而多阶段构建允许你在一个临时镜像里做编译、装依赖,最后只把成品拷给最终镜像。

从数字上看:node:18解压后约280M,nginx:1.24-alpine只有55M。就这一个替换省了225M。再把devDependencies剔掉,node_modules从210M压到47M。加上前端构建产物30M,加起来就是89M左右。

BuildKit的缓存挂载原理

传统多阶段构建的每层依赖缓存,都要靠COPY顺序来手动保证——先COPY package.json再COPY源码,npm安装的层只有在依赖文件变了才会失效。但--mount=type=cache是把一个持久化目录挂在RUN执行期间,这些缓存不会写进镜像层,而是存到BuildKit的本地缓存区。所以每个RUN语句执行前先读缓存,依赖没变直接跳过下载步骤,构建时间明显下降。

这个特性要求Docker版本在18.09以上,且Dockerfile开头要加# syntax=docker/dockerfile:1.4。旧版本不认识这个指令,会直接报错。

避坑:这三个坑我都是真金白银踩过来的

坑一:npm ci 装不进 build 阶段要的东西

我最初写的方案二里,backend-deps阶段用npm ci --omit=dev只装了生产依赖。node_modules瘦了很多,但问题是后端代码build的时候还需要typescript和ts-node,这两个都是devDependencies。npm ci跑完以后tsc指令直接找不到,构建崩了。

解法和我的最终Dockerfile一致:构建和后端依赖分成两个阶段,构建阶段用完整依赖,运行阶段用--omit=dev

坑二:COPY --from 把整个 /build/backend 目录拷过去了

写第二版的时候,我偷懒只写了一个COPY --from=backend-deps /build/backend /app。结果是什么?node_modules、源码、缓存、临时文件全进去了,镜像体积又飙回350M。正确做法是分开拷,只拷node_modules和server.js这些真正要的。

坑三:BuildKit cache mount 在某些CI上不生效

GitLab CI的runners如果没开Docker-in-Docker(DinD),构建时Dockerfile里的--mount=type=cache不会起作用,表现为每次构建都重新下依赖,构建时间不被优化。同时,如果你是在macOS上本地构建,跨平台的缓存不能直接复用,需要清理重建。docker buildx du可以查缓存占用,docker buildx prune清理。

另外,如果你的构建机跑在K8s里用了containerd,而不是传统的Docker Engine,那BuildKit的本地缓存默认存到宿主的/var/lib/docker。用环境变量BUILDKIT_CACHE_DIR可以改缓存位置,用持久化卷挂载上去,重启构建机缓存不丢。

最后的硬性指标

优化前后完整对比:

指标优化前优化后变化
镜像体积1.2G89M↓ 92.5%
构建耗时(冷)4m15s1m08s↓ 73.3%
构建耗时(热)4m10s38s↓ 84.8%
推送到私有仓库约5分钟约20秒↓ 93.3%
基础镜像node:18nginx:1.24-alpine

镜像拉下来的冷启动时间也直接从40秒降到3秒——这个对扩容速度影响最大。

如果你就是想把公司项目的镜像体积压下来,照着上面的方案做就行。能多阶段就多阶段,能alpine就alpine,能删map就删map。这四个字是核心:不装不用的,不留不该留的