4.1 应用容器与Pod资源

封装了应用容器的Pod资源代表运行在Kubernetes系统之上的进程或进程组,它可由单个容器或少量具有强耦合关系并共享资源的容器组成,用于抽象、组织和管理集群之上的应用进程。本节重点解析Pod内部组织和管理容器的方式。

4.1.1 Pod资源基础

现代应用容器技术用来运行单个进程(包括子进程),它在容器中PID名称空间中的进程号为1,可直接接收并处理信号,因而该进程终止也将导致容器终止并退出。这种设计使得容器与内部进程具有共同的生命周期,更容易发现和判定故障,也更利于对单个应用程序按需求进行扩容和缩容。单进程容器可将应用日志直接送往标准输出(STDOUT)和标准错误(STDERR),有利于让容器引擎及容器编排工具获取、存储和管理。因此,一个容器中仅应该运行一个进程是应用容器“立身之本”,这也是Docker及Kubernetes使用容器的标准方式。

凡事有利就有弊,单进程模型的应用容器显然不利于有IPC通信等需求的多个具有强耦合关系的进程间的协同,除非在组织这类容器时人为地让它们运行于同一内核之上共享IPC名称空间。于是,跨多个主机运行的容器编排系统需要能够描述这种容器间的强耦合关系,并确保它们始终能够被调度至集群中的同一个节点之上,Kubernetes为应对这种需求发明了Pod这一抽象概念,并将其设计为顶级API对象,是集群的“一等公民”。

事实上,任何人都能够配置容器运行时工具(例如Docker CLI)来控制容器组内各容器之间的共享级别:首先创建一个基础容器作为父容器,而后使用必要的命令选项来创建与父容器共享指定环境的新容器,并管理好这些容器的生命周期即可。Kubernetes使用名为pause的容器作为Pod中所有容器的父容器来支撑这种构想,因而也被称为Pod的基础架构容器,如图4-1所示。若用户为Pod启用了PID名称空间共享功能,pause容器还能够作为同一Pod的各容器的1号PID进程以回收僵尸进程。不过,Kubernetes默认不会为Pod内的各容器共享PID名称空间,它依赖于用户的显式设定。

图4-1  Pod内的容器共享PID、Network、IPC和UTS名称空间

Pod除了是一组共享特定名称空间的容器的集合之外,其设计还隐藏了容器运行时复杂的命令行选项以及管理容器实例、存储卷及其他资源的复杂性,也隐藏了不同容器运行时彼此间的差异。从而让最终用户只需要掌握Pod资源配置格式便能够创建并运行容器,且无须关心具体的运行时就能够平滑地在不同的OCI容器运行时之间迁移容器。

同一Pod中,这些共享PID、IPC、Network和UTS名称空间的容器彼此间可通过IPC通信,共享使用主机名和网络接口、IP地址、端口和路由等各种网络资源,因而各容器进程能够通过lo网络接口通信且不能使用相同的网络套接字地址。尽管可以把Pod类比为物理机或虚拟机,但一个Pod内通常仅应该运行具有强耦合关系的容器,否则除了pause以外,只应该存在单个容器,或者只存在单个主容器和一个以上的辅助类容器(例如服务网格中的Sidecar容器等),这也更符合单进程应用容器的设计初衷。

例如,对于典型的传统三层应用来说,处理业务的服务器应用和数据库管理系统就应该分别组织在不同的Pod中,毕竟二者之间虽然存在通信需求,但并不存在强耦合关系。更重要的是,因为运行于不同的Pod中,这两个应用的Pod有可能被调度至不同的工作节点之上,从而能更好地利用分布于集群中的计算资源和存储资源。再者,考虑到服务器应用和数据库系统各自需要应对的请求量和业务处理压力,它们在容量设计上也会存在不同需求,运行于各自的Pod中更有利于独立扩容和缩容操作。究竟哪些容器应该组织在同一个Pod中,以及如何组织,我们将在下一节详细描述。

Pod本身并不能自愈,因工作节点故障或计算资源吃紧、管理需求以及Pod自身故障等,集群中的每个Pod都存在被删除的可能性,Kubernetes可使用kubelet和控制器件来“复活”这种被弃用的Pod对象,并进行其他管理工作。因此,我们一般不应该手动创建这些“裸”Pod,而要通过控制器对象,并借助于“Pod模板”来创建和管理。另外,重建或者重新调度“复活”的Pod对象虽然与原Pod具有相同的名称,然则二者并非完全等同,极有可能被分配了一个新的IP地址,再考虑到Pod应用的水平伸缩导致的同一应用的Pod对象的规模变动,因而便有了Service资源这个中间层结合KubeDNS进行服务发现。再者,跨工作节点生命周期实现Pod应用数据持久化存储的能力,则要依赖另一种称为Volume的资源来实现。后面的几章会分别介绍控制器、Service和Volume的相关话题。

