深入篇(1):为什么需要Pod?

208 阅读12分钟

Pod,是 K8s 的原子调度单位。

那为什么我们需要 Pod?

回答这个问题前先回忆一下:容器的本质到底是什么? 容器的本质是进程。

没错。容器就是未来云计算系统中的进程;容器镜像就是这个系统里的“.exe”安装包。那么 K8s 呢?

你应该也能立刻回答上来:K8s 就是操作系统!

在一台 Linux 机器里,执行如下命令:

pstree -g

这条命令的作用是展示当前系统中正在运行的进程的树状结构。它的返回结果如下所示:

image.png

在一个操作系统里,进程并不是独自运行,而是以进程组的方式组织在一起。比如这个叫作 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 容器关联在一起。这样的组织关系,可以用下面这样一个示意图来表达:

image.png

这个 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 对应的宿主机目录。比如下面这个例子:

image.png

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 来做,那该如何处理这个组合关系呢?

  1. 把 WAR 包放在 Tomcat 镜像的 webapps 目录下,做成一个新的镜像。可如果要更新 WAR 包的内容,或者要升级 Tomcat 镜像,就要重新制作一个新的发布镜像,非常麻烦。
  2. 不管 WAR 包,永远只发布一个 Tomcat 容器。这个容器的 webapps 目录声明一个 hostPath 类型的 Volume,从而把宿主机上的 WAR 包挂载进 Tomcat 容器中运行。不过如何让每一台宿主机,都预先准备好这个存储有 WAR 包的目录呢?这样来看,你只能独立维护一套分布式存储系统了。

有了 Pod 就很容易解决了。可以把 WAR 包和 Tomcat 分别做成镜像,然后把它们作为一个 Pod 里的两个容器“组合”在一起:

image.png

在这个 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 这种在生产环境中后患无穷的解决方案,恐怕最后往往会得不偿失。