Docker 技术原理

1. docker工作原理

当我们的程序运行时,它们在计算机中表示为进程。docker容器可以运行各种程序。容器技术的核心功能是通过约束和修改流程的动态表示来为流程创建“边界”。

Docker容器启动的进程仍然在主机中运行,这与主机中运行的其他进程没有什么不同,只是Docker容器会向这些进程添加各种Namespace参数,以将它们与主机中的其他进程隔离,并且不会感知其他进程的存在。

对于大多数Linux容器(如Docker),Cgroups技术用于资源限制,而Namespace技术用于隔离。

容器是一个特殊的过程:

当docker容器创建进程时,它指定进程需要启用的一组命名空间参数。这样,容器只能“查看”受当前命名空间限制的资源、文件、设备、状态或配置。对于主机和其他无关程序来说,它是完全不可见的。

在容器中启动的进程仍在主机中运行,但Docker容器将向这些进程添加各种命名空间参数,以便这些进程与主机中的其他进程隔离,不会察觉到其他进程的存在。

2. docker容器核心技术

Docker的核心技术包含以下几点:

  • Linux namespace
  • Linux cgroup
  • rootfs
  • 镜像分层
  • 网络
  • Libnetwork
  • 存储驱动

下面我会依次介绍这些技术的原理。

注:本篇基于linux的docker来阐述。其他操作系统的docker容器实现原理不尽相同,各位可回想一下在win10下安装docker时,是不是要求必须启动hyper-V服务了?这个是win10自带的虚拟化服务,也就是说docker在win10下采用了虚拟化技术,并且借助创建MobyLinuxVM虚拟机来实现win10下的容器化

2.1 隔离:Namespace

命名空间(namespaces)是 Linux 为我们提供的用于分离进程树、网络接口、挂载点以及进程间通信等资源的方法。在日常使用 Linux 或者 macOS 时,我们并没有运行多个完全分离的服务器的需要,但是如果我们在服务器上启动了多个服务,这些服务其实会相互影响的,每一个服务都能看到其他服务的进程,也可以访问宿主机器上的任意文件,这是很多时候我们都不愿意看到的,我们更希望运行在同一台机器上的不同服务能做到完全隔离,就像运行在多台不同的机器上一样。

Linux 的命名空间机制提供了以下七种不同的命名空间,包括 CLONE_NEWCGROUP、CLONE_NEWIPC、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWUSER 和 CLONE_NEWUTS,通过这七个选项我们能在创建新的进程时设置新进程应该在哪些资源上与宿主机器进行隔离。

这里我们先观察一下已经搭建好的集群容器的情况:

1
2
3
4
5
6
# kubectl get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE
business-manager-666f454f7f-bg2bt 1/1 Running 0 27s 172.30.76.4 192.168.0.21
business-manager-666f454f7f-kvn5z 1/1 Running 0 27s 172.30.76.5 192.168.0.21
business-manager-666f454f7f-ncjp7 1/1 Running 0 27s 172.30.9.4 192.168.0.22
data-product-6664c6dcb9-p5xkw 1/1 Running 0 7m17s 172.30.9.3 192.168.0.22

接下来我们进入其他一个容器执行ps,查看容器里都有些什么进程:

1
2
3
4
5
6
# kubectl exec -ti business-manager-666f454f7f-ncjp7 sh
$ ps -efj
UID PID PPID PGID SID C STIME TTY TIME CMD
root 1 0 1 1 0 14:47 ? 00:00:03 ./business-manager -conf /business-manager/config/config.json
root 22 0 22 22 0 15:25 pts/0 00:00:00 sh
root 28 22 28 22 0 15:29 pts/0 00:00:00 ps -efj

进入docker 容器内部,ps查看所有的进程,pid=1的是我们的应用程序,pid=22和28的分别是我们这一步操作执行的sh程序和ps程序。了解linux系统的同学应该知道,pid=1的不是内核的init进程吗。那么init进程哪去了呢?
其实上面显示的1号进程,是docker容器的障眼法,这个business-manager进程就是跑在宿主机上的一个特殊的进程,我们查看下宿主机的真实进程情况:

