34 KiB
Docker进阶
作者:行癫(盗版必究)
一:深入理解容器
1.Docker的镜像和容器的区别
Docker镜像:
假设Linux内核是第0层,那么无论怎么运行Docker,它都是运行于内核层之上的。这个Docker镜像,是一个只读的镜像,位于第1层,它不能被修改或不能保存状态
一个Docker镜像可以构建于另一个Docker镜像之上,这种层叠关系可以是多层的。第1层的镜像层我们称之为基础镜像(Base Image),其他层的镜像(除了最顶层)我们称之为父层镜像(Parent Image)。这些镜像继承了他们的父层镜像的所有属性和设置,并在Dockerfile中添加了自己的配置
要列出本地所有有效的镜像,可以使用命令
[root@xingdian ~]# docker images
Docker容器:
Docker容器可以使用命令创建:
[root@xingdian ~]# docker run imagename
它会在所有的镜像层之上增加一个可写层。这个可写层有运行在CPU上的进程,而且有两个不同的状态:
运行态(Running)和退出态 (Exited)
这就是Docker容器。当我们使用docker run启动容器,Docker容器就进入运行态,当我们停止Docker容器时,它就进入退出态。当我们有一个正在运行的Docker容器时,从运行态到停止态,我们对它所做的一切变更都会永久地写到容器的文件系统中。要切记,对容器的变更是写入到容器的文件系统的,而不是写入到Docker镜像中的。我们可以用同一个镜像启动多个Docker容器,这些容器启动后都是活动的,彼此还是相互隔离的。我们对其中一个容器所做的变更只会局限于那个容器本身。如果对容器的底层镜像进行修改,那么当前正在运行的容器是不受影响的,不会发生自动更新现象
64字符的十六进制的字符串来定义容器ID,它是容器的唯一标识符。容器之间的交互是依靠容器ID识别的,由于容器ID的字符太长,我们通常只需键入容器ID的前4个字符即可。当然,我们还可以使用容器名
2.容器名称
--name= Assign a name to the container
--为容器分配一个名字,如果没有指定,会自动分配一个随机名称
--docker run子命令的参数
容器命名方式
使用UUID长命名("f78375b1c487e03c9438c729345e54db9d20cfa2ac1fc3494b6eb60872e74778")
使用UUID短命令("f78375b1c487")
使用Name("xingdian")
注意:
这个UUID标识是由Docker deamon生成的
如果你在执行docker run时没有指定--name,那么deamon会自动生成一个随机字符串UUID
但是对于一个容器来说有个name会非常方便,当你需要连接其它容器时或者类似需要区分其它容器时,使用容器名称可以简化操作。无论容器运行在前台或者后台,这个名字都是有效的
如果在使用Docker时有自动化的需求,你可以将containerID输出到指定的文件中(PIDfile)类似于某些应用程序将自身ID输出到文件中,方便后续脚本操作
--cidfile="": Write the container ID to the file
3.镜像名称
镜像是Docker最核心的技术之一,也是应用发布的标准格式。无论你是用docker pull image,或者是在Dockerfile里面写FROM image,下载镜像应该是Docker操作里面最频繁的动作之一
下面是在本地机器运行docker images的输出结果:
常说的"ubuntu"镜像其实不是一个镜像名称,而是代表了一个名为ubuntu的Repository,同时在这个Repository下面有一系列打了tag的Image,Image的标记是一个GUID,为了方便也可以通过Repository:tag来引用
那么Registry又是什么呢?Registry存储镜像数据,并且提供拉取和上传镜像的功能
Registry中镜像是通过Repository来组织的,而每个Repository又包含了若干个Image
Registry包含一个或多个Repository
Repository包含一个或多个Image
Image用GUID表示,有一个或多个Tag与之关联
注意:
当一个镜像的名称不足以分辨这个镜像所代表的含义时,你可以通过tag将版本信息添加到run命令中,以执行特定版本的镜像
[root@xingdian ~]# docker run ubuntu:14.04
4.名字空间
namespace 空间隔离
cgroup 资源限制
rootfs 文件系统
名字空间是 Linux 内核一个强大的特性。每个容器都有自己单独的名字空间,运行在其中的应用都像是在独立的操作系统中运行一样。名字空间保证了容器之间彼此互不影响
pid 名字空间
不同用户的进程就是通过 pid 名字空间隔离开的,且不同名字空间中可以有相同 pid
net 名字空间
有了pid名字空间, 每个名字空间中的 pid 能够相互隔离,但是网络端口还是共享 host 的端口。网络隔离是通过 net 名字空间实现的,每个 net 名字空间有独立的 网络设备, IP 地址, 路由表, /proc/net 目录。这样每个容器的网络就能隔离开来
mnt名字空间
类似 chroot,将一个进程放到一个特定的目录执行。mnt 名字空间允许不同名字空间的进程看到的文件结构不同,这样每个名字空间 中的进程所看到的文件目录就被隔离开了
uts 名字空间
UTS("UNIX Time-sharing System") 名字空间允许每个容器拥有独立的 hostname 和 domain name, 使其在网络上可以被视作一个独立的节点而非主机上的一个进程
user 名字空间
每个容器可以有不同的用户和组 id, 也就是说可以在容器内用容器内部的用户执行程序而非主机上的用户
二:Docker资源限制
虽然容器内的第 1 号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第 100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上被隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光
Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能
Linux Cgroups 的全称是 Linux Control Group。
它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等
Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作
1.系统压力测试工具
stress是一个linux下的压力测试工具,专门为那些想要测试自己的系统,完全高负荷和监督这些设备运行的用户
安装:
[root@xingdian ~]# yum install stress -y
测试场景举例:
测试CPU负荷
[root@xingdian ~]# stress –c 4
增加4个cpu进程,处理sqrt()函数函数,以提高系统CPU负荷
内存测试
[root@xingdian ~]# stress –i 4 --vm 10 --vm-bytes 1G --vm-hang 100 --timeout 100s
新增4个io进程,10个内存分配进程,每次分配大小1G,分配后不释放,测试100S
磁盘I/O测试
# stress –d 1 --hdd-bytes 3G
新增1个写进程,每次写3G文件块
硬盘测试(不删除)
# stress -i 1 -d 10 --hdd-bytes 3G –hdd-noclean
新增1个IO进程,10个写进程,每次写入3G文件块,且不清除,会逐步将硬盘耗尽。
stress各主用参数说明:
--help 显示帮助信息
--version 显示软件版本信息
-t secs:
--timeout secs指定运行多少秒
-c forks:
--cpu forks 产生多个处理sqrt()函数的CPU进程
-m forks
--vm forks:产生多个处理malloc()内存分配函数的进程,后接进程数量
-i forks
--io forks:产生多个处理sync()函数的磁盘I/O进程
--vm-bytes bytes:指定内存的byte数,默认值是1
--vm-hang:表示malloc分配的内存多少时间后在free()释放掉
-d :
--hdd:写进程,写入固定大小,通过mkstemp()函数写入当前目录
--hdd-bytes bytes:指定写的byte数,默认1G
--hdd-noclean:不要将写入随机ascii数据的文件unlink,则写入的文件不删除,会保留在硬盘空间。
2.限制CPU share
CPU 资源:
主机上的进程会通过时间分片机制使用 CPU,CPU 的量化单位是频率,也就是每秒钟能执行的运算次数。为容器限制 CPU 资源并不能改变 CPU 的运行频率,而是改变每个容器能使用的 CPU 时间片。理想状态下,CPU 应该一直处于运算状态(并且进程需要的计算量不会超过 CPU 的处理能力)
Docker 限制 CPU Share:
docker 允许用户为每个容器设置一个数字,代表容器的 CPU share,默认情况下每个容器的 share 是 1024。这个 share 是相对的,本身并不能代表任何确定的意义。当主机上有多个容器运行时,每个容器占用的 CPU 时间比例为它的 share 在总额中的比例。docker 会根据主机上运行的容器和进程动态调整每个容器使用 CPU 的时间比例
例子:
如果主机上有两个一直使用 CPU 的容器(为了简化理解,不考虑主机上其他进程),其 CPU share 都是 1024,那么两个容器 CPU 使用率都是 50%;如果把其中一个容器的 share 设置为 512,那么两者 CPU 的使用率分别为 67% 和 33%;如果删除 share 为 1024 的容器,剩下来容器的 CPU 使用率将会是 100%
好处:
能保证 CPU 尽可能处于运行状态,充分利用 CPU 资源,而且保证所有容器的相对公平
缺点:
无法指定容器使用 CPU 的确定值
设置 CPU share 的参数:
-c --cpu-shares,它的值是一个整数
我的机器是 4 核 CPU,因此运行一个stress容器,使用 stress 启动 4 个进程来产生计算压力:
# docker pull progrium/stress
# yum install htop -y
# docker run --rm -it progrium/stress --cpu 4
stress: info: [1] dispatching hogs: 4 cpu, 0 io, 0 vm, 0 hdd
stress: dbug: [1] using backoff sleep of 12000us
stress: dbug: [1] --> hogcpu worker 4 [7] forked
stress: dbug: [1] using backoff sleep of 9000us
stress: dbug: [1] --> hogcpu worker 3 [8] forked
stress: dbug: [1] using backoff sleep of 6000us
stress: dbug: [1] --> hogcpu worker 2 [9] forked
stress: dbug: [1] using backoff sleep of 3000us
stress: dbug: [1] --> hogcpu worker 1 [10] forked
在另外一个 terminal 使用 htop 查看资源的使用情况:
上图中看到,CPU 四个核资源都达到了 100%。四个 stress 进程 CPU 使用率没有达到 100% 是因为系统中还有其他机器在运行
为了比较,另外启动一个 share 为 512 的容器
# docker run --rm -it -c 512 progrium/stress --cpu 4
stress: info: [1] dispatching hogs: 4 cpu, 0 io, 0 vm, 0 hdd
stress: dbug: [1] using backoff sleep of 12000us
stress: dbug: [1] --> hogcpu worker 4 [6] forked
stress: dbug: [1] using backoff sleep of 9000us
stress: dbug: [1] --> hogcpu worker 3 [7] forked
stress: dbug: [1] using backoff sleep of 6000us
stress: dbug: [1] --> hogcpu worker 2 [8] forked
stress: dbug: [1] using backoff sleep of 3000us
stress: dbug: [1] --> hogcpu worker 1 [9] forked
因为默认情况下,容器的 CPU share 为 1024,所以这两个容器的 CPU 使用率应该大致为 2:1,下面是启动第二个容器之后的监控截图:
两个容器分别启动了四个 stress 进程,第一个容器 stress 进程 CPU 使用率都在 54% 左右,第二个容器 stress 进程 CPU 使用率在 25% 左右,比例关系大致为 2:1,符合之前的预期
3.限制容器能使用的 CPU 核数
-c --cpu-shares 参数只能限制容器使用 CPU 的比例,或者说优先级,无法确定地限制容器使用 CPU 的具体核数;从 1.13 版本之后,docker 提供了 --cpus 参数可以限定容器能使用的 CPU 核数。这个功能可以让我们更精确地设置容器 CPU 使用量,是一种更容易理解也因此更常用的手段
--cpus 后面跟着一个浮点数,代表容器最多使用的核数,可以精确到小数点二位,也就是说容器最小可以使用 0.01 核 CPU
限制容器只能使用 1.5 核数 CPU
# docker run --rm -it --cpus 1.5 progrium/stress --cpu 3
stress: info: [1] dispatching hogs: 3 cpu, 0 io, 0 vm, 0 hdd
stress: dbug: [1] using backoff sleep of 9000us
stress: dbug: [1] --> hogcpu worker 3 [7] forked
stress: dbug: [1] using backoff sleep of 6000us
stress: dbug: [1] --> hogcpu worker 2 [8] forked
stress: dbug: [1] using backoff sleep of 3000us
stress: dbug: [1] --> hogcpu worker 1 [9] forked
在容器里启动三个 stress 来跑 CPU 压力,如果不加限制,这个容器会导致 CPU 的使用率为 300% 左右(也就是说会占用三个核的计算能力)。实际的监控如下图:
可以看到,每个 stress 进程 CPU 使用率大约在 50%,总共的使用率为 150%,符合 1.5 核的设置
如果设置的 --cpus 值大于主机的 CPU 核数,docker 会直接报错:
# docker run --rm -it --cpus 8 progrium/stress --cpu 3
docker: Error response from daemon: Range of CPUs is from 0.01 to 4.00, as there are only 4 CPUs available.
See 'docker run --help'.
如果多个容器都设置了 --cpus ,并且它们之和超过主机的 CPU 核数,并不会导致容器失败或者退出,这些容器之间会竞争使用 CPU,具体分配的 CPU 数量取决于主机运行情况和容器的 CPU share 值。也就是说 --cpus 只能保证在 CPU 资源充足的情况下容器最多能使用的 CPU 数,docker 并不能保证在任何情况下容器都能使用这么多的 CPU(因为这根本是不可能的)
4.内存资源
默认情况下,docker 并没有对容器内存进行限制,也就是说容器可以使用主机提供的所有内存。这当然是非常危险的事情,如果某个容器运行了恶意的内存消耗软件,或者代码有内存泄露,很可能会导致主机内存耗尽,因此导致服务不可用。对于这种情况,docker 会设置 docker daemon 的 OOM(out of memory) 值,使其在内存不足的时候被杀死的优先级降低。另外,就是你可以为每个容器设置内存使用的上限,一旦超过这个上限,容器会被杀死,而不是耗尽主机的内存
限制内存上限虽然能保护主机,但是也可能会伤害到容器里的服务。如果为服务设置的内存上限太小,会导致服务还在正常工作的时候就被 OOM 杀死;如果设置的过大,会因为调度器算法浪费内存。因此,合理的做法包括
为应用做内存压力测试,理解正常业务需求下使用的内存情况,然后才能进入生产环境使用,一定要限制容器的内存使用上限,尽量保证主机的资源充足,一旦通过监控发现资源不足,就进行扩容或者对容器进行迁移,如果可以(内存资源充足的情况),尽量不要使用 swap,swap 的使用会导致内存计算复杂,对调度器非常不友好
Docker 限制容器内存使用量:
在 docker 启动参数中,和内存限制有关的包括(参数的值一般是内存大小,也就是一个正数,后面跟着内存单位 b、k、m、g,分别对应 bytes、KB、MB、和 GB):
-m --memory:容器能使用的最大内存大小,最小值为 4m
--memory-swap:容器能够使用的 swap 大小
--memory-swappiness:默认情况下,主机可以把容器使用的匿名页(anonymous page)swap 出来,你可以设置一个 0-100 之间的值,代表允许 swap 出来的比例
--memory-reservation:设置一个内存使用的 soft limit(软限制),如果 docker 发现主机内存不足,会执行 OOM 操作。这个值必须小于 --memory 设置的值
--kernel-memory:容器能够使用的 kernel memory (内核内存)大小,最小值为 4m。
--oom-kill-disable:是否运行 OOM 的时候杀死容器。只有设置了 -m,才可以把这个选项设置为 false(假),否则容器会耗尽主机内存,而且导致主机应用被杀死
关于 --memory-swap 的设置: --memory-swap 必须在 --memory 也配置的情况下才能有用
如果 --memory-swap 的值大于 --memory,那么容器能使用的总内存(内存 + swap)为 --memory-swap 的值,能使用的 swap 值为 --memory-swap 减去 --memory 的值
如果 --memory-swap 为 0,或者和 --memory 的值相同,那么容器能使用两倍于内存的 swap 大小,如果 --memory 对应的值是 200M,那么容器可以使用 400M swap
如果 --memory-swap 的值为 -1,那么不限制 swap 的使用,也就是说主机有多少 swap,容器都可以使用
如果限制容器的内存使用为 64M,在申请 64M 资源的情况下,容器运行正常(如果主机上内存非常紧张,并不一定能保证这一点)
# docker run --rm -it -m 64m progrium/stress --vm 1 --vm-bytes 64M --vm-hang 0
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
stress: info: [1] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: dbug: [1] using backoff sleep of 3000us
stress: dbug: [1] --> hogvm worker 1 [7] forked
stress: dbug: [7] allocating 67108864 bytes ...
stress: dbug: [7] touching bytes in strides of 4096 bytes ...
stress: dbug: [7] sleeping forever with allocated memory
.....
而如果申请 100M 内存,会发现容器里的进程被 kill 掉了(worker 7 got signal 9,signal 9 就是 kill 信号)
# docker run --rm -it -m 64m progrium/stress --vm 1 --vm-bytes 100M --vm-hang 0
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
stress: info: [1] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: dbug: [1] using backoff sleep of 3000us
stress: dbug: [1] --> hogvm worker 1 [7] forked
stress: dbug: [7] allocating 104857600 bytes ...
stress: dbug: [7] touching bytes in strides of 4096 bytes ...
stress: FAIL: [1] (415) <-- worker 7 got signal 9
stress: WARN: [1] (417) now reaping child worker processes
stress: FAIL: [1] (421) kill error: No such process
stress: FAIL: [1] (451) failed run completed in 0s
5.IO资源
对于磁盘来说,考量的参数是容量和读写速度,因此对容器的磁盘限制也应该从这两个维度出发。目前 docker 支持对磁盘的读写速度进行限制,但是并没有方法能限制容器能使用的磁盘容量(一旦磁盘 mount 到容器里,容器就能够使用磁盘的所有容量)
限制磁盘的读写速率,docker 允许你直接限制磁盘的读写速率,对应的参数有:
--device-read-bps:磁盘每秒最多可以读多少比特(bytes)
--device-write-bps:磁盘每秒最多可以写多少比特(bytes)
上面两个参数的值都是磁盘以及对应的速率,限制 limit 为正整数,单位可以是 kb、mb 和 gb
比如可以把设备的读速率限制在 1mb:
# docker run -it --device /dev/sda:/dev/sda --device-read-bps /dev/sda:1mb ubuntu:16.04 bash
root@6c048edef769:/# cat /sys/fs/cgroup/blkio/blkio.throttle.read_bps_device
8:0 1048576
root@6c048edef769:/# dd iflag=direct,nonblock if=/dev/sda of=/dev/null bs=5M count=10
10+0 records in
10+0 records out
52428800 bytes (52 MB) copied, 50.0154 s, 1.0 MB/s
从磁盘中读取 50m 花费了 50s 左右,说明磁盘速率限制起了作用
另外两个参数可以限制磁盘读写频率(每秒能执行多少次读写操作):
--device-read-iops:磁盘每秒最多可以执行多少 IO 读操作
--device-write-iops:磁盘每秒最多可以执行多少 IO 写操作
上面两个参数的值都是磁盘以及对应的 IO 上限
比如,可以让磁盘每秒最多读 100 次:
# docker run -it --device /dev/sda:/dev/sda --device-read-iops /dev/sda:100 ubuntu:16.04 bash
root@2e3026e9ccd2:/# dd iflag=direct,nonblock if=/dev/sda of=/dev/null bs=1k count=1000
1000+0 records in
1000+0 records out
1024000 bytes (1.0 MB) copied, 9.9159 s, 103 kB/s
从测试中可以看出,容器设置了读操作的 iops 为 100,在容器内部从 block 中读取 1m 数据(每次 1k,一共要读 1000 次),共计耗时约 10s,换算起来就是 100
总结:
Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合
三:理解容器文件系统
Namespace 的作用是"隔离",它让应用进程只能看到该 Namespace 内的"世界";而 Cgroups 的作用是"限制",它给这个"世界"围上了一圈看不见的墙。这么一折腾,进程就真的被"装"在了一个与世隔绝的房间里,而这些房间就是 PaaS 项目赖以生存的应用"沙盒"
可是,还有一个问题:这个房间四周虽然有了墙,但是如果容器进程低头一看地面,又是怎样一副景象呢?
换句话说,容器里的进程看到的文件系统又是什么样子的呢?
可能你立刻就能想到,这一定是一个关于 Mount Namespace 的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如 /tmp)下进行操作,而完全不会受宿主机以及其他容器的影响
那么,真实情况是这样吗?
一段小程序:作用是,在创建子进程时开启指定的 Namespace。使用它来验证一下刚刚提到的问题
#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
"/bin/bash",
NULL
};
int container_main(void* arg)
{
printf("Container - inside the container!\n");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}
int main()
{
printf("Parent - start a container!\n");
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
waitpid(container_pid, NULL, 0);
printf("Parent - container stopped!\n");
return 0;
}
代码功能非常简单:在 main 函数里,通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)
而这个子进程执行的,是一个"/bin/bash"程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中
编译一下这个程序:
[root@xingdian ~]# gcc -o ns ns.c
[root@xingdian ~]# ./ns
Parent - start a container!
Container - inside the container!
这样,就进入了这个"容器"当中(表面上看不大出来-xingdian注)。可是,如果在"容器"里执行一下 ls 指令的话,就会发现一个有趣的现象: /tmp 目录下的内容跟宿主机的内容是一样的
[root@xingdian ~]# ls /tmp
# 你会看到好多宿主机的文件
也就是说:即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样
这是怎么回事呢?
仔细思考一下,你会发现这其实并不难理解:Mount Namespace 修改的,是容器进程对文件系统"挂载点"的认知。但是,这也就意味着,只有在"挂载"这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点
这时,你可能已经想到了一个解决办法:创建新进程时,除了声明要启用 Mount Namespace 之外,还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个 /tmp 目录。于是,在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作:
int container_main(void* arg)
{
printf("Container - inside the container!\n");
// 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
// mount("", "/", NULL, MS_PRIVATE, "");
mount("none", "/tmp", "tmpfs", 0, "");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}
在修改后的代码里,在容器进程启动之前,加上了一句 mount("none", "/tmp", "tmpfs", 0, "") 语句。就这样,告诉了容器以 tmpfs(内存盘)格式,重int container_main(void* arg)
{
printf("Container - inside the container!\n");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}新挂载了 /tmp 目录。
编译执行修改后的代码结果又如何呢?试验一下:
[root@xingdian ~]# gcc -o ns ns.c
[root@xingdian ~]# ./ns
Parent - start a container!
Container - inside the container!
[root@xingdian ~]# ls /tmp
这次 /tmp 变成了一个空目录,这意味着重新挂载生效了。
用 mount -l 检查:
[root@xingdian ~]# mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)
容器里的 /tmp 目录是以 tmpfs 方式单独挂载的。可以卸载一下/tmp目录看看效果
更重要的是,因为创建的新进程启用了 Mount Namespace,所以这次重新挂载的操作,只在容器进程的 Mount Namespace 中有效。如果在宿主机上用 mount -l 来检查一下这个挂载,你会发现它是不存在的:
在宿主机上
[root@xingdian ~]# mount -l | grep tmpfs
这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效
我们希望的是:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?
可以在容器进程启动之前重新挂载它的整个根目录"/"。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了
在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你"change root file system",即改变进程的根目录到你指定的位置
假设,现在有一个 /home/xingdian/test 目录,想要把它作为一个 /bin/bash 进程的根目录
首先,创建一个 test 目录和几个 lib 文件夹:
[root@xingdian ~]# mkdir -p /home/xingdian/test
[root@xingdian ~]# mkdir -p /home/xingdian/test/{bin,lib64,lib}
然后,把 bash 命令拷贝到 test 目录对应的 bin 路径下:
# cp -v /bin/{bash,ls} /home/xingdian/test/bin
接下来,把 ls和bash命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令:(ldd 列出动态依赖库)
[root@xingdian ~]# list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
[root@xingdian ~]# for i in $list; do cp -v "$i" "/home/xingdian/test/${i}"; done
[root@xingdian ~]# list="$(ldd /bin/bash | egrep -o '/lib.*\.[0-9]')"
[root@xingdian ~]# for i in $list; do cp -v "$i" "/home/xingdian/test/${i}"; done
最后,执行 chroot 命令,告诉操作系统,将使用 /home/xingdian/test 目录作为 /bin/bash 进程的根目录:
[root@xingdian ~]# chroot /home/xingdian/test /bin/bash
这时,执行 "ls /",就会看到,它返回的都是 /home/xingdian/test 目录下面的内容,而不是宿主机的内容
更重要的是,对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被"修改"成 /home/xingdian/test 了
这种视图被修改的原理,是不是跟我之前介绍的 Linux Namespace 很类似呢
实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace
为了能够让容器的这个根目录看起来更"真实",一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,在容器里通过执行 "ls /" 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件
而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的"容器镜像"。它还有一个更为专业的名字,叫作:rootfs(根文件系统)
所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等
[root@xingdian ~]# ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
而进入容器之后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同
所以,对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:
启用 Linux Namespace 配置
设置指定的 Cgroups 参数
切换进程的根目录(Change Root)
这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用功能类似
rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核,同一台机器上的所有容器,都共享宿主机操作系统的内核。如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个"全局变量",牵一发而动全身
在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像
这是容器相比于虚拟机的缺陷之一:虚拟机不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便用
容器的一致性 :
由于云端与本地服务器环境不同,应用的打包过程,一直是使用 PaaS 时最"痛苦"的一个步骤
但有了容器镜像(即 rootfs)之后,这个问题被解决了
由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起
对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如 Golang 的 Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的"依赖库"
有了容器镜像"打包操作系统"的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了
这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟
联合文件系统:Union File System 也叫 UnionFS:
Docker 公司在实现 Docker 镜像时并没有沿用以前制作 rootfs 的标准流程,做了一个创新:
Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。用到了一种叫作联合文件系统(Union File System)的能力
主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下
比如,现在有两个目录 A 和 B,它们分别有两个文件:
[root@xingdian ~]# tree
├── A
│ ├── a
│ └── x
└── B
├── b
└── x
然后,使用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上:
[root@xingdian ~]# mkdir C
[root@xingdian ~]# yum install funionfs -y //我这里用的是centos7自带的联合文件系统,效果一样
[root@xingdian ~]# funionfs -o dirs=./A:./B none ./C
再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一起:
[root@xingdian ~]# tree ./C
./C
├── a
├── b
└── x
可以看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,并且 x 文件只有一份。这,就是"合并"的含义
此外,如果在目录 C 里对 a、b、x 文件做修改,这些修改也会在对应的目录 A、B 中生效
[root@xingdian ~]# echo hello >> C/a
[root@xingdian ~]# cat C/a
hello
[root@xingdian ~]# cat A/a
hello
[root@xingdian ~]# echo hello1 >> A/a
[root@xingdian ~]# cat A/a
hello
hello1
[root@xingdian ~]# cat C/a
hello
hello1