在 Kubernetes 集群中维护容器状态更像是一种艺术,而不是科学。原文: The Art and Science of Probing a Kubernetes Container
在 Kubernetes 集群中维护容器状态更像是一种艺术,而不是科学。
本文将带你深入理解容器探测,并特别关注相对较新的启动探测。在此过程中,通过文中的推荐链接,可以进一步了解相关领域,以实现文中的各种建议。
启动……不对……是在 Kubernetes 集群中请求启动新容器相对简单: 只需要为集群提供一个pod规范,尤其是封装了各种工作负载资源(比如Deployment或Job)的pod模板。在接收到 pod 规范后,kube-scheduler将为 pod 分配一个节点,然后该节点的kubelet负责启动 pod 中的容器。
pod 遵循明确的生命周期,其中就允许 kubelet 探测 pod 容器,以确保始终都有响应。探测器遵循如下契约: pod 容器通告端点,kubelet 从端点轮询其内部不同状态。
简单来说,有三种类型的探针来表示容器的内部状态:
- 准备就绪探针(Readiness probes): 该探针告诉 kubelet 容器何时准备好处理请求,是最普遍的容器探测。
- 活跃探针(Liveness probes): 该探针在紧急情况下会触发容器中断。kubelet 将终止在指定时间间隔内没有成功响应的容器,理想情况下,容器应该在意识到无法继续工作后退出,但在有 bug 的情况下很少能够优雅退出。
- 启动探针(Startup probes): 意思是"我刚到这里,别管我"。它告诉 kubelet 何时开始对 readiness 和 liveness 进行探测。
图 1: 容器探测和 pod 生命周期之间的关系
Readiness probes
如果容器未能响应其 readiness 探测,kubelet 将从服务负载均衡器中删除容器,将流量从容器中转移。在这种情况下,开发人员希望其他地方的副本可以处理流量。
readiness 探测的设计比较简单,只需要考虑依赖状态和容器中的资源使用情况:
- 依赖关系(Dependencies)。 假设容器依赖数据库服务器或另一个远程服务,在这种情况下,这些依赖项往往会提供一个端点或命令行接口来评估它们的就绪情况。如果依赖关系处于关键路径中,则在计算探测的 readiness 状态时需要考虑依赖关系状态。
- 允许的最大连接数。 许多框架都有接受多少新连接的阈值,因此考虑这些限制并在超过阈值时报告 readiness 失败。
- 系统资源。 这一点并不明显,但是在接近内存上限和文件系统空间不足的情况下运行会导致进程不稳定。我们希望集群在耗尽系统资源之前停止向 pod 发送流量,因此可以考虑在这些限制达到最大值的某个阈值时探测调用失败。我最不喜欢的资源耗尽形式之一是耗尽文件句柄,这比简单的磁盘空间不足更难发现,甚至可能阻碍最基本的故障排除任务。
要做:
- 实现探针。 始终为运行时容器定义 readiness 探测。可能你觉得容器客户机可以处理容器无响应问题。尽管如此,让集群将请求路由到还没有准备好处理请求的容器,从来都不是个好选择。
- 准备好状态。 考虑用单独的 readiness 线程向外部汇报远程依赖和资源利用状态。readiness 探测超时时,最好立即返回清晰的故障代码,而不是冒着暂停响应的风险从所有依赖项收集输入。
不要做:
- 不要超过 liveness 探针的探测时间。 如果容器也有 liveness 探测,不要使最大超时时间(
failureThreshold * periodSeconds
)超过 liveness 探测的最大时间。这会让集群将请求路由到可能没有希望的即将崩溃的容器。 - 不要把反应缓慢和 readiness 混在一起。 缓慢的反应也是一种反应。由于依赖关系处理请求的时间比通常要长得多,可能你会觉得容器需要让调用者知道它还没有准备好。不过,监视服务性能是应用级的关注点,最好通过可观察性解决。
Liveness probes
如果容器不能连续响应此探测,kubelet 将终止容器。具体是"终止"还是"重启",取决于 pod 的重启策略。
众所周知,对这些探针进行正确编码非常困难,因为探针开发人员的目标是预测意外情况,比如进程中的 bug 可能会使整个容器处于不可恢复状态这样的情况。
如果探测过于宽松,容器可能会处于无响应状态而不会被终止,从而减少了可以提供服务的 pod 副本数量。
如果探测过于严格,容器可能会一直被不必要的终止,这种情况在间歇性发生时很难察觉,当你排查问题时,pod 可能看起来很健康。
要做:
- 定义 liveness 探针。 没有 liveness 探测意味着容器可能会因为某个错误而永远无法响应,所以即使无法完全确定万无一失的 liveness 标准,还是要尝试定义。
- 监控资源。 覆盖文件系统空间、文件句柄和内存等资源。这些资源在耗尽时会发生容器锁定这样臭名昭著的现象。当内存利用率超过 90%时返回错误,比在达到 100%后完全失去响应更明智。探测有超时值,但最好返回清晰的失败代码,而不是让 kubelet 从超时来推断崩溃。
- 使用命令。 调用命令而不是 tcp 或 http 请求。这个建议可能有争议,但是调用 shell 命令将使用较低级别的接口,反过来又有更多机会评估容器内部状态。我遇到过一些网络服务器,即使由于内存不足而半死不活,仍然能够响应简单的 ping 请求。
- 微控制面(Micro control-planes)。 如果可能的话,使用与用于服务客户流量的连接池不同的连接池,或者专门为探测设置连接,将其设想为集群中与常规工作负载不同(但规模要小得多)的控制平面。除非 liveness 探针具有响应探测的专用连接,否则容器可能正在忙于服务客户流量(并通过 readiness 探针报告了未准备就绪状态)。在这种情况下终止容器对业务有害,因为集群最后(临时)只有更少的容器来处理业务流量。
- 准备好状态。 类似于 liveness 探针一节中的建议,考虑用单独的 liveness 探针服务线程向外部汇报活跃状态。liveness 探测超时时,最好立即返回清晰的故障代码,而不是冒着暂停响应的风险从所有依赖项收集输入。
不要做:
- readiness 不是 liveness: 不要检查依赖关系的可用性,如果失败,这是 readiness 探针的责任,终止 pod 不太可能有帮助。
- 不要重用 readiness 条件: 经常看到容器探测使用相同的端点,但在
failureThreshold
和periodSeconds
中使用不同的阈值。liveness 探测的关注点不同于 readiness 探测的关注点。容器可能由于外部因素而无法处理流量,而 liveness 探测使用与 readiness 探测相同的端点,可能会告诉 kubelet 终止容器,从而加剧问题。 - 超时时间超过 readiness 探测: 无论是查看
failureThreshold
、periodSeconds
还是考虑容器中系统资源的可用性,都要确保 liveness 探测的超时范围超过 readiness 探测的超时范围。例如,最大超时时间(initialDelaySeconds + failureThreshold * periodSeconds
)比 readiness 探测的最大超时时间更短会造成容器在仍然为远程请求服务的情况下被过早终止。 - 不要过于保守: 在有疑问时,宁可宽大处理,也要为
failureThreshold
、periodSeconds
和initialDelaySeconds
设置更高的值,给容器足够的自由度来报告生存状态。一个不错的经验法则是使用比 readiness 探针的最大超时时间长两倍或更长的总超时时间。意外挂起进程是一种边缘情况,比响应缓慢的进程更罕见,liveness 探测应该支持最常见的情况。
图 2: liveness 探测应该比 readiness 探测的时间更长。
Startup probes
startup 探针是容器探针中相对较新的成员,2020 年底在Kubernetes 1.20中达到了 GA。注意,这要归功于我的同事,博学的Nathan Brophy,他指出这个功能在 Kubernetes 1.18 尽管还处于测试阶段,但已经默认可用了。
startup 探测在容器生命周期中为需要大量时间才能准备就绪的容器创建了一个"缓冲区"。
在过去,在没有 startup 探测的情况下,开发人员使用初始化容器,并为 readiness 探测和 liveness 探测设置较长的initialDelaySeconds
值,每一种都有自己的权衡:
- 可以等待,但不应该等待。 初始化容器允许开发人员将特定工具和安全特权与运行时容器隔离开来,但这是一种等待外部条件的笨拙方式。初始化容器是独立容器,因此将持续运行直到完成,将它们的工作结果转移到 pod 中的其他容器中可能会很麻烦。
- 启动缓慢。 在 readiness 探测上通过
initialDelaySeconds
字段设置长时间等待会在容器启动期间浪费时间,因为 kubelet 总是需要在将流量发送到 pod 之前等待这么长时间。
考虑现有 liveness 探测和 readiness 探测是否需要相对较长的时间来启动,并将较大的initialDelaySeconds
值替换为等效的 startup 探测。
例如,下面的容器规范:
spec: containers: - name: myslowstarter image: ... ... readinessProbe: tcpSocket: port: 8080 initialDelaySeconds: 600 periodSeconds: 10 livenessProbe: tcpSocket: port: 8080 initialDelaySeconds: 600 periodSeconds: 20
复制代码
可以通过将延迟挪到 startup 探测中来显著改善,如下例所示:
spec: containers: - name: myslowstarter image: ... ... readinessProbe: tcpSocket: port: 8080 # i̵n̵i̵t̵i̵a̵l̵D̵e̵l̵a̵y̵S̵e̵c̵o̵n̵d̵s̵:̵ ̵6̵0̵0̵ periodSeconds: 10 livenessProbe: tcpSocket: port: 8080 # i̵n̵i̵t̵i̵a̵l̵D̵e̵l̵a̵y̵S̵e̵c̵o̵n̵d̵s̵:̵ ̵6̵0̵0̵ periodSeconds: 20 startupProbe: tcpSocket: port: 8080 failureThreshold: 60 periodSeconds: 10
复制代码
在第一个示例中,kubelet 在评估 readiness 和 liveness 之前等待 600 秒。相比之下,在第二个示例中,kubelet 以 10 秒的间隔检查最多 60 次,从而使容器能够在满足启动条件时立即启动。
在 startup 探测中频繁检查的隐藏好处是,允许开发人员为failureThreshold
和periodSeconds
设置较高的值,而不用担心减慢容器启动速度。相反,死板的设置initialDelaySeconds
给开发人员施加了压力,忽略了边缘情况,只能通过设置较低的值才能让整个应用更快启动。根据我的经验,"边缘情况"是"我们在开发过程中没有看到的东西"的同义词,这意味着在某些生产环境中会出现不稳定的容器。
根据经验,如果 readiness 探测和 liveness 探测中的initialDelaySeconds
字段超过failureThreshold * periodSeconds
字段指定的总时间,则使用 startup 探测。另一条经验法则是,readiness 探测或 liveness 探测中initialDelaySeconds
的时间如果超过 60 秒,就说明应用将受益于使用 startup 探测。
速度就是一切
在看到本文的建议后,你可能不可避免的会问下面的问题:
"那么我应该用什么来设置探针呢?"
我们通常希望 readiness 探针比较敏感,并且在容器开始难以响应请求时就报告失败。另一方面,希望 liveness 探针稍微宽松一点,只在代码失去对有效内部状态的控制时才报告失败。
对于timeoutSeconds
,我建议保持默认值(1 秒)。这个建议建立在另一个建议之上,即通过用于评估响应 kubelet 请求的线程之外的探针的响应。使用较高的值将扩大窗口,集群会将流量路由到无法处理请求的容器。
对于periodSeconds
和failureThreshold
的组合,在相同间隔内进行更多检查往往比较少检查更准确。假设我们遵循了将容器状态与响应请求的线程分开评估的建议,那么更频繁的检查不会给容器增加显著的开销。
注意 CPU 限制
不同的集群,不同的速度。
探测(尤其是 liveness 探测)的一个常见问题是,假设集群总是按照要求为容器提供足够的 CPU,另一个常见错误是假设集群总是能够精确观察到部分请求。
从 hypervisor 和承载工作节点的 VM 开始,一直到pod规范中的CPU限制,容器有无数原因可以以不同速度运行同一段代码。
以下是最容易让开发者措手不及的因素:
- hypervisor 中的 cpu 复用: IaaS 供应商的共享虚拟机,即使硬件和网络速度相同,也会偶尔受到"邻居"的影响,从而导致 CPU 使用量激增。现代 hypervisors 非常擅长补偿这样的突发状况,甚至会采取节流措施。尽管如此,IaaS 还是可能会超卖 CPU,并假设不会同时出现流量突发状况。
- 无穷小的 CPU 请求: 将 CPU 分配给容器的 CPU 限制设置为 20ms 似乎是一个负责任的、有意识的决定,因为容器很少进行任何处理。然而,在现实世界中,工作节点的 vCPU 并不只有完整 vCPU 大小的 2%。工作节点试图通过在短时间内为容器提供整个 vCPU 来模拟这个微小的 vCPU,从而导致对容器 CPU 分配产生碎片。因此,容器可能在短时间内运行比所请求的更多的 CPU 时间,然后暂停比预期更长的时间。
了解一些关于 IaaS 提供商的硬件特性和超卖设置的知识,可以帮助我们决定将安全乘数添加到timeoutSeconds
、failureThreshold
和periodSeconds
等设置中。在为探针,特别是 liveness 探针设置时,请记住这两个因素。根据所了解的内容,还可以重新考虑 CPU requests 和 limits 的设置,以便探针有足够的处理能力及时响应请求。
图 3: 由于严格的 CPU 限制而终止正常运行的容器
结论
本文提供了一系列建议来提高容器探测的精度和性能,使容器能够更快启动并运行更长时间。
下一步是仔细分析容器中运行的内容,并研究在不同集群和条件下的实际运行时行为,模拟依赖关系失败或者降低系统资源可用性。使用 kubectl 及其格式化和过滤内容的功能,是查找重新启动多次以及探测不充分的容器的好方法,在The art and science of probing a Kubernetes container, part 2: kubectl queries这篇文章中有更多相关技术内容。将 PromQL 与 Kubernetes 指标结合使用可以用各种时间序列图表进一步扩展该技术,这也是那篇文章的主题。
总之,在编写探针时牢记目标,并确保快速可靠的运行,在对 kubelet 的响应中以尽可能(如果有的话)精确的方式提供清晰的信息。然后,信任集群会以最佳方式处理数据,确保容器对其客户端提供最大的可用性。
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。
微信公众号:DeepNoMind