之前已经有很多文章讨论就绪(readiness)和存活(liveness)检查了,我不打算再讨论这个问题。相反,我想非常具体地介绍它们在大型微服务架构中的应用。
由于 Kubernetes 被广泛应用于托管这类应用程序,我此前认为这一领域也已经有很好的讨论结果了。但是,在网络中并没有查找到很多相关内容,尤其是在微服务领域实现就绪检查这一方面。最近与一些客户交流的经验表明,人们在使用和正确实现就绪检查方面存在一定程度的误解。
已有的大部分内容都倾向于关注如何在服务中实现就绪和存活检查,且通常与数据库、缓存或其他所依赖的基础设施的使用有关。当应用依赖其他服务时,就绪和存活检查应该如何实现呢?这是我想进一步探讨的领域。
让我们举一个简单的例子,服务 A 暴露一个公共的 REST API,为了实现这个 API,服务 A 调用了服务 B。当为服务 A 实现就绪检查的时候,你或许尝试引入一个对服务 B 的调用,来检查服务 B 是否可用(假设你使用就绪端点)。从表面上看,这似乎有道理。你需要服务 B 可用,以便能够响应请求。为此,你可能认为可以调用服务 B 上的一个健康检查端点。
现在我只想说这是个坏主意。
容器、pod 和 service
为了理解其中的原因,让我们后退一步,看看 Kubernetes 如何使用就绪和存活检查,以及在编写它们时要考虑哪些问题。Kubernetes 文档声明:
kubelet 使用存活探针来了解何时重启容器……kubelet 使用就绪探针来了解容器何时准备开始接受流量
这段引文中的关键词是容器。这些检查是为了在 Kubernetes 中检查单个容器的就绪或存活状态。在这个背景下,这看起来很明显。让我们设想一下,服务 B 通过 REST 终端实现了就绪检查。一切都很好,作为一个开发人员,我看到服务 B 的 REST API,想从服务 A 调用它;我看到这个看起来不错的端点叫做 /health/ready。正是这个原因促使我们从服务 A 的就绪检查中调用服务 B 的 /health/ready,以确定我们是否能够实际处理流量。
这里就会出问题了。kubelet 将在单个容器上调用 /health/ready,并将使用该结果来管理该容器(或 pod)。然而,当你调用 /health/ready 时,你有可能不是在 pod 上调用它。你实际调用的是 Kubernetes 的 service 对象(或者甚至是随后调用 service 的路由),它是跨许多个 pod 的负载均衡器。
虽然这似乎是一个微不足道的区别,但现在发生的情况是,/health/ready 端点现在试图为两个目的服务。首先由 Kubernetes 来确定容器的状态,其次由服务的用户来确定服务是否可用。第一个是有意义的,因为 pod 可以确定自己的状态并作出适当的响应。第二种不作为单个 pod 的就绪状态,并不需要对应于整个服务的就绪状态。
当一个 pod 所有的容器都就绪,它就被认为已经就绪。这个信号的一个用途是决定哪些 pod 被用作服务的后端。
现在,你可能会争辩说,根据 Kubernetes 文档的这一陈述,我们可以假设任何未就绪的 pod 都不能响应,因为不会有任何路由到它们的流量。听起来很好对吧?
但是不要忘记,Kubernetes 只会每隔一段时间进行一次就绪检查。这意味着在任何给定的时间,当你调用 /health/ready 端点时,你得到的答案可能与 Kubelet 最后一次调用它时收到的响应不同。我同意这种情况 99% 不会发生,大多数时候调用该端点得到的结果都是一样的。
让我们想想什么时候会有所不同:当你的系统在高负载下,在边缘情况和数据争用情况下。正是在这种情况下,你需要一种机制来帮助提高它的可靠性。
以这种方式超载就绪端点将在你需要稳定性的时候导致不可预测的行为。
还值得记住的是,Kubernetes 只能重新启动容器,如果你在一个 pod 中运行多个容器,可能并不会导致 pod 重启。
你仍然需要失败处理逻辑
现在,如果你通过 Kubernetes service 访问 /health/ready 端点,那么你是在隐式地验证服务的状态,这是正确的。如果至少有一个 pod 可用,你将得到一个响应。现在确实是这样(上面提到了警告),但它仍然不是一个好主意。
让我们暂时忽略上面提到的就绪检查过载。假设你使用服务 B 的 /health/ready 端点来实现服务 A 的就绪检查,现在,如果服务 B 变得不可用,Kubernetes 也会停止路由到服务 A 的流量,而你不需要担心处理来自服务 B 的响应中的错误,对吗?错了。
如上所述,就绪探针将每隔一段时间运行一次。例如,如果我们将此值设置为 2 秒(相当激进的设置),这仍然意味着在 2 秒的间隔内,服务 B 可能已经宕机,而 Kubernetes 仍然将流量路由到服务 A 中的 pod,而你的用户正在等待响应。
这意味着你仍然需要编写恰当的代码来处理依赖项不可用的情况。不管这是通过重试、故障转移还是减少功能,你的代码仍然需要处理不可用的依赖资源。
就绪失效的代价过于沉重
即使你愿意在这个时间窗口内接受失败的响应,一旦 Kubernetes 停止路由流量到你的 pod,你的应用的行为可能仍然与预期不同。
假设服务 B 不再可用,这意味着服务 A 中的任何 pod 都不能使用它。这将导致没有可用 pod 以接收流量。服务 A 的调用者调用服务 A 的任何端点都将收到 HTTP404(或者是 HTTP500,我忘记了)响应代码。
就连不依赖服务 B 的端点也会遇到这种问题。
从调用者的角度(包括您的监视系统)来看,该服务根本不再可用。如果您不使用就绪探针,则可以向调用方提供降级的功能,或者至少可以提供一条有用得多的错误信息,指向系统不可用的确切部分。这将大大加快问题识别和解决的速度,并为用户提供更友好的 API。
级联故障
在一个微服务体系结构中,有多少实际系统(尤其是企业系统)只包含两个微服务?我想(或者我希望?)不会很多。在现实中,生态系统中的微服务数量将大大增多,它们之间的交互和依赖也将更加复杂。
你可能有一个服务被多个服务所依赖,或者有三到四个服务的依赖链。
在这样的情况下,编写就绪检查(存活检查完全不行,但这超出了本文的范围)来检查您所依赖的服务的状态,将导致整个系统存在潜在的级联故障风险,而不是为系统提供弹性。
举一个简单的生造例子。假设有一个电子商务系统,其中有三个服务分别处理发货状态、当前订单和历史订单(我确实说过,这相当不自然!)用户可以直接与其中任何一个服务交互。
发货状态服务将同时被当前订单服务和历史订单服务使用。历史订单服务将由当前订单服务使用(将新订单存入历史记录)。
在这种情况下,假设你要为每个服务编写就绪检查,如下所示(伪代码)
历史订单服务
发货状态服务
当前订单服务
考虑到上面描述的依赖关系,乍一看,这对你来说可能是合理的。但是,现在假设历史订单服务与数据库失去连接,历史订单服务上的所有 pod 将无法通过就绪检查,导致 Kubernetes 停止路由任何流量到该服务。
理想情况下,你希望你的用户在这种情况下仍然能够看到当前正在进行的订单及其发货状态,对吗?系统应当在任何给定的时间提供最多的功能。
然而,有了上述就绪检查,实际发生的情况是,发货服务和当前订单服务都将无法通过就绪检查,Kubernetes 将停止向这两个服务路由流量,而且每次对系统的调用都将返回一个 HTTP404。仅仅因为无法查看订单历史记录,就将导致整个系统实际上变得不可用。这并不是预期的结果。
更复杂的是,监控基础设施还将显示整个系统处于宕机状态,如果不查看日志,就不可能确定问题出于系统中的哪个位置。相反,如果就绪检查隔离性更好,那么系统将只会丢失订单历史记录功能,监控系统也将准确地指出问题所在。
总结
简而言之,不要采用这样的方式在你自己的就绪端点中引入对其他服务就绪端点的调用。
在边缘情况和高负载情况下,如此使用就绪端点将导致无法解释的行为,这会增加你编写代码的数量和复杂性,因为你仍然需要处理服务响应中的故障。一个非常粗粒度的控制可能导致整个系统的级联故障。
在编写就绪端点时,只关注它所运行的特定 pod 需要关注的内容。相反,把精力花在编写出色的错误处理上,并确保假定你所依赖的每个依赖项都可能失败,并在某个时刻不可用。考虑这些边缘情况,以及如何在这些边缘条件下运行,你会构建一个更有弹性的系统。
原文链接
评论