Docker 镜像构建 (Image Building) 是 Docker 容器化技术的核心环节,它指的是根据 Dockerfile 中定义的一系列指令,逐步创建一个 Docker 镜像的过程。这个过程最终会生成一个轻量级、独立、可执行的软件包,其中包含运行应用程序所需的一切:代码、运行时环境、系统工具、库和配置。

核心思想:
Docker 镜像构建是一个分层 (Layered)基于指令 (Instruction-based) 的过程。每条 Dockerfile 指令都会在现有镜像层之上创建一个新的只读层,这些层的集合构成了最终的 Docker 镜像。


一、Docker 镜像与分层存储

在深入构建过程之前,理解 Docker 镜像的核心概念——分层存储 (Layered Storage) 至关重要。

  1. 只读层 (Read-Only Layers)
    • Docker 镜像由一系列只读的文件系统层组成,这些层叠加在一起。
    • Dockerfile 中的每条指令(例如 FROM, RUN, COPY 等)都会在现有镜像层之上创建一个新的只读层。
    • 这些层是可重用的。如果多个镜像共享相同的父层,它们可以复用这些层,从而节省存储空间。
  2. 可写容器层 (Writable Container Layer)
    • 当容器从镜像启动时,Docker 会在镜像的最顶层添加一个薄的、可写的容器层。
    • 所有对容器文件系统的修改(例如创建、修改、删除文件)都发生在这个可写层中。
    • 这意味着基础镜像保持不变,多个容器可以共享同一个只读镜像,同时拥有各自独立的读写文件系统。
  3. 写时复制 (Copy-on-Write, CoW)
    • Docker 利用写时复制机制来优化存储空间。当容器需要修改只读镜像层中的文件时,该文件首先会被复制到容器的可写层中,然后再进行修改。原始文件在只读层中保持不变。
    • 这使得镜像能够高效地共享,并且容器的启动速度很快。
1
2
3
4
5
6
graph TD
A[基础镜像层 (FROM)] --> B[RUN 指令 1]
B --> C[COPY 指令]
C --> D[RUN 指令 2]
D --> E[CMD/ENTRYPOINT (执行时创建容器层)]
E -- 容器启动 --> F[可写容器层 (Container Layer)]

二、docker build 命令详解

docker build 命令用于根据 Dockerfile 构建 Docker 镜像。

2.1 基本语法

1
docker build [OPTIONS] PATH | URL | -
  • PATH:指定构建上下文的路径。通常是 . (当前目录)。
  • URL:可以是一个 Git 仓库的 URL。Docker 会克隆该仓库并使用其中的 Dockerfile 进行构建。
  • -:从标准输入读取 Dockerfile 内容。

2.2 常用选项

  • -t, --tag <name>:<tag>
    • 为构建的镜像指定名称和标签。可以多次使用此选项来添加多个标签。
    • 例如:docker build -t myapp:1.0 -t myapp:latest .
  • -f, --file <path/to/Dockerfile>
    • 指定 Dockerfile 文件的路径(如果 Dockerfile 不在构建上下文的根目录或名称不是 Dockerfile)。
    • 例如:docker build -f myproject/Dockerfile.dev -t myapp-dev .
  • --build-arg <name>=<value>
    • Dockerfile 中定义的 ARG 变量传递构建时参数。
    • 例如:docker build --build-arg VERSION=1.2.3 -t myapp .
  • --no-cache
    • 在构建过程中禁用缓存。Docker 会重新执行 Dockerfile 中的所有指令,而不是使用之前的缓存层。
    • 当你确定某个层的内容已经改变,但 Docker 仍旧使用了旧的缓存时,这个选项很有用。
  • --pull
    • 始终尝试拉取最新版本的基础镜像,即使本地已存在。
  • --progress
    • 设置构建过程输出的进度显示类型(autoplaintty)。plain 可以避免一些终端显示问题。
  • --platform
    • 指定要构建镜像的目标平台,例如 linux/amd64linux/arm64。这在构建多架构镜像时非常有用。
  • .dockerignore 文件
    • 类似于 .gitignore,这个文件列出了在构建上下文传输到 Docker Daemon 时应该忽略的文件和目录。它可以显著减小构建上下文的大小,提高构建速度,并防止将不必要或敏感的文件包含到镜像中。

