Clarify image build semantics

This commit is contained in:
yeasy
2026-04-24 10:51:49 -07:00
parent 4b44d64cd8
commit 94f74fc86e
3 changed files with 39 additions and 46 deletions
+15 -13
View File
@@ -1,21 +1,21 @@
## 4.4 利用 commit 理解镜像构成
> 注意如果是初学者可以暂时跳过后面的内容直接学习[容器](../05_container/)一节
> 注意如果是初学者可以暂时跳过后面的内容直接学习[容器](../05_container/)一节
注意`docker commit` 命令除了学习之外还有一些特殊的应用场合比如被入侵后保存现场等但是不要使用 `docker commit` 定制镜像定制镜像应该使用 `Dockerfile` 来完成如果你想要定制镜像请查看下一小节
`docker commit` 除了帮助理解镜像分层之外在少数场景下也可用于留存现场例如事后分析被入侵容器的状态但是日常定制镜像不应依赖 `docker commit`而应使用下一节介绍的 `Dockerfile`
镜像是容器的基础每次执行 `docker run` 的时候都会指定哪个镜像作为容器运行的基础在之前的例子中我们所使用的都是来自于 Docker Hub 的镜像直接使用这些镜像是可以满足一定的需求而当这些镜像无法直接满足需求时我们就需要定制这些镜像接下来的几节就将讲解如何定制镜像
回顾一下之前我们学到的知识镜像是多层存储每一层是在前一层的基础上进行的修改而容器同样也是多层存储是在以镜像基础在其基础上加一层作为容器运行时的存储
回顾一下之前我们学到的知识镜像是多层存储每一层是在前一层的基础上进行的修改而容器同样也是多层存储以镜像层为只读基础并在最上方增加一层供运行时写入的容器
现在让我们以定制一个 Web 服务器为例子来讲解镜像是如何构建的
```bash
$ docker run --name webserver -d -p 80:80 nginx
$ docker run --name webserver -d -p 8080:80 nginx
```
这条命令会用 `nginx` 镜像启动一个容器命名为 `webserver`并且映射了 80 端口这样我们可以用浏览器去访问这个 `nginx` 服务器
这条命令会用 `nginx` 镜像启动一个容器命名为 `webserver`其中`-p 8080:80` 表示把宿主机的 `8080` 端口映射到容器内的 `80` 端口这样我们可以用浏览器去访问这个 `nginx` 服务器
如果是在本机运行的 Docker那么可以直接访问`http://localhost`如果是在虚拟机云服务器上安装的 Docker则需要将 `localhost` 换为虚拟机地址或者实际云服务器地址
如果是在本机运行的 Docker那么可以直接访问`http://localhost:8080`如果是在虚拟机云服务器上安装的 Docker则需要将 `localhost` 换为虚拟机地址或者实际云服务器地址并保留 `8080` 端口
直接用浏览器访问的话我们会看到默认的 Nginx 欢迎页面
@@ -58,6 +58,8 @@ A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
```
其中`A` 表示新增Added`C` 表示变更Changed`D` 表示删除Deleted
现在我们定制好了变化我们希望能将其保存下来形成镜像
要知道当我们运行一个容器的时候 (如果不使用卷的话)我们做的任何文件修改都会被记录于容器存储层里 Docker 提供了一个 `docker commit` 命令可以将容器的存储层保存下来成为镜像换句话说就是在原有镜像的基础上再叠加上容器的存储层并构成新的镜像以后我们运行这个新镜像的时候就会拥有原有容器最后的文件变化
@@ -67,7 +69,7 @@ A /var/cache/nginx/uwsgi_temp
```bash
docker commit [选项] <容器ID或容器名> [<仓库名>[:<标签>]]
```
我们可以用下面的命令将容器保存为镜像
我们可以用下面的命令将容器保存为镜像默认情况下`docker commit` 会在提交时暂停容器进程以降低数据损坏的风险如果确实不希望暂停可以显式指定 `--no-pause`
```bash
$ docker commit \
@@ -79,7 +81,7 @@ sha256:07e33465974800ce65751acc279adc6ed2dc5ed4e0838f8b86f0c87aa1795214
```
其中 `--author` 是指定修改的作者 `--message` 则是记录本次修改的内容这点和 `git` 版本控制相似不过这里这些信息可以省略留空
我们可以在 `docker image ls` 中看到这个新定制的镜像
我们可以在 `docker image ls` 中看到这个新定制的镜像下面的输出仅为示例标签创建时间和大小会随着镜像版本和本地环境不同而变化
```bash
$ docker image ls nginx
@@ -88,7 +90,7 @@ nginx v2 07e334659748 9 seconds ago
nginx 1.27 05a60462f8ba 12 days ago 181.5 MB
nginx latest e43d811ce2f4 4 weeks ago 181.5 MB
```
我们还可以用 `docker history` 具体查看镜像内的历史记录如果比较 `nginx:latest` 的历史记录我们会发现新增了我们刚刚提交的这一
我们还可以用 `docker history` 具体查看镜像内的历史记录例如先执行 `docker history nginx:v2`再对比 `docker history nginx:latest`就能看到我们刚刚提交出来的新
```bash
$ docker history nginx:v2
@@ -106,18 +108,18 @@ e43d811ce2f4 4 weeks ago /bin/sh -c #(nop) CMD ["nginx" "-g" "da
新的镜像定制好后我们可以来运行这个镜像
```bash
docker run --name web2 -d -p 81:80 nginx:v2
$ docker run --name web2 -d -p 81:80 nginx:v2
```
这里我们命名为新的服务 `web2`且映射到 `81` 端口访问 `http://localhost:81` 看到结果内容应该和之前修改后的 `webserver` 一样
这里我们将新容器命名为 `web2`把宿主机的 `81` 端口映射到容器的 `80` 端口访问 `http://localhost:81` 看到的内容应该和之前修改后的 `webserver` 一样
至此我们第一次完成了定制镜像使用的是 `docker commit` 命令手动操作给旧的镜像添加了新的一层形成新的镜像对镜像多层存储应该有了更直观的感觉
### 4.4.1 慎用 `docker commit`
使用 `docker commit` 命令虽然可以比较直观帮助理解镜像分层存储的概念是实际环境中并不会这样使用
使用 `docker commit` 命令虽然可以比较直观帮助理解镜像分层存储的概念它不应作为常规定制镜像的方式
首先如果仔细观察之前的 `docker diff webserver` 的结果你会发现除了真正想要修改的 `/usr/share/nginx/html/index.html` 文件外由于命令的执行还有很多文件被改动或添加了这还仅仅是最简单的操作如果是安装软件包编译构建那会有大量的无关内容被添加进来将会导致镜像极为臃肿
此外使用 `docker commit` 意味着所有对镜像的操作都是黑箱操作生成的镜像也被称为 **黑箱镜像**换句话说就是除了制作镜像的人知道执行过什么命令怎么生成的镜像别人根本无从得知而且即使是这个制作镜像的人过一段时间后也无法记清具体的操作这种黑箱镜像的维护工作是非常痛苦的
而且回顾之前提及的镜像所使用的分层存储的概念除当前层外之前的每一层都不会发生改变换句话说任何修改的结果仅仅是在当前层进行标记添加修改而不会改动上一层如果使用 `docker commit` 制作镜像以及后期修改的话每一次修改都会让镜像更加臃肿一次所删除的上一层的东西并不会丢失会一直如影随形的跟着这个镜像即使根本无法访问到这会让镜像更加臃肿
而且回顾之前提及的镜像所使用的分层存储的概念除当前层外之前的每一层都不会发生改变换句话说任何修改的结果仅仅是在当前层进行标记添加修改而不会改动上一层如果使用 `docker commit` 制作镜像以及后期继续修改那么每一次修改都会让镜像再膨胀一层即使某些文件在更高层里被删除了它们仍然存在于更低层中只是在最终视图里被隐藏而已因此日常定制镜像应使用下一节介绍的 `Dockerfile`把构建过程写成可重复执行便于审查的文本
+21 -30
View File
@@ -1,17 +1,17 @@
## 4.5 使用 Dockerfile 定制镜像
从刚才的 `docker commit` 的学习中我们可以了解到镜像的定制实际上就是定制每一层所添加的配置文件如果我们可以把每一层修改安装构建操作的命令都写入一个脚本用这个脚本来构建定制镜像那么之前提及的无法重复的问题镜像构建透明性的问题体积的问题就都会解决这个脚本就是 Dockerfile
从刚才的 `docker commit` 的学习中我们可以了解到镜像的定制实际上就是定制每一层所添加的配置文件如果我们可以把每一层修改安装构建操作的命令都写入一个脚本用这个脚本来构建定制镜像那么之前提及的无法重复镜像构建透明体积难以控制等问题就会更容易解决这个脚本就是 Dockerfile
Dockerfile 是一个文本文件其内包含了一条条的 **指令 (Instruction)**每一条指令构建一层因此每一条指令的内容就是描述该应当如何构建
Dockerfile 是一个文本文件其内包含了一条条的 **指令 (Instruction)**其中会修改文件系统的指令通常会创建新层 `LABEL``CMD` 这类只修改镜像元数据的指令则不会新增文件系统层每一条指令的内容都是在描述该镜像应当如何构建
### 4.5.1 使用 docker init 快速创建推荐
Docker 提供了 `docker init` 命令可以根据项目类型自动生成 Dockerfile.dockerignore compose.yaml 文件
Docker 提供了 `docker init` 命令可以根据项目类型自动生成 `Dockerfile``.dockerignore``compose.yaml` `README.Docker.md` 文件
```bash
$ docker init
```
该命令会交互式地询问项目类型支持 GoNode.jsPythonRustJavaASP.NET CorePHP 并生成符合最佳实践的配置文件对于新项目这是推荐的起步方式
该命令会交互式地询问项目类型支持 GoNode.jsPythonRustJavaASP.NET CorePHP with Apache 并生成可作为起点的配置文件对于新项目这是一个很好的起步方式但生成后的内容仍应结合项目实际情况继续调整
### 4.5.2 手动创建 Dockerfile
@@ -59,9 +59,9 @@ FROM scratch
```docker
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
```
* *exec* 格式`RUN [可执行文件, 参数1, 参数2]`这更像是函数调用中的格式
* *exec* 格式`RUN ["可执行文件", "参数1", "参数2"]`这更像是函数调用中的格式
Dockerfile 中每一个指令都会建立一层`RUN` 也不例外每一个 `RUN` 的行为就和刚才我们手工建立镜像的过程一样新建立一层在其上执行这些命令执行结束后`commit` 这一层的修改构成新的镜像
在会修改文件系统的指令里`RUN` 是最典型的一类每一个 `RUN` 的行为都可以类比为我们刚才手工建立镜像的过程先基于当前结果启动一个临时构建环境在其上执行这些命令再把这一步产生的文件系统变化保存为新的结果层
> **注意**
>
@@ -79,6 +79,11 @@ Dockerfile 中每一个指令都会建立一层,`RUN` 也不例外。每一个
```bash
$ docker build -t nginx:v3 .
```
在当前版本的 Docker `docker build` 默认会通过 Buildx 调用 BuildKit因此你更常看到的是 `[+] Building ...` 这类输出为了帮助理解每一步如何形成镜像历史下面仍展示一种较容易阅读的经典输出形式
```bash
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
---> e43d811ce2f4
@@ -101,11 +106,11 @@ docker build [选项] <上下文路径/URL/->
如果注意会看到 `docker build` 命令最后有一个 `.``.` 表示当前目录 `Dockerfile` 就在当前目录因此不少初学者以为这个路径是在指定 `Dockerfile` 所在路径这么理解其实是不准确的如果对应上面的命令格式你可能会发现这是在指定 **上下文路径**那么什么是上下文呢
首先我们要理解 `docker build` 的工作原理Docker 在运行时分为 Docker 引擎 (也就是服务端守护进程) 和客户端工具Docker 的引擎提供了一组 REST API被称为 [Docker Remote API](https://docs.docker.com/develop/sdk/),而如 `docker` 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种 `docker` 功能,但实际上,一切都是使用的远程调用形式在服务端 (Docker 引擎) 完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。
首先要理解 `docker build` 的工作原理今天的 `docker build` 默认会通过 Buildx BuildKit 后端发起构建请求无论后端运行在本机还是远端位置参数指定的都是 **构建上下文**也就是构建器可以访问到的文件集合
当我们进行镜像构建的时候并非所有定制都会通过 `RUN` 指令完成经常需要将一些本地文件复制进镜像比如通过 `COPY` 指令`ADD` 指令等 `docker build` 命令构建镜像其实并非在本地构建而是在服务端也就是 Docker 引擎中构建的那么在这种客户端/服务端的架构中如何才能让服务端获得本地文件呢
当我们进行镜像构建的时候并非所有定制都会通过 `RUN` 指令完成经常需要本地文件复制进镜像比如通过 `COPY` 指令`ADD` 指令等因此构建器必须能够访问这些文件而它能访问的范围正是你传给 `docker build` 的那个上下文
这就引入了上下文的概念当构建的时候用户会指定构建镜像上下文的路径`docker build` 命令得知这个路径后会将路径下的所有内容打包然后上传给 Docker 引擎这样 Docker 引擎收到这个上下文包后展开就会获得构建镜像所需的一切文件
如果上下文是本地目录那么这个目录中的文件和子目录就会成为可用输入如果上下文是远端 Git 仓库或 tar 那么构建器会直接获取对应内容对于本地目录BuildKit 会按需读取构建过程中真正需要的文件而不是让 Dockerfile 任意访问宿主机上的任意路径
如果在 `Dockerfile` 中这么写
@@ -114,18 +119,18 @@ COPY ./package.json /app/
```
这并不是要复制执行 `docker build` 命令所在的目录下的 `package.json`也不是复制 `Dockerfile` 所在目录下的 `package.json`而是复制 **上下文 (context)** 目录下的 `package.json`
因此`COPY` 这类指令中的源文件路径都*相对路径*这也是初学者经常会问的为什么 `COPY ../package.json /app` 或者 `COPY /opt/xxxx /app` 无法工作的原因因为这些路径已经超出了上下文的范围Docker 引擎无法获得这些位置的文件如果真的需要那些文件应该将它们复制到上下文目录中去
因此`COPY` 这类指令中的源文件路径都应该以构建上下文为基准来理解对于 legacy builder `COPY ../package.json /app` 这样的写法会直接报错而在 BuildKit 前导的越界 `../` 会被剥离并重新解释为上下文内路径无论是哪种情况构建器都无法读取上下文之外的宿主机文件如果真的需要那些文件应该先把它们放进上下文目录或重新选择合适的上下文
现在就可以理解刚才的命令 `docker build -t nginx:v3 .` 中的这个 `.`实际上是在指定上下文目录`docker build` 命令会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像
现在就可以理解刚才的命令 `docker build -t nginx:v3 .` 中的这个 `.`实际上是在指定上下文目录而不是单纯指定 `Dockerfile` 所在目录
如果观察 `docker build` 输出我们其实已经看到了这个发送上下文的过程
如果观察 `docker build` 的经典输出 BuildKit 输出中的 `transferring context` 提示我们其实都能看到上下文传输的过程
```bash
$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...
```
理解构建上下文对于镜像构建是很重要的避免犯一些不应该的错误比如有些初学者在发现 `COPY /opt/xxxx /app` 不工作后于是干脆将 `Dockerfile` 放到了硬盘根目录去构建结果发现 `docker build` 执行后在发送一个几十 GB 的东西极为缓慢而且很容易构建失败那是因为这种做法是在让 `docker build` 打包整个硬盘这显然是使用错误
理解构建上下文对于镜像构建是很重要的避免犯一些不应该的错误比如有些初学者在发现需要的文件不在上下文里后干脆把上下文切到硬盘根目录去构建这样做即使在 BuildKit 下也会让可见上下文变得过大并且在使用 `COPY . .``ADD . /app` 之类写法时仍可能触发大规模上下文传输导致构建缓慢甚至失败这显然是使用错误
一般来说应该会将 `Dockerfile` 置于一个空目录下或者项目根目录下如果该目录下没有所需文件那么应该把所需文件复制一份过来如果目录下有些东西确实不希望构建时传给 Docker 引擎那么可以用 `.gitignore` 一样的语法写一个 `.dockerignore`该文件是用于剔除不需要作为上下文传递给 Docker 引擎的
@@ -139,26 +144,12 @@ Sending build context to Docker daemon 2.048 kB
#### 直接用 Git repo 进行构建
或许你已经注意到了`docker build` 还支持从 URL 构建比如可以直接从 Git repo 中构建
或许你已经注意到了`docker build` 还支持从 URL 构建也就是直接把远端 Git 仓库作为上下文传统写法可以使用 URL 片段 `#ref:dir`例如
```bash
## $env:DOCKER_BUILDKIT=0
## export DOCKER_BUILDKIT=0
$ docker build -t hello-world https://github.com/docker-library/hello-world.git#master:amd64/hello-world
Step 1/3 : FROM scratch
--->
Step 2/3 : COPY hello /
---> ac779757d46e
Step 3/3 : CMD ["/hello"]
---> Running in d2a513a760ed
Removing intermediate container d2a513a760ed
---> 038ad4142d2b
Successfully built 038ad4142d2b
$ docker build https://github.com/user/myrepo.git#mybranch:docker
```
这行命令指定了构建所需的 Git repo并且指定分支为 `master`构建目录为 `/amd64/hello-world/`然后 Docker 就会自己去 `git clone` 这个项目切换到指定分支并进入到指定目录后开始构建
这行命令表示 Git 仓库作为构建上下文使用 `mybranch` 分支中的 `docker/` 子目录来构建在较新的 Buildx 也可以改用结构更清晰的查询参数写法例如 `?branch=mybranch&subdir=docker`
#### 用给定的 tar 压缩包构建
+3 -3
View File
@@ -15,7 +15,7 @@ Docker 镜像并不是一个单纯的文件,而是由一组文件系统叠加
这种分层存储结构使得镜像的复用分发变得非常高效
* **复用**如果多个镜像都基于同一个基础镜像 (例如都基于 `ubuntu:24.04`)那么宿主机只需要下载一份 `ubuntu:24.04`所有镜像都可以共享它
* **轻量**镜像仅仅记录了与基础镜像的差异因此体积非常小
* **轻量分发**镜像可以复用已有层只传输和存储新增差异层不过镜像是否足够小仍然取决于基础镜像和新增内容本身
### 4.7.2 容器层与读写
@@ -57,8 +57,8 @@ Docker 镜像的每一层都有一个唯一的 ID,这个 ID 是根据该层的
### 4.7.4 联合文件系统
Docker 使用联合文件系统 (Union FS) 来实现这种分层挂载常见的驱动包括 `overlay2` (目前推荐)`aufs` (早期使用)`btrfs``zfs`
Docker 使用联合文件系统 (Union FS) 与写时复制思路来实现这种分层挂载传统的实现方式常见于 `overlay2``aufs``btrfs``zfs` 等存储驱动而在 Docker Engine 29.0 及之后的全新安装中默认镜像后端已经变为 containerd image store它使用 snapshotter 来管理这些层
虽然实现细节不同但它们都遵循上述的 **分层 + CoW** 模型
虽然底层实现细节不同但它们都遵循上述的 **分层 + CoW** 模型因此无论你看到的是 `overlay2` 还是 containerd snapshotter理解镜像层容器层和写时复制的方式都是一样重要的
> 想要深入了解 Overlay2 等文件系统的具体实现原理包括 WorkDirUpperDirLowerDir 等底层细节请阅读 **[第十二章 底层实现](../12_implementation/README.md)** 中的 **[联合文件系统](../12_implementation/12.4_ufs.md)** 章节