目录

Docker-04 Dockerfile 与镜像构建:缓存、最小化和多阶段构建

1. Dockerfile 到镜像的过程

执行:

docker build -t example/api:dev .

最后的 .构建上下文。CLI 会把上下文发送给 daemon/BuildKit;Dockerfile 中能访问的 COPY 源文件必须位于上下文内。上下文过大不仅拖慢构建,还可能把密钥、.git、日志和本地依赖上传给构建器。

构建上下文 + Dockerfile
          ↓ BuildKit
        多个 layer
       镜像 config + manifest

每个产生文件系统变化的指令通常形成一个 layer。镜像 layer 是不可变的,缓存命中要求指令、输入文件和相关构建参数一致。

2. 常用指令

FROM alpine:3.20
LABEL org.opencontainers.image.title="example"
ARG VERSION=dev
ENV APP_ENV=production
WORKDIR /app
COPY . .
RUN chmod +x ./server
EXPOSE 8080
USER 10001:10001
ENTRYPOINT ["/app/server"]
CMD ["--listen", ":8080"]
指令作用常见坑
FROM指定基础镜像不固定版本或 digest,构建不可复现
ARG构建时变量运行容器时不可直接读取
ENV写入镜像/运行时环境不要放密码和 token
WORKDIR设置工作目录RUN cd 更可靠
COPY复制上下文文件.dockerignore 和上下文边界影响
ADD复制并支持归档/URL语义复杂,普通复制优先 COPY
RUN构建阶段执行命令清理缓存,避免把包管理器缓存写入 layer
EXPOSE声明端口元数据不会自动对外开放端口
USER设置默认运行用户先确保目录和端口权限正确
ENTRYPOINT固定主程序exec form 才能正确传递信号
CMD默认参数/命令运行时容易被覆盖
HEALTHCHECK定义健康探针只能反映容器内探针看到的状态

3. .dockerignore

.git
.gitignore
.DS_Store
node_modules
vendor
tmp
coverage
*.log
*.pem
*.key
.env
Dockerfile*
compose*.yaml

.dockerignore 不只是优化速度,也是降低机密泄露风险的边界。不要把 .env、云凭证、SSH 私钥和整个 target/node_modules 目录送进构建上下文。

4. 缓存为什么会命中

下面的写法把变化频繁的源代码放在依赖安装之后:

FROM golang:1.24 AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

如果先 COPY . .go mod download,任意源文件变化都会让依赖下载层失效。缓存优化的原则是:把稳定输入放前面,把高频变化输入放后面。

缓存不是正确性的替代品。升级依赖、基础镜像或构建工具时,使用明确的 cache bust 参数或 --no-cache,并在 CI 中定期重建验证。

5. 多阶段构建:编译环境和运行环境分离

一个适合 Go 服务的完整例子:

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src

RUN apk add --no-cache ca-certificates git
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 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

优势:

  • 最终镜像不包含 Go 编译器、源码、包管理器和调试工具。
  • CGO_ENABLED=0 生成静态二进制时可使用 scratch/distroless,但要确认 DNS、TLS CA 和时区文件需求。
  • USER nonroot 降低应用被利用后的权限。
  • BuildKit 的 RUN --mount=type=cache 可以复用构建缓存,又不把缓存写入最终 layer。

如果应用需要动态链接、时区、证书或 shell,选择 distroless/base、alpine 或 Debian slim 时要基于实际依赖测试,而不是只追求镜像体积。

6. 多架构构建

docker buildx create --name multi --use
docker buildx inspect --bootstrap
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/team/api:1.0.0 \
  --push .

--platform 生成的是多架构 manifest。不要在 Apple Silicon 上直接构建一个只含 arm64 的镜像然后部署到 amd64 服务器;用 docker image inspect、registry manifest 或 CI 构建矩阵确认目标架构。

7. 机密和供应链

7.1 不要把秘密写进 layer

下面的方式即使后面 rm,密码仍可能出现在历史 layer 中:

# 错误示例
ENV NPM_TOKEN=secret
RUN curl -H "Authorization: Bearer $NPM_TOKEN" ... && rm -f token

BuildKit 支持 secret mount:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t example/frontend:dev .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

secret 不会作为普通 layer 保存。仍应限制构建器权限,并避免在构建日志中打印秘密。

7.2 固定基础镜像

FROM alpine:3.20@sha256:<经过验证的digest>

固定 digest 能保证输入不随 tag 漂移,但会增加升级维护成本。生产流程应同时做漏洞扫描和定期更新,而不是永久锁死旧 digest。

8. Healthcheck 和优雅退出

HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1

健康检查应是轻量、幂等、不会修改业务数据的探针。它失败时,Docker 默认把容器标记为 unhealthy,不一定自动重启;重启行为要由 --restart 或编排平台决定。

应用作为 PID 1 时必须处理 SIGTERM:停止接收新请求、等待在途请求、关闭连接和 flush 日志。Docker 默认先发 SIGTERM,超时后才 SIGKILL。

9. 构建、查看和验证

docker build --progress=plain -t example/api:dev .
docker history --no-trunc example/api:dev
docker image inspect example/api:dev
docker run --rm --entrypoint sh example/api:dev -c 'id; ls -l /server'
docker run --rm -p 18080:8080 example/api:dev

在 CI 中至少做:

  1. Dockerfile lint 和 .dockerignore 检查。
  2. 单元测试、集成测试和镜像启动测试。
  3. 镜像漏洞扫描、SBOM 生成和签名/证明。
  4. 按 digest 部署,并保留构建日志、源码 commit 和基础镜像版本。

