1、背景
源码阅读环境搭好了,怎么也得看看源码。对docker的最深认识就是docker是一个配置了不同namespace的进程,其余一概不懂。让我来看看docker run命令到底干了啥,顺便学习下go,
为捣鼓k8s源码打打基础。
2、摘要
docker run
整个命令的执行过程中,cli会给守护进程dockerd
发送两个HTTP请求,分别是/containers/create
以及/containers/{name:.*}/start
。
/containers/create
请求,dockerd进程主要是准备好容器启动所需要文件系统rootfs
以及dockerd
管理容器所需要的一些元数据对象,然后返回容器ID给cli
。cli
收到回复后,会再发送一个/containers/{name:.*}/start
来启动容器,这里参数name就是前面返回的容器ID。
/containers/{name:.*}/start
请求,dockerd
进程继续完善容器的配置信息,例如网络相关的配置,随后dockerd
进程会通过client将运行所需要相关参数发送给containerd
进程,由containerd
进程调用runc
完成容器的创建。
3、代码入口
如何知道docker run命令对应的API是什么,以及对应的处理逻辑代码入口在哪里,请看我前面的文章《了解docker架构》。
/containers/create
请求以及 /containers/{name:.*}/start
对应的代码在
以下面命令为示例,进行部分docker源码逻辑说明
./build/docker run --rm --name test hello-world
4、/containers/create请求源码阅读
想要大致读懂这部分的代码,需要掌握一些基础的知识
1、当我们执行ls命令时,我们看到的目录是怎么来的
2、为什么容器里面有自己独立的一套目录
3、什么是overlayFS, docker与overlayFS是什么关系
由于我也只是了解一点点,只能简单说下我的浅薄理解,错了的话,那就没办法了,只能说尽力了
4.1 ls命令输出的目录是从哪里来的?
我没看过linux的源码,但前一段时间跟着《操作系统真象还原》一书学习写了个小型操作系统。里面有参考ext2的思路实现了一个简化的文件系统。
在操作系统层面,在内存某个位置记录了根目录信息。所谓的根目录也就是平时大家说的/
,根目录信息就是,目录/
下有哪些文件以及目录。当我们要查找文件的时候,例如执行 ls
命令的时候就是先找到根目录/
然后递归查找下去。找到当前目录,然后把当前目录对应的目录信息查询出来。
这就是ls命令输出的原理。
4.2 容器怎么有自己独立的一套目录?
前面知道根目录是操作系统维护的,ls命令以及输出结果也是操作系统提供的。当你在容器里面
执行ls /
命令的时候,操作系统,不去读取真正的目录,而是返回提前准备好的某个目录的的内容,而这个提前准备好的目录的内容和真正的根目录的内容一样呢?是不是就好像返回了真正的根目录一样。
如果你执行写文件、删除文件时候,操作系统操作的就是这个提前的目录,而不是真正的目录。这样子,容器的所有操作影响的只是这提前准备好的目录,就达到了容器拥有自己独立的一套目录的效果了。
前面是我粗浅的了解,本质是利用了linux的 mount namespace机制,直接将容器的根目录设置为docker提前准备好的假的根目录。这个在极客时间里面张磊大神 的《深入剖析kubernetes》的第7课深入理解容器镜像 里面有说。
4.3 OverlayFS与docker是什么关系?
前面说的,docker准备好了个假的根目录,这个目录是基于OverlayFS文件系统构建的。
OverlayFS 是 Linux 系统中的一种联合文件系统(UnionFS),它能将多个目录合并在一起,形成一个虚拟视图,对外看起来就像是一个目录。这玩意有什么用呢?
例如我现在有两个目录 A 和 B, A作为底层目录是只读的,B作为上层目录是可写的,B目录中如果如果有文件或者目录在A中也有,则以B中的为准。这个时候就可以用下面的命令得到一个C目录
sudo mount -t overlay overlay -o lowerdir=/path/to/A,upperdir=/path/to/B,workdir=/path/to/workdir /path/to/C
C
目录里面会有A
、B
目录中所有内容,当我们读取C目录的时候,OverlayFS 会优先查看 B
中的文件。如果文件存在,返回该文件;如果文件在 B
中不存在,则返回 A
中的文件;当我们要对 C
中的文件进行修改时,修改会写入到 B
中,而 A
不受影响。
如果我又用A
和D
搞一个E
出来呢?是不是就做到了 我们都有相同的A
,且互不影响的情况下,复用了 A
,减少了空间的占用。
这就是docker镜像的分层复用原理,在docker启动容器的时候,把镜像的文件夹作为只读的A目录,然后属于容器自己变化了的文件放在B目录来达到,然后用OverlayFS生成虚拟视图目录作为容器的根目录,从而达到容器文件目录独立的效果的。
4.4源码阅读笔记
/containers/create
用于创建容器。但不会立即启动容器,只是完成容器的初始化配置,
例如你在容器里面看到的绝大部分文件以及文件夹,也就是rootfs。为什么说是绝大部分呢,因为
还有一些是在后面那个请求里面创建的。
最外层接口的代码很清晰,一开始会执行一大堆的配置解析与设置,以及版本参数校验等等,然后在最后,调用Daemon.containerCreate
函数
这个函数会去查询镜像是否存在,从实际执行效果来看,如果镜像不存在应该会触发pull 操作的,按照docker api的风格来看,应该是返回错误,然后由cli触发pull操作。这个不是我想看的逻辑,且我镜像已经存在所以跳过了。
if opts.params.Platform == nil && opts.params.Config.Image != "" {
img, err := daemon.imageService.GetImage(ctx, opts.params.Config.Image, backend.GetImageOpts{Platform: opts.params.Platform})
if err != nil {
return containertypes.CreateResponse{}, err
}
if img != nil {
...省略
}
}
找到镜像后接着又做了一些校验后才会,调用create
函数去创建Contianer对象,核心逻辑入口在这,创建完成后就会返回容器的ID给client
这个 Container
结构体是 Docker 源代码中用来表示容器的核心数据结构之一。它包含了容器的各种信息,包括容器的配置、网络设置、存储层等。
type Container struct {
StreamConfig *stream.Config // 配置流的设置,如容器的日志流等
*State `json:"State"` // 容器的状态信息,使用嵌入结构体(State),容器直接支持状态,并且在 JSON 中作为一个结构体序列化。
Root string `json:"-"` // 容器的“家”目录路径,包括元数据。`json:"-"` 表示该字段不会被序列化成 JSON。
BaseFS string `json:"-"` // 图层驱动挂载点路径,通常用于容器的文件系统。
RWLayer layer.RWLayer `json:"-"` // 只读写层,用于容器的文件存储。
ID string // 容器的唯一标识符。
Created time.Time // 容器创建时间。
Managed bool // 是否由 Docker 管理。
Path string // 容器启动时的路径。
Args []string // 启动容器时传递的参数。
Config *containertypes.Config // 容器的配置(如环境变量、挂载路径等)。
ImageID image.ID `json:"Image"` // 容器使用的镜像 ID。
ImageManifest *ocispec.Descriptor // 容器镜像的描述符,提供镜像元数据。
NetworkSettings *network.Settings // 容器的网络设置。
LogPath string // 容器日志的路径。
Name string // 容器名称。
Driver string // 容器使用的存储驱动。
MountPoints map[string]*volumemounts.MountPoint // 容器的挂载点。
... 省略
}
这个结构体集合了容器运行时的各种状态、配置和文件系统信息, 是Docker守护进程用来管理容器的生命周期和操作用的。
在daemon.Create
函数中,就会遇到该接口重点逻辑代码了
然后又会调用layStore.CreateRWLayer
在这里,因为我没怎么看过docker相关源码的文章,这是我自己理解的,可能不太对,但我个人
带着这几个概念去看的话,就容易很多:
1、镜像其实是打包好的一堆文件,相关的操作是通过imageService来操作的
2、镜像在imageService是中是按层存储的,层逻辑概念是layer,物理概念是一个文件夹。通俗理解就是每个镜像都有对应的一个文件夹。
3、容器是在镜像的基础上构建的,也是按层的,但叫mountedLayer,所以也归imageService管
4、imageService并不是直接处理layer的,而是让它的手下layerStore来执行layer相关的操作的
下面是layStore.CreateRWLayer
中逻辑
顾名思义,是创建一个可写层,创建流程分为3步
1、创建init层
2、以init层为基准创建容器目录
3、保存创建的层信息
以下是各个步骤中的说明
1、创建init层
ls.initMount
函数会先创建一个init层(因为文件夹是以容器ID-init这样命名的,我不知道怎么称呼),init文件夹里面会创建,work
目录、diff
目录、lower
文件、link
文件
work
目录就是使用linux mount api
的时候,挂载overlayfs
时要指定的work目录;diff
目录对应的是upper
目录;
lower
中则是镜像对应的文件夹的软连接,就是挂载overlayfs
时指定的lower
目录
link文件则是记录了
diff文件夹的软链接路径
,就是挂载overlayfs
时指定的upper
目录
docker创建的软链接都放在/var/lib/docker/overlay2/l
目录下,为什么这里要用软链接而不是直接使用绝对路径,从后续的校验逻辑来看,我猜是OverlayFS
在实现的时候,会现申请一个页的大小来做工作空间,用来存放这个lower的路径字符串,如果这个字符串太长就会报错。
还有两个函数需要说明一下,分别是ls.driver.Get
,ls.driver.Put
Get函数是调用linux的mount API挂载overlay目录,Put函数是调用unmout API
卸载overlay
目录。driver
默认是overlay2
。
创建init层的时候,会先挂载为overlayfs目录,然后又卸载掉,也就是先Get,然后紧接又Put,据说是为了测试挂载是否有潜在的问题。
initMount函数执行完毕,/var/lib/docker/overlay2/{容器id}-init
目录就创建好了
此时,目录中会有work
目录、diff
目录、lower
文件、link
文件。
2、以init层为基准创建容器层
init层创建完成后,会继续调用ls.driver.CreateReadWrite
以init层为父层,创建容器层(同上,不知道叫啥名),文件夹名字为容器ID。所谓的父层就是从init层中的lower文件中的目录 加上link文件中的目录作为 容器层的 lower文件的内容。
其余流程和init层创建的差不多,这里就不再描述。
3、保存创建的层信息
这步逻辑就是将id写到mount目录下,保存元数据信息,用于管理,比较容易理解就不再描述
三个步骤完成后,返回结果给cli前,还会再执行一次mount以及unmout操作,理解了上面的操作之后,就很容易理解了。至此,没啥好看的了。
5、/containers/{name:.*}/start
请求源码阅读
该接口的代码相对就容易理解了,因为大部分逻辑都是在校验参数以及初始化一下网络配置文件
真正的启动逻辑在这里
继续跟下去,你会发现最后调用了grpc请求containerd
进程。
要继续看代码,就得看containerd
的源码了,下面是gpt给出的容器启动流程
假设用户通过 docker run
启动一个容器,调用流程大致如下:
1.用户执行 docker run
命令 → dockerd
接收并处理请求。
2.dockerd
将请求转发给 containerd
,让 containerd
启动容器。
3.containerd
根据请求使用 runc
启动容器,调用 runc
创建容器并设置相关的内核特性(如命名空间、cgroups 等)。
4.runc
启动容器进程,并将其与宿主机的内核隔离,确保容器能够在独立的环境中运行。
总结
虽然只是读了些上层代码,但是还是有点收获的,例如知道了dockerd在运行docker run命令到底干了点啥,理解了/var/lib/docker/overlay2
目录下的文件到底是干嘛的,了解了docker是如何管理容器的,了解了dockerd
是如何与containerd
整合的。