Atlantis
GitHub Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

3.3 构建容器镜像

1. 构建镜像流程

在当前目录下执行 docker build 命令时,会发生哪些事情?

  1. 解析 Dockerfile:读取当前目录中的 Dockerfile,解析其中的指令。
  2. 准备上下文目录:Docker 客户端会将当前目录作为构建上下文目录,将该目录下的所有文件和子目录打包发送给 Docker 守护进程,可以在当前目录下创建 .dockerignore 文件来排除无需的文件和目录。
  3. 创建构建容器:Docker 守护进程根据 FROM 指令中的基础镜像创建一个临时容器。
  4. 逐步执行指令:每执行一条指令,Docker 都会创建一个新的镜像层并将其缓存,如果某个步骤发生变化(如命令变更、上下文目录内文件变更等),该步骤及其后的所有步骤都会重新执行并创建镜像层。
  5. 生成最终镜像:所有指令都执行完毕后,所有层合并成一个最终的镜像,并为该镜像分配一个唯一的镜像 ID。
  6. 保存镜像:建完成的镜像会被保存到本地 Docker 镜像存储库中,可以使用 docker images 命令查看。
  7. 输出镜像 ID:Docker 客户端输出构建的镜像 ID

下面记录一些最佳实践。

2. 使用代理

网络问题,依旧是网络问题,构建镜像时能有无数的网络问题可以阻塞,比如使用 apt 安装依赖、使用 pip、npm 下载第三方库等,总会碰到被防火墙阻拦的网址,解决办法与拉取镜像时使用代理的方法类似。

  1. 使用 –build-arg 传递环境变量,如:docker build --build-arg HTTP_PROXY="http://proxy.example.com:3128 HTTP_PROXY="https://proxy.example.com:3128" .
  2. 在网关配置透明代理,让网关分流网络请求解决绕过防火墙。

如果不想每次构建镜像时都传递 --build-arg,也可以修改文件: ~/.docker/config.json,添加代理服务器,如下:

{
 "proxies": {
   "default": {
     "httpProxy": "http://proxy.example.com:3128",
     "httpsProxy": "https://proxy.example.com:3129",
     "noProxy": "*.test.example.com,.example.org,127.0.0.0/8"
   }
 }
}

这样无论是在构建镜像阶段还是运行容器阶段,都会自动注入三个代理相关的环境变量。

不过笔者认为这并不是最佳方案,在使用 PVE 搭建家庭服务器后,笔者认为有条件的话应该在网关上配置透明代理。因为除了使用 docker 构建容器外,还有利用容器构建镜像的场景,为每一个构建镜像的实例手动配置代理着实麻烦,不如在网关层面解决网络问题。

3. 利用构建缓存

在构建镜像时,Docker 会为 Dockerfile 中的每一条指令创建一个新的镜像层并缓存,假设 Dockerfile 以及上下文目录中的文件、子目录都未发生变化,那么将会跳过构建,直接输出上一轮构建生成的镜像 ID。

下面是一些可能导致缓存失效的情况:

  1. 对 RUN 指令命令的任何更改
  2. COPY、ADD 指令拷贝到镜像中的任何文件、目录的任何更改(无论是内容更改还是权限等属性的更改)
  3. 从失效的镜像层开始的后续所有镜像层都将失效

为了规避缓存失效,可以调整 Dockerfile 指令的先后顺序,如:

  1. 前置不易发生变化的指令,如 FROM、WORKDIR、CMD、VOLUME、ENTRYPOINT 等
  2. 其次是安装依赖的指令,如执行 RUN apk add --no-cache python3 py3-pip 安装依赖库
  3. 最后是编译和拷贝文件的指令,如执行 RUN 编译代码以及拷贝可执行文件

总之就是尽可能将导致镜像内容变化的指令推后,这样就可以充分利用缓存。

4. 进一步加速构建镜像

除了缓存外还有两个办法来加速构建,本质上也是缓存的另一种体现:

  1. 多阶段构建:分离依赖和最终产物,让构建阶段专用镜像编译可执行文件,再使用运行阶段专用镜像保存可执行文件,发布到生产环境
  2. 基础镜像:提取 Dockerfile 指令分别制作构建阶段和运行阶段使用的专用镜像,将构建阶段的内容变化控制在拷贝文件与执行编译,运行阶段的内容变化控制在使用 COPY 拷贝可执行文件。

下面是一个原始的 Dockerfile,我们会在其中使用基础镜像安装依赖,编译可执行文件,最终拷贝到 /usr/bin 目录下:

# 引用基础镜像
FROM golang:1.22.4-bookworm

# 安装依赖
RUN apt update -y && apt install -y build-essential libvirt-dev 

# 复制所有文件到构建目录
COPY . /build

# 编译 Go 应用、拷贝可执行文件、清理构建阶段产生的文件
RUN cd /build && CGO_ENABLED=1 go build -o myapp . && cp -f myapp /usr/bin/myapp && rm -rf /build

# 设置工作目录
WORKDIR /app

# 设置容器启动命令
CMD ["/usr/bin/myapp"]

这是一个依赖 C 动态库调用 libvirtd 的应用,我们可以拆分出两个基础镜像,如下:

构建阶段镜像

# 引用基础镜像
FROM golang:1.22.4-bookworm

# 安装依赖
RUN apt update -y && apt install -y build-essential libvirt-dev

假设镜像标签为 golang:1.22-build

运行阶段镜像

# 引用基础镜像
FROM debian:12

# 安装依赖
RUN apt update -y && apt install -y libvirt-dev

假设镜像标签为 golang:1.22-base

调整后的 Dockerfile

# 引用构建阶段镜像
FROM golang:1.22-build AS build

# 复制所有文件到构建目录
COPY . /build

# 编译 Go 应用、拷贝可执行文件、清理构建阶段产生的文件
RUN cd /build && CGO_ENABLED=1 go build -o myapp .

# 引用运行阶段镜像
FROM golang:1.22-base

# 设置工作目录
WORKDIR /app

# 设置容器启动命令
CMD ["/usr/bin/myapp"]

# 拷贝可执行文件
COPY --from=builder /build/myapp /usr/bin/myapp