让Docker镜像缩小90%:多阶段构建完整教程

Docker多阶段构建完整教程,从2.1GB到48MB实战总结。Node.js/Python/Go三种语言的多阶段构建模板,拿来即用。

你的Docker镜像,几个G?我的一个Node.js项目,从2.1GB降到48MB。

这不是删文件能做到的。这是一套叫「多阶段构建」的技术带来的结果。

问题:为什么你的Docker镜像这么大

先说一个事实:大多数项目的Docker镜像体积是不正常的。

看几个典型案例。一个基于Create React App的Node.js项目,打出来的镜像通常在800MB-1.2GB之间。一个Python Django项目,镜像轻松超过1.5GB。一个Go服务如果直接用 golang:1.21 作为基础镜像,体积也在900MB以上。

这些体积带来的真实成本是什么?

CI/CD环节:更大的镜像意味着更慢的 docker pull。在网络条件一般的情况下,拉取一个1GB的镜像可能需要5-10分钟。这5-10分钟,每天积累下来就是巨大的效率损耗。

生产环境:每次Pod重启都要重新拉取镜像。在K8s的滚动更新场景下,大镜像意味着更长的更新时间窗口和更高的服务不可用风险。

存储成本:无论你用阿里云ACK还是AWS EKS,镜像仓库的存储都是按GB收费的。100个项目,每个项目节省500MB,一年下来就是600GB的免费存储空间。

问题清楚了。接下来看怎么解决。

多阶段构建原理

什么是多阶段构建

多阶段构建是Docker 17.05引入的特性。它的核心思想是:一个Dockerfile可以包含多个 FROM 指令,每个 FROM 开启一个独立的构建阶段。

第一个阶段称为 Build Stage,负责编译、构建、安装依赖——这些操作需要完整的开发工具链。第二个阶段称为 Final Stage,只复制 Build Stage 的产物,配置运行时环境。

Build Stage 产生的所有冗余——编译工具、源码、构建依赖——都不会进入 Final Stage。这是体积减少的根本原因。

Build Stage vs Final Stage

看一个最简单的对比。

传统单阶段 Dockerfile(Node.js):

FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["npm", "start"]

问题在哪?node:18 这个基础镜像本身就超过900MB,里面包含了npm、yarn、整个Node.js运行时,以及所有构建工具。而且 npm install 会把开发依赖也装进来,这些依赖在生产环境毫无用处。

多阶段构建版本:

# Build Stage
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --only=production
COPY . .
RUN npm run build

# Final Stage
FROM node:18-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["npm", "start"]

node:18-slim 体积约200MB,比 node:18 小70%。更重要的是,生产镜像里只有编译好的 dist 目录和必要的 node_modules,没有源码,没有构建工具链。

阶段间如何传递产物

多阶段构建中,阶段间的产物传递通过 COPY –from=stage_name 实现。

当你用 AS 关键字给 Build Stage 命名后,Final Stage 可以通过名字引用它:

FROM xxx AS builder
# ... 构建步骤 ...

FROM yyy
COPY --from=builder /app/output ./output

也可以用数字索引引用(从0开始):

COPY --from=0 /app/output ./output

这个设计非常灵活。一个 Dockerfile 可以包含任意多个构建阶段,每个阶段负责特定任务,最终只把需要的东西合并到生产镜像里。

实战案例

Node.js项目多阶段构建

完整的 Node.js 多阶段构建模板:

# Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Stage 2: Builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage 3: Runner
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser

COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

三个阶段的职责划分:

  • deps:只安装依赖,不编译任何东西
  • builder:运行构建,生成静态文件或编译TypeScript
  • runner:生产运行环境,最小化配置

实测结果:一个中等规模的 Next.js 项目,镜像体积从 1.1GB 降到 67MB。

Python项目多阶段构建

Python 项目的多阶段构建稍微复杂一点,因为 Python 的依赖安装和运行是两个不同的环境。

# Stage 1: Builder
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