10. 常见反模式

  • 一个镜像安装多个无关服务,并用 supervisord 隐藏进程管理问题。
  • FROM ubuntu:latest,每次构建输入都可能变化。
  • RUN apt-get update 和安装拆成两层,导致索引过期或缓存失效。
  • 把源码、构建工具和测试数据一起带到生产镜像。
  • 以 root 运行 Web 服务,并给它 --privileged
  • 在 Dockerfile 中写入密码、SSH key、云凭证。
  • 为了“减小镜像”删除证书、时区或动态库,部署后才发现 HTTPS/DNS 失败。

11. Layer、缓存与镜像体积的细节

11.1 “后面删除文件”为什么不一定减小镜像

镜像 layer 不可变。如果第一层写入 100 MB,第二层删除它,最终文件视图看不到该文件,但第一层 blob 仍然存在:

# 反例:下载和删除分处两个 layer
RUN wget -O /tmp/tool.tgz https://example.com/tool.tgz
RUN tar xzf /tmp/tool.tgz -C /usr/local && rm /tmp/tool.tgz

应在同一个 RUN 中下载、校验、解包和删除,或使用 BuildKit 临时 mount:

ARG TOOL_SHA256
RUN set -eux; \
    wget -O /tmp/tool.tgz https://example.com/tool.tgz; \
    echo "$TOOL_SHA256  /tmp/tool.tgz" | sha256sum -c -; \
    tar xzf /tmp/tool.tgz -C /usr/local; \
    rm -f /tmp/tool.tgz

命令合并的目的不是追求“每个镜像只能有一层”,而是确保临时大文件没有进入前一层,同时让缓存失效范围仍可理解。

11.2 Debian/Ubuntu 安装包的正确层次

RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends ca-certificates curl; \
    rm -rf /var/lib/apt/lists/*

apt-get updateapt-get install 放在同一层,避免复用旧索引;删除 /var/lib/apt/lists 避免把包索引带进镜像。生产镜像还应固定基础镜像并定期重建,单纯锁死某个包版本会阻止安全修复。

11.3 Cache mount 与 layer cache 的区别

RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    go build -trimpath -o /out/server ./cmd/server

layer cache 复用整个构建步骤的结果;cache mount 允许步骤重新执行时复用编译器/包管理器缓存,而且这些缓存默认不进入最终 layer。共享 CI 构建器上要考虑 cache ID、读写模式、污染和清理策略。

12. 可复现构建与供应链元数据

镜像可复现不等于“同一个 Dockerfile”。输入还包括:

  • 基础镜像实际 digest 与目标架构。
  • Git commit、submodule、生成代码和 .dockerignore
  • 依赖锁文件、代理仓库和下载内容摘要。
  • BuildKit/frontend 版本、构建参数和时间戳。
  • CGO、编译器、系统库和跨架构模拟器。

建议写入标准 OCI label:

ARG VERSION
ARG REVISION
LABEL org.opencontainers.image.title="example-api" \
      org.opencontainers.image.version="$VERSION" \
      org.opencontainers.image.revision="$REVISION" \
      org.opencontainers.image.source="https://example.com/acme/api"

构建时:

docker buildx build \
  --build-arg VERSION=1.4.0 \
  --build-arg REVISION="$(git rev-parse HEAD)" \
  --provenance=true \
  --sbom=true \
  -t registry.example.com/acme/api:1.4.0 \
  --push .

不同 registry、Buildx 驱动和镜像格式对 attestation 展示方式不同;在发布链路中验证 SBOM/provenance 能被保存和查询,而不是只确认构建命令没有报错。

13. Distroless、scratch 与调试的取舍

运行镜像优点代价
scratch没有额外用户态文件,极小没有 CA、时区、用户数据库、shell,动态链接程序不能直接运行
distroless提供精简运行依赖和非 root 变体通常没有 shell/包管理器,现场调试方式不同
Alpine体积小,有 BusyBox/apkmusl 与 glibc 行为、CGO 兼容和 DNS 边缘差异
Debian/Ubuntu slimglibc 兼容较好、工具生态熟悉体积和包数量通常更大

不要为了“最小”删除应用需要的 CA bundle。Go 静态二进制也可能依赖时区文件、/etc/passwd、DNS resolver 行为或 CGO 动态库。

无 shell 镜像的调试思路:

  1. 通过日志、指标、trace 和 pprof 在应用层观察。
  2. 在同一 network/volume namespace 启动专用 debug 容器。
  3. Linux 主机上用 nsenterstracetcpdump 观察目标进程。
  4. 构建带工具但使用相同产物的 debug stage,绝不在生产容器里临时安装后当作修复。

14. 构建实验与验收

# 查看上下文和每一步输出
docker buildx build --progress=plain --load -t example/api:lab .

# 第二次构建观察 CACHED,修改 go.mod 后比较失效范围
docker buildx build --progress=plain --load -t example/api:lab .

# 检查历史、用户、入口、层和架构
docker history --no-trunc example/api:lab
docker image inspect example/api:lab | jq '.[0] | {
  user: .Config.User,
  entrypoint: .Config.Entrypoint,
  cmd: .Config.Cmd,
  arch: .Architecture,
  layers: .RootFS.Layers
}'

# 运行时验证只读根、非 root、信号和健康接口
docker run --rm --read-only --tmpfs /tmp \
  --cap-drop=ALL --security-opt no-new-privileges:true \
  example/api:lab

验收问题:换一台干净构建器是否成功、源码变化是否只失效预期层、最终镜像是否包含编译器或秘密、arm64/amd64 是否都能启动、SIGTERM 是否能在 timeout 内退出。

15. 本篇小结

高质量镜像的四个关键词是:可复现、可审计、足够小、最小权限。构建阶段使用缓存和多阶段,运行阶段只留下必需的二进制、证书、配置入口和非 root 用户;秘密通过运行时或 BuildKit secret 注入,不进入镜像历史。