linuxea:重新审视kubernetes活跃探针和就绪探针 如何避免给自己挖坑2

marksugar
2022-02-19 / 0 评论 / 701 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2022年02月19日,已超过280天没有更新,若内容或图片失效,请留言反馈。

之前,我写过一篇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 会自动重启容器,并且这样做会带来指数回退延迟的额外好处,所以当你遇到错误时最可靠的做法可能就是让它崩溃!

  1. 例如,Google C++ 样式指南不鼓励使用异常,因为将引发异常的代码安全地合并到遵循无异常约定的大量现有代码中是不切实际的,例如使用错误代码和断言
  2. Go 没有异常,但它确实强制程序员显式处理错误,否则无法编译。当然,程序员仍然可以随意忽略这些错误。

延伸阅读

linuxea:Kubernetes Liveness 和 Readiness 探测避免给自己挖坑续集

linuxea:Kubernetes探针补充

0

评论

博主关闭了当前页面的评论