Docker 工作原理分析

时间:2022-11-28 09:12:55

docker 容器原理分析

docker 的工作方式

当我们的程序运行起来的时候,在计算机中的表现就是一个个的进程,一个 docker 容器中可以跑各种程序,容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

Docker 容器启动的进程还是在宿主机中运行的,和宿主机中其他运行的进程是没有区别的,只是 docker 容器会给这些进程,添加各种各样的 Namespace 参数,使这些进程和宿主机中的其它进程隔离开来,感知不到有其它进程的存在。

对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来进行资源限制,而 Namespace 技术用来做隔离作用。

容器是一种特殊的进程:

docker 容器在创建进程时,指定了这个进程所需要启用的一组 Namespace 参数。这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。

容器中启动的进程,还是在宿主机中运行的,只是 docker 容器会给这些进程,添加各种各样的 Namespace 参数,使这些进程和宿主机中的其它进程隔离开来,感知不到有其它进程的存在。

下面来看下 docker 容器中,Namespace 和 Cgroups 的具体作用

Namespace

Namespace 是 Linux 中隔离内核资源的方式,通过 Namespace 可以这些进程只能看到自己 Namespace 的相关资源,这样和其它 Namespace 的进程起到了隔离的作用。

Linux namespaces 是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个 namespace 中的系统资源只会影响当前 namespace 里的进程,对其他 namespace 中的进程没有影响。

docker 容器的实现正是用到了 Namespace 的隔离,docker 容器通过启动的时候给进程添加 Namespace 参数,这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。

容器对比虚拟机

虚拟机

使用虚拟机,就需要使用 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且里面需要运行 Guest OS 才能执行用户的应用进程,这就不可避免会带来额外的资源消耗和占用。

虚拟机本身的运行就会占用一定的资源,同时虚拟机对宿主机文件的调用,就不可避免的需要经过虚拟化软件的连接和处理,这本身就是一层性能消耗,尤其对计算资源、网络和磁盘 I/O 的损耗非常大。

容器

容器化后的应用,还是宿主机上的一个普通的进程,不会存在虚拟化带来的性能损耗,同时容器使用的是 Namespace 进行隔离的,所以不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

容器的缺点

基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。

1、容器只是运行在宿主机中的一中特殊的进程,容器时间使用的还是同一个宿主机的操作系统内核;

尽管你可以在容器里通过 Mount Namespace 单独挂载其他不同版本的操作系统文件,比如 CentOS 或者 Ubuntu,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

2、在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间;

如果容器中的程序使用了 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,所以容器中我们应该尽量避免这种操作。

3、容器共享宿主机内核,会给应用暴露出更大的攻击面。

在是生产环境中,不会把物理机中 Linux 的容器直接暴露在公网上。

Cgroups

docker 容器中的进程使用 Namespace 来进行隔离,使得这些在容器中运行的进程像是运行在一个独立的环境中一样。但是,被隔离的进程还是运行在宿主机中的,如果这些进程没有对资源进行限制,这些进程可能会占用很多的系统资源,影响到其他的进程。Docker 使用 Linux cgroups 来限制容器中的进程允许使用的系统资源。

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。

centos 7.2 下面的文件

# mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。

总结下来就是,一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。

容器看到的文件

Mount namespace

首先来了解下 Mount namespace

Mount namespace 为进程提供独立的文件系统视图。简单点说就是,mount namespace 用来隔离文件系统的挂载点,这样进程就只能看到自己的 mount namespace 中的文件系统挂载点。

进程的 mount namespace 中的挂载点信息可以在 /proc/[pid]/mounts、/proc/[pid]/mountinfo/proc/[pid]/mountstats 这三个文件中找到。

每个 mount namespace 都有一份自己的挂载点列表。当我们使用 clone 函数或 unshare 函数并传入 CLONE_NEWNS 标志创建新的 mount namespace 时, 新 mount namespace 中的挂载点其实是从调用者所在的 mount namespace 中拷贝的。但是在新的 mount namespace 创建之后,这两个 mount namespace 及其挂载点就基本上没啥关系了(除了 shared subtree 的情况),两个 mount namespace 是相互隔离的。

Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

chroot