4.1.2 容器设计模式

软件工程领域,设计模式是对软件设计中普遍存在或反复出现的各种问题而提出的通用解决方案。对于使用容器和微服务的云原生应用程序,同样存在实现类似功能的设计模式,它们被称为微服务设计模式或容器设计模式。这些设计模式蕴含着最佳实践,它们能简化开发并有效增强基于容器和微服务的分布式系统的可靠性。

Google的Brendan Burns和David Oppenheimer在论文Design patterns for container-based distributed systems描述了基于容器的分布式系统中出现的3类设计模式:①单容器模式;②由强耦合容器协同共生的单节点模式(例如Pod),即单节点多容器模式;③基于特定部署单元(Pod)实现分布式算法的多节点模式。这些容器设计模式也代表了它们可以在Kubernetes运行的模式,即于Pod中组织容器的机制。

1. 单容器模式

单容器模式就是指将应用程序封装为应用容器运行,这也是我们开始容器技术之旅的方式。需要特别强调的一点是,该模式需要遵循简单和单一原则,每个容器仅承载一种工作负载,因而在同一个容器中同时运行Web服务器和日志收集代理程序便违反了该设计原则。

传统的容器管理接口极为有限,尽管它暴露的run、pause和stop这3个管理接口相当有用,但是更丰富的接口将为开发者及运维人员赋予更多的管理能力。现代的编程语言几乎普遍支持HTTP Web服务和JSON数据格式,因而云应用程序开发人员可以在应用程序的核心功能之外轻松定义一个基于HTTP的管理API,并通过特定的URL端点予以暴露。

2. 单节点多容器模式

单节点多容器模式是指跨容器的设计模式,其目的是在单个主机之上同时运行多个共生关系的容器,因而容器管理系统需要将它们作为一个原子单位进行统一调度。Kubernetes编排系统设计的Pod概念就是这个设计模式的实现之一。

若多个容器间存在强耦合关系,它们具有完全相同的生命周期,或者必须运行于同一节点之上时,通常应该将它们置于同一个Pod中,较常见的情况是为主容器并行运行一个助理式管理进程。单节点多容器模式的常见实现有Sidecar(边车)、适配器(Adapter)、大使(Ambassador)、初始化(Initializer)容器模式等。

(1)Sidecar模式

Sidecar模式是多容器系统设计的最常用模式,它由一个主应用程序(通常是Web应用程序)以及一个辅助容器(Sidecar容器)组成,该辅助容器用于为主容器提供辅助服务以增强主容器的功能,是主应用程序是必不可少的一部分,但却不一定非得存在于应用程序本身内部。Sidecar模式如图4-2所示。

图4-2 Sidecar模式

最常见的Sidecar容器是日志记录服务、数据同步服务、配置服务和代理服务等。对于主容器应用的每个实例,Sidecar的实例都被部署并托管在它旁边,主容器与Sidecar容器具有相同的生命周期,毕竟主容器未运行时,运行Sidecar容器并无实际意义。尽管完全可以将Sidecar容器集成到主容器内部,但是使用不同的容器来进行处理不同功能还是存在较多的优势:

▪辅助应用的运行时环境和编程语言与主应用程序无关,因而无须为每种编程语言分别开发一个辅助工具;

▪二者可基于IPC、lo接口或共享存储进行数据交换,不存在明显的通信延迟;

▪容器镜像是发布的基本单位,将主应用与辅助应用划分为两个容器使得其可由不同团队开发和维护,从而变得方便及高效,单独测试及集成测试也变得可能;

▪容器限制了故障边界,使得系统整体可以优雅降级,例如Sidecar容器异常时,主容器仍可继续提供服务;

▪容器是部署的基本单元,每个功能模块均可独立部署及回滚。

事实上,这些优势对于其他模型来说同样存在。

(2)大使模式

云原生应用程序需要诸如断路、路由、计量和监视等功能,以及具有进行与网络相关的配置更新的功能,但更新旧有的应用程序或现有代码库以添加这些功能可能很困难,甚至难以实现,进程外代理便成了一种有效的解决方案。大使模式(见图4-3)本质上就是这么一类代理程序,它代表主容器发送网络请求至外部环境中,因此可以将其视作与客户端(主容器应用)位于同一位置的“外交官”。

图4-3 大使模式