1
2
3
# ps -efj | grep business-manager
root 38156 38138 38156 38156 0 14:47 ? 00:00:03 ./business-manager -conf /business-manager/config/config.json
root 47393 3471 47392 3471 0 15:34 pts/2 00:00:00 grep --color=auto business-manager

上面这个pid=33156的才是宿主机上对应business-manager容器的真实进程。

在当前的宿主机器上,可能就存在由上述的不同进程构成的进程树:

image-20221212155344389

这就是在使用 clone 创建新进程时传入 CLONE_NEWPID 实现的,也就是使用 Linux 的命名空间实现进程的隔离,Docker 容器内部的任意进程都对宿主机器的进程一无所知。

容器(指容器里的应用),是linux系统里的一个特殊进程,docker通过linux namespace技术对应用进程进行了隔离,使的应用只能看到指定的有限的系统信息,这就使应用“以为”自己在一个独立的操作系统环境下。

Linux namespace,跟K8S、C++的namespace的功能是类似的,目的都是将一组资源限定在一个有限的可见范围内。Linux namespace支持以下几项资源隔离:

名称 宏定义 隔离内容
Cgroup CLONE_NEWCGROUP 资源限制Cgroup root directory (since Linux 4.6)
IPC CLONE_NEWIPC IPC资源System V IPC, POSIX message queues (since Linux 2.6.19)
Network CLONE_NEWNET 网络Network devices, stacks, ports, etc. (since Linux 2.6.24)
Mount CLONE_NEWNS 文件系统Mount points (since Linux 2.4.19)
PID CLONE_NEWPID 进程号Process IDs (since Linux 2.6.24)
User CLONE_NEWUSER 用户User and group IDs (started in Linux 2.6.23 and completed in Linux 3.8)
UTS CLONE_NEWUTS 主机名Hostname and NIS domain name (since Linux 2.6.19)
这些隔离属性,基本涵盖了一个小型操作系统的运行要素,包含主机名、网络、文件系统等。
要使用上述namespace很容易,在调用内核api clone()函数创建新的进程时,加上上述参数即可。这正是Docker在创建容器(现在大家知道了,就是创建我们的应用程序进程)时所要做的事情。

2.2 限制:Cgroup

Linux的Cgroup机制,是Docker利用的又一大利器。上一节我们知道,容器其实就是宿主机里的一个被框进来的进程,它不能看到外面,但它与宿主机上其他的进程共享了内核资源,所以接下来我们需要对它所能使用的资源作限制,这就是Cgroup机制所提供的。
Cgroup的使用,比较简单粗暴,它利用一组目录和文件的组合,来实现配置和控制。这些目录和文件在/sys/fs/cgroup目录下:

1
2
3
# cd /sys/fs/cgroup/
# ls
blkio cpu cpuacct cpu,cpuacct cpuset devices freezer hugetlb memory net_cls net_cls,net_prio net_prio perf_event pids systemd

上述文件夹各自对一些资源进行控制。要使用cgroup很简单,在对应目录下创建一个新的文件夹,cgroup会自动为我们生成相关的一些配置文件:

1
2
3
4
5
6
# cd cpu
# mkdir JoTest
# cd JoTest/
# ls
cgroup.clone_children cgroup.procs cpuacct.usage cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.event_control cpuacct.stat cpuacct.usage_percpu cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks

我们可以修改period和quota文件,配置进程能够占用的CPU百分比,然后将需要应用这组限制的进程的ID写入tasks文件,即可完成cpu的限制。

2.3 rootfs

Namespace对应用进行了隔离,而cgroup则完成了资源的分配和限制,现在一个针对应用程序的沙盒已经成型。接下来就是考虑一致性问题了?

一致性主要是为了解决应用跑在不同的宿主机上不受宿主机环境的差异影响的问题。容器技术出来之前,手动或脚本迁移应用的时候,往往会遇到新的宿主机缺少某个关键组件、或是某些依赖版本差异甚至是操作系统内核差异等因素导致的不一致问题。现在我们来看看docker是怎么解决这个问题的。

