驱逐机制

前面笔者动不动就提要杀掉某个 Pod,听起来实在是欠优雅的,在 Kubernetes 中专业的称呼是“驱逐”(Eviction,即资源回收)。Pod 的驱逐机制是通过 kubelet 来执行的,kubelet 是部署在每个节点的集群管理程序,由于本身就运行在节点中,所以最容易感知到节点的资源实时耗用情况。kubelet 一旦发现某种不可压缩资源将要耗尽,就会主动终止节点上较低服务质量等级的 Pod,以保证其他更重要的 Pod 的安全。被驱逐的 Pod 中所有的容器都会被终止,Pod 的状态会被更改为 Failed。

我们已经接触过内存这一种最重要的不可压缩资源,默认配置下,前面所说的“资源即将耗尽”的“即将”,具体阈值是可用内存小于 100 Mi。除了可用内存(memory.available)外,其他不可压缩资源还包括有:宿主机的可用磁盘空间(nodefs.available)、文件系统可用inode数量(nodefs.inodesFree),以及可用的容器运行时镜像存储空间(imagefs.available)。后面三个的阈值都是按照实际容量的百分比来计算的,具体的默认值如下:

memory.available < 100Mi
nodefs.available < 10%
nodefs.inodesFree < 5%
imagefs.available < 15%

管理员可以在 kubelet 启动时,通过命令行参数来修改这些默认值,譬如可用内存只剩余 100 Mi 时才启动驱逐对于多数生产系统来说都过于危险了,笔者建议在生产环境中考虑通过以下命令调整为剩余 10%内存时即开始驱逐:

$ kubelet --eviction-hard=memory.available<10%

如果你是一名 Java、C#、Golang 等习惯了自动内存管理机制的程序员,笔者还要提醒你 Kubernetes 的驱逐不能完全等同于编程语言中的垃圾收集器。垃圾收集是安全的内存回收行为,而驱逐 Pod 是一种毁坏性的清理行为,有可能会导致服务产生中断,必须更加谨慎。譬如,要同时兼顾到硬件资源可能只是短时间内间歇性地超过了阈值的场景,以及资源正在被快速消耗,很快就会危及高服务质量的 Pod 甚至是整个节点稳定的场景。因此,驱逐机制中就有了软驱逐 (Soft Eviction)、硬驱逐 (Hard Eviction)以及优雅退出期 (Grace Period)的概念:

  • 软驱逐 :通常配置一个较低的警戒线(譬如可用内存仅剩 20%),触及此线时,系统将进入一段观察期。如果只是暂时的资源抖动,在观察期内能够恢复到正常水平的话,那就不会真正启动驱逐操作。否则,资源持续超过警戒线一段时间,就会触发 Pod 的优雅退出(Grace Shutdown),系统会通知 Pod 进行必要的清理工作(譬如将缓存的数据落盘),然后自行结束。在优雅退出期结束后,系统会强制杀掉还未曾自行了断的 Pod。
  • 硬驱逐 :通常配置一个较高的终止线(譬如可用内存仅剩 10%),一旦触及此红线,立即强制杀掉 Pod,不理会优雅退出。

软驱逐是为了减少资源抖动对服务的影响,硬驱逐是为了保障核心系统的稳定,它们并不矛盾,一般会同时使用,譬如以下例子所示:

$ kubelet --eviction-hard=memory.available<10% \
		  --eviction-soft=memory.available<20% \
		  --eviction-soft-grace-period=memory.available=1m30s \
		  --eviction-max-pod-grace-period=600

Kubernetes 的驱逐与编程语言中垃圾收集器另一个不同之处是垃圾收集可以“应收尽收”,而驱逐显然不行,不能无缘无故把整个节点中所有可驱逐的 Pod 都清空掉。但是,通常也不能只清理到刚刚低于警戒线就停止,必须考虑到驱逐之后的新 Pod 调度与旧 Pod 运行的新增消耗。譬如 kubelet 驱逐了若干个 Pod,让资源使用率勉强低于阈值,那么很可能在极短的时间内,资源使用率又会因某个 Pod 稍微占用了些许资源而重新超过阈值,再产生新一次驱逐,如此往复。为此,Kubernetes 提供了--eviction-minimum-reclaim参数用于设置一旦驱逐发生之后,至少清理出来多少资源才会终止。

不过,问题到这里还是没有全部解决,Kubernetes 中很少会单独创建 Pod,通常都是由 ReplicaSet、Deployment 等更高层资源来管理的,这意味着当 Pod 被驱逐之后,它不会从此彻底消失,Kubernetes 将自动生成一个新的 Pod 来取代,并经过调度选择一个节点继续运行。如果没有额外的处理,那很大概率这个 Pod 会被系统调度到当前这个节点上重新创建,因为上一次调度就选择了这个节点,而且这个节点刚刚驱逐完一批 Pod 得到了空闲资源,那它显然应该符合此 Pod 的调度需求。为了避免被驱逐的 Pod 出现“阴魂不散”的问题,Kubernetes 还提供了另一个参数--eviction-pressure-transition-period来约束调度器,在驱逐发生之后多长时间内不得往该节点调度 Pod。

关于驱逐机制,你还应该意识到,这些措施既然被设计为以参数的形式开启,就说明了它们一定不是放之四海皆准的通用准则。举个例子,假设当前 Pod 是由 DaemonSet 控制的,一旦该 Pod 被驱逐,你又强行不允许节点在一段时间内接受调度,那显然这就有违 DaemonSet 的语义。目前 Kubernetes 并没有办法区分 Pod 是由 DaemonSet 抑或是别的高层次资源创建的,因此这种假设情况确实有可能发生,比较合理的方案是让 DaemonSet 创建 Guaranteed 而不是 BestEffort 的 Pod。总而言之,在 Kubernetes 还没有成熟到变为“傻瓜式”容器编排系统之前,因地制宜地合理配置和运维是都非常必要的。

最后,关于服务质量、优先级、驱逐机制这些概念,都是在 Pod 层面上限制资源,是仅针对单个 Pod 的低层次约束,现实中我们还常会遇到面向更高层次去控制资源的需求,譬如,想限制由多个 Pod 构成的微服务系统耗用的总资源,或者是由多名成员组成的团队耗用的总资源。举个具体例子,想要在拥有 32 GiB 内存和 16 个处理器的集群里,允许 A 团队使用 20 GiB 内存和 10 个处理器的资源,再允许 B 团队使用 10 GiB 内存和 4 个处理器的资源,再预留 2 GiB 内存和 2 个处理器供将来分配。要满足这种资源限制的需求,Kubernetes 的解决方案是应该先为它门建立一个专用的名称空间,然后再在名称空间里建立 ResourceQuota 对象来描述如何进行整体的资源约束。

但是 ResourceQuota 与调度就没有直接关系了,它针对的对象也不是 Pod,所以这里所说的资源可以是广义上的资源,不仅能够设置处理器、内存等物理资源的限额,还可以设置诸如 Pod 最大数量、ReplicaSet 最大数量、Service 最大数量、全部 PersistentVolumeClaim 的总存储容量等各种抽象资源的限额。甚至当 Kubernetes 预置的资源模型不能满足约束需要时,还能够根据实际情况去拓展,譬如要控制 GPU 的使用数量,完全可以通过 Kubernetes 的设备插件(Device Plugin)机制拓展出诸如nvidia.com/gpu: 4这样的配置来。