1 从进程说开去
1.1 前言
- 容器技术的兴起来源于PaaS技术的普及
- Docker公司发布的Docker项目具有里程碑式的意义
- Docker项目通过“容器镜像”,解决了应用打包这个根本性难题
- 容器本身没有价值,有价值的是“容器编排”
1.2 进程
1.2.1 概念
-
一个程序运行起来后的计算机执行环境的综合,即进程。
-
容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。
-
对于Docker等大多数Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术则是用来修改进程视图的主要方法。
1.2.2 容器进程
-
创建容器进程时,指定这个进程所需要启用的一组Namespace参数,这样,容器就只能“看”到当前Namespace所限定的资源、文件、设备、状态,或者配置,而对于宿主机以及其他不相关程序,它就完全看不到了。例如容器进程在自己命名空间下的PID是1,在宿主机上PID可能是100。
-
容器其实是一种特殊的进程而已。
2 隔离与限制
2.1 容器与虚拟机
2.1.1 容器优势
- 容器具备“敏捷”和“高性能”的优势,而虚拟机对宿主机OS的调用存在较高的计算、网络和磁盘I/O性能损耗
2.1.2 容器劣势
容器使用基于Linux Namespace的隔离机制相比虚拟化技术也有很多不足之处。
- 最主要的问题是隔离得不彻底,多个容器共享宿主机的操作系统内核,例如无法在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的Linux容器
- 在Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是时间,容器中的程序使用settimeofday(2)修改时间后,整个宿主机的时间都会被随之修改
2.2 资源限制
Linux Cgroup的全称是Linux Control Group,它的主要作用,就是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等。此外,Cgroups还能对进程进行优先级设置、审计,以及进程挂起和恢复等操作。
2.2.1 Cgroups实现
Linux Cgroups可以简单理解为一个子系统目录加上一组资源限制文件的组合。对于Docker等Linux容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的PID填写到对应控制组的tasks文件中就可以了。
2.2.2 总结
- 容器是一个“单进程”模型
- 一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程,一个容器中没法同时运行两个应用,除非能事先找到一个公共的PID=1的程序来充当两个不同应用的父进程,例如systemd或supervisord,代替应用本身作为容器的启动进程。
- Cgroups对资源的限制能力有很多不完善的地方,提交最多的自然是/proc文件系统的问题(/proc目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以访问这些文件,查看系统及当前正在运行的进程信息,例如CPU使用情况、内存占用等,这些文件也是top指定的主要数据来源),/proc文件系统不了解Cgroups限制的存在,因此在容器里执行top命令,显示的信息是宿主机的CPU和内存数据而不是当前容器的数据。常用的解决办法为lxcfs。
3 深入理解容器镜像
3.1 Mount Namespace
mount namespace跟其他namespace的使用略有不同,它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。
3.2 根文件系统
- rootfs(根文件系统):挂载在容器根目录上,用来为容器进程提供隔离后执行环境的文件系统,也就是所谓的“容器镜像”。
- rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
- rootfs打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及运行它所需要的所有依赖,都被封装在了一起,实现了容器的重要特性:一致性
3.3 Docker核心原理
Docker项目最核心的原理实际上就是为待创建的用户进程:
- 启用Linux Namespace配置
- 设置指定的Cgroups参数
- 切换进程的根目录(docker优先使用pivot_root命令,如果系统不支持,使用chroot命令)
3.4 Docker镜像
3.4.1 联合文件系统
Docker在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs,它是通过联合文件系统(Union File System)的能力实现的。
以AuFS为例,Docker镜像的层存储在/var/lib/docker/aufs/diff/<layer_id>
,在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点/var/lib/docker/aufs/mnt/<id>
。
3.4.2 Docker镜像结构
-
只读层
它是容器的rootfs最下面的层,挂载方式为只读(ro+wh,即readonly+whiteout)。
-
可读写层
它是容器的rootfs最上面的层,挂载方式为可读写(rw,即read write)。在没有写入文件前,这个目录是空的,一旦在容器里执行了写操作,修改产生的内容就会以增量的方式出现在这个层中。
如果要删除只读层中的文件,AuFS会在可读写层创建一个whiteout文件,把只读层里的文件”遮挡“起来。
-
Init层
它是一个以“-init"结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。这些文件本来属于只读的镜像,但往往需要在启动容器时写入特定的值比如hostname,就需要在可读写层对它们进行修改,但是修改往往只对当前容器有效,并不希望执行docker commit时,连同可读写层一起提交,因此Docker修改了这些文件后,以一个单独的层挂载出来,用户执行docker commit只会提交可读写层,不包含这些内容。
最终,这些层被联合挂载到/var/lib/docker/aufs/mnt
目录下,表现为一个完整的操作系统供容器使用。
3.4.3 Overlay2
最新版本的Docker容器文件系统默认overlay2存储驱动。
4 重新认识Docker容器
4.1 制作容器镜像
4.1.1 Dockerfile
# 使用官方提供的Python开发镜像作为基础镜像
FROM python:2.7-slim
# 将工作目录切换为/app
WORKDIR /app
# 将当前目录下的所有内容复制到/app下
ADD . /app
# 使用pip命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt
# 允许外界访问容器的80端口
EXPOSE 80
# 设置环境变量
ENV NAME World
# 设置容器进程为:python app.py,即:这个Python应用的启动命令
CMD ["python", "app.py"]
4.1.2 制作镜像
$ docker build -t helloworld .
docker build 会自动加载当前目录下的 Dockerfile 文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以等同于 Docker 使用基础镜像启动了一个容器,然后在容器中依次执行 Dockerfile 中的原语。
需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。
4.1.3 启动容器
$ docker run -p 4000:80 helloworld
4.2 docker exec进入容器
Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在,位置在/proc/[进程号]/ns
。
查看当前正在运行的 Docker 容器的进程号:
docker inspect --format '{{ .State.Pid }}' 4ddf4638572d
docker exec的原理:使用系统调用setns,让新启动的进程与容器共享多种namespace。
使用示例为拿到容器进程的ns文件句柄fd,通过setns(fd,0)加入到这个文件对应的namespace进程中。
4.3 Docker Volume
4.3.1 Docker挂载宿主机目录到容器
Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。
在 Docker 项目里,它支持两种 Volume 声明方式,可以把宿主机目录挂载进容器的 /test 目录当中:
$ docker run -v /test ...
$ docker run -v /home:/test ...
而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的 /test 目录。
在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。
4.3.2 Docker挂载原理
Docker在rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上。由于mount namespace已经开启,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。
注意:这里提到的"容器进程",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。
4.3.3 Linux挂载绑定
Linux 的绑定挂载(bind mount)机制,主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。
绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。
在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。
进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。
容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。
4.3.4 Docker容器全景图
-
这些 rootfs 层的最下层,是来自 Docker 镜像的只读层。
-
在只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。
-
而 rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层。
5 从容器到容器云:谈谈Kubernetes的本质
5.1 理解容器
一个“容器”,实际上是一个由 Linux Namespace、Linux Cgroups 和 rootfs 三种技术构建出来的进程的隔离环境。
一个正在运行的 Linux 容器,其实可以被“一分为二”地看待:
- 一组联合挂载在 /var/lib/docker/aufs/mnt 上的 rootfs,这一部分我们称为“容器镜像”(Container Image),是容器的静态视图
- 一个由 Namespace+Cgroups 构成的隔离环境,这一部分我们称为“容器运行时”(Container Runtime),是容器的动态视图
5.2 Kubernetes
Kubernetes 项目的本质,是为用户提供一个具有普遍意义的容器编排工具。不过,更重要的是,Kubernetes 项目为用户提供的不仅限于一个工具。它真正的价值,乃在于提供了一套基于容器构建分布式系统的基础依赖。
5.2.1 来源与架构
Kubernetes 项目依托着 Borg 项目的理论优势,确定了一个如下图所示的全局架构:
Kubernetes 项目的架构,跟它的原型项目 Borg 非常类似,都由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。
控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是负责 API 服务的 kube-apiserver、负责调度的 kube-scheduler,以及负责容器编排的 kube-controller-manager。整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中。
计算节点,即Node节点,最核心的部分则是一个叫作 kubelet 的组件。
kubelet 主要负责同容器运行时(比如 Docker 项目)打交道。而这个交互所依赖的,是一个称作 CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。Kubernetes 项目并不关心你部署的是什么容器运行时、使用的什么技术实现,只要你的这个容器运行时能够运行标准的容器镜像,它就可以通过实现 CRI 接入到 Kubernetes 项目当中。
5.2.2 Kubernetes的优势
- 从一开始,Kubernetes 项目就没有像同时期的各种“容器云”项目那样,把 Docker 作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现。
- 从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。
- 使用”声明式API“,对应的”编排对象”(比如 Pod、Job、CronJob 等,用来描述你试图管理的应用)和“服务对象”(比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等,用来负责具体的平台级功能),都是 Kubernetes 项目中的 API 对象(API Object)。