docker 设计及源码分析

发布时间 2023-12-18 11:34:15作者: 等会儿我呀

1、dockerd

是一个长期运行的守护进程(docker daemon)。负责管理 docker 容器的生命周期、镜像和存储等。实际还是通过grpc 的协议调用 containerd 的 api 接口,来完成容器管理。

代码所在路径:cmd/dockerd/docker.go

1、newDaemonCommand()

创建一个 config 的初始化对象

2、config.New()

创建配置的实例化对象

他配置了 dockerd 服务默认的运行配置,

3、config_linux.setPlatformDefaults()

设置默认值,其中 linux 的默认值较 Windows 比较特殊。

  • 用户资源限制(ulimits):用于限制用户可以使用的系统资源,如文件描述符数量、进程数等。当前为初始化状态
  • 共享内存大小(shmSize):用于设置容器共享内存大小,是一种进程间通信(IPC)机制,允许多个进程共享统一内存,默认 64M
  • 安全计算配置文件(seccompProfile):用于限制容器可以使用的系统调用,从而降低容器的攻击面
  • 进程间通信模式(IPC mode):决定了容器之间如何共享IPC资源,如共享内存、信号量等,默认为 private,表示为每个容器创建一个新的 IPC 命名空间。
  • 容器运行时(runtimes):设置 docker 启动时支持的容器运行时,当前为初始化状态

然后判断系统的 cgroup 模式,其中 cgroups.Unified 表示 cgroup v2,那么使用 private 的容器 cgroup 模式,如果为 cgroup v1,那么使用 host 的容器 cgroup 模式。

rootless.RunningWithRootlessKit(),判断当前 docker 守护进程是否在 rootless 模式(允许在不需要root权限的情况下运行Docker守护进程)下运行,如果是的话,则需要按Rootless管理的模式去获取对应可执行文件路径,否则,为默认路径。

4、daemonCli.start()

构建守护进程启动脚本

loadDaemonCliConfig 先加载配置文件,如证书、日志等级等,如果启动命令中指定了配置文件,就合并下,如果有冲突就直接报错。

然后新建一个加密和保护 docker api 通信的,TLS 配置。

检查一下 root 用户,设置默认 umask 值为 0022,然后创建docker daemon 的根目录,默认是 /var/lib/docker,也就是 setPlatformDefaults() 函数中获取到的路径的配置。

如果指定了 pidfile 的路径,检查是否存在,不存在就创建。然后把当前进程的 pid 写入到 pid 文件,在启动成功后,使用 defer在最后删除这个文件。

然后如果配置开启了 rootless 模式的话,需要设置当前这个 pid 文件的粘滞位,来确保运行时目录只能被文件的所有者修改或删除。,防止未授权的访问和修改。

5、loadListeners()

加载 docker daemon 的监听器,处理包括客户端请求、镜像拉取等。

解析命令行启动时入参指定的 host

检查是否绑定到 tcp 地址以及 TLS 配置中客户端身份验证模式是否为正常开启,没开的话就告警一下。

checkTLSAuthOK,检查 TLS 验证是否明确禁用,未来设计会默认要求开启身份认证。如果没开启,检查 ip 是 localhost 或者 回环地址,IPv4 就是 127.0.0.1,都不是的话就打印对应错误日志。

如果协议是 tcp 的,先确保端口没有被容器占用。然后初始化监听器,其中入参包括协议格式,地址,socket 和 tls 相关的配置。

6、listeners.Init()

监听器初始化,分别对应了三种协议格式的处理。

首先是 fd 的协议格式,-H fd://将告诉 docker 该服务正在由 Systemd 启动,并将使用套接字激活。然后,systemd 将创建目标套接字并将其传递给 Docker 守护进程以供使用。相关配置:https://github.com/moby/moby/tree/master/contrib/init/systemd。具体分析可以看下:https://stackoverflow.com/questions/43303507/what-does-fd-mean-exactly-in-dockerd-h-fd

/usr/bin/dockerd -H fd://