对于单个应用程序进程来说,对环境的依赖,关键体现在对操作系统所提供的文件系统的依赖,所以docker所要做的就是通过以下3步给你一套想要的文件系统:

  • 通过mount namespace(2.1章节)将应用的文件系统隔离开
  • 将应用所需要的文件系统(比如centos:7的所有文件)拷贝到某个目录D下
  • 调用chroot将应用的根目录调整为目录D(mount namespace的隔离作用在这里体现出来了,chroot只对在当前namespace下的应用生效,应用在这个“根目录”下可以随便折腾,而不会影响到真实的宿主机根目录。)

上面这3步所构造出来的文件系统,我们称之为rootfs(根文件系统)。应用程序执行“cd /“指令进入的根目录,将被限定在上述目录D下。下面我们简单验证一下这个rootfs。
这里我们在容器的根目录新建一个文件jo1,然后在宿主机上查找这个文件,定位到如下位置:

1
2
3
# cd /var/lib/docker/overlay2/b2231f9f15050ae8d609726d308c2ead60114df3fc5404a24c688d805d4a9883/merged/
# ls
anaconda-post.log bin business-manager data dev etc home jo1 lib lib64 lost+found media mnt opt proc root run sbin srv sys tmp usr var

可以看到,上面这个文件夹正是我们的容器所在的“根目录”。

我们也可以用mount指令查看当前宿主机的挂载情况,限于篇幅,这里就不展开解析mount了,下面是mount的输出节选:

1
2
3
4
# mount|grep overlay2
overlay on /var/lib/docker/overlay2/be512d9faf97c7d860fa16ecc4ecd5057e12feffc8b0804115923f0795bb9f75/merged type overlay (rw,relatime,seclabel,lowerdir=/var/lib/docker/overlay2/l/ZSXN3J4UXRRBMRG3F3RNUZGWUB:/var/lib/docker/overlay2/l/2CCAOTBFIGCYNR73TQEE333CO6:/var/lib/docker/overlay2/l/6Y5GTV75CBUR4WORCMOHYJZQ7Y:/var/lib/docker/overlay2/l/VWENHOE7X3WDA4NK5P7DTTPSIX,upperdir=/var/lib/docker/overlay2/be512d9faf97c7d860fa16ecc4ecd5057e12feffc8b0804115923f0795bb9f75/diff,workdir=/var/lib/docker/overlay2/be512d9faf97c7d860fa16ecc4ecd5057e12feffc8b0804115923f0795bb9f75/work)
overlay on /var/lib/docker/overlay2/8a7546027e2e354bf0bba12600fdb6ac87c199d09589ba90952f28aed74d13b6/merged type overlay (rw,relatime,seclabel,lowerdir=/var/lib/docker/overlay2/l/KOZRXUFTWITQUNAKOZMWSYWTSH:/var/lib/docker/overlay2/l/2CCAOTBFIGCYNR73TQEE333CO6:/var/lib/docker/overlay2/l/6Y5GTV75CBUR4WORCMOHYJZQ7Y:/var/lib/docker/overlay2/l/VWENHOE7X3WDA4NK5P7DTTPSIX,upperdir=/var/lib/docker/overlay2/8a7546027e2e354bf0bba12600fdb6ac87c199d09589ba90952f28aed74d13b6/diff,workdir=/var/lib/docker/overlay2/8a7546027e2e354bf0bba12600fdb6ac87c199d09589ba90952f28aed74d13b6/work)
...

2.4 镜像分层

上面3节已经可以实现一个基本的容器了。接下来的问题是,我们这么多应用的docker镜像如果全部都包含了整个操作系统的文件,势必给镜像的传播下载带来不便,所以docker为我们实现了镜像分层来解决镜像大小问题。

1
2
3
4
5
6
7
8
FROM reg.miz.so/library/centos:7
MAINTAINER "maizuo <aura@hyx.com>"
RUN rm /etc/localtime && \
ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
WORKDIR /business-manager
ADD ./build/main /business-manager/business-manager
ADD ./build/resource /business-manager/resource
RUN mkdir /data && cd /data && mkdir logs

