Alan Hou的个人博客

云原生系列Kubernetes篇 Pods

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

在之前的文章中,我们讨论过如何将应用容器化,但现实中对容器化应用的部署,常常需要将多个应用放到独立的单元、调度到同一台机器。

类比示例参见下图,包含伺服web请求的容器和与远程Git仓库同步的文件系统容器。

图5-1:两个容器及共享文件系统的示例Pod

一开始可能会想将 web 服务端和Git 同步工具放到同一个容器中。在仔细分析后,分开的原因就很明显了。首先在资源使用上两者有着明显的不同。比如说内存:因为web服务端处理的是用户请求,需要保障其随时可用并返回响应。而Git同步工具并不是真的面向用户,其服务质量等级(QoS)为Best Effort(注:Guaranteed > Burstable > BestEffort)。

假设Git同步工具存在内存泄漏。我们需要确保Git同步工具不会用光web服务器所要使用的内存,因为这会影响性能甚或是导致服务崩溃。

这类资源隔离正是设计容器所完成的工作。通过将应用分成两个独立的容器,我们可以保障可靠的web服务。

当然,这两个工作是花生的,不太需要将web服务放到一台机器而将Git同步工具放到另一台机器上。因此,Kubernetes将多个容器分组为一个原子单元,称之为Pod。(这一名称与Docker容器的鲸鱼主题是很搭的,因为一群鲸鱼就称为pod)。

注:多个容器组成单个Pod,这在一开始引入至Kubernetes时是具有争议且让人费解的,最终被一批在基础设施上部署各类应用时所采用。

Kubernetes中的Pod

Pod是一组运行于同一执行环境Pod的应用容器和卷,而不同一容器,它是Kubernetes集群的最小调度对象。这也表示同一Pod中的容器总是会放到同一台机器上。

Pod中的每个容器都运行于自己的cgroup中,但共享Linux命名空间。

运行于同一个Pod中的应用共享IP地址和端口空间(网络命名空间)、具胡相同的主机名(UTS命名空间)并可与使用基于System V IPC或POSIX消息队列(IPC命名空间)的跨进程通讯通道进行通讯。但不同Pod中的容易相互隔离,IP地址、主机名等均不相同。同一节点上运行的不同Pod中的容器也可运行在不同的服务器上。

关于Pod的思考

在使用Kubernetes时人们最常问的问题是为“在Pod中应该放些什么?”

有时人们会想:“啊!WordPress容器和MySQL数据库容器共同组成了WordPress实例,它们应该放到同一个Pod中。”但这类Pod实际上是Pod构建中反模式的案例。有两大原因。首先WordPress及其数据库并不是真的共生关系。如果WordPress容器和数据库容器放到了不同的机器上,仍然可以有效地进行使用,因为可能过网络连接进行通讯。其次是不一定会将WordPress和数据库看成一个单元进行扩容。WordPress本身几乎是无状态的,因此可通过创建更多WordPress的Pod来扩容WordPress前端响应前端的负载。MySQL数据库扩容就更麻烦了,我们更有可能做的是为单个MySQL Pod增加更多的资源。如果将WordPress和MySQL容器放到一个Pod中,就不得不同时对这两个容器进行扩容,这样会出现问题。

通常在设计Pod时应该问自己的是“这些容器放到不同机器中是否还能正常使用?”如果答案是否定的,就应使用Pod来组合这些容器。如果答案是肯定的,可能正确的方案是使用多个Pod。本文开头的例子中,容器通过本地文件系统进行交互。将容器调度到不同的机器上就无法正常使用了。

在本文后续的小节中我们会讲解如何在Kubernetes中创建、内省(introspect)、管理和删除Pod。

Pod声明文件

Pod通过声明文件进行描述,声明文件是一个表示Kubernetes API对象的文本文件。Kubernetes深度青睐声明式配置,也就是说在配置文件中写入所希望的状态,然后将配置文件提交给服务,让其执行操作实现所需的状态。