第二个是 tcp 的协议格式,通过指定协议地址、端口的形式来构建服务,可能会导致任何有权访问该端口的人都具有完全的 docker 访问权限。

第三个是 unix,先是获取了默认 docker 组的组标识符 gid,创建一个 unix 域套接字监听器。然后尝试给 addr 这个套接字文件设置粘滞位(保证目录所有者才能删除或更改文件)。

到此监听器 listeners 和 hosts 都已经构造完成。

7、initContainerd()

先检查是否有正在运行的 containerd 服务,这里默认的地址就是 /var/run/containerd/containerd.sock 。判断 honorXDG( 即开启 rootlessKit 模式),那么就通过用户指定的环境变量来获取当前 runtime 的目录并构建 Containerd 的默认地址路径,然后检查该路径下是否存在 containerd.sock 文件。

获取 containerd 守护进程的相关配置,先获取平台相关配置,然后判断日志等级。如果容器运行时与容器运行时接口(CRI)的集成被禁用,则通过 supervisor.WithCRIDisabled() 方法禁用 CRI 支持

通过 supervisor 的形式,来启动 containerd 的服务,其中套接字文件就是 containerd.sock

8、start() 启动 containerd 服务

到这里成功启动了 containerd 服务,也就是下面 9962 这个进程。

在函数入口处先拼接了要启动的命令行,其中

rootRir 是 docker daemon 的状态目录,存储各种状态、持久化数据等,默认是 /var/lib/docker/containerd/

statDir是 docker daemon 的根目录,存储二进制文件、配置文件等,默认是 /var/run/docker/containerd/

configFile 是相关配置文件存储,路径默认为 /var/run/docker/containerd/containerd.toml

然后通过 goroutine 实现了用于监控和启动服务的功能。使用定时器来定时执行健康检查。如果守护进程没在运行(daemonPid 为 -1),就通过 startContainerd 启动服务,然后建立grpc 协议监控的client,客户端建立成功,进行连接健康检查,通过 client.IsServing 判断它健康检查是否通过。

在下面判断 pid 状态如果是活跃的,那么说明 pid 不等于 -1,但是 client 是空,是有问题的。所以杀掉当前进程,在下次循环时重启。

通过 startContainerd() 函数,拼接启动命令,即 containerd --config /var/run/docker/containerd/containerd.toml --log-level info然后使用 exec.Command 启动 containerd 服务。通过代码的日志打印也可以对照查看。

创建一个用于监控 containerd 服务的客户端,其中 address 就是 containerd.tmol 中的配置,然后进行连接健康检查。

 

9、apiShutdown

实现了一个 http 服务的优雅关闭,保证在接收到关闭信号时,http 服务器能够优雅地处理已有链接,并在关闭后通知主流程。并且在函数最后,根据 apiShutdown 通道是否关闭,来决定是预留一点额外处理时间,还是直接关闭服务器。

10、preNotifyReady()

在设置守护进程之前,通过 api 处于活跃状态,这里只针对 Windows,暂时不关注。

11、middlewares()

加载用于 api 服务的中间件,包括用于处理实验性功能的,检查请求的 api 版本是否在支持范围内、跨域处理以及授权相关的。

12、NewDaemon()

检查系统是否满足运行要求,然后创建要注册的 service 服务对象,检查根密钥大小是否满足限制,以及是否开启了默认网关配置。

这里 verifyDaemonSettings 需要重点关注下,他校验配置文件中的配置是否通过格式化,并且 configureRuntimes 配置了默认的 runtime 。其中,DefaultRuntime 就是 runc,然后生成了一个默认Runtimes的 map,具体内容为:

{
    "io.containerd.runc.v2": {  // types.Runtime
        "Path": "runc",
        "ShimConfig": {  // types.ShimConfig
            "Binary": "io.containerd.runc.v2",
            "Opts": {  // v2runcoptions.Options
                "BinaryName": "runc",
                "Root": "/var/run/docker/runtime-runc",
                "SystemdCgroup": false, // 如果没有通过 exec-opts 参数指定为 systemd,那么默认就是 cgroupfs
                "NoPivotRoot": false // 默认和宿主机不共享文件系统,而是隔离
            }
        }
    }
}