查看上面这个典型的dockerfile文件,其中FROM字段表示我们当前打包的镜像是基于reg.miz.so/library/centos:7来创建的,dockerfile文件里每条指令均会创建一个新的镜像层,这些层合在一起就是一个完整的镜像。

镜像分层的应用,简单的举个例子,比如我们的business-manager和data-product都是基于reg.miz.so/library/centos:7这个镜像建立的,那么我们第一次下载data-product镜像时,需要把reg.miz.so/library/centos:7也下载下来,但当我们再次下载其他的镜像比如business-manager,已经存在的镜像层如reg.miz.so/library/centos:7镜像则无需重复下载,只需要下载增量部分即可,所以多个应用如果基于同样的基础层创建,除第1次外,后面的镜像下载往往只需要下几十M的应用程序即可,这就很大程度上解决了镜像大小的问题。

不同的容器镜像共用相同的层,那么容器如果修改了底层的文件,会不会影响到其他容器呢?

答案是不会,docker在这里运用了copy-on-write的手法,在后面存储驱动会有介绍

2.5 网络

如果 Docker 的容器通过 Linux 的命名空间完成了与宿主机进程的网络隔离,但是却有没有办法通过宿主机的网络与整个互联网相连,就会产生很多限制,所以 Docker 虽然可以通过命名空间创建一个隔离的网络环境,但是 Docker 中的服务仍然需要与外界相连才能发挥作用。
每一个使用 docker run 启动的容器其实都具有单独的网络命名空间,Docker 为我们提供了四种不同的网络模式,Host、Container、None 和 Bridge 模式。

在这一部分,我们将介绍 Docker 默认的网络设置模式:网桥模式。在这种模式下,除了分配隔离的网络命名空间之外,Docker 还会为所有的容器设置 IP 地址。当 Docker 服务器在主机上启动之后会创建新的虚拟网桥 docker0,随后在该主机上启动的全部服务在默认情况下都与该网桥相连。

image-20221212161734964

在默认情况下,每一个容器在创建时都会创建一对虚拟网卡,两个虚拟网卡组成了数据的通道,其中一个会放在创建的容器中,会加入到名为 docker0 网桥中。我们可以使用如下的命令来查看当前网桥的接口:

docker0 会为每一个容器分配一个新的 IP 地址并将 docker0 的 IP 地址设置为默认的网关。网桥 docker0 通过 iptables 中的配置与宿主机器上的网卡相连,所有符合条件的请求都会通过 iptables 转发到 docker0 并由网桥分发给对应的机器。

我们在当前的机器上使用 docker run -d -p 6379:6379 redis 命令启动了一个新的 Redis 容器,在这之后我们再查看当前 iptables 的 NAT 配置就会看到在 DOCKER 的链中出现了一条新的规则:

1
DNAT       tcp  --  anywhere             anywhere             tcp dpt:6379 to:192.168.0.4:6379

上述规则会将从任意源发送到当前机器 6379 端口的 TCP 包转发到 192.168.0.4:6379 所在的地址上。

Docker 通过 Linux 的命名空间实现了网络的隔离,又通过 iptables 进行数据包转发,让 Docker 容器能够优雅地为宿主机器或者其他容器提供服务。

2.6 Libnetwork

整个网络部分的功能都是通过 Docker 拆分出来的 libnetwork 实现的,它提供了一个连接不同容器的实现,同时也能够为应用给出一个能够提供一致的编程接口和网络层抽象的容器网络模型。

libnetwork 中最重要的概念,容器网络模型由以下的几个主要组件组成,分别是 Sandbox、Endpoint 和 Network:

image-20221212162618226

endpoint可以理解为容器中的多个虚拟网卡。

在容器网络模型中,每一个容器内部都包含一个 Sandbox,其中存储着当前容器的网络栈配置,包括容器的接口、路由表和 DNS 设置,Linux 使用网络命名空间实现这个 Sandbox,每一个 Sandbox 中都可能会有一个或多个 Endpoint,在 Linux 上就是一个虚拟的网卡 veth,Sandbox 通过 Endpoint 加入到对应的网络中,这里的网络可能就是我们在上面提到的 Linux 网桥或者 VLAN。