注:声明式配置与指令式配置不同,后者是执行一系列操作(如apt-get install foo)来更改系统的状态。多年的生产经验告诉我们通过书写所需系统状态的记录进行维护可以系统更易管理、更可靠。声明式配置有很多好处,如对配置进行代码审查以及存档分布式系统的当前状态。此外,这是Kubernetes中无用户参与保持应用运行的自愈行为的基础。

Kubernetes API服务在将其持续化存储 (etcd)之前接收、处理Pod声明。调度器还使用Kubernetes API查找尚未调度至节点的Pod。在机器有充足资源时调度器可将多个Pod放到同一台机器上。但将同一应用的多个副本调度到同一台机器在可靠性上是不佳的,因为该机器是一个单故障域。因此,Kubernetes 调度器会尝试确保将同一应用的不同Pod分布到不同机器上以保障出现这类故障时的可靠性。Pod在调度到节点上时,就不再移动,必须要被销毁再重新调度。

一个Pod的多实例可通过重复这里描述的工作流进行部署。但第9章中的复制集更适合运行一个Pod的多个实例。(其实对运行单个Pod也更好,我们到时再讨论。)

创建Pod

创建Pod最简单的方式是通过声明式命令kubectl run。例如,要运行原来的kuard 服务,可使用:

注:网上有关Kubernetes的教程可能会使用--generator=run-pod/v1,这是在1.18之前中使用的,现在已经弃用,如出现error: unknown flag: --generator报错说明你用的Kubernetes是新版本,请去除这一参数。

可以通过运行如下命令查看Pod的状态:

一开始可能会看到容器的状态是Pending,但最终状态会变成Running,这时该Pod及其容器就创建成功了。

此时可以删除掉运行中的Pod:

下面我们会学习如何手写完整的Pod声明文件。

创建Pod声明文件

可以使用YAML或JSON格式编写Pod声明文件,但更多人使用YAML,因为更便于人类阅读而且支持注释。Pod声明文件(以及其它Kubernetes API对象)应当看成和源代码是一样的,注释可以新团队成员理解Pod的功能。

Pod声明文件包含一组键名和属性:具体来说,metadata 版块用于描述Pod及其标签,spec 版块用于描述数据卷以及在Pod中运行的容器列表。

云原生系列Kubernetes篇 创建、运行容器中,我们使用如下Docker命令部署了kuard :

可以编写例5-1中这样的kuard-pod.yaml文件然后运行kubectl 命令将声明文件加载到Kubernetes实现同样的效果。

例5-1 kuard-pod.yaml

这种管理应用的方式一开始看起来可能很笨重,但从长远来看这种记录所需状态的方式才是最佳实践,对管理很多应用的大型团队尤其如此。

运行Pod

>在前面的小节中,我们创建了一个可启动Pod运行kuard的Pod声明文件。使用kubectl apply命令可启动kuard单实例:

会将Pod声明文件提交给Kubernetes API服务端。接着Kubernetes系统会将这一Pod调度到集群中的健康节点运行,kubelet 守护进程则会进行监控。不必担心现在还不了解Kubernetes中的各个组成部分,我们会在后续详细讲解到。

列举Pod

此时Pod已运行起来了,我们来进一步查看。使用kubectl命令行工具,可以列举出集群中运行的所有Pod。现在我们只有前一步中所创建的单个Pod:

可以看到Pod的名称(kuard)是我们此前在YAML文件中指定的。除了显示就绪的容器数量 (1/1)之外,输出中还显示了状态、Pod重启次数以及Pod运行的时间。

在创建Pod后立即运行该命令时可能会看到:

Pending 状态表示Pod已进行了提交但尚未调度。如果出现了错误,比如尝试通过一个存在的容器镜像创建Pod,也会在状态字段中进行体现。

注:默认kubectl 命令行工具所报告的信息很简洁,但可通过命令行参数获取到更多的信息。对kubectl命令添加-o wide会打印出更多的信息(同样会显示在单行中)。添加-o json-o yaml参数会分别输出完整的JSON或YAML对象。如果希望查看kubectl 操作的终极完整日志,可以使用--v=10参数来获取到复杂日志,代价是可读性较差。

