Pod,是 K8s 的原子调度单位。
那为什么我们需要 Pod?
回答这个问题前先回忆一下:容器的本质到底是什么? 容器的本质是进程。
没错。容器就是未来云计算系统中的进程;容器镜像就是这个系统里的“.exe”安装包。那么 K8s 呢?
你应该也能立刻回答上来:K8s 就是操作系统!
在一台 Linux 机器里,执行如下命令:
pstree -g
这条命令的作用是展示当前系统中正在运行的进程的树状结构。它的返回结果如下所示:
在一个操作系统里,进程并不是独自运行,而是以进程组的方式组织在一起。比如这个叫作 rsyslogd 的程序,负责的是 Linux 里的日志处理。可以看到,rsyslogd 的主程序 main,和它要用到的内核日志模块 imklog 等,同属于 1632 进程组。
而 K8s 所做的就是将“进程组”的概念映射到了容器技术中,并使其成为了这个云计算“操作系统”里的“一等公民”。
K8s 之所以要这么做的原因,有些部署的应用之间有着密切的协作关系,使得它们必须部署在同一台机器上。
而如果事先没有“组”的概念,像这样的运维关系就会非常难以处理。
已知 rsyslogd 由三个进程组成:一个 imklog 模块,一个 imuxsock 模块,一个 rsyslogd 自己的 main 函数主进程。这三个进程一定要运行在同一台机器上,否则,它们之间基于 Socket 的通信和文件交换,都会出现问题。
现在把 rsyslogd 容器化,由于受限于容器的“单进程模型”,这三个模块必须被分别制作成三个不同的容器。而在这三个容器运行的时候,它们设置的内存配额都是 1 GB。
容器的“单进程模型”,并不是指容器里只能运行一个进程,而是指容器没有管理多个进程的能力。因为容器里 PID=1 的进程就是应用本身,其他的进程都是这个 PID=1 进程的子进程。可是,用户编写的应用,并不能够像正常OS里的 init 进程或者 systemd 那样拥有进程管理的功能。比如,你的应用是一个 Java Web (PID=1),然后docker exec 启动了一个 Nginx 进程(PID=3)。可是,当这个 Nginx 进程异常退出时,你该怎么知道呢?这个进程退出后的垃圾收集工作,又应该由谁去做呢?
假设我们的 K8s 集群上有两个节点:node1 上有 3 GB 可用内存,node2 有 2.5 GB 可用内存。
这时,假设用 Docker Swarm 来运行这个 rsyslogd 程序。为了让这三个容器运行在一台机器上,必须在另外两个容器上设置一个 affinity=main(与 main 容器有亲密性)的约束,即:它们必须和 main 容器运行在同一台机器上。
顺序执行:“docker run main”“docker run imklog”和“docker run imuxsock”,创建这三个容器。进入 Swarm 的待调度队列。然后,main 容器和 imklog 容器都先后出队并被调度到了 node2 上。
当 imuxsock 容器被调度时,Swarm 就有点懵了:node2 上的可用资源只有 0.5 GB 了,并不足以运行 imuxsock 容器;可是,根据 affinity=main 的约束,imuxsock 容器又只能运行在 node2 上。
这就是一个典型的成组调度没有被妥善处理的例子。
Pod 是 K8s 的原子调度单位。这就意味着,K8s 的调度器是统一按照 Pod 而非容器的资源需求进行计算的。
所以,像 imklog、imuxsock 和 main 函数主进程这三个容器,正是一个典型的由三个容器组成的 Pod。K8s 在调度时,自然就会去选择可用内存等于 3 GB 的 node1 节点进行绑定,而根本不会考虑 node2。
像这样容器间的紧密协作,我们可以称为“超亲密关系”。这些具有“超亲密关系”容器的典型特征包括但不限于:互相之间会发生直接的文件交换、使用 localhost 或者 Socket 文件进行本地通信、会发生非常频繁的远程调用、需要共享某些 Linux Namespace(比如,一个容器要加入另一个容器的 Network Namespace)等等。
这也意味着并不是所有有关系的容器都属于同一个 Pod。比如,PHP 容器和 MySQL 虽然会发生访问关系,但并没有必要、也不应该部署在同一台机器上,它们更适合做成两个 Pod。
不过,相信此时你可能会有第二个疑问:
对于初学者来说,一般都是先学会了用 Docker 这种单容器的工具,才会开始接触 Pod。
而如果 Pod 的设计只是出于调度上的考虑,那么 K8s 项目似乎完全没有必要非得把 Pod 作为“一等公民”吧?这不是故意增加用户的学习门槛吗?
没错,如果只是处理“超亲密关系”这样的调度问题,K8s 可以在调度器层面解决。
不过,Pod 在 K8s 里还有更重要的意义,那就是:容器设计模式。
为了理解这一层含义,必须先了解 Pod 的实现原理。
首先,关于 Pod 最重要的一个事实是:它只是一个逻辑概念。
也就是说,K8s 真正处理的,还是宿主机OS上 Linux 容器的 Namespace 和 Cgroups,而并不存在一个所谓的 Pod 的边界或者隔离环境。
那么,Pod 又是怎么被“创建”出来的呢?
答案是:Pod,其实是一组共享了某些资源的容器。
Pod 里的所有容器,共享的是同一个 Network Namespace,并且可以声明共享同一个 Volume。
那这么来看的话,一个有 A、B 两个容器的 Pod,不就是等同于一个容器(容器 A)共享另外一个容器(容器 B)的网络和 Volume 的玩儿法么?
这好像通过 docker run --net --volumes-from 这样的命令就能实现嘛,比如:
$ docker run --net=B --volumes-from=B --name=A image-A ...
但如果这样的话,容器 B 就必须比容器 A 先启动,这样一个 Pod 里的多个容器就不是对等关系,而是拓扑关系了。
所以,在 K8s 里,Pod 的实现需要使用一个中间容器,这个容器叫作 Infra 容器。在这个 Pod 中,Infra 容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过 Join Network Namespace 的方式,与 Infra 容器关联在一起。这样的组织关系,可以用下面这样一个示意图来表达:
这个 Pod 里有两个用户容器 A 和 B,还有一个 Infra 容器,该容器一直处于pending状态。而在 Infra 容器“Hold 住”Network Namespace 后,用户容器就可以加入到 Infra 容器的 Network Namespace 当中了。
这也就意味着,对于 Pod 里的容器 A 和容器 B 来说:
- 它们可以直接使用 localhost 进行通信;
- 它们看到的网络设备跟 Infra 容器看到的完全一样;
- 一个 Pod 只有一个 IP 地址,也就是这个 Pod 的 Network Namespace 对应的 IP 地址;
- 当然,其他的所有网络资源,都是一个 Pod 一份,并且被该 Pod 中的所有容器共享;
- Pod 的生命周期只跟 Infra 容器一致,而与容器 A 和 B 无关。
而对于同一个 Pod 里面的所有用户容器来说,它们的进出流量,也可以认为都是通过 Infra 容器完成的。这一点很重要,因为将来如果你要为 K8s 开发一个网络插件时,应该重点考虑的是如何配置这个 Pod 的 Network Namespace,而不是每一个用户容器如何使用你的网络配置,这是没有意义的。
这就意味着,如果你的网络插件需要在容器里安装某些包或者配置才能完成的话,是不可取的:Infra 容器镜像的 rootfs 里几乎什么都没有,没有你随意发挥的空间。当然,这同时也意味着你的网络插件完全不必关心用户容器的启动与否,而只需要关注如何配置 Pod,也就是 Infra 容器的 Network Namespace 即可。
有了这个设计之后,共享 Volume 就简单多了:K8s 只要把所有 Volume 的定义都设计在 Pod 层级即可。
这样,一个 Volume 对应的宿主机目录对于 Pod 来说就只有一个,Pod 里的容器只要声明挂载这个 Volume,就一定可以共享这个 Volume 对应的宿主机目录。比如下面这个例子:
debian-container 和 nginx-container 都声明挂载了 shared-data 这个 Volume。而 shared-data 是 hostPath 类型。它对应在宿主机上的目录就是:/data。而这个目录被同时绑定挂载进了上述两个容器当中。
这就是为什么,nginx-container 可以从它的 /usr/share/nginx/html 目录中,读取到 debian-container 生成的 index.html 文件的原因。
明白了 Pod 的实现原理后,讨论“容器设计模式”就容易多了。
Pod 这种“超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器。
为了能够掌握这种思考方式,你就应该尽量尝试使用它来描述一些用单个容器难以解决的问题。
第一个最典型的例子是:WAR 包与 Web 服务器。
现在有一个 Java Web 的 WAR 包,它需要被放在 Tomcat 的 webapps 目录下运行。
假如只能用 Docker 来做,那该如何处理这个组合关系呢?
- 把 WAR 包放在 Tomcat 镜像的 webapps 目录下,做成一个新的镜像。可如果要更新 WAR 包的内容,或者要升级 Tomcat 镜像,就要重新制作一个新的发布镜像,非常麻烦。
- 不管 WAR 包,永远只发布一个 Tomcat 容器。这个容器的 webapps 目录声明一个 hostPath 类型的 Volume,从而把宿主机上的 WAR 包挂载进 Tomcat 容器中运行。不过如何让每一台宿主机,都预先准备好这个存储有 WAR 包的目录呢?这样来看,你只能独立维护一套分布式存储系统了。
有了 Pod 就很容易解决了。可以把 WAR 包和 Tomcat 分别做成镜像,然后把它们作为一个 Pod 里的两个容器“组合”在一起:
在这个 Pod 中定义了两个容器,第一个容器使用的镜像是 geektime/sample:v2,这个镜像里只有一个 WAR 包(sample.war)放在根目录下。第二个容器使用的是一个标准的 Tomcat 镜像。
WAR 包容器是一个 Init Container 类型的容器。
在 Pod 中,所有 Init Container 定义的容器,都会比 containers 定义的容器先启动。并且,Init Container 容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。
WAR 包容器启动后,执行了"cp /sample.war /app",把应用的 WAR 包拷贝到 /app 目录下,然后退出。
而后这个 /app 目录,就挂载了一个名叫 app-volume 的 Volume。
接下来 Tomcat 容器同样声明了挂载 app-volume 到自己的 webapps 目录下。
Tomcat 容器启动时,它的 webapps 目录下就一定会存在 sample.war 文件:这个文件正是 WAR 包容器启动时拷贝到这个 Volume 里面的,而这个 Volume 是被这两个容器共享的。
我们用一种“组合”方式,解决了 WAR 包与 Tomcat 容器之间耦合关系的问题。
这个所谓的“组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。指的是可以在一个 Pod 中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。
在这个 Pod 中,Tomcat 容器是主容器,而 WAR 包容器的存在,只是为了给它提供一个 WAR 包而已。所以用 Init Container 的方式优先运行 WAR 包容器,扮演了一个 sidecar 的角色。
总结
一个运行在虚拟机里的应用,哪怕再简单,也是被管理在 systemd 或者 supervisord 之下的一组进程,而不是一个进程。这跟本地物理机上应用的运行方式是一样的。这也是为什么,从物理机到虚拟机的应用迁移,往往并不困难。
可是一个容器只能管理一个进程。更确切地说,一个容器,就是一个进程。所以将一个原本运行在虚拟机里的应用,“无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。
所以,你现在可以这么理解 Pod 的本质:
Pod,实际上是在扮演传统基础设施里“虚拟机”的角色;而容器,则是这个虚拟机里运行的用户程序。
当你需要把一个运行在虚拟机里的应用迁移到容器中时,一定要仔细分析到底有哪些进程(组件)运行在这个虚拟机里。
然后你就可以把整个虚拟机想象成为一个 Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为 Init Container。这才是更加合理的、松耦合的容器编排诀窍,也是从传统应用架构,到“微服务架构”最自然的过渡方式。
注意:Pod 这个概念,提供的是一种编排思想,而不是具体的技术方案。所以,如果愿意的话,你完全可以使用虚拟机来作为 Pod 的实现,然后把用户容器都运行在这个虚拟机里。甚至,你可以去实现一个带有 Init 进程的容器项目,来模拟传统应用的运行方式。
相反的,如果强行把整个应用塞到一个容器里,甚至不惜使用 Docker In Docker 这种在生产环境中后患无穷的解决方案,恐怕最后往往会得不偿失。