2.7 存储驱动

Docker 使用了一系列不同的存储驱动管理镜像内的文件系统并运行容器,这些存储驱动与 Docker 卷(volume)有些不同,存储引擎管理着能够在多个容器之间共享的存储。

想要理解 Docker 使用的存储驱动,我们首先需要理解 Docker 是如何构建并且存储镜像的,也需要明白 Docker 的镜像是如何被每一个容器所使用的;Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层:

1
2
3
4
FROM ubuntu:15.04
COPY . /app
RUN make /app
CMD python /app/app.py

容器中的每一层都只对当前容器进行了非常小的修改,上述的 Dockerfile 文件会构建一个拥有四层 layer 的镜像:

image-20221212163700397

当镜像被 docker run 命令创建时就会在镜像的最上层添加一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。

容器和镜像的区别就在于,所有的镜像都是只读的,而每一个容器其实等于镜像加上一个可读写的层,也就是同一个镜像可以对应多个容器。

3. 容器与虚拟机

3.1 虚拟机

使用虚拟机时,需要使用虚拟机管理程序来创建虚拟机。此虚拟机是真实的,需要运行来宾操作系统来执行用户的应用程序过程,这将不可避免地导致额外的资源消耗和占用。

虚拟机本身的操作将占用一定数量的资源。同时,虚拟机对主机文件的调用将不可避免地需要由虚拟化软件进行连接和处理。这本身就是一个性能消耗层,特别是对于计算资源、网络和磁盘I/O。

3.2. 容器

容器化应用程序仍然是主机上的一个常见进程。虚拟化不会造成性能损失。同时,容器使用命名空间隔离,因此不需要单独的来宾操作系统。这使得容器的额外资源消耗几乎可以忽略不计。

但是容器也存在一些缺点:

  1. 与虚拟化技术相比,基于Linux命名空间的隔离机制也有许多缺点。主要问题是隔离不完整。容器只是在主机中运行的一个特殊进程,容器时间仍然由同一主机的操作系统内核使用;

    尽管您可以通过容器中的mount Namespace分别装载不同版本的其他操作系统文件,如CentOS或Ubuntu,但这不会改变主机内核共享的事实。这意味着,如果您想在Windows主机上运行Linux容器,或者在较低版本的Linux主机上运行更高版本的Linux容器,它将无法工作。

  2. 在Linux内核中,有许多资源和对象无法命名空间化。最典型的例子是:时间;

    如果容器中的程序使用 settimeofday 系统调用来修改时间,则整个主机时间将相应地修改,因此我们应该尽量避免在容器中执行此操作。

  3. 共享主机内核的容器将向应用程序暴露更大的攻击面。

    在生产环境中,物理机器中的Linux容器不会直接暴露于公共网络。root

3.3 虚拟机和容器之间的区别

特性或原理 Docker Container 虚拟机
核心原理 进程隔离,共享操作系统内核 硬件虚拟化,在宿主操作系统上再跑一个操作系统
如何保证一致性 镜像(rootfs) 安装一个相同的操作系统
启动速度 秒级-进程级启动 分钟级-系统级启动
硬盘使用 MB-镜像分层的优势 GB
性能 几乎无损耗 有损耗
单机支持量 单机支持上千个容器 一般几十个

当然,docker也存在一些弊端,主要是隔离性不足以及隔离不足所带来的安全风险。虚拟机基于硬件虚拟化,每个虚拟机上都运行着独立的系统内核,可以保证与宿主机和其他虚拟机有强隔离,跑在虚拟机内的应用可以随便折腾而无需担心影响到“邻居”。而docker所依赖的namespace提供的是有限的隔离,典型的是系统时间没有被隔离,容器内修改系统时间会直接体现在宿主机上;所以开发过程中,当我们的应用需要修改内核参数时,务必谨慎,明确自己在干啥。

刘小恺(Kyle) wechat
如有疑问可联系博主