设置 dns 文件,通过判断文件中是否只包含 127.0.0.53 作为唯一 dns ,存在则为使用 systemd-resolved 的系统,那么就是用他生成他生成的文件路径,/run/systemd/resolve/resolv.conf,否则就使用默认的/etc/resolv.conf 。

配置用户命名空间的根目录重映射,并加载用户身份信息,确保容器内的用户和组,跟宿主机有正确的映射关系,用来进行用户隔离和命名空间隔离。通过读取启动参数 --userns-remap 指定,如--userns-remap=username:groupname

获取进程在明湖命名空间中的 root 用户和组的标识符。然后设置进程的包括 oom 分数、进程卸载文件系统时的行为等配置。

设置通用资源,将CPU、内存等资源解析转为 grpc 的类型。

并捕捉 SIGUSR1 信号时,触发导出当前进程中所有 go 的栈信息。

设置 seccomp (secure computing mode)配置文件,用于限制进程系统调用的安全机制,包括builtin模式等。

设置默认隔离模式,只针对 win。

配置 go 的线程限制,读取/proc/sys/kernel/threads-max内核允许最大线程数,设置为他的 90%。

确保默认的 appArmor 的安全模式存在,该模式允许管理员为每个程序指定安全策略。

创建 containers 和 runtimes 的文件夹,并设置对应的所属用户和组。

containers,存储容器的相关数据,每个容器都有个单独的子目录,包括元数据、文件系统、文件状态等。

runtimes,存储不同的 OCI 来允许 docker 启动不同的运行时。

其中加载运行时,会有一个初始化的操作,首先通过配置文件(默认 /etc/docker/daemon.json)作为入参,先删除老的 runtimes 文件夹,在读取这个配置中的。配置一般为:

{
  "runtimes": {
    "myruntime": {
      "path": "/path/to/myruntime",
      "runtimeArgs": ["arg1", "arg2"]
    }
  }
}

会遍历这个 runtimes,校验各种参数格式,之后存储为新的 runtimes 文件。

遍历配置文件中的 runtimes 。如果配置中的 path 不为空,那么通过路径配置及额外的参数,来生成一个对应的脚本文件。

V2 Shim 是 Containerd 中的一个组件,用于在容器运行时创建和管理容器进程获取默认配置文件,默认值就是 io.containerd.runc.v2,默认的 runtimeName 就是 runc。通过配置文件判断是否使用了 systemd 作为 cgroup 的驱动程序。

否则,path 为空,就使用 type ,则不需要 args 参数,创建一个新的 shimconfig,并设置 binary 字段为运行时。

然后创建了一个用于暴露 metrics 的 metrics.sock,并提供相关的 api。在回调函数中,将创建的 sock 文件挂载到容器的中,这样可以通过接口获取到容器内监控的数据,来达到监控容器数据的目的。

如果配置文件中 containerd的参数,也就是 containerd grpc 的地址不为空,创建一个与 containerd 实例连接的客户端。

创建 docker 镜像数据的根目录,如 /var/lib/docker/image/overlay2 ,然后创建一个基于文件系统的镜像存储后端,用于存储 docker 镜像各层(layers)的文件。

创建用于存储镜像引用信息的文件,如 /var/lib/docker/image/overlay2/repositories.json 。

返回一个 store 的接口类型,这个接口是进行过存储的通用接口,包括创建、删除、查找等操作。也就是创建了一个新的 docker 镜像存储对象,该对象提供了一组用于管理镜像的方法。

创建用于存储镜像分发元数据的文件 distribution ,其中包括镜像的标签、大小、创建时间等。

判断 docker daemon 是否使用了 dockerd,如果是,就从里面获取 leases 服务(用于管理资源的租约,允许一个实体,比如容器运行时,在一段时间独占某个资源,防止其他实体对该资源并发访问),其中包括文件系统层的写入、镜像拉取和推送。以及获取内容存储。

然后构造一个镜像管理的服务对象。

