容器只是一个特殊的进程,被namespace与cgroups技术限定“边界”后的一个进程。
namespace技术修改了容器的视图,namespace是在linux创建新进程时的一个可选参数(clone()),namespace有很多种:PID、Mount、UTS、IPC、Network、User等等。这些namespace为各种不同的进程上下文进行障眼法操作。
比如在Mount Namespace中隔离进程只能看到当前Namespace中的挂在电信系,Network Namespace让被隔离进程智能看到当前Namespace里的网络设备和配置。
所以,容器只是一种特殊的进程而已。用户运行在容器里的应用进程与宿主机上的其他进程一样,都由宿主机操作系统管理,只不过这些被隔离的进程拥有额外设置过的namespace参数。
由于容器和宿主机之间没有虚拟机这一层拦截和处理,所以也就没有虚拟化带来的性能损耗,而且namespace相对于guestos,资源占用几乎可以忽略不计。所以敏捷和高性能是容器相对于虚拟机最大的优势。
namespace相比于虚拟化技术的隔离是不彻底的。容器只是运行在宿主机上的一种特殊进程,多个容器之间使用的还是同一个宿主机的内核。所以win上不能运行linux容器,低版本linux不能运行高版本容器。其次由于很多资源和对象没有namespace,一个容器的行为有可能会影响到其他容器的运行逻辑。
Cgroups可以限制一个进程组能使用的资源上线,包括CPU、内存、磁盘、带宽等。
Cgroups以文件的形式对外暴露操作接口:
/sys/fs/cgroup
对于docker这类linux容器项目,只需要在每个子系统(cpu、cpuset、memory。。)下面为每个容器创建一个控制组(创建一个新目录),然后在启动容器进程之后把进程的PID写入对应控制组的tasks文件中就可以。
通过namespace和cgroups的隔离限制之后,容器进程像是被装在了一个与世隔绝的房间里,他的四面都是墙,但是低头看文件系统,看到的却是宿主机的目录。这是因为mount namespace修改的是容器进程对文件系统“挂载点”的认知,这意味着之后当挂载操作发生之后,进程的视图才会被改变,在此之前新创建的容器会直接挂载宿主机的各个挂载点。
所以我们在启用Mount namespace之后要执行一步挂载操作,我们希望新创建的容器进程看到的是一个独立的隔离环境,而不是继承宿主机的文件系统,所以我们直接在容器进程启动之前重新挂载他的整个根目录“/”。而这个挂载在容器根目录上用来为容器进程提供隔离后执行环境的文件系统就是所谓的“容器镜像”,他还有一个名字叫做rootfs。
常见的rootfs包括的目录和文件:
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
挂载rootfs之后还需要chroot,将容器进程的根目录改变到rootfs上
Linux 的挂载是文件挂载到一个已存在的目录上
docker会优先使用pivot_root,如果系统不支持才会使用chroot
chroot只是改变即将运行的某个进程的根目录;pviot_root会把整个系统切换到一个新的root目录,然后去掉对之前rootfs的依赖,以便于umount之前的文件系统。
由于rootfs里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着应用以及应用运行的依赖也都被封装到了里面。有了操作系统的能力也就有了一致性,无论在本地、云端还是任意机器上只要解押打包好的容器镜像,这个应用的运行所需的完整的执行环境就都被重现了。
新问题:难道每开发一个应用或升级现有应用都要重新制作一次rootfs(镜像)吗?
答:以增量的方式做修改,所有人只需要维护相对于base rootfs修改的增量内容,而不是每次都制造一个“fork”。docker的镜像设计引入了层的概念。
这种增量修改用到了Union File System技术。UnionFS的主要功能是将多个不同位置的目录联合挂载到同一个目录下。
镜像是分层的,这些层分为从下到上一次分为只读层、init层、读写层。
读写层的作用是存放修改rootfs产生的增量,无论是增删改都发生在这里。当我们修改完之后还可以使用
docker commit/docker push
命令保存这个被修改过的可读写层。如果我们要在读写层中删除只读层中的文件会创建一个whitout文件,把只读层中的文件遮挡起来,这样当两层被UnionFS联合挂载后,原来的文件就看不见了。比如只读层中有foo文件,读写层删除foo文件时会创建一个
.wh.foo
文件,当联合挂载之后,foo文件就被.wh.foo
遮挡了,也就是删除
了init层是以-init结尾的层,在只读层和读写层中间,是docker项目单独生成的内部层,专门存放/etc/hosts、/etc/resolv.conf等信息。用户往往需要在启动容器是写入这些值,比如hostname,而这些修改支队当前容器有效,我们不希望在docker commit是连同读写层一起提交,所以docker在修改这些文件后以单独的层挂载出来,用户执行commit是只会提交读写层,不会提交init层。
Docker的核心原理:
- 启动name space设置
- 设置Cgroups参数;
- 切换进程的根目录(chroot)
docker exec是怎么进入到容器中的呢?
进程的namespace是以文件的方式存在在宿主机上的。先查看容器进程的PID,然后查看PID对应的/proc下的文件。/proc/PID/ns/下有进程所有的namespace对应的文件。
加入到某个进程已有的namespace的方式是使用setns()。setns()接收两个参数,一个是namespace文件的路径,一个是要在这个namespace中运行的进程,比如/bin/bash。
setns会打开指定的namespace文件,然后将要运行的进程加入到这个文件对应的namespace中。
也就是说一个进程可以选择加入到某个进程已有的namespace中,从而达到“进入”这个进程所在的容器的目的,这就是docker exec的原理。
volume是怎么挂载的?
容器的文件系统挂载分两步:开启mount namespace、chroot或pivot_root。volume的挂载就是在开启mount namespace和chroot之间。在chroot之前把volume指定的宿主机目录挂载到指定的容器目录对在宿主机上对应的目录上。
而且在执行上述挂载操作时,“容器进程”已经创建了,mount namespace已经创建了,所以挂载事件只在容器进程内部可见,宿主机上时看不见容器内部的挂载点的。
上面这段话中的容器进程值得不是容器内的应用进程ENTRYPOINT,而是容器的初始化进程dockerinit。dockerinit会负责完成根目录的准备、挂载设备和目录、配置hostname等需在容器内进行的初始化操作,最后通过execv()系统调用,让应用进程取代自己成为PID=1的进程。
上面所说的挂载技术指的是linux的绑定挂载(bind mount)机制。他的作用是:允许你挂载一个目录或文件而不是整个设备,挂载到一个指定的目录上。并且这个你在这个挂载点上的任何操作都只发生在被挂载的目录或文件上,原挂载点的内容会被隐藏切不受影响。
挂载到容器rootfs的可读写层中的内容会被docker commit提交吗?
不会。因为docker commit发生在宿主机上,由于mount namespace的隔离,宿主机并不知道这个绑定挂载的存在,所以在宿主机看来,容器中的可读写层的挂载点的原始内容始终是没有变化的。但如果挂载点在rootfs中不存在时,在commit之后就会多出一个空的挂载点目录,因为挂载点需要新建,而新建目录不是挂载操作,mount namespace不会屏蔽掉这个操作。