当一个容器被创建的时候,我们希望容器中进程看到的文件是一个独立的隔离环境,我们可以在容器进程重启之前挂载整个根目录 /,由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。

在 Linux 中可以使用 chroot 来改变某进程的根目录。

来看下 chroot

chroot 主要是用来改换根目录的,在新设定的虚拟根目录中运行指定的命令或交互 Shell。一个运行在这个环境下,经由 chroot 设置根目录的程序,它不能够对这个指定根目录之外的文件进行访问动作,不能读取,也不能更改它的内容。

rootfs

为了让容器这个根目录看起来更'真实',一般会在容器的根目录下面挂载一个完整的操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容器里通过执行 ls / 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件。

这个挂载到容器根目录,用来给容器提供隔离后的执行环境的文件系统,称为为'容器镜像',或者 rootfs(根文件系统)。

对于 Docker 来讲,最核心的原理就是为待创建的用户进程执行下面三个操作:

1、启用 Linux Namespace 配置;

2、设置指定的 Cgroups 参数;

3、切换进程的根目录(Change Root)。

第三步,进程根目录的切换,Docker 会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。

rootfs 是一个操作系统包含的所有的文件、配置和目录,并不包括操作系统内核。同一宿主机中的容器都共享主机操作系统的内核。

正是由于 rootfs 的存在,容器中的一个很重要的特性才能实现,一致性。

因为 rootfs 中打包的不止是应用,还包括整个操作系统的文件和目录,应用和应用运行的所有依赖都会被封装在一起。这样无在任何一台机器中,只需要解压打包好的镜像,直接运行即可,因为镜像里面已经包含了应用运行的所有环境。

对于基础 rootfs 的制作,如果后续有更改的需求,一个很简单的操作就是,新 fork 一个然后修改,这样的缺点就是有很多碎片化的版本。

rootfs 的制作,也是支持增量的方式进行操作的,Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

Docker 工作原理分析

上面的读写层也称为容器层,下面的只读称为镜像层,所有的增删查改都只会作用在容器层,相同的文件上层会覆盖下层。

上面的读写层,在没有文件写入之前里面是空的,如果在容器里面做了修改,修改的内容就会以增量的方式出现在这个层中。

例如进行文件的修改,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,对容器来讲可以看到容器层中的这个文件,看不到镜像层中的这个文件。

进行删除的时候,也是在读写层做个标记,当这两个层被联合挂载之后,读写层的文件删除标记,会把容器中对应的文件“遮挡”起,对外面展示的效果就是该文件找不到了,被删除了。

最上面的可读可写层,就是专门存放修改后 rootfs 后产生的增量,修改,新增,删除产生的文件都会被记录到这里。这就是 rootfs 制作能支持增量模式的最主要实现。

这些增量的 rootfs,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上。同时,原先只读层中的内容不会发生任何变化。当然这些读写层的增量 rootfs 在 commit 之后就会变成一个新的只读层了。

Volume(数据卷)

Volume 机制,允许将宿主机中指定的目录或者文件,挂载到容器中进行取和修改操作。

Volume 有两种挂载方式

$ docker run -v /test ...
$ docker run -v /home:/test ...

两种挂载方式实质上是一样的,第一种,没有指定挂载的宿主机的目录,docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。

第二种,指定了宿主机中的目录,docker 就会把指定的宿主机中的 /home 目录挂载到容器的 /test 目录上。

docker 中使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。对于 Volume 挂载又是如何实现的呢?这里来具体的分析下。

当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

所以只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录挂载到容器中的目录上即可,这样 Volume 挂载工作就完成了。

在执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

这里用到了 Linux 的绑定挂载(bind mount)机制,它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

绑定挂载实际上是一个 inode 替换的过程,在 Linux 操作系统中,inode 可以理解为存放文件内容的"对象",dentry 也叫目录项,就是访问 inode 所有的指针。

Docker 工作原理分析

上面图片的栗子

mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。

如果执行 umount 命令,解除绑定,/test 文件中的内容就会恢复,因为修改发生的目录是在 /home 中。

同样如果对这个镜像执行 commit 操作,docker 容器 Volume 里的信息也是不会被提交的,但是这个挂载点的 /test 空目录会被提交。