Pod详情

有时单行的显示不够,因为太过简短。此外Kubernetes维护着大量有关Pod的事件,位于事件流中,并未绑定到具体的Pod对象。

要查看更多有关Pod(或是其它Kubernetes对象)的信息,可以使用kubectl describe命令。例如,要描述出此前创建的Pod,可以运行:

输出中分段显示了一堆信息。顶部是有关该Pod的基本信息:

然后是Pod中运行容器的信息:

最后中是与Pod相关联的事件,比如何时进行的调度、镜像在何时拉取,以及是否/何时因健康检查失败而重启:

删除Pod

要删除Pod可通过其名称删除:

也可通过创建时使用的相同文件进行删除:

删除Pod时,并不会立即杀死。如果运行kubectl get pods,会发现Pod处于Terminating 状态。所有的Pod都有一个优雅停用时间。默认是30秒。在Pod进入Terminating 状态时,不再接收任何请求。在提供服务的场景,优雅关闭时期对于可用性来说很重要,因为这样在关闭之前Pod可以完成处理期间未完成的活跃请求。

警告:在删除Pod时,任何Pod相关容器内存储的数据也会被删除。如果希望持久化存储Pod多个实例中的数据,需要使用持久化卷(Persistent​Vo⁠lume),在本文最后会进行讲解。

访问Pod

这时Pod已经正常运行了,出于各种原因可能需要对其进行访问。比如要加载Pod中运行的web服务、查看日志来调试所看到的问题或是在Pod内执行其它命令来辅助调试。下面的小节详细讲解了与Pod内的代码和数据交互的多种方式。

通过日志获取更多信息

在需要对应用进行调试时,如果能比describe 再深入一些地了解应用所做的操作会很有帮助。Kubernetes提供了两个调试运行中容器的命令。kubectl logs命令从运行中的实例中下载当前日志:

添加-f参数日志会以流的形式持续输出。

kubectl logs总是会从当前运行的容器中获取日志。添加--previous参数会从容器之前的实例中获取日志。这很有用,比如在容器因配置问题持续重启的时候。

注:虽然使用kubectl logs对于偶尔调试生产环境容器很有帮助,通常使用日志聚合服务会更好。有一些开源的日志聚合工具,比如Fluentd和Elasticsearch,还有很多的云端日志服务商。日志聚合服务为存储更长周期的日志提供了更大容量,以及丰富的搜索和过滤功能。很多还具备将多个Pod日志聚合到单个视图中的能力。

在容器中通过exec运行命令

有时日志还不够,要真正了解所执行的操作,需要在容器自身环境中执行一些命令。这时可以使用:

还可以添加-it参数来获取交互式的会话:

对容器拷入拷出文件

在上一篇文章中,我们展示了如何使用kubectl cp来访问Pod中的文件。通常来说,将文件拷贝到容器中是其反向操作。一定要将容器中的内容看成是不可变的。但有时这是处理问题、使用服务恢复健康的最快速方式,因为这要比构建、推送并滚动更新新容器要快很多。但在处理好问题后,应立即执行镜像的构建和滚动更新,否则一定会在后续的常规调度中忘记曾经对容器所做过的本地修改。

健康检查

在Kubernetes中通过容器运行应用时,使用进程健康检查会自动保活。健康检查就是确保应用的主进程保持运行。如果未运行,Kubernetes就会对其重启。

但大部分时候,简单的进程检查还不够。例如,进程出现了死锁,无法处理请求,健康检查仍然会觉得应用是健康的,因为进程仍在运行。

为解决这一问题,Kubernetes引入了应用活跃度(liveness)的健康检查。活跃度健康检查运行应用相关的逻辑,比如加载一个网页,来验证应用不仅仅在运行,还要正常运作。因为这些活跃性健康检查与应用相关,我们需要在Pod声明文件中进行定义。

liveness探测