2.3 构建上下文的重要性

当你运行 docker build . 命令时,Docker CLI 会将当前目录(即 .)及其所有子目录和文件打包发送给 Docker Daemon。这个打包的内容就是构建上下文 (Build Context)

  • COPYADD 指令只能引用构建上下文中的文件。它们无法访问构建上下文外部的文件。
  • 最佳实践
    • Dockerfile 放在项目根目录。
    • 使用 .dockerignore 文件,明确排除不需要的文件,例如 .git 目录、node_modules 目录、构建过程中产生的大型临时文件等。这可以加速构建上下文的传输,减少镜像层缓存失效的概率,并避免将敏感信息或大型无用文件打包到镜像中。

三、Docker 镜像构建过程剖析

当执行 docker build 命令时,Docker Daemon 会按顺序执行 Dockerfile 中的指令。

  1. FROM
    • Docker Daemon 首先尝试查找本地是否存在指定的基础镜像。
    • 如果不存在,它会从配置的注册表(默认为 Docker Hub)拉取该镜像。
    • 这会形成新镜像的第一个只读层。
  2. 构建上下文传输
    • Docker CLI 将构建上下文目录(排除 .dockerignore 中指定的文件)发送给 Docker Daemon。
  3. 指令逐行执行与层创建
    • Dockerfile 中的每条指令都会作为一个独立的步骤执行。
    • 通常,一条指令会创建一个新的只读镜像层。
    • 例如,RUN 指令会在容器中执行命令,并将文件系统更改提交为一个新层。COPY 指令会将文件复制到新层中。
  4. 缓存机制
    • Docker 在构建时会尝试利用缓存。如果 Docker Daemon 发现某个指令与其之前的镜像层完全相同,并且后续指令的构建上下文文件没有发生变化,它会直接复用之前构建好的镜像层,而不是重新执行该指令。这大大加速了重复构建的速度。
    • 缓存失效规则
      • Dockerfile 指令本身或其参数发生改变,缓存失效。
      • ADDCOPY 指令所引用的文件内容发生改变(校验和不同),缓存失效。
      • ADDCOPY 指令的文件路径或文件名发生改变,缓存失效。
      • 某个层缓存失效后,所有后续指令的缓存也将失效,需要重新构建。
      • --no-cache 选项会完全禁用缓存。
  5. 镜像 ID 与标签
    • 每成功构建一个层,都会生成一个临时的容器 ID,然后提交并生成一个新的镜像层 ID。
    • 最终的镜像会获得一个唯一的 ID,并可根据 -t 选项进行标记 (tag)。

四、Docker 镜像构建最佳实践(Go 语言示例)

以下是一些重要的最佳实践,尤其结合 Go 语言应用的构建,可以帮助你构建出更高效、更小、更安全的 Docker 镜像。

4.1 多阶段构建 (Multi-stage Builds)

多阶段构建是优化 Docker 镜像大小和安全性的最重要策略之一。它允许你使用多个 FROM 语句,每个 FROM 都代表一个构建阶段。你可以在一个阶段中进行编译、测试等,然后只将最终的构建产物复制到下一个更轻量级的阶段。

示例:Go 应用程序的多阶段构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 阶段 1: 构建阶段
# 使用一个包含 Go 编译器和所有构建工具的较大镜像
FROM golang:1.20 AS builder

# 设置工作目录
WORKDIR /app

# 复制 go.mod 和 go.sum,先下载依赖,利用 Docker 缓存
# 如果 go.mod/go.sum 未改变,这一步会被缓存
COPY go.mod go.sum ./
RUN go mod download

# 复制应用程序源代码
COPY . .

# 构建 Go 应用程序
# CGO_ENABLED=0 禁用 Cgo,生成一个静态链接的二进制文件
# GOOS=linux 编译为 Linux 平台可执行文件
# -o myapp 指定输出文件名为 myapp
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myapp .

# 阶段 2: 最终镜像阶段
# 使用一个非常小的基础镜像(如 alpine 或 scratch)来运行最终的二进制文件
# scratch 是最精简的镜像,不包含任何操作系统组件,只有你的应用二进制文件
FROM alpine:latest