大使模式的最佳用例之一是提供对数据库的访问。实践中,开发环境、测试环境和生产环境中的主应用程序可能需要分别连接到不同的数据库服务。尽管使用环境变量可配置主容器应用完成此类功能,但更好的方案是让应用程序始终通过localhost连接至大使容器,而如何正确连接到目标数据的责任则由大使容器完成。另外,需要以智能客户端连接至外部的分布式数据库时(例如Redis Cluster等),也可以使用大使来扮演此类的客户端程序。

代理会增加网络开销并导致一定的延迟,因而对延迟敏感的应用应该仔细权衡模式的得失。

(3)适配器模式

适配器模式(见图4-4)用于为主应用程序提供一致的接口,实现了模块重用,支持标准化和规范化主容器应用程序的输出以便于外部服务进行聚合。相比较来说,大使模式为内部容器提供了简化统一的外部服务视图,适配器模式则刚好反过来,它通过标准化容器的输出和接口,为外界展示了一个简化的应用视图。

图4-4 适配器模式

一个实际的例子就是借助于适配器容器确保系统内的所有容器提供统一的监控接口。如今应用通过各种各样的方式暴露不尽相同的指标格式(例如JMX和Statsd等),而通过一个固定的监控接口暴露指标将有助于分布式应用的监控工具收集、聚合及可视化等功能。尽管目前一些监控解决方案支持与多种类型的后端通信,但这种在监控系统内部置入与特定应用程序相关的代码却有违代码解耦及整洁性。

(4)初始化容器模式

初始化容器模式(见图4-5)负责以不同于主容器的生命周期来完成那些必要的初始化任务,包括在文件系统上设置必要的特殊权限、数据库模式设置或为主应用程序提供初始数据等。但这些初始化逻辑无法包含在应用程序的镜像文件中,或者出于安全原因,应用程序镜像没有执行初始化活动的权限,再或者用户期望能延迟应用程序启动,直到外部环境满足其启动条件为止。

图4-5 初始化容器模式

初始化容器将Pod内部的容器分成了两组:初始化容器和应用程序容器(主容器和Sidecar容器等),初始化容器可以不止一个,但它们需要以特定的顺序串行运行,并需要在启动应用程序容器之前成功终止。不过,多个应用程序容器一般需要并行启动和运行。

就Kubernetes来说,除了初始化容器之外,还有一些其他可用的初始化技术,例如admission controllers、admission webhooks和PodPresets等。

3. 多节点模式

多节点模式就是将分布式应用的每个任务实例分布于多个节点,分别以单节点模式运行,并以更高级的形式进行彼此通信和协同的更高级模式。典型的分布式应用程序具有许多以协调方式并行执行的任务,这些任务可能依赖于外部的协调机制来确保各任务实例互不冲突,以避免导致争抢共享资源或者意外干扰其他实例正在执行的工作。下面描述了几种以容器方式运行的分布式云应用程序的设计模式。

(1)领导者选举模式

领导者选举的思想是,若一个分布式应用支持同时运行某一任务的多个无差别、完全对等实例以提高服务可用性级别,但这些实例存在可写入的共享资源,或者支持将复杂计算分割为多个并行执行的任务实例并需要对结果进行聚合时,就需要选举一个实例来充当领导者以对其他下属任务实例的操作进行协调。运行相同的代码的每个实例都可能被选举为领导者,因而必须确保选举流程正确进行,以防止两个或更多实例同时接管领导者角色。一个系统也可能并行运行多个选举流程,例如每个数据分片子集需要分别选举各自的领导者等。图4-6为领导者选举模式示意图。

图4-6 领导者选举模式

以etcd、ZooKeeper和Consul为代表的主流选举服务或存储系统业已相当成熟,但配套用于选举的代码库对特定业务领域的程序员来说通常难以掌握和正确使用,且因编程语言而限制了适用范围,于是通过为每个分布式应用程序的每个任务实例外挂一个选举容器协同进行领导者选举便成了不错的解决方案。这些能够提供选举能力的容器可以由分布式协同领域的专业人员提供,相关代码编译一次之后该容器即可由各类编程语言的开发者所复用。

(2)工作队列模式

分布式应用程序的各组件间存在大量的事件传递需求,当某应用组件需要将信息广播至大量订阅者时,可能需要与多个独立开发的,可能使用了不同平台、编程语言和通信协议的应用程序或服务通信,并且无须订阅者实时响应地通信,此时工作队列模式将是较为适用的解决方案。它具有解耦子系统、提高伸缩能力和可靠性、支持延迟事件处理、简化异构组件间的集成等优势。图4-7为工作队列模式示意图。

图4-7 工作队列模式

