容器技术基础
对于进程来说,它的静态表现就是程序,平常都安安静静的待在磁盘上,而一旦运行起来,它就变成了计算机里数据和状态的总和,这就是它的动态表现。容器技术的核心功能就是通过约束和修改进程的动态表现,为其创造一个边界。
对于Docker等大多数Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术是用来修改进程视图的主要方法。
Namespace技术
Namespace技术实际上修改了应用进程看待整个计算机"视图"的视野,即它的"视线"受到了操作系统的限制,只能"看到"某些指定内容。但对宿主机来说,这些被"隔离"了的进程和其他进程没有太大区别,用户在容器中运行的进程跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置的Namespace参数。
PID Namespace
Linux中创建进程的系统调用
int pid=clone(main_function,stack_size,SIGCHLD,NULL)
指定CLONE_NEWPID参数:
int pid=clone(main_function,stack_size,CLONE_NEWPID|SIGCHLD,NULL)
此时这个系统调用出创建的进程将会看到一个全新的进程空间。在这个进程里,它的PID是1。之所以说"看到",是因为这只是一个"障眼法",在宿主机真实的进程空间里,这个进程的PID还是真实数值,比如100。
每个如此新建的进程都会认为自己是当前容器里的1号进程,他们既看不到宿主机里真正的进程空间,也看不到其他PID Namespace里的具体情况。
除了PID Namespace,Linux操作系统还提供了Mount,UTS,IPC,NNetwork和User这些Namespace,用来对各种进程上下文事实"障眼法"。
在创建容器进程时指定该进程所需要启用的一组Namespace参数,这样容器就只能看到当前Namespace所限定的资源,文件,设备,状态或者配置了。
Docker项目扮演的角色,更多的是旁路式的辅助和管理工作,容器化后的用户进程依然是宿主机上的普通进程,这意味着不存在因为虚拟化而产生的性能消耗,此外使用Namespace作为隔离手段的 容器并不需要单独的客户操作系统,这就使得容器额外的资源占用几乎可以忽略不计。
不足之处:
隔离的不彻底
1.容器只是宿主机上运行的一种特殊进程,多个容器共享一个宿主机操作系统内核
尽管可以在容器中通过Mount Namespace单独挂载其他版本的操作系统文件,比如CentOS和Ubuntu,但是并不能改变共享主机内核的事实,这意味着如果要在Windows上运行Linux容器或者在低版本的Linux宿主机上运行高版本的Linux容器,都是行不通的
2.在Linux内核中有很多资源和对象是不能被Namespace化的,最典型的例子就是时间
如果你的容器中使用settimeofday(2)系统调用你修改了时间那么整个宿主机的时间都会被随之修改。
Cgroups技术
Linux Cgroups是Linux内核中用来为进程设置资源的一个重要功能。它的最主要作用就是限制一个进程组能够使用的资源上限,包括CPU,内存,磁盘,网络带宽等。此外,Cgroups还能够对进程进行优先级设置,审计以及将进程挂起和恢复等操作。
在Linux中,Cgroups向用户暴露出来的操作接口时文件系统,即它以文件和目录的方式组织在操作系统的/sys/fs/cgroup路径下。在此目录下有很多诸如cpuset,cpu,memory这样的子目录,也叫子系统。这些都是当前机器可以被Cgroups限制的资源种类,而在子系统目录下,你可以看到这类资源具体可被限制的方法。
对于CPU子系统,在/sys/fs/cgroup/cpu目录下创建一个目录,操作系统就会自动生成该子系统对应的资源限制文件,cpu.cfs_period_us文件和cpu.cfs_quota_us文件可以限制进程在长度为cfs_period中设置的时间内只能被分配到cfs_quota中设置的CPU时间。比如cpu.cfs_period_us文件写入100ms(默认值,即100000us),cpu.cfs_quota_us文件写入20ms(即20000us),则该进程只能使用到20%的CPU带宽。最后把要被限制的进程PID写入tasks文件,限制即可对相应的进程生效。
除了CPU子系统外,Cgroups的每一项子系统都有其独有的资源限制能力,比如:
1.blkio,为块设备设定I/O限制,一般用于磁盘等设备;
2.cpuset,为进程分配的单独的CPU内核和对应的内存节点;
3.memory,为进程设定内存使用限制。
对于Docker等Linux容器项目来说,只需在每个子系统下为每个容器创建一个控制组(即创建一个目录),然后再启动容器进程之后,把这个进程的PID填入对应控制组的tasks文件中即可。至于填上什么值,Docker中可以在docker run时指定,比如:
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
容器启动后可以在宿主机的/sys/fs/cgroups/cpu/docker/{containerId}/下查看cpu.cfs_period_us和cpu.cfs_quota_us文件来验证是否写入对用值。
不足之处
1./proc文件系统问题
Linux下/proc目录存储的是记录当前内核运行状态的一系列特殊文件,是top命令查看系统信息的主要数据来源,但是当你在容器中执行top命令,就会发现它显示的信息居然是宿主机的CPU和内存数据,而非当前容器的数据。造成这个问题的原因是因为/proc文件系统不了解Cgroups限制的存在,在生产环境中,必须修正这个问题,否则应用在容器中读取到的CPU核数,可用内存等信息都是宿主机上的数据,这会给应用的运行带来很大的风险。
容器的单进程模型
一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是后续创建的所有进程的父进程。(能否在容器中运行两个进程--todo)
容器镜像
rootfs
clone函数的第三个参数如果加上|CLONE_NEWNS则会启用Mount Namespace,但是Mount Namespace修改的是容器进程对文件系统"挂载点"的认知,在"挂载"操作完成之前,新创建的容器会直接继承主机的各个挂载点,所以若是只开启了Mount Namespace但未挂载,容器中的文件内容和宿主机一致。挂载操作可以通过mount系统调用完成。此次的挂载只会在容器的Mount Namespace中有效,在宿主机上使用mount -l是 找不到此挂载的。
若是希望每当创建一个容器时,容器看到的文件系统是一个独立的隔离环境,我们可以在容器进程启动之前重新挂载它的整个根目录"/",且这个挂载对于宿主机是不可见的。为了能让容器的这个根目录看起来更真实,我们一般会在容器单独根目录下挂载一个完整的操作系统的文件系统。
这个挂载在容器根目录上用来为容器进程提供隔离后执行环境的文件系统,就是所谓的"容器镜像",它还有一个更专业的名字:rootfs(根文件系统)。
rootfs只是一个操作系统所包含的文件,配置和目录,并不包括操作系统内核。宿主机上的所有容器共享操作系统的内核。
联合挂载
Docker在镜像的设计中引入了层(layer)的概念,用户制作镜像的每一步都会生成一个层,也就是一个增量的rootfs。这个功能用到了一种叫做UnionFS的能力,它主要的功能是将不同位置的目录联合挂载到同一个目录下,并且会对重复的文件去重。AuFS是UnionFS的一个实现,对AuFS来说它最关键的目录结构是在/var/lib/docker路径下的diff目录:
/var/lib/docker/aufs/diff/<layer_id>
Docker镜像的层都放置在/var/lib/docker/aufs/diff目录下,在使用镜像时被联合挂载在/var/lib/docker/aufs/mnt/
容器的rootfs分为可读写层(rw),Init层(ro+wh),只读层(ro+wh)
可读写层
最上面的可读写层就是专门用来存放你修改rootfs后产生的增量的,无论增删改都发生在这里。当我们使用完了这个修改过的容器之后,还可以使用docker commit和docker push指令保存这个修改过的可读写层,上传到镜像仓库供他人使用。
Init层
Init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts,/etc/resolv.conf等信息。需要这样一个层的原因在于,用户往往需要在启动时写入一些原本应该属于只读层的值,所以需要在可读写层修改他们,而我们并不希望在docker commit时把这些信息连同可读写层一起提交,所以Docker的做法是在修改了这些文件之后以一个单独的层挂载出来。
只读层
存放的是一些基础的文件,这部分内容不允许修改。
docker commit实际上就是在容器运行起来后,把最上面的可读写层加上原先容器镜像的只读层,打成了一个新的镜像提交。只读层在宿主机上是共享的,不会占用额外空间。
核心原理
Docker项目最核心的原理实际上就是为待创建的用户进程:
1.启用Linux Namespace配置
2.设置指定的Cgroups参数
3.切换进程的根目录(通过pivot_root或者chroot系统调用)