创建一个用于每 5 分钟执行,检查已经完成的执行命令并清理不再被容器引用的执行命令。也就是 exec 时指定的命令,如 /bin/bash 。

然后初始化 docker daemon 的与 containerd 的链接,并创建一个用于通信的客户端。

13、restore()

负责在 daemon 启动时先恢复已经存在的容器状态。

1、遍历容器存储目录:默认为 /var/lib/docker/containers ,获取之前已经存在的容器列表。然后并发加载多个容器状态。

2、初始化网络控制器:通过 initNetworkController 初始化一个 libnetwork 控制器,并设置连接方式,默认为 bridge 桥接,然后获取网络信息中的 ipv4 等信息,将该网关 ip 设置为 HostGatewayIP 字段。

3、注册容器和连接:将容器注册到 docker daemon 中,并注册容器之间的链接关系,便于容器间访问。

4、重启容器:对检测到需要重启的容器进行重启操作。

 

14、containerStart()

上面提到有一个重启容器的操作,也就是 containerStart() 。这里文件所在路径一般在:/var/lib/docker/容器 id/

大概分为这几个步骤:

  1. conditionalMountOnStart:进行文件挂载操作
  2. initializeNetworking:初始化网络,包括分配 ip 等
  3. createSpec:创建容器运行规范,包括设置 cgroups、资源限制、namespace 等
  4. saveAppArmorConfig:保存 AppArmor 配置到容器对象。用于限制进程权限,提高安全性。
  5. getCheckpointDir:获取检查点路径,用于恢复容器状态,包括容器的内存、文件系统和进程状态等。

获取创建容器的二进制文件路径及创建参数,其中在 initRuntimes 通过遍历 runtimes,构造除了ShimConfig 的相关配置。

在 NewDaemon() 入口处,有一个 verifyDaemonSettings() 的操作,加载了默认的 runtime 和 shimConfig。所以 getLivecontainerdCreateOptions 获取到对应的 shim 和 opts。默认是 io.containerd.runc.v2。

这里通过 docker info 也能看到。

在 ReplaceContainer 返回了一个 container 的 interface,实现了对 containerd 的访问。包括启动、创建任务、检索任务、删除。

但是接口的具体实现是由具体的 runtime 来实现的。所以这里就没有具体代码了。

最后,回到第一个函数,通过 execute 执行 /usr/bin/dockerd -H unix://var/run/docker.sock命令。

15、startMetricsServer()

开启一个用于监控的 api 接口服务,这里默认是为空,即不开启。

16、createAndStartCluster()

默认读取 exec-data ,即/var/run/docker 中 swarm文件夹中配置,来开启 swarm 集群,因为默认文件夹内是空的,所以是不开启的。

然后将带有 swarm 端点的容器进行重新启动。

至此,daemon 已经初始化完成。

17、CreateMux()

创建一个10s 超时的新建路由器,内部包含了接口路由、服务注册、ImageService的接口(包括拉取、推送、创建等)。其中路由、接口的相关具体实现在 moby/api中。

然后开启一个 goroutine,用来监听上面 swarm 集群是否创建成功。

通过 setupConfigReloadTrap() 监听 SIGHUP 信号,可以触发配置的重新加载。

使用 notifyReady(),通知系统 init 进程(systemd),发送一个 READY=1 消息给 systemd,表示服务准备完成。

18、httpServer.Serve(ls)

在第 5 步的时候,加载了 Listeners,这个时候遍历所有的监听器,并创建对应的 http 服务。

这里默认就是 docker 的 socket

19、clean()

上面httpServer.Server()是一个阻塞操作,当他返回错误的时候,即需要关闭服务。

然后确保如果 daemon 在关闭时,需要进行一些清理,如果有错误信息的话,进行记录。

20、小结

到这里,dockerd 相关的源码已经结束,其中主体流程就是

  1. 加载配置文件,初始化一些默认配置,防止配置文件中不存在
  2. 初始化监听器
  3. 启动 containerd 服务
  4. 通过 shim 重启历史容器
  5. 加载中间件,鉴权相关等
  6. 开启 http 服务
  7. defer 完成优雅关闭