04_image/4.5_build.md
从刚才的 docker commit 的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复、镜像构建不透明、体积难以控制等问题就会更容易解决。这个脚本就是 Dockerfile。
Dockerfile 是一个文本文件,其内包含了一条条的 指令 (Instruction)。其中,会修改文件系统的指令通常会创建新层;而 LABEL、CMD 这类只修改镜像元数据的指令,则不会新增文件系统层。每一条指令的内容,都是在描述该镜像应当如何构建。
Docker 提供了 docker init 命令,可以根据项目类型自动生成 Dockerfile、.dockerignore、compose.yaml 和 README.Docker.md 等文件:
$ docker init
该命令会交互式地询问项目类型(支持 Go、Node.js、Python、Rust、Java、ASP.NET Core、PHP with Apache 等),并生成可作为起点的配置文件。对于新项目,这是一个很好的起步方式,但生成后的内容仍应结合项目实际情况继续调整。
还以之前定制 nginx 镜像为例,这次我们使用 Dockerfile 来定制。
在一个空白目录中,建立一个文本文件,并命名为 Dockerfile:
$ mkdir mynginx
$ cd mynginx
$ touch Dockerfile
其内容为:
版本提示:下面示例中
FROM nginx使用的是latest标签。在实际应用中应使用明确的版本号(如FROM nginx:1.30),以确保 Dockerfile 的可重现性和稳定性。
FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
这个 Dockerfile 很简单,一共就两行。涉及到了两条指令,FROM 和 RUN。
所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制。就像我们之前运行了一个 nginx 镜像的容器,再进行修改一样,基础镜像是必须指定的。而 FROM 就是指定 基础镜像,因此一个 Dockerfile 中 FROM 是必备的指令,并且必须是第一条指令。
版本号最佳实践:在
FROM指令中 务必指定具体版本号(如FROM ubuntu:24.04或FROM python:3.12-slim)而非FROM ubuntu或FROM python:latest。这样可以确保 Dockerfile 在不同时间、不同环境下构建出的镜像内容一致,避免因基础镜像更新导致的不可预期的变化。
在 Docker Hub 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginx、redis、mongo、mysql、httpd、php、tomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 node、openjdk、python、ruby、golang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。
如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntu、debian、centos、fedora、alpine 等,这些操作系统的软件库为我们提供了更广阔的扩展空间。
除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。
FROM scratch
...
如果你以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。
不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接 FROM scratch 会让镜像体积更加小巧。使用 Go 语言开发的应用很多会使用这种方式来制作镜像,这也是有人认为 Go 是特别适合容器微服务架构的语言的原因之一。
RUN 指令是用来执行命令行命令的。由于命令行的强大能力,RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:
RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。在会修改文件系统的指令里,RUN 是最典型的一类。每一个 RUN 的行为,都可以类比为我们刚才手工建立镜像的过程:先基于当前结果启动一个临时构建环境,在其上执行这些命令,再把这一步产生的文件系统变化保存为新的结果层。
注意
每一个
RUN指令都会产生一个新的镜像层。为了减少镜像体积和层数,我们通常会将多个命令合并到一个RUN指令中执行。更多关于
RUN指令的详细用法、最佳实践 (如清理缓存、使用 pipefail 等) 及Union FS的层数限制等内容,请参阅 第七章 Dockerfile 指令详解 中的 RUN 指令 小节。
要想编写优秀的 Dockerfile,必须了解每一条指令的作用和副作用。在 第七章 Dockerfile 指令详解 中,我们将对 COPY,ADD,CMD,ENTRYPOINT 等指令进行详细讲解。
好了,让我们再回到之前定制的 nginx 镜像的 Dockerfile 来。现在我们明白了这个 Dockerfile 的内容,那么让我们来构建这个镜像吧。
在 Dockerfile 文件所在目录执行:
$ docker build -t nginx:v3 .
在当前版本的 Docker 中,docker build 默认会通过 Buildx 调用 BuildKit,因此你更常看到的是 [+] Building ... 这类输出。为了帮助理解“每一步如何形成镜像历史”,下面仍展示一种较容易阅读的经典输出形式:
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
---> e43d811ce2f4
Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
---> Running in 9cdc27646c7b
---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
Successfully built 44aa4490ce2c
从命令的输出结果中,我们可以清晰的看到镜像的构建过程。在 Step 2 中,如同我们之前所说的那样,RUN 指令启动了一个容器 9cdc27646c7b,执行了所要求的命令,并最后提交了这一层 44aa4490ce2c,随后删除了所用到的这个容器 9cdc27646c7b。
这里我们使用了 docker build 命令进行镜像构建。其格式为:
docker build [选项] <上下文路径/URL/->
在这里我们指定了最终镜像的名称 -t nginx:v3,构建成功后,我们可以像之前运行 nginx:v2 那样来运行这个镜像,其结果会和 nginx:v2 一样。
如果注意,会看到 docker build 命令最后有一个 .。. 表示当前目录,而 Dockerfile 就在当前目录,因此不少初学者以为这个路径是在指定 Dockerfile 所在路径,这么理解其实是不准确的。如果对应上面的命令格式,你可能会发现,这是在指定 上下文路径。那么什么是上下文呢?
首先要理解 docker build 的工作原理。今天的 docker build 默认会通过 Buildx 向 BuildKit 后端发起构建请求;无论后端运行在本机还是远端,位置参数指定的都是 构建上下文,也就是构建器可以访问到的文件集合。
当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常还需要把本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。因此,构建器必须能够访问这些文件,而它能访问的范围正是你传给 docker build 的那个上下文。
如果上下文是本地目录,那么这个目录中的文件和子目录就会成为可用输入;如果上下文是远端 Git 仓库或 tar 包,那么构建器会直接获取对应内容。对于本地目录,BuildKit 会按需读取构建过程中真正需要的文件,而不是让 Dockerfile 任意访问宿主机上的任意路径。
如果在 Dockerfile 中这么写:
COPY ./package.json /app/
这并不是要复制执行 docker build 命令所在的目录下的 package.json,也不是复制 Dockerfile 所在目录下的 package.json,而是复制 上下文 (context) 目录下的 package.json。
因此,COPY 这类指令中的源文件路径都应该以构建上下文为基准来理解。对于 legacy builder,像 COPY ../package.json /app 这样的写法会直接报错;而在 BuildKit 下,前导的越界 ../ 会被剥离并重新解释为上下文内路径。无论是哪种情况,构建器都无法读取上下文之外的宿主机文件;如果真的需要那些文件,应该先把它们放进上下文目录,或重新选择合适的上下文。
现在就可以理解刚才的命令 docker build -t nginx:v3 . 中的这个 .,实际上是在指定上下文目录,而不是单纯指定 Dockerfile 所在目录。
如果观察 docker build 的经典输出,或 BuildKit 输出中的 transferring context 提示,我们其实都能看到上下文传输的过程:
$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...
理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。比如有些初学者在发现需要的文件不在上下文里后,干脆把上下文切到硬盘根目录去构建。这样做即使在 BuildKit 下也会让可见上下文变得过大,并且在使用 COPY . .、ADD . /app 之类写法时,仍可能触发大规模上下文传输,导致构建缓慢甚至失败。这显然是使用错误。
一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。
那么为什么会有人误以为 . 是指定 Dockerfile 所在目录呢?这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。
这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f ../Dockerfile.php 参数指定某个文件作为 Dockerfile。
当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中。
docker build 的用法或许你已经注意到了,docker build 还支持从 URL 构建,也就是直接把远端 Git 仓库作为上下文。传统写法可以使用 URL 片段 #ref:dir,例如:
$ docker build https://github.com/user/myrepo.git#mybranch:docker
这行命令表示:把 Git 仓库作为构建上下文,使用 mybranch 分支中的 docker/ 子目录来构建。在较新的 Buildx 中,也可以改用结构更清晰的查询参数写法,例如 ?branch=mybranch&subdir=docker。
$ docker build http://server/context.tar.gz
如果所给出的 URL 不是个 Git repo,而是个 tar 压缩包,那么 Docker 引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。
docker build - < Dockerfile
或
cat Dockerfile | docker build -
如果标准输入传入的是文本文件,则将其视为 Dockerfile,并开始构建。这种形式由于直接从标准输入中读取 Dockerfile 的内容,它没有上下文,因此不可以像其他方法那样可以将本地文件 COPY 进镜像之类的事情。
$ docker build - < context.tar.gz
如果发现标准输入的文件格式是 gzip、bzip2 以及 xz 的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。