# Stage 2: Final
FROM python:3.11-slim AS runner
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/* \
    && useradd --create-home appuser

COPY --from=builder /root/.local /home/appuser/.local
COPY --from=builder /app /app

ENV PATH=/home/appuser/.local/bin:$PATH \
    PYTHONUNBUFFERED=1

USER appuser
WORKDIR /app
CMD ["python", "-m", "myapp"]

关键点:用 –no-cache-dir 避免pip缓存,用 –user 把包安装到用户目录而非系统目录。Final Stage 只需要运行时的动态库(libpq5),不需要 gcc 等编译工具。

实测结果:一个 Django 项目,镜像体积从 1.4GB 降到 89MB。

Go项目多阶段构建

Go 项目的多阶段构建是最简单的,因为 Go 是编译型语言,产出物是一个独立的二进制文件。

# Stage 1: Builder
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

# Stage 2: Final
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

这是最小化的 Go 生产镜像。Alpine Linux 基础镜像只有5MB,加上证书大约8MB。整个镜像可以控制在15MB以内。

如果追求更小的体积,可以用 FROM scratch(完全空白的基础镜像):

FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags '-w -s' -o main .
# -w -s 去掉调试信息和符号表,体积再减30%

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/main /main
EXPOSE 8080
ENTRYPOINT ["/main"]

scratch 镜像是真正意义的零体积——没有操作系统,没有Shell,什么都没有。只有你的二进制文件和它依赖的证书文件。Go静态编译的二进制文件完全可以运行在 scratch 上。

实测结果:一个中等规模的 Go HTTP 服务,镜像体积从 940MB(golang:1.21)降到 12MB(scratch)。

进阶技巧

选择合适的基础镜像

基础镜像是体积的主要来源。优先使用 -alpine、-slim、-distroless 等轻量版本。

常见基础镜像体积对比(Node.js):

镜像 体积
node:18 1.1GB
node:18-alpine 180MB
node:18-slim 200MB
node:18-distroless 140MB

Alpine 用 apk 包管理器,体积最小但有时会遇到兼容性问题(特别是涉及glibc的场景)。Slim 用apt,兼容性最好。Distroless 是Google维护的超精简镜像,不包含Shell,适合安全要求高的场景。

合并RUN指令减少层数

Docker镜像的每一层都会占用体积。合并多个RUN指令可以减少层数和总体积:

# 差的写法:4层
RUN apt-get update
RUN apt-get install -y nginx
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# 好的写法:1层
RUN apt-get update && \
    apt-get install -y nginx curl && \
    rm -rf /var/lib/apt/lists/*

每一次 RUN 都会生成一个新的镜像层。用 && 合并操作,层数更少,体积更小。

.dockerignore的正确配置

.dockerignore 决定了哪些文件不会被传送到Docker构建上下文。不配置 .dockerignore,你的 node_modules、.git 目录、本地测试数据都会被传进去,既浪费带宽又增大体积。

node_modules
.git
.env
.env.*
!.env.example
*.log
npm-debug.log*
.DS_Store
dist
coverage
.vscode
.idea
*.md
!README.md
tests
spec
__tests__

关键点:node_modules 一定要排除(重新安装比复制更快),.git 必须排除,*.log 排除避免日志文件进入镜像。

结尾

多阶段构建不是什么新技术,但它能带来的改变被严重低估了。

一个从 2.1GB 降到 48MB 的镜像,拉取时间从 8 分钟降到 20 秒,CI/CD 的 docker pull 步骤从瓶颈变成几乎无感。这就是为什么要认真对待 Docker 镜像优化——它直接转化为效率。

现在开始检查你的项目 Dockerfile。三个问题:

  1. 你的基础镜像是完整版还是 slim/alpine 版?
  2. 你的构建依赖有没有进入生产镜像?
  3. 有没有用多阶段构建?

如果任何一个问题的答案是「没有」,你现在就知道从哪开始了。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注