Docker 镜像瘦身:多阶段构建与缓存命中技巧

优秀的 Dockerfile 同时追求构建快、镜像小、依赖可审计和结果可复现。最常见的问题是先复制整个仓库再下载依赖,导致任何源码变化都让依赖层缓存失效;或者把编译工具、测试缓存和密钥一起留在运行镜像。

先固定依赖层

Go 项目先复制模块文件:

# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/app

FROM alpine:3.22
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/app /app/app
USER app
ENTRYPOINT ["/app/app"]

前端同理,先复制 package.json 和 Lockfile 安装依赖,再复制源码。Lockfile 必须进入版本控制,CI 使用 npm ci 等确定性命令。

.dockerignore 决定上下文质量

至少排除:

.git
node_modules
dist
coverage
*.log
.env*
_backups

构建上下文越小,传输、Hash 和意外泄密风险越低。不要依赖“Dockerfile 没 COPY 这个文件”来保护密钥,先让它根本不进入上下文。

使用 BuildKit 缓存挂载

缓存挂载能复用包下载与编译缓存,但不会把缓存内容写入最终层。CI 可使用外部缓存:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/app:buildcache \
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max \
  -t registry.example.com/app:sha-123 .

缓存不是可信输入。依赖校验仍由 Lockfile、Checksum 和软件源完成,不能因为命中缓存就跳过完整性检查。

控制最终镜像

  • 编译工具只留在 Builder Stage。
  • 运行进程使用非 Root 用户。
  • 显式复制时区、CA 证书等真正需要的运行文件。
  • 不在镜像中写配置密钥,运行时挂载或使用 Secret。
  • 固定基础镜像版本,关键生产环境可固定 Digest。
  • 生成 SBOM 并扫描已知漏洞。

Scratch 镜像最小,但诊断能力和证书、时区处理更严格;Distroless 或 Alpine 也各有兼容性取舍。最小不是唯一目标,可维护和可排障同样重要。

找出镜像为什么大

docker history --no-trunc your-image:tag
docker image inspect your-image:tag

删除文件必须与创建文件发生在同一层,否则旧层仍占空间。不要 RUN download 后在下一层 RUN rm。更好的方式是多阶段构建或在同一 RUN 完成并清理。

参考资料