Docker 多阶段构建优化镜像

引言

大家有没有遇到过这种情况:自己写的 Node.js、Go 或者 Python 项目,用 Docker 打包完后,镜像体积大得离谱?我之前做一个 Go 服务,编译出来的二进制才 20MB,结果镜像愣是干到了 1GB 多,里面一堆没用的依赖和构建工具。这不仅占用磁盘空间,部署的时候也特别慢,镜像拉取半天不动弹。

后来我学会了 Docker 的多阶段构建(Multi-stage Build),镜像体积直接砍到只剩几十兆,拉取速度飞起。今天就给大家好好聊聊这个技术,怎么用多阶段构建来优化你的 Docker 镜像。

什么是多阶段构建

多阶段构建是 Docker 17.05 之后引入的特性,简单来说就是在一个 Dockerfile 里定义多个阶段(Stage),每个阶段可以有不同的基础镜像,最后只把需要的内容复制到最终镜像里。

传统的构建方式是这样的:用一个包含完整开发环境的镜像,比如各种语言的基础镜像再加上编译工具,然后在容器里跑完构建,最后直接提交。这个镜像里就会残留很多编译依赖、临时文件之类的垃圾。

多阶段构建的好处就在于,它允许我们“用完就扔”。第一个阶段专门用来构建,把代码编译成可执行文件或者安装依赖;第二个阶段用一个干净的基础镜像,只把构建产物复制过来。这样最终镜像里就不会有那些没用的东西了。

基础用法演示

先来看一个最简单的例子,这是优化前的 Dockerfile:

FROM node:18

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

这个镜像体积保守估计在 1GB 左右,因为 node:18 本身就不小,再加上各种依赖。

改成多阶段构建后是这样的:

# 第一阶段:构建阶段

FROM node:18 AS builder

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

第二阶段:运行阶段

FROM node:18-alpine

WORKDIR /app

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/node_modules ./node_modules

COPY package*.json ./

EXPOSE 3000

CMD ["node", "dist/index.js"]

看到区别了吗?第一个阶段用完整的 node:18 来跑构建,第二个阶段用轻量的 node:18-alpine,而且只复制构建产物(dist 目录和 node_modules)。这样镜像体积能少一大截,alpine 镜像本身才几十MB。

Go 项目的优化案例

再给大家看一个 Go 项目的例子,这个更典型,因为 Go 是静态编译的语言,理论上只需要一个二进制就能跑,但很多人不会配置 Dockerfile,导致镜像特别大。

传统写法:

FROM golang:1.21

WORKDIR /app

COPY . .

RUN go build -o main .

EXPOSE 8080

CMD ["./main"]

这个镜像体积轻松超过 1GB,因为 golang:1.21 这个镜像本身就很大,包含了完整的 Go 编译工具链。

优化后的多阶段版本:

# 构建阶段

FROM golang:1.21 AS builder

WORKDIR /app

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

运行阶段

FROM alpine:latest

RUN apk --no-cache add ca-certificates

WORKDIR /app

COPY --from=builder /app/main .

EXPOSE 8080

CMD ["./main"]

这里有几个小技巧:

1. CGO_ENABLED=0 禁用 CGO,编译纯静态的二进制

2. GOOS=linux 确保编译产物是 Linux 可执行文件

3. -installsuffix cgo 避免链接额外的 C 库

4. 第二个阶段用 alpine,只加了一个 ca-certificates 用于 HTTPS

最终镜像大概只有 20-30MB,比原来的 1GB 少了 97%!

Python 项目同样适用

Python 项目也能用多阶段构建,不过 Python 一般是解释型语言,不像 Go 那样能直接编译成二进制。但我们可以用这个思路来减少依赖。

# 构建阶段

FROM python:3.11-slim AS builder

WORKDIR /app

COPY requirements.txt .

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

运行阶段

FROM python:3.11-slim

WORKDIR /app

COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages

COPY . .

EXPOSE 8000

CMD ["python", "main.py"]

这里利用 --no-cache-dir 安装依赖,然后只把 site-packages 目录复制到最终镜像里,省去了 pip 本身和构建工具。

进阶技巧

除了上面说的基本用法,还有几个可以进一步优化的地方:

1. 减少 COPY 次数

Docker 的每一层 COPY 都会增加镜像体积,尽量合并:

# 不好的写法

COPY package.json .

COPY package-lock.json .

RUN npm install

COPY . .

好的写法

COPY package*.json ./

RUN npm install

COPY . .

2. 利用 .dockerignore

跟 .gitignore 类似,把不需要的文件排除掉,减少 COPY 进去的内容:

node_modules

.git

*.md

Dockerfile

.dockerignore

dist

3. 合理选择基础镜像

alpine 镜像虽然小,但有时候会有兼容性问题,比如 glibc 相关的库。如果遇到问题,可以考虑用 debian:slim 或者直接用官方的基础镜像。

4. 分阶段构建可以多个

复杂项目可能需要多个构建阶段,比如先编译前端,再编译后端:

FROM node:18 AS frontend-builder

WORKDIR /frontend

COPY frontend/ .

RUN npm run build

FROM golang:1.21 AS backend-builder

WORKDIR /app

COPY backend/ .

RUN go build

FROM alpine

COPY --from=frontend-builder /frontend/dist /var/www/html

COPY --from=backend-builder /app/main /usr/local/bin/

CMD ["main"]

总结

多阶段构建真的是 Docker 优化的一把利器,学会了能省不少事儿。它核心思想就是“分离构建环境和运行环境”,用完的依赖就扔掉,只保留最终需要的东西。

不管你是写 Node.js、Go、Python 还是其他语言,都可以尝试用多阶段构建来精简镜像。镜像小了,部署速度快,存储成本也低,何乐而不为呢?

赶紧回去检查一下自己的 Dockerfile,看看有没有优化空间吧!如果有什么问题,欢迎在评论区交流。