从Docker到Kubernetes,再结合云计算的推广,各公司的应用服务,现在基本都使用Kubernetes进行部署和管理了。
现在从事后端开发的同学,无论在工作中,还是去面试,Docker镜像和Kubernetes部署都是一项必备技能了。
基于进程开始
容器是一种沙盒技术。而沙盒就像一个集装箱一样,能够把你的应用"装"起来的技术。这样,应用与应用之间,就有了边界而不至于相互干扰。而被装进集装箱的应用,也可以非常方便地搬来搬去,这也是PaaS最理想的状态。
而这个"边界"的实现就是关键。
对于进程来说,它的静态表现就是程序,不运行时就是磁盘中一堆文件而已;而一旦运行起来,它就变成了计算机中的数据和状态的总和,这是它的动态表现。
而容器技术的核心能力,就是通过约束和修改进程的动态表现,从而为其创造出一个"边界"。
对于Docker等大多数Linux容器来说:
- Cgroups技术主要用来制造约束。
- Namespace技术主要用来修改进程视图。
在Linux中,我们通过clone()系统调用创建一个新进程时,可以在参数中指定CLONE_NEWPID参数,比如:
js
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
这时,新创建的进程将会"看到"一个全新的进程空间,在这个进程空间里,它的PID是1。之所以说"看到",是因为这只是一个"障眼法",在宿主机真实的进程空间里,这个进程的PID还是真实的数值,比如100。
除了PID Namespace,Linux操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些Namespace, 用来对各种不同进程上下文进行"障眼法"操作。
这就是Linux容器最基本的实现原理了。
Docker 容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数。这样,容器就只能"看到"当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。所以说,容器其实是一种特殊的进程而已。
隔离和限制
Namespace 技术实际上修改了应用进程看待整个计算机"视图",即它的"视线"被操作系统做了限制,只能"看到"某些指定的内容。但对于宿主机来说,这些被"隔离"了的进程跟其他进程并没有太大区别。
Docker其实跟应用是同一个级别。用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的Namespace参数。而 Docker 在这里扮演的角色,更多的是旁路式的辅助和管理工作。
这样的架构也说明了为什么 Docker 项目比虚拟机更受欢迎的原因。
- 使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。
- 容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。
"敏捷"和"高性能"是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因。
不过,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。
- 容器只是运行在宿主机上的一种特殊的进程,多个容器之间使用的就还是同一个宿主机的操作系统内核。
- 在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。
而 Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。
js
$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。
容器镜像
Namespace, 它让应用进程只看到该Namespace内的"世界";Cgroups 给这个"世界"围上了一圈看不见的墙。这样,进程就被"装"在了一个与世隔绝的集装箱里,而这些集装箱就是PaaS项目赖以生存的"沙盒"。
这样,应用进程的环境和资源都进行了隔离,那么进程看到的文件系统又是什么样子?
容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如 /tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。
在容器进程启动之前,加上了一句 mount("none", "/tmp", "tmpfs", 0, "")
语句,容器以 tmpfs(内存盘)格式,会重新挂载了 /tmp 目录。
查看一下,发现/tmp变成了一个空目录,意味着重新挂载生效了,是以 tmpfs 方式单独挂载的。
js
$ ls /tmp
$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)
如果在宿主机上用 mount -l 来检查一下这个挂载,你会发现它是不存在的:
js
$ mount -l | grep tmpfs
Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。
作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。我们可以在容器进程启动之前重新挂载它的整个根目录"/"。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。Mount Namespace 是基于对 Linux chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。
为了能够让容器的这个根目录看起来更"真实",我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统。而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的"容器镜像"。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。rootfs 只包括了操作系统的"躯壳",并没有包括操作系统的"灵魂"。如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个"全局变量",牵一发而动全身。
容器相比于虚拟机的主要缺陷:虚拟机不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便折腾。不过,正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。
Docker容器
Dockerfile 的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的 Docker 镜像。并且这些原语,都是按顺序处理的。
相较于我之前介绍的制作 rootfs 的过程,Docker 为你提供了一种更便捷的方式,叫作 Dockerfile。
js
# 使用官方提供的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"]
读懂这个 Dockerfile 之后,我再把上述内容,保存到当前目录里一个名叫"Dockerfile"的文件中:
js
$ ls
Dockerfile app.py requirements.txt
接下来,我就可以让 Docker 制作这个镜像了,在当前目录执行:
js
$ docker build -t helloworld .
docker build 操作完成后,我可以通过 docker images 命令查看结果:
js
$ docker image ls
REPOSITORY TAG IMAGE ID
helloworld latest 653287cdf998
接下来,我使用这个镜像,通过 docker run 命令启动容器:
js
$ docker run -p 4000:80 helloworld
容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时候,我们就需要考虑这样两个问题:
- 容器里进程新建的文件,怎么才能让宿主机获取到?
- 宿主机上的文件和目录,怎么才能让容器里的进程访问到?
这是 Docker Volume 解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。
- 首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上:
js
$ docker run -d -v /test helloworld
cf53b766fa6f
$ docker volume ls
DRIVER VOLUME NAME
local cb1c2f7221fa 9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d
- 然后,使用这个 ID,可以找到它在 Docker 工作目录下的 volumes 路径,这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。
js
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
- 接下来,我们在容器的 Volume 里,添加一个文件 text.txt:
js
$ docker exec -it cf53b766fa6f /bin/sh
cd test/
touch text.txt
- 这时,我们再回到宿主机,就会发现 text.txt 已经出现在了宿主机上对应的临时目录里:
js
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
text.txt
Kubernetes本质
容器就从一个开发者手里的小工具,一跃成为了云计算领域的绝对主角;而能够定义容器组织和管理规范的"容器编排"技术,则当仁不让地坐上了容器技术领域的"头把交椅"。这其中,最具代表性的容器编排工具,当属 Docker 公司的 Compose+Swarm 组合,以及 Google 与 RedHat 公司共同主导的 Kubernetes 项目。
跟很多基础设施领域先有工程实践、后有方法论的发展路线不同,Kubernetes 项目的理论基础则要比工程实践走得靠前得多,这当然要归功于 Google 公司在 2015 年 4 月发布的 Borg 论文了。Borg 要承担的责任,是承载 Google 公司整个基础设施的核心依赖。在 Google 公司已经公开发表的基础设施体系论文中,Borg 项目当仁不让地位居整个基础设施技术栈的最底层。
相比于"小打小闹"的 Docker 公司、"旧瓶装新酒"的 Mesos 社区,Kubernetes 项目从一开始就比较幸运地站上了一个他人难以企及的高度:在它的成长阶段,这个项目每一个核心特性的提出,几乎都脱胎于 Borg/Omega 系统的设计与经验。更重要的是,这些特性在开源社区落地的过程中,又在整个社区的合力之下得到了极大的改进,修复了很多当年遗留在 Borg 体系中的缺陷和问题。
Kubernetes 项目要解决的问题是什么?
这个问题到目前为止都没有固定的答案。因为在不同的发展阶段,Kubernetes 需要着重解决的问题是不同的。 Kubernetes 能给我提供路由网关、水平扩展、监控、备份、灾难恢复等一系列运维能力。
Kubernetes 项目正是依托着 Borg 项目的理论优势,才在短短几个月内迅速站稳了脚跟,进而确定了一个如下图所示的全局架构:
Kubernetes 项目的架构,跟它的原型项目 Borg 非常类似,都由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。其中,控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是负责 API 服务的 kube-apiserver、负责调度的 kube-scheduler,以及负责容器编排的 kube-controller-manager。整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中。
而计算节点上最核心的部分,则是一个叫作 kubelet 的组件。
- kubelet 主要负责同容器运行时(比如 Docker 项目)打交道。而这个交互所依赖的,是一个称作 CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。
- kubelet 还通过 gRPC 协议同一个叫作 Device Plugin 的插件进行交互。这个插件,是 Kubernetes 项目用来管理 GPU 等宿主机物理设备的主要组件,也是基于 Kubernetes 项目进行机器学习训练、高性能作业支持等工作必须关注的功能。
- kubelet 的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与 kubelet 进行交互的接口,分别是 CNI(Container Networking Interface)和 CSI(Container Storage Interface)。
Kubernetes 项目就没有像同时期的各种"容器云"项目那样,把 Docker 作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现。
Borg 对于 Kubernetes 项目的指导作用又体现在哪里呢?
Kubernetes 项目要着重解决的问题:运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。
Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。
Kubernetes 项目如何启动一个容器化任务呢?
比如,我现在已经制作好了一个 Nginx 容器镜像,希望让平台帮我启动这个镜像。并且,我要求平台帮我运行两个完全相同的 Nginx 副本,以负载均衡的方式共同对外提供服务。
你需要编写如下这样一个 YAML 文件(比如名叫 nginx-deployment.yaml):
js
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
然后执行:
js
$ kubectl create -f nginx-deployment.yaml
这样,两个完全相同的 Nginx 容器副本就被启动了。