kuard 进程启动运行后,我们需要一种方式确定其健康性而不进行重启操作。活跃性探测按容器进行定义,也就是说Pod内部的每个容器都单独进行健康检查。在例5-2中,我们对kuard 容器添加了活跃性探测,它是对容器的/healthy路径运行HTTP请求。

例5-2 kuard-pod-health.yaml

以上的Pod声明文件 使用了httpGet 探针,对kuard 容器的8080端口/healthy端点执行HTTP GET请求。探测将initialDelaySeconds 设置为了5,因此会在Pod创建5秒后才开始调用。探测必须在1秒超时时间内收到响应,并且HTTP状态码必须大于等于200少于400,这样才算成功。Kubernetes会每10秒调用 一次探测。如果连续3次探测失败,容器就会失败重启。

可以通过kuard 的状态页面查看到这一操作。使用该声明文件创建一个Pod,然后端口转发至该Pod:

在浏览器中访问http://localhost:8080。点击Liveness Probe标签页。应该会看到一列有kuard 实例所接收到的所有探测的表格。如果点击页面上面的Fail链接,kuard会开始让健康检查失败。等待片刻,Kubernetes会重启容器。此时,显示会进行重置并重头开始。重启的详情可通过运行kubectl describe pods kuard命令进行查看。Event版块中会显示类似下面的文本:

注:虽然对liveness检测的默认响应是重启Pod,该行为实际上是通过Pod的restartPolicy进行控制的。重启策略有3个选项:Always (默认值)、OnFailure(仅在活跃性检测失败或进程退出码不为0时)和Never

就绪探测

当然我们可执行的健康检测不止liveness 一种类型。Kubernetes对活跃和就绪进行了区分。活跃决定了应用是否正常运行。未通过活跃性检测的容器会进行重启。就绪表示容器什么时候可以处理用户请求。未通过就绪性检测的容器会从服务负载均衡中删除。就绪探测的配置与活跃性探测类似。我们会在第7章中详细探讨Kubernetes服务。

将活跃性和就绪性检测进行结合有助于确保集群中运行的都是健康的容器。

启动探测

Kubernetes最近又引入了启动探测,将其作为管理慢启动容器的一种方式。在Pod启动时,会在其它探测之前先执行启动探测。启动探测会等待其超时(此时会重启Pod)或成功,接下就会进行活跃性探测。启动探测让我们可以对慢启动容器延迟检测,同时又可在慢启动容器初始化完成后进行活跃检测。

高级探测配置

Kubernetes中的探测有很多高级选项,包含在Pod启动后等待多久进行探测,把多少次失败看成是真的失败,以及需要多少次成功才能重置失败次数。所有这些在未指定时都会收到默认配置值,但在更高级的用例中需要对其修改,比如应用自身比较古怪或是启动时间过长。

其它类型的健康检查

除HTTP检测外,Kubernetes还支持打开TCP套接字的tcpSocket健康检测,如果连接成功,探测就成功。这种探测对于非HTTP应用非常有用,比如说数据库或其它非HTTP类型的API。

最后,Kubernetes还允许进行exec 探测。它会在容器上下文中执行一个脚本或程序。按照惯例,如果脚本的返回码是0,则探测成功,否则失败。exec 脚本通常用于还适用于HTTP调用的自定义应用验证逻辑。

资源管理

大部分人使用容器或Kubernetes这样的容器编排工具的原因是镜像打包的改进以及它们所提供的可靠部署。除了简化系统开发的面向应用原语,可增加组成集群的计算节点的整体利用率也同等重要。操作虚拟或实体机器的基本成本不论是空闲还是占满大致相同。因此,保障这些机器的最大活跃性可提高在基础设施所投入真金白银的使用效率。

通常我们通过利用率来度量这一效率。利用率由在用资源量除以总资源量。例如,如果购买了单核主机,应用使用了单核的十分之一,利用率就是10%。借助Kubernetes这样的调度系统管理资源打包,我们可以将利用率提高到50%以上。要实现这点,我们需要告诉Kubernetes应用所需的资源,这样Kubernetes可以知道将容器打包到哪台主机上。

