减小Docker镜像体积:实现更快构建的实用指南
通过多阶段构建、缓存友好的Dockerfile和精简构建上下文,让Docker镜像更小、构建更快。
减小Docker镜像体积:实现更快构建的实用指南
减小Docker镜像体积并非追求docker image ls中显示的最小数字。真正有价值的目标是构建过程快速、可重复、易于修补,并且足够小,不会拖慢CI、部署或本地开发。
臃肿的镜像通常源于一些常见习惯:过早复制整个仓库、将构建工具安装到运行时镜像中、残留包管理器缓存、使用latest标签,以及向Docker发送庞大的构建上下文。修正这些习惯后,大多数团队无需采用特殊手段就能获得更简洁的构建。
首先测量当前镜像:
docker image ls my-app
docker history --no-trunc my-app:latest
docker history会显示哪些Dockerfile指令增加了体积。如果某个RUN步骤增加了大量体积,在修改其他内容之前先检查该步骤。如需更深入的分析,dive等工具可以显示每一层添加和删除的文件,但内置命令足以完成初步检查。
优先选择兼容性好的基础镜像,其次考虑体积
基础镜像的选择很重要,但最小的镜像不一定是最好的。Alpine体积小是因为它使用musl libc和最小化的用户空间。这对于许多Go、Node、Python和工具镜像来说效果很好,但可能会与原生扩展、预编译二进制文件、DNS行为或需要glibc的包产生摩擦。
一个实用的选择顺序是:
- 使用运行时的官方镜像。
- 优先选择带版本号的标签,如
python:3.12-slim,而不是python:latest。 - 如果应用有原生依赖,先尝试
slim版本,再考虑Alpine。 - 当你确切知道运行时需要什么时,使用distroless或
scratch。
例如,一个使用psycopg、图像库或加密包的Python服务,在python:3.12-slim上可能比python:3.12-alpine更容易维护。Alpine镜像最初可能更小,但如果你添加编译器和兼容性包来构建依赖,最终结果可能不会更简单或更小。
对于Go服务,scratch或distroless是非常好的选择,因为静态编译的二进制文件在运行时几乎不需要什么。即便如此,也要记得包含TLS证书、时区数据和非root用户。一个无法发起HTTPS请求的微小镜像并不是有用的生产镜像。
使用多阶段构建将构建工具排除在生产环境之外
对于编译型应用和前端构建,多阶段构建是减小Docker镜像体积最可靠的方法。构建阶段可以很大,包含编译器、包管理器、测试工具和源文件。最终阶段只应包含应用运行所需的内容。
一个Go示例:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
重要的不是具体的基础镜像,而是边界:源代码、模块缓存和编译器留在builder中;最终镜像只获取二进制文件。
Node应用也有类似的模式,但需要将开发依赖与生产依赖分开:
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
对于由nginx提供的前端资源,最终镜像完全不需要Node:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
这种模式将node_modules、源代码树和构建工具从运行时镜像中移除。
让Docker缓存为你工作
Docker缓存失效规则简单但严格。如果一条指令发生变化,Docker会重建该层及其后的所有层。如果在安装依赖之前复制了整个项目,每次代码编辑都可能强制重新安装依赖。
这种方式很慢:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
这种方式通常更快:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
依赖层仅在requirements.txt更改时重建。源代码更改只重建后面的COPY . .层。
同样的规则适用于Maven、Gradle、npm、pnpm、Cargo和Go模块。先复制依赖清单,安装或下载依赖,然后再复制其余源代码。
使用.dockerignore保持构建上下文小巧
Docker在开始构建之前会将构建上下文发送到守护进程。如果你的上下文包含.git、本地node_modules、覆盖率报告、视频、截图、测试数据库和构建产物,那么你的构建在Dockerfile运行之前就已经变慢了。
一个基本的.dockerignore可能如下所示:
.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store
注意宽泛的忽略规则。如果你忽略了dist但Dockerfile期望复制预构建的dist,构建会失败。如果你忽略了.env,这通常对密钥有好处,但你的构建不应依赖它。构建应通过显式的构建参数或CI配置接收所需的值。
你可以在经典Docker构建输出的开头看到上下文大小,BuildKit输出也会显示大的上下文传输。如果第一步在任何Dockerfile指令运行之前就花费了很长时间,请检查.dockerignore。
在同一层中清理包管理器缓存
一个常见的错误是在一层中安装包,在后面的层中删除缓存。文件会从最终文件系统视图中消失,但它们可能仍然存在于镜像历史中。
对于Debian和Ubuntu镜像:
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
对于Alpine:
RUN apk add --no-cache ca-certificates curl
对于Python:
RUN pip install --no-cache-dir -r requirements.txt
对于npm:
RUN npm ci --omit=dev && npm cache clean --force
不要盲目复制清理命令。使用与包管理器和基础镜像匹配的清理方式。同时,在大多数应用Dockerfile中避免使用apt-get upgrade。它会使构建变得不可预测,并可能引入比你预期更多的更改。通过定期从维护的基础镜像重建并固定你期望的镜像系列来打补丁。
除非需要ADD,否则优先使用COPY
COPY从构建上下文复制文件。ADD有额外的行为:在某些情况下它可以解压本地tar归档文件并获取远程URL。这种额外行为可能会让维护者感到意外,并使缓存行为更难理解。
对于普通应用文件使用COPY:
COPY ./src ./src
仅在你有意使用其归档提取行为时才使用ADD。对于远程下载,优先在RUN步骤中使用curl或wget,这样你可以验证校验和并清晰地处理失败情况。
减少安装的内容,而不仅仅是删除的内容
最好的缓存清理是首先不安装不必要的文件。在apt-get中使用--no-install-recommends。避免在生产镜像中安装编辑器、shell、包管理器和调试工具,除非你有理由这样做。
话虽如此,如果移除所有工具会使操作变得更困难,就不要这样做。生产镜像可以在保持最小化的同时仍然可观测。对于某些团队,带有shell的slim镜像比distroless镜像更好,因为值班调试更简单。对于其他拥有强大日志、追踪和临时调试容器的团队,distroless是很好的选择。
诚实的答案是因工作负载而异。较小的镜像通常拉取更快,暴露的补丁包更少,但可维护性仍然很重要。
使用BuildKit缓存挂载进行依赖下载
BuildKit可以将包缓存保留在最终镜像之外,同时在构建之间重用它们。这与将缓存留在镜像内部不同。
对于npm:
# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
对于apt,缓存挂载可以帮助在CI环境中重复构建,但许多CI系统使用全新的工作节点,除非你配置了持久缓存。BuildKit功能很强大,但要让Dockerfile对维护它的团队保持可读性。
检查最终镜像内容
更改后,验证改进效果:
docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized
然后以生产运行相同的方式运行镜像。一个更小的镜像如果因为缺少证书、区域数据、时区文件或可写目录而失败,那并不是改进。
有用的冒烟测试包括:
docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health
如果应用写入文件,测试该路径。如果它调用HTTPS服务,测试TLS。如果它以非root用户运行,测试权限。
实用的优化顺序
当我审查一个过大的Docker镜像时,我使用这个顺序:
- 添加或修复
.dockerignore。 - 重新排列Dockerfile,使依赖安装可以被缓存。
- 引入多阶段构建。
- 在兼容的情况下,从完整的基础镜像切换到
slim、Alpine、distroless或scratch。 - 在创建包管理器缓存和开发依赖的同一层中移除它们。
- 使用
docker history或dive检查,并仅在证据指向问题时重复。
这个顺序避免了过早的取巧。它也使Dockerfile保持可理解性,这比挤出最后几兆字节更重要。