Docker具有隔离性、可配额、安全性、便携性的特点,此篇博客将从资源隔离、资源配额控制、存储、网络四个方面来认识docker。在了解隔离实现原理前,先了解Docker中容器的定义,基于Linux内核的Cgroup,Namespace,以及Union FS等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术,由于隔离的进程独立于宿主和其它的隔离进程,因此也称其为容器。Docker在容器的基础上,进行了进一步封装,从文件系统、网络互联到进程隔离等等,极大简化了容器的创建和维护。而进程隔离主要靠Linux Namespace隔离方案来实现。
资源隔离:
Linux Namespace是一种Linux Kernel提供的资源隔离方案,系统可以为进程分配不同的Namespace,并保证不同的Namespace资源独立分配,进程间彼此隔离,即不同的Namespace下进程互不干扰。具体的namespace如下所示:
pid namespace:不同用户进程是通过Pid namespace进行隔离的,不同namespace中可以有相同的Pid
net namesapce:每个net namespace有独立的network devices,ip address,pi routing table,/proc/net目录。docker默认采用veth的方式将container中的虚拟网卡同host上的一个docker bridge:docker0连接在一起。
ipc namesapce:ipc:interprocess communication,进程间交互方法,包括常见的信号量、消息队列等。
mnt namespace:每个namespace中进程所看到的文件目录被隔离了
uts namespace:Unix Time-sharing system namesapce允许每个container拥有独立的hostname和domain name,使其在网络上可以被视作一个独立的节点而非Host上的一个进程。
user namespace: 每个container可以有不同的user和group id,等同于可以让container内部的用户执行程序而非Host上的用户。接下来先来看看Pid是如何隔离的。
用docker命令启动一个busybox(docker run -it busybox /bin/sh),通过此命令启动了一个busybox容器,且通过-it命令进入到了容器内部,此时在容器内部查看所有的进程信息,结果如下所示:
此时退出容器到宿主机上,执行lnns命令,可以看到/bin/sh的进程号是95957.
这就是Linux系统本身提供的pid namespace隔离技术。可以理解为全班有100个同学,对于第10个同学来说,把他隔离在一个小房间里面,他看不到前面的9个同学,在自己小房间里面,他认为自己就是编号为1的同学。这也再次说明容器实际就是一种特殊的进程。
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的PID=1的程序来充当两个不同应用的父进程,故容器是一个单进程模型。这也是为什么很多人都会用systemd或者supervisord这样的软件来代替应用本身作为容器的启动进程(备注:不推荐用systemd或者supervisord作为容器启动程序,因为容器本身的设计,就希望容器和应用能够同生命周期,否则,一旦出现类似于“容器是正常运行的,但是里面的应用已经挂了”的情况,编排系统处理起来就比较麻烦了)。
在宿主机上执行命令“lsns -t type”: 查看当前系统的namespace,可以看到每个进程都有不同的namespace,例如:pid=1的进程(备注:pid=1的进程是守护进程),有pid/user/uts/ipc/mnt/net六种namespace。
ls -la /proc/$pid/ns:查看当前进程的namespace
nsenter -t $pid -n ip addr:查看某个进程的网络配置
资源配额控制
隔离是通过Linux namespace来实现,那么资源配额又是如何实现的呢?Cgroup是Linux对于一个或者一组进程进行资源控制和监控的机制,可以对cpu,内存,磁盘I/O等进程所需的资源进行限制。进入/sys/fs/cgroup目录,里面又有cpu,memory目录
进入cpu目录,如果要对进程所使用的cpu进行限制,那么需要先把进程号加入到cgroup.procs中,然后在相关配置文件中设置,即可完成对进程所使用的cpu的控制。例如
cpu.cfs_period_us
cfs_period_us表示一个cpu带宽,单位为微秒。系统总CPU带宽: cpu核心数 * cfs_period_us
cpu.cfs_quota_us
cfs_quota_us表示Cgroup可以使用的cpu的带宽,单位为微秒。cfs_quota_us为-1,表示使用的CPU不受cgroup限制。cfs_quota_us的最小值为1ms(1000),最大值为1s。结合cfs_period_us,就可以限制进程使用的cpu。例如配置cfs_period_us=10000,而cfs_quota_us=2000。那么该进程最大可占比的cpu百分比是20%。
接下来做一下小实验,用go语言写个无限循环占用cpu,如果不设置cpu占比的情况下,启动该应用,那么cpu占比应该是200%,此时在cpu目录下创建cpudemo目录,创建该目录后,会自动生成cpu相关配置文件,在cpudemo目录下的cgroup.procs中添加上进程号,查看cpu.cfs_period_us默认值是100000,此时如果设置cpu.cfs_quota_us的值为10000,那么cpu使用占比会降低到10%,如果设置cpu.cfs_quota_us的值为50000,那么cpu占比会变成50%。
go语言编写的for循环代码如下所示:
package main
func main() {
go func() {
for {
}
}()
for {
}
}
top命令查看cpu占比情况,可以看到是10%和50%两个值。
除了修改配置文件控制进程所能使用的最大cpu外,还可以在docker启动容器时,通过设置参数控制容器进程能占用的最大cpu。
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
对于docker启动的容器进程,在/sys/fs/cgroup/cpu/docker/容器编号/目录下能查看对于这个容器的资源限制信息。
除了对cpu进行控制外,还可以对memory进行控制,如果要控制进程占用的memory,也是先把进程号写入cgroup.procs中,然后配置可使用的内存总量,例如可设置memory.limit_in_bytes来控制该进程可占用的内存总量,如果超出了设置的值,那么会爆out of memory的错误。
通过上面资源限制的演示,我们可以再来看看容器的定义:一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。
存储
在理解容器存储前,我们先来看看容器镜像本质是什么?
用docker命令启动一个ubuntu的容器镜像,进入到容器中,用ls命令查看根目录下存在的主要目录,如下所示
通过前面的知识可以知道容器本身只是一个特殊进程而已,而容器里面这些文件是从哪里来的呢?答案是镜像。上面通过ls查看到的内容就是ubuntu所有的目录和文件。而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
如果是启动busybox的容器镜像,进入容器后查看到的根目录内容如下所示:因为镜像不同,所以能查看到的根文件系统也不同。同时也可以看到容器的根目录内容和宿主机的根目录内容肯定也是不相同的。
这种更改根目录的逻辑,可以通过chroot去理解,Linux系统中可以通过执行chroot命令,将指定的目录修改为根目录,更多chrooot信息可以看这里。所以,对Docker来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用 Linux Namespace 配置;
- 设置指定的Cgroups参数;
- 切换进程的根目录(Change Root)
这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别。另外,需要注意的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。容器进程是共享宿主机内核。
正是由于rootfs的存在,容器才有了一个被反复宣传至今的重要特性:一致性。由于 rootfs 里打包的不只是应用,还有整个操作系统的文件和目录,也就是说:应用以及它运行所需要的所有依赖,都被封装在了一起。对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
通过前面对rootfs的理解,引出另外一个为你:难道每开发一个应用,或升级一下现有的应用,都要重复制作一次rootfs吗?当然不是。这里Docker在制作镜像是引入了层Layer的概念,也就是说用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs。在制作镜像过程中Docker还用到了一种能力叫联合文件系统(Union File System)的能力,Union File System 也叫UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。
那Docker在整个过程中是如何利用rootfs和UnionFs的呢?Docker启动时将rootfs以readonly方式加载并检查,接着通过union mount的方式将一个readwrite文件系统挂载在readonly的rootfs上,且允许再次将下层FS设定为readonly向上叠加,这样一组readonly和一个writeable的结构组成了container的运行时状态。容器存储有多种类型驱动,比较常见的有OverlayFs存储驱动,接下来将看看overlay工作原理。
一个 overlay 文件系统包含两个文件系统,一个 upper 文件系统和一个 lower 文件系统,是一种新型的联合文件系统。overlay是“覆盖…上面”的意思,overlay文件系统则表示一个文件系统覆盖在另一个文件系统上面。如下图所示,OverlayFS在单个Linux主机上分层两个目录,并将它们显示为单个目录。这些目录称为“ 层” ,统一过程称为“ 联合安装” 。OverlayFS将较低的目录称为lowerdir ,将较高的目录称为upperdir 。统一视图通过其自己的目录称为merged公开,如下图所示
- lower目录:可以是多个,是处于最底层的目录,作为只读层
- upper目录:只有一个,作为读写层
- work目录:为工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见,
- merged目录:为最后联合挂载完成给用户呈现的统一视图,也就是说merged目录里面本身并没有任何实体文件,给我们展示的只是参与联合挂载的目录里面文件而已,真正的文件还是在lower和upper中。所以,在merged目录下编辑文件,或者直接编辑lower或upper目录里面的文件都会影响到merged里面的视图展示。
接下来做一个小实验,在demo目录下,创建lower,upper,work, merged四个目录,且在lower和upper目录下创建文件,然后执行mount命令,执行完成后,可以看到merged目录中包含了lower和upper目录下的所有文件。
执行df -h命令,可以看到demo/merged目录挂载成功
对于docker而言,镜像层就是lower层,容器层就是upper层,那么容器如何通过overflay完成读写操作呢?
读取文件
- 如果文件在容器层(upperdir),直接读取文件;
- 如果文件不在容器层(upperdir),则从镜像层(lowerdir)读取;
修改文件或目录
- 首次写入: 如果在upperdir中不存在,overlay2执行copy_up操作,把文件从lowdir拷贝到upperdir,由于overlayfs是文件级别的(即使文件只有很少的一点修改,也会产生copy_up的行为),后续对同一文件的写入操作将对已经复制到容器层的文件的副本进行操作。值得注意的是,copy_up操作只发生在文件首次写入,以后都是只修改副本。
- 删除文件和目录: 当文件在容器层被删除时,在容器层(upperdir)创建whiteout文件,镜像层(lowerdir)的文件是不会被删除的,因为他们是只读的,但without文件会阻止他们显示。
- 重命名目录 :仅当源路径和目标路径都在顶层时,才允许对目录调用
rename
。否则,它将返回EXDEV
错误(“不允许跨设备链接”)。您的应用程序需要设计为可以处理EXDEV
并退回到“复制和取消链接”策略。
通过上面的介绍可以知道,如果要对容器镜像层进行写操作,那么实际上执行的就是Copy-on-Write的机制。
如下图所示,通过命令(docker image inspect nginx:latest)查看一个nginx镜像的层,可以看到总共有六层,在Data目录下,又区分了LowerDir,MergedDir,UpperDir,WorkDir。
关于存储部分,除了了解容器镜像的分层和写时复制外,还需要了解另外一个内容Volume。这里需要使用到Linux的挂载技术,即Linux 的绑定挂载(bind mount)机制。它的主要作用是,允许将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。如果要在启动容器的时候挂载目录,那么可以通过- v参数来挂载volume。
$ docker run -v /test ...
$ docker run -v /home:/test ...
上面第一种情况没有显示声明宿主机目录,那么Docker就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。而第二种情况,Docker就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。接下来通过一个小实验来验证一下。启动一个nginx的容器镜像,且将默认的宿主机目录挂载到容器的test目录。
挂载后,在_data目录下,ls查看该目录下无任何内容,此时在容器内部新增一个文件。
切换会宿主机,查看对应目录,可以看到文件写入的宿主机的相应目录。
可以看到,进行一次绑定挂载,Docker就可以成功地将一个宿主机上的目录或文件很容易地挂载到容器中。
结合前面介绍的隔离、资源限制、容器镜像、存储等内容,假设我们的业务应用程序是一个由python编写的应用,那么可以通过下面的一幅图来表示docker容器全景图。
这个容器进程“python app.py”,运行在由Linux Namespace和Cgroups构成的隔离环境里;而它运行所需要的各种文件,比如 python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的rootfs层提供。这些rootfs层的最下层,是来自 Docker镜像的只读层。在只读层之上,是Docker自己添加的Init层,用来存放被临时修改过的/etc/hosts等文件。而 rootfs的最上层是一个可读写层,它以Copy-on-Write的方式存放任何对只读层的修改,容器声明的Volume 的挂载点,也出现在这一层。
网络
前面介绍了存储等内容,接下来看看网络方面,即同一个instance上不同容器间如何进行网络通信。容器启动的时候可以设置不同的网络模式,默认情况时bridge模式,为了更好的理解bridge工作模式,我们会启动一个Null的网络模式(即不进行任何网络配置),然后通过手动配置的方式实现Bridge。
步骤一:启动一个null模式nginx容器(docker run --network=none -d nginx)
步骤二:获取容器的Pid(docker inspect ff8b08a72f93 (containerID)|grep -i pid)
步骤三:查看容器的网络配置(nsenter -t 69984(pid) -n ip a),可以看到只有loopback配置
步骤四:进行桥接配置
4.1创建veth pair
可以理解成创建了一根网线,网线的一头为A,另外一头为B
ip link add A type veth peer name B
4.2网线的A口连接到宿主机的docker0上
brctl addif docker0 A
ip link set A up
4.3将网线的B口连接到容器进程上
SETIP=172.17.0.10
SETMASK=16
GATEWAY=172.17.0.1
ip link set B netns $pid
可以理解成通过容器的进程号将网线的B口连接上容器
ip netns exec $pid ip link set dev B name eth0
将网线的B口名称修改为eth0
ip netns exec $pid ip link set eth0 up
ip netns exec $pid ip addr add $SETIP/$SETMASK dev eth0
通过容器的进程号设置容器的ip地址
ip netns exec $pid ip route add default via $GATEWAY
通过容器的进程号设置容器的网关
步骤五:配置完成后,通过curl命令(curl 容器IP地址)访问启动nginx容器,可以看到获取nginx内容成功,说明网络配置成功。
再次查看容器进程的网络配置,可以看到除了loopback外,eth0进行了网络配置。
再次用docker run -d nginx命令新启动一个nginx容器,因为默认是桥接方式,查看新启动的nginx容器进程,会发现网络配置和上面手动配置结果相同。
总结网桥模式,docker 服务默认会创建一个 docker0
网桥,docker 默认指定了 docker0
接口 的 IP 地址和子网掩码,让主机和容器之间可以通过网桥相互通信。通过创建veth pair,每个启动的容器和主机的docker进行联通,这样容器之间也进行了互通。