打包一个go镜像

了解了 docker 的基本原理,这里来构建一个简单的 docker 镜像

首先一个简单的 go 服务,示例代码

package main

import (
	"encoding/json"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/hello", sayHello)

	log.Println("【默认项目】服务启动成功 监听端口 80")
	er := http.ListenAndServe("0.0.0.0:80", nil)
	if er != nil {
		log.Fatal("ListenAndServe: ", er)
	}
}

func sayHello(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	log.Println("request hello")
	data := map[string]interface{}{
		"status":  "ok",
		"message": "hello",
	}

	json.NewEncoder(w).Encode(&data)
}

交叉编译

export CGO_ENABLED=0
export GOOS=linux
export GOARCH=amd64

go build  -o go-server .

编写 Dockerfile 文件

# 基础镜像
FROM alpine

# Dockerfile 后面的操作都以这一句指定的 /app 目录作为当前目录
WORKDIR /app

# 将编译好的go程序,复制到 app 目录下  
COPY ./go-server ./app

# 允许外接访问的端口
EXPOSE 80

CMD  ["/app/go-server"]

Dockerfile 中的命令都是按照顺序执行的。

最后使用 CMD 来启动 go 应用,在 Dockerfile 中除了 CMD 还可以使用 ENTRYPOINT 来执行一些容器中的命令操作。

默认情况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c。所以,在不指定 ENTRYPOINT 时,比如在我们这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c “/app/go-server”,即 CMD 的内容就是 ENTRYPOINT 的参数。

总结

1、对于 Docker 来讲,最核心的原理就是为待创建的用户进程执行下面三个操作:

  • 1、启用 Linux Namespace 配置;

  • 2、设置指定的 Cgroups 参数;

  • 3、切换进程的根目录(Change Root)。

2、Docker 容器启动的进程还是在宿主机中运行的,和宿主机中其他运行的进程是没有区别的,只是 docker 容器会给这些进程,添加各种各样的 Namespace 参数,使这些进程和宿主机中的其它进程隔离开来,感知不到有其它进程的存在;

3、Docker 通过 Namespace 可以这些进程只能看到自己 Namespace 的相关资源,这样和其它 Namespace 的进程起到了隔离的作用,使得这些在容器中运行的进程像是运行在一个独立的环境中一样;

4、Docker 使用 Linux cgroups 来限制容器中的进程允许使用的系统资源,防止这些进程可能会占用很多的系统资源,影响到其他的进程;

5、Mount namespace 为进程提供独立的文件系统视图。简单点说就是,mount namespace 用来隔离文件系统的挂载点,这样进程就只能看到自己的 mount namespace 中的文件系统挂载点;

6、当一个容器被创建的时候,我们希望容器中进程看到的文件是一个独立的隔离环境,为了让容器这个根目录看起来更'真实',一般会在容器的根目录下面挂载一个完整的操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容器里通过执行 ls / 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件;

7、rootfs 是一个操作系统包含的所有的文件、配置和目录,并不包括操作系统内核。同一宿主机中的容器都共享主机操作系统的内核;

8、正是由于 rootfs 的存在,容器中的一个很重要的特性才能实现,一致性;

9、对于基础 rootfs 的制作,如果后续有更改的需求,一个很简单的操作就是,新 fork 一个然后修改,这样的缺点就是有很多碎片化的版本。rootfs 的制作,也是支持增量的方式进行操作的,Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

参考

【深入剖析 Kubernetes】https://time.geekbang.org/column/intro/100015201?code=UhApqgxa4VLIA591OKMTemuH1%2FWyLNNiHZ2CRYYdZzY%3D
【Linux Namespace】https://www.cnblogs.com/sparkdev/p/9365405.html
【浅谈 Linux Namespace】https://xigang.github.io/2018/10/14/namespace-md/
【理解Docker(4):Docker 容器使用 cgroups 限制资源使用 】https://www.cnblogs.com/sammyliu/p/5886833.html
【Linux Namespace : Mount 】https://www.cnblogs.com/sparkdev/p/9424649.html
【About storage drivers】https://docs.docker.com/storage/storagedriver/
【Docker工作原理分析】https://boilingfrog.github.io/2022/11/27/docker实现原理/