之前,我写过一篇Kubernetes Liveness 和 Readiness 探测避免给自己挖坑续集,描述了 Kubernetes 的 liveness 和 readiness 探针如何无意中降低服务可用性,或者导致长时间的中断。
Kubernetes liveness 和 readiness 探针是旨在提高服务可靠性和可用性的工具。但是,如果不考虑整个系统的动态,尤其是异常动态,你就有可能使服务的可靠性和可用性变得更差,而不是更好。我遇到了更多的情况,在这些情况下,liveness 和 readiness 探针可能会无意中降低服务可用性。我将在本文中扩展其中两个案例。
没有活性探针的就绪探针
下面定义的服务器(在 Scala 中使用 Akka HTTP)会在处理请求之前将大型缓存加载到内存中。加载缓存后,原子变量loaded
设置为true
. 请注意,与我之前的示例不同,我修改了程序以记录错误并在加载缓存失败时继续运行。我将在本文后面详细说明我这样做的原因。
object CacheServer extends App with CacheServerRoutes with CacheServerProbeRoutes with StrictLogging {
implicit val system = ActorSystem()
implicit val materializer = ActorMaterializer()
implicit val executionContext = ExecutionContext.Implicits.global
val routes: Route = cacheRoutes ~ probeRoutes
Http().bindAndHandle(routes, "0.0.0.0", 8888)
val loaded = new AtomicBoolean(false)
val cache = Cache()
cache.load().onComplete {
case Success(_) => loaded.set(true)
case Failure(ex) => logger.error(s"Failed to load cache : $ex")
}
}
由于缓存可能需要几分钟才能加载,因此 Kubernetes 部署定义了一个就绪探针,以便在 Pod 能够响应请求之前,请求不会被路由到 Pod。
spec:
containers:
- name: linuxea-server
image: linuxea-server/latest
readinessProbe:
httpGet:
path: /readiness
port: 8888
periodSeconds: 60
为就绪探测服务的 HTTP 路由定义如下。
trait CacheServerProbeRoutes {
def loaded: AtomicBoolean
val probeRoutes: Route = path("readiness") {
get {
if (loaded.get) complete(StatusCodes.OK)
else complete(StatusCodes.ServiceUnavailable)
}
}
}
返回缓存中给定标识符的值的 HTTP 路由定义如下。如果缓存尚未加载,服务器将返回 HTTP 503,服务不可用。如果缓存已加载,服务器将在缓存中查找标识符并返回值,如果标识符不存在,则返回 HTTP 404, Not Found。
trait CacheServerRoutes {
def loaded: AtomicBoolean
def cache: Cache
val cacheRoutes: Route = path("cache" / IntNumber) { id =>
get {
if (!loaded.get) {
complete(StatusCodes.ServiceUnavailable)
}
cache.get(id) match {
case Some(body) =>
complete(HttpEntity(ContentTypes.`application/json`, body))
case None =>
complete(StatusCodes.NotFound)
}
}
}
}
考虑如果缓存加载失败会发生什么。该服务仅在启动时尝试加载一次缓存。如果它无法加载缓存,它会记录一条错误消息,但服务会继续运行。如果缓存没有加载,readiness-probe 路由不会返回成功的 HTTP 状态码,因此,Pod永远不会准备好。支持该服务的 pod 可能如下所示。所有五个 Pod 的状态均为Running
,但五个 Pod 中只有两个准备好处理请求,尽管运行了超过 50 分钟
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
linuxea-server-674c544685-5x64f 0/1 Running 0 52m
linuxea-server-674c544685-bk5mk 1/1 Running 0 54m
linuxea-server-674c544685-ggh4j 0/1 Running 0 53m
linuxea-server-674c544685-m7pcb 0/1 Running 0 52m
linuxea-server-674c544685-rtbhw 1/1 Running 0 52m
这提出了一个潜在的问题。该服务可能看起来运行正常,服务级别的健康检查响应成功,但可用于处理请求的 pod 数量少于预期。正如我在上一篇文章中提到的,我们还需要考虑的不仅仅是初始部署。Pod 将在集群中重新平衡或 Kubernetes 节点重新启动时重新启动。随着 pod 重新启动,服务最终可能会变得完全不可用,特别是如果同时发生的事件(例如对象存储暂时不可用)阻止缓存同时加载到所有 pod 上。
相反,考虑一下如果这个部署也有一个活动探测来练习cache
路由会发生什么。
spec:
containers:
- name: linuxea-server
image: linuxea-server/latest
readinessProbe:
httpGet:
path: /readiness
port: 8888
periodSeconds: 60
livenessProbe:
httpGet:
path: /cache/42
port: 8080
initialDelaySeconds: 300
periodSeconds: 60
如果缓存加载失败,liveness 探测最终会失败,容器将重新启动,给它另一个加载缓存的机会。最终,缓存应该会成功加载,这意味着服务将自行恢复正常运行,而无需提醒他人进行干预。pod 可能会重新启动多次,直到阻止缓存加载的瞬态最终消失。输出kubectl get pods
可能如下所示,其中所有 pod 都已准备就绪,但某些 pod 已多次重新启动。
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
linuxea-server-7597c6d795-g4tzg 1/1 Running 0 10d
linuxea-server-7597c6d795-jhp4s 1/1 Running 4 32m
linuxea-server-7597c6d795-k9szq 1/1 Running 3 32m
linuxea-server-7597c6d795-nd498 1/1 Running 3 32m
linuxea-server-7597c6d795-q6mbv 1/1 Running 0 10d
由于缓存需要几分钟的时间来加载,因此活性探针的初始延迟很重要,使用initialDelaySeconds
, 比加载缓存所需的时间更长,否则会有永远不会启动 pod 的风险,正如我在之前的文章中详述的那样。
与我刚刚介绍的示例类似,对于有可能因死锁而变得不可用的服务器,除了准备就绪探针之外,配置活性探针同样重要,否则它可能会遇到相同的问题。
允许崩溃
我刚刚介绍的示例的一个问题是,服务器在加载缓存失败时会尝试处理错误,而不是仅仅抛出异常并退出进程。对处理错误的强调来自编程模型,在这种模型中,程序自行恢复或以这样一种方式处理异常很重要,即在一个线程上执行的工作不会影响在另一个线程上执行的工作。想想用 C++ 编写的多线程应用程序服务器或设备驱动程序。处理错误对于在发生故障后清理资源(如内存分配或文件句柄)也很重要。这种编程风格继续产生很大影响——也许在某种程度上可以理解。但是,存在用于处理错误的替代编程模型——如函数式编程语言中的单子错误处理,或角色模型中的细粒度监督策略,如 Erlang、Akka 和 Microsoft Orleans,它们是为可靠的分布式计算而设计的.
Erlang 编程语言的共同创造者乔·阿姆斯特朗(Joe Armstrong)在他的博士论文题为“在存在软件错误的情况下构建可靠的分布式系统”中质疑什么是错误:
但是什么是错误?出于编程目的,我们可以这样说:
- 当运行时系统不知道该做什么时,就会发生异常。
- 当程序员不知道该做什么时,就会发生错误。
如果运行时系统产生了异常,但程序员已经预见到这一点并且知道如何纠正导致异常的条件,那么这不是错误。例如,打开一个不存在的文件可能会导致异常,但程序员可能会认为这不是错误。因此,他们编写了捕获此异常并采取必要纠正措施的代码。
当程序员不知道该做什么时,就会发生错误。
这告知了 程序员在发生错误时应该做什么的看法:
我们处理错误的理念如何与编码实践相适应?程序员在发现错误时必须编写什么样的代码?哲学是:让其他进程修复错误,但这对他们的代码意味着什么?答案是:让它崩溃。我的意思是,如果发生错误,程序应该会崩溃。
如果硬件故障需要立即采取任何管理措施,则服务根本无法经济有效且可靠地扩展。整个服务必须能够在没有人工管理交互的情况下幸免于难。故障恢复必须是一条非常简单的路径,并且必须经常测试该路径。斯坦福大学的 Armando Fox 认为,测试故障路径的最佳方法是永远不要正常关闭服务。只是硬失败。这听起来违反直觉,但如果不经常使用故障路径,它们将在需要时不起作用。
这正是活跃性和就绪性探测的重点:无需立即采取行政措施即可处理故障。
阿姆斯特朗认为进程应该“尽快完成它们应该做的事情或失败”,并且重要的是“可以通过远程进程检测到失败和失败的原因”。回到我的例子,如果程序在加载缓存失败后简单地退出,默认情况下,Kubernetes 会检测到容器已经崩溃并重新启动它,并具有指数回退延迟。最终,缓存应该会成功加载,实现与配置活性探针相同的结果,就像前面的示例一样。
通过退出容器或利用 liveness 探针重新启动容器可能会提高服务的可靠性和可用性,但监控容器重新启动可能很重要,以最终解决潜在问题。
作为程序员,在决定如何处理错误时,你需要考虑所有可用的工具,包括运行时环境中的工具,而不仅仅是编程语言或框架的原生工具。由于 Kubernetes 会自动重启容器,并且这样做会带来指数回退延迟的额外好处,所以当你遇到错误时最可靠的做法可能就是让它崩溃!
延伸阅读
linuxea:Kubernetes Liveness 和 Readiness 探测避免给自己挖坑续集
linuxea:Kubernetes探针补充