Kubernetes允许用户指定两种资源指标。requests 指定了运行应用所需的最小资源。limits 指定应用所能消耗的最大资源量。我们在下一小节中进一步分析。

Kubernetes可识别很多指定资源的标记法,涵盖字面量(“12345”) 到毫核(“100m”)。最需要说明的是 MB/GB/PB和MiB/GiB/PiB之间的区别。前者采用的是二进制(如1 MB == 1,024 KB),而后者采用10进制(1MiB == 1000KiB)。

注:最常见的错误是使用小写m指定的是小单位,使用大写M指定的是大单位。具体来说,400m是0.4 MB,而不是400Mb,这有着很大的区别。

资源请求量:所需最小资源

在Pod请求所需资源运行容器时,Kubernetes会保障Pod可以使用这些资源。最常见的请求资源是CPU和内存,但Kubernetes还支持其它资源类型,如GPU等。例如,要请求kuard放到有半个CPU空闲及可分配128MB内存的主机上时,可使用例5-3的文件来定义Pod。

例5-3 kuard-pod-resreq.yaml

注:资源按容器进行请求,而不是Pod。Pod所请求的资源是Pod中所有容器请求资源的总和,因为不同容器经常需要不同大小的CPU。例如,一个包含web服务端和数据同步应用的Pod,web服务端面向用户,需要使用大量的CPU,而数据同步应用有少量就可将就使用。

请求在将Pod调度到节点上时使用。Kubernetes调用工具会保障节点上的所有Pod的所有请求的总和不超过该节点的容量。因此,Pod在该节点上运行时一定会至少获得到所请求的资源。重要的是request请求的是最小量。它并没有指定一个Pod可能使用的资源上限。我们来举个例子了解这一点。

想象有个容器的代码尝试使用所有的CPU内核。假如我们在创建一个Pod设定这个容器请求0.5个CPU。Kubernetes将这个Pod调度到共2核CPU的主机上。只要这台主机上只有这一个Pod,它就可以消耗总共2.0个CPU,虽然它只请求了0.5个CPU。

如果在该主机上创建了相同容器的另一个Pod,也是请求0.5个CPU,那个每个Pod会收到1.0核。而假如第3个相同的Pod调度到了这台主机上,每个Pod就会收到0.66核。最后假如第4个相同的Pod调度到了这台主机上,每个Pod就会收到所请求的0.5核,节点也达到了最大容量。

CPU请求通过使用Linux内核的cpu-shares功能实现。

注:内存请求处理方式类似于CPU,但有一个很重要的区别。如果有容器超出了内存请求,操作系统无法从进程中移除内存,因此已被分配。因此,在系统内存溢出时,kubelet 会终止内存使用量大于请求内存的容器。这些容器会自动重启,但只会有更少的主机内存可供容器使用。

因为资源请求保障对Pod的资源可用性,它对高负载场景中保障容器具有充足资源非常重要。

通过limits设置资源请求上限

除于可设置Pod所需要资源,获取到最小可用资源,还可通过limits设置最大资源使用量。

在前面的例子中,我们创建了一个kuard Pod请求了0.5核和128 MB内存的最少资源量。而在下面的例5-4中,我们将这一配置扩展为指定1.0核CPU和256 MB内存的上限。

例5-4 kuard-pod-reslim.yaml

在配置容器的上限时,内核建立了配置保障所消耗资源不能超过上限。上限为0.5核CPU的容器只能获取到0.5核,哪怕有再多的空闲CPU也是这样。内存上限为256 MB的容器无法使用更多的内存,比如在超过时256 MB时malloc 会失败。

使用数据卷持久化数据

在删除Pod或容器重启时,容器文件系统中的所有数据都会进行删除。这通常是好事,因为我们不希望无状态web应用所写入的内容有任何残留。在其它场景下,可访问到持久化磁盘存储是健康应用的一个重要组成部分。Kubernetes建模了这种持久化存储。

对Pod使用数据卷