# 更新证书(如果应用程序需要进行 HTTPS 连接)
# 生产环境中如果不需要SSL证书或者应用自身打包了证书,此行可省略
RUN apk update && apk add --no-cache ca-certificates && rm -rf /var/cache/apk/*

# 设置工作目录
WORKDIR /root/

# 从 builder 阶段复制编译好的二进制文件
COPY --from=builder /app/myapp .

# 暴露应用程序监听的端口
EXPOSE 8080

# 定义容器启动时执行的命令
ENTRYPOINT ["./myapp"]

优点

  • 减小镜像体积:最终镜像不包含编译器、开发库、中间构建文件等,只包含运行应用程序所需的最小依赖。
  • 提高安全性:攻击面大大减小,因为最终镜像没有 Bash、Curl 等常用工具。
  • 清理过程简化:无需在单个 RUN 指令中进行复杂的清理操作。

4.2 选择合适的基础镜像

  • scratch:最轻量级的镜像,完全空白。适用于所有依赖都静态编译到二进制文件中的应用程序(如 Go 应用,通过 CGO_ENABLED=0 实现)。
  • alpine:基于 Alpine Linux,非常小的精简版 Linux 发行版,包含 BusyBox 工具集。适用于需要少量操作系统工具和动态库的场景。
  • 官方语言运行时镜像 (如 golang:1.20-alpine):提供了语言运行时环境和一些常用工具,但比 alpine 稍大。

4.3 合并 RUN 指令与清理

将多个 RUN 指令通过 && 连接起来,并在同一指令中进行清理操作,可以减少镜像层数和镜像体积。

反例:多次 RUN 创建多层垃圾

1
2
3
4
5
FROM ubuntu:latest
# 每次 RUN 都会创建新层
RUN apt-get update
RUN apt-get install -y some-package
RUN rm -rf /var/lib/apt/lists/* # 清理也成了新的一层

推荐:单次 RUN 合并与清理

1
2
3
4
FROM ubuntu:latest
RUN apt-get update && \
apt-get install -y --no-install-recommends some-package && \
rm -rf /var/lib/apt/lists/*

4.4 .dockerignore 文件

创建 .dockerignore 文件来排除不必要的构建上下文文件。

1
2
3
4
5
6
7
.git
.gitignore
node_modules
tmp/
*.log
Dockerfile
docker-compose.yml

4.5 精确指定标签

始终使用具体的版本标签,而不是 latest,以确保构建的可复现性。

1
FROM golang:1.20-alpine # 明确指定 Go 版本和 Alpine 版本

4.6 非 Root 用户运行

在容器中以非 root 用户运行应用程序,可以最小化潜在的安全漏洞。

1
2
3
4
5
6
7
8
FROM alpine:latest
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/myapp .
RUN chown appuser:appgroup /app/myapp # 确保应用文件归属正确
USER appuser # 后续指令以 appuser 身份运行
EXPOSE 8080
CMD ["./myapp"]

4.7 小心 ADD,优先 COPY

除非你需要 ADD 的自动解压或 URL 下载功能,否则始终使用 COPYCOPY 更具可预测性。

4.8 依赖管理和缓存重用

Dockerfile 中,将不经常变化的指令(如依赖下载)放在前面,将经常变化的指令(如代码复制)放在后面,以最大限度地利用 Docker 构建缓存。

1
2
3
4
# 示例:Go 应用中 go.mod 和 go.sum 变化频率低于源文件
COPY go.mod go.sum ./ # 如果这些文件没变,依赖下载步骤会缓存
RUN go mod download
COPY . . # 如果任何源文件改变,此步骤及后续步骤会重新构建

五、总结

Docker 镜像构建是容器化工作流中的基石。通过理解 Docker 的分层存储机制、docker build 命令的用法以及采纳多阶段构建、选择合适基础镜像、优化 RUN 指令、使用 .dockerignore 和非 root 用户运行等最佳实践,你可以构建出高效、安全、可管理且体积更小的 Docker 镜像。这些镜像不仅能加速开发和部署,还能显著提升应用程序的整体运维效率和安全性。