类似领导者选举模式,工作队列模式也受益于容器技术。工作队列已被深入研究并且有许多框架进行了实现,但这些框架同样受限于编程语言或存在实现不完整的问题。容器实现的接口支持开发人员便捷地创建出一个通用队列的容器,而后创建另一个支持接受输入数据并将其转换为目标数据格式的容器作为可重用框架容器,以便能够让主应用程序轻松使用工作队列。

(3)分散/聚集

分散/聚集模式与工作队列模式非常相似,它同样将大型任务拆分为较小的任务,区别是容器会立即将响应返回给用户,一个很好的例子是MapReduce算法。该模式需要两类组件:一个称为“根”节点或“父”节点的组件,将来自客户端的请求切分成多个小任务并分散到多个节点并行计算;另一类称为“计算”节点或“叶子”节点,每个节点负责运行一部分任务分片并返回结果数据,“根”节点收集这些结果数据并聚合为有意义的数据返回给客户端。开发这类分布式系统需要请求扇出、结果聚合以及与客户端交互等大量的模板代码,但大部分都比较通用。因而要实现该模式,我们只需要分别将两类组件各自构建为容器即可。

4.1.3 Pod的生命周期

Pod对象从创建开始至终止退出之间的时间称为其生命周期,这段时间里的某个时间点,Pod会处于某个特定的运行阶段或相位(phase),以概括描述其在生命周期中所处的位置。Kubernetes为Pod资源严格定义了5种相位,并将特定Pod对象的当前相位存储在其内部的子对象PodStatus的phase字段上,因而它总是应该处于其生命进程中以下几个相位之一。

▪Pending:API Server创建了Pod资源对象并已存入etcd中,但它尚未被调度完成,或仍处于从仓库中下载容器镜像的过程中。

▪Running:Pod已经被调度至某节点,所有容器都已经被kubelet创建完成,且至少有一个容器处于启动、重启或运行过程中。

▪Succeeded:Pod中的所有容器都已经成功终止且不会再重启。

▪Failed:所有容器都已经终止,但至少有一个容器终止失败,即容器以非0状态码退出或已经被系统终止。

▪Unknown:API Server无法正常获取到Pod对象的状态信息,通常是由于其无法与所在工作节点的kubelet通信所致。

需要注意的是,阶段仅是对Pod对象生命周期运行阶段的概括性描述,而非Pod或内部容器状态的综合汇总,因此Pod对象的status字段中的状态值未必一定是可用的相位,它也有可能是Pod的某个错误状态,例如CrashLoopBackOff或Error等。

Pod资源的核心职责是运行和维护称为主容器的应用程序容器,在其整个生命周期之中的多种可选行为也是围绕更好地实现该功能而进行,如图4-8所示。其中,初始化容器(init container)是常用的Pod环境初始化方式,健康状态检测(startupProbe、livenessProbe和readinessProbe)为编排工具提供了监测容器运行状态的编程接口,而事件钩子(preStop和postStart)则赋予了应用容器读取来自编排工具上自定义事件的机制。尽管健康状态检测也可归入较为重要的操作环节,但这其中仅创建和运行主容器是必要任务,其他都可根据需要在创建Pod对象时按需定义。

图4-8 Pod的生命周期

若用户给出了上述全部定义,则一个Pod对象生命周期的运行步骤如下。

1)在启动包括初始化容器在内的任何容器之前先创建pause基础容器,它初始化Pod环境并为后续加入的容器提供共享的名称空间。

2)按顺序以串行方式运行用户定义的各个初始化容器进行Pod环境初始化;任何一个初始化容器运行失败都将导致Pod创建失败,并按其restartPolicy的策略进行处理,默认为重启。

3)待所有初始化容器成功完成后,启动应用程序容器,多容器Pod环境中,此步骤会并行启动所有应用容器,例如主容器和Sidecar容器,它们各自按其定义展开其生命周期;本步骤及后面的几个步骤都将以主容器为例进行说明;容器启动的那一刻会同时运行主容器上定义的PostStart钩子事件,该步骤失败将导致相关容器被重启。

4)运行容器启动健康状态监测(startupProbe),判定容器是否启动成功;该步骤失败,同样参照restartPolicy定义的策略进行处理;未定义时,默认状态为Success。

5)容器启动成功后,定期进行存活状态监测(liveness)和就绪状态监测(readiness);存活状态监测失败将导致容器重启,而就绪状态监测失败会使得该容器从其所属的Service对象的可用端点列表中移除。

6)终止Pod对象时,会先运行preStop钩子事件,并在宽限期(terminationGrace-PeriodSeconds)结束后终止主容器,宽限期默认为30秒。