在Pod声明文件中添加数据卷,要在配置中添加两段。第一段是新的spec.volumes版块。这个数组定义了Pod声明文件中容器可访问的所有数据卷。要注意不是所有容器都要求挂载Pod中定义的所有数据卷。第二段是容器定义中的volumeMounts 数组。这个数组定义了挂载到具体容器的数据卷以及所挂载的各容器的路径。注意Pod中的两个不同容器可在不同路径上挂载同一数据卷。

例5-5的声明文件中定义了一个名为kuard-data的新数据卷,kuard 容器会将其挂载到/data路径。

例5-5 kuard-pod-vol.yaml

Pod使用数据卷的不同方式

应用中有很多使用数据的方式。以及是其中的一部分以及Kubernetes的推荐模式:

通讯/同步
在第一个Pod示例中, 我们看到了两个容器如何使用共享数据卷来提供站点服务,同进又让其与远程Git进行同步 (图 5-1)。实现这一功能,Pod使用emptyDir数据卷。这种数据卷作用域在Pod的生命周期内,但可在两个容器间共享,开成一个Git同步工具与web服务容器之间通讯的基石。
缓存
应用可能会使用数据卷来提升性能,这种场景不要求应用操作的正确性。例如,应用保留了大图的预渲染缩略图。当然可通过原图进行重建,但这样缩略图的开销就会比较大。我们希望这种缓存在健康检测失败重启容器后仍然存在,因此emptyDir同样可适用这种缓存用例。
持久化数据
有时我们会使用数据卷来做真正的数据持久化 – 数据与具体Pod的生命周期无关,并且在节点出错或节点迁移到其它主机上时数据也能随节点迁移。实现这一功能, Kubernetes支持大量的远程网络存储数据卷,包括广泛支持的协议NFS和iSCS以及像 Amazon Elastic Block Store、Azure File和Azure Disk以及Google的Persistent Disk等云提供商的网络存储。
挂载宿主机文件系统
其它应用并不真的需要持久化数据卷,而是需要访问底层宿主机的文件系统。例如,可能需要访问文件系统中的/dev来执行对系统设备的块级访问。对于这类场景,Kubernetes支持hostPath数据卷,可将工作节点中的任意位置挂载到容器中。例 5-5使用了hostPath数据卷类型。主机上所创建的数据卷为/var/lib/kuard

下面是使用了NFS服务的示例:

持久化数据卷是一个比较高级的话题。我们在第16章会更深入地探讨这一问题。

综合应用

很多应用都是有状态的,因此必须要保留所有数据并且不管应用运行在哪台主机上都可以访问底层的存储数据卷。在前面也学习到,这可通过使用网络相关存储的持久化数据卷来实现。我们还希望保障全天候运行应用的健康实现,这意味着在将kuard 暴露能用户时容器应该是准备就绪的。

通过组合持久化数据卷、就绪性和活跃性探测以及资源限制,Kubernetes提供了运行可靠有状态应用所需要的所有功能。例5-6将所有这些串到了同一个声明文件中。

例5-6 kuard-pod-full.yaml

随着本文的不断递进,Pod声明文件也在不断增多。对应用所增加的每种能力都对应声明文件中的新版块。

小结

Pod是Kubernetes集群中运行的原子单元。由一个或多个共生运行的容器组成,创建Pod需要编写Pod声明文件并将其提交给Kubernetes API服务,使用命令行工具或(较少用到)通过直接对服务端做HTTP和JSON调用。

一旦将声明文件提交给了API服务,Kubernetes调度工具会查找适用于Pod的主机并将Pod调度到该主机上。在调度完成后,主机上的kubelet 守护进程负责创建Pod对应的容器,以及执行声明文件中定义的健康检查。

在Pod调度到了节点后,节点出问题前不会进行重新调度。此外,要创建同一Pod的多个副本,需要手动创建并对其命名。在第9章中,我们引入了ReplicaSet对象,展示了如何自动创建多个相同Pod并可保障节点主机故障时进行重建。

退出移动版