Kubernetes Liveness 和 Readiness探针可用于通过减少运行问题和提高服务质量来使服务更健壮和更具弹性。但是,如果不仔细设置这些探针,则它们可能会严重降低服务的整体运行性能。
在本文中,我将探讨在实现KubernetesLiveness 和 Readiness探针时如何避免使服务可靠性变差。虽然本文的重点是Kubernetes,但我将重点介绍的概念适用于用于推断服务的运行状况并采取自动补救措施的任何应用程序或基础设施机制。
Kubernetes Liveness 和 Readiness Probes
Kubernetes使用Liveness探针来决定何时重新启动容器。如果容器没有响应(可能是由于多线程缺陷而导致应用程序死锁),则尽管程序代码存在缺陷,重新启动容器仍可以使应用程序可用。无疑,它比在半夜安排运维来重新启动容器要好。
Kubernetes使用Readiness探针来确定容器何时可用于接受流量。Readiness探针用于控制将哪些Pod用作服务的后端。当Pod的所有容器都准备就绪时,将其视为就绪。如果未准备就绪,则将其从服务负载平衡器中删除。例如,如果某个容器在启动时加载了较大的缓存,并且花了几分钟启动,那么您不希望在该容器就绪之前将请求发送到该容器,否则请求将失败,您希望将请求路由到其他容器。能够处理请求。
在撰写本文时,Kubernetes支持三种用于实现Liveness 和 Readiness探针的机制:1)在容器内运行命令,2)针对容器发出HTTP请求,或3)针对容器打开TCP套接字。
探针具有许多配置参数来控制其行为,例如执行探针的频率。启动容器后启动探针要等待多长时间;探测失败之前经过的秒数;以及在放弃之前探针可以失败多少次。对于Liveness探针,放弃意味着将重新启动Pod。对于Readiness探针,放弃意味着不将流量路由到Pod,但是Pod不会重新启动。Liveness 和 Readiness探针可以结合使用。
Readiness Probes最佳实践
Kubernetes文档以及许多博客文章和示例在某种程度上误导了人们在启动容器时强调了Readiness探针的使用。通常这是最常见的考虑因素-我们希望避免在将Pod准备好接受流量之前将请求路由到该Pod。但是,在容器的整个生命周期中将周期性(periodSeconds
)调用Readiness探针,以便当容器中的某个依赖项不可用时,或者在运行大型批处理作业,执行维护等操作时,容器可以使其自身暂时不可用。
如果您没有意识到启动容器后将继续调用Readiness探针,这些探针可能在运行时导致严重问题。即使您了解这种行为,但如果准备Readiness探针未考虑异常的系统动态,您仍然可能会遇到严重的问题。我将通过一个例子来说明这一点。
下面的应用程序是在Scala中使用Akka HTTP实现的,它会在启动之前将大缓存加载到内存中,然后才能处理请求。加载缓存后,将加载的原子变量设置为true。如果缓存加载失败,该容器将退出并由Kubernetes重新启动,并具有指数退避延迟。
object CacheServer extends App with CacheServerRoutes with CacheServerProbeRoutes {
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) =>
system.terminate().onComplete {
sys.error(s"Failed to load cache : $ex")
}
}
}
该应用程序将以下 /readiness
HTTP路由用于Kubernetes Readiness探针。如果加载了缓存,则 /readiness
路由将始终成功返回。
trait CacheServerProbeRoutes {
def loaded: AtomicBoolean
val probeRoutes: Route = path("readiness") {
get {
if (loaded.get) complete(StatusCodes.OK)
else complete(StatusCodes.ServiceUnavailable)
}
}
}
HTTP Readiness 探针的配置如下:
spec:
containers:
- name: cache-server
image: cache-server/latest
readinessProbe:
httpGet:
path: /readiness
port: 8888
initialDelaySeconds: 300
periodSeconds: 30
这种Readiness探针实现极为可靠。在加载缓存之前,请求不会路由到应用程序。加载缓存后,/readiness
路由将永久返回HTTP 200,并且将始终将pod视为就绪。
将此实现与以下应用程序进行对比,该应用程序向其依赖服务发出HTTP请求,这是其准备情况检查的一部分。像这样的Readiness探针对于在部署时捕获配置问题很有用,例如使用错误的证书进行双向TLS或错误的凭据进行数据库身份验证,以确保服务在准备就绪之前可以与其所有依赖项进行通信。
trait ServerWithDependenciesProbeRoutes {
implicit def ec: ExecutionContext
def httpClient: HttpRequest => Future[HttpResponse]
private def httpReadinessRequest(
uri: Uri,
f: HttpRequest => Future[HttpResponse] = httpClient): Future[HttpResponse] = {
f(HttpRequest(method = HttpMethods.HEAD, uri = uri))
}
private def checkStatusCode(response: Try[HttpResponse]): Try[Unit] = {
response match {
case Success(x) if x.status == StatusCodes.OK => Success(())
case Success(x) if x.status != StatusCodes.OK => Failure(HttpStatusCodeException(x.status))
case Failure(ex) => Failure(HttpClientException(ex))
}
}
private def readinessProbe() = {
val authorizationCheck = httpReadinessRequest("https://authorization.service").transform(checkStatusCode)
val inventoryCheck = httpReadinessRequest("https://inventory.service").transform(checkStatusCode)
val telemetryCheck = httpReadinessRequest("https://telemetry.service").transform(checkStatusCode)
val result = for {
authorizationResult <- authorizationCheck
inventoryResult <- inventoryCheck
telemetryResult <- telemetryCheck
} yield (authorizationResult, inventoryResult, telemetryResult)
result
}
val probeRoutes: Route = path("readiness") {
get {
onComplete(readinessProbe()) {
case Success(_) => complete(StatusCodes.OK)
case Failure(_) => complete(StatusCodes.ServiceUnavailable)
}
}
}
}
这些并发的HTTP请求通常以毫秒为单位非常快速地返回。Readiness探针的默认超时为一秒。由于这些请求在绝大多数时间都成功,因此大多时候默认值就可以满足。
但是请考虑一下,如果某个临时服务的延迟稍有暂时的增加,该怎么办?可能是由于网络拥塞,垃圾收集暂停或临时增加了相关服务的负载。如果对依赖项的等待时间增加到甚至稍大于一秒,则准备就绪探测将失败并且Kubernetes将不再将流量路由到Pod。由于所有Pod都共享相同的依赖关系,因此支持该服务的所有Pod很可能会同时使“就绪性”探测失败。这将导致所有Pod从服务路由中删除。没有Pod支持该服务,Kubernetes将针对所有对该服务的请求返回HTTP 404(默认后端)。尽管我们已尽最大努力提高可用性,但我们已经创建了单点故障,使服务完全不可用。在这种情况下,我们将通过使客户端请求成功(尽管延迟稍有增加)来提供更好的最终用户体验,而不是一次或几秒钟地使整个服务不可用。
如果Readiness探针正在验证容器专有的依赖关系(私有缓存或数据库),则可以假设容器依赖项是独立的,那么您可以更加积极地使Readiness探针失败。但是,如果Readiness探针正在验证共享依赖关系(例如用于身份验证,授权,指标,日志记录或元数据的通用服务),则在使Readiness探针失败时应该非常保守。
所以建议如下:
- 如果容器在Readiness探针中包含了共享的依赖关系,则将就绪探针超时设置为大于该依赖关系的最大响应时间。
- 默认的
failThreshold
计数为3,即Readiness探针在不再将Pod视为就绪之前探测失败的次数。Readiness探针的频率(由periodSeconds
参数确定),您可能需要增加failureThreshold
计数。这样做的目的是避免在临时系统故障已经过去并且响应等待时间恢复正常之前,过早地使Readiness探针失败。
Liveness Probes 最佳实践
回想一下,Liveness探针故障将导致容器重新启动。与Readiness探针不同,Liveness探针检测依赖项是非常危险的。应使用Liveness探针检查容器本身是否没有响应。
Liveness探针的一个问题是,探针可能实际上无法验证服务的响应能力。例如,如果服务托管两台Web服务器-一台用于服务路由,一台用于状态路由(如readiness和liveness或指标收集),则该服务可能会变慢或无响应,而Liveness探针路由会返回正常。为了有效,Liveness探针必须以与依赖服务类似的方式设置。
与Readiness 探针类似,考虑随时间变化的动态变化也很重要。如果Liveness探针超时太短,则响应时间的少量增加(可能是负载的暂时增加所引起的)可能会导致容器重新启动。重新启动可能会为支持该服务的其他Pod带来更多负载,从而导致Liveness探针故障进一步级联,从而使服务的整体可用性变得更糟。按客户端超时的顺序配置Liveness探针超时,并使用failureThreshold
计数,可以防止这些级联失败。
Liveness探针的一个细微问题来自容器启动延迟随时间的变化。这可能是由于网络拓扑更改,资源分配更改或随服务扩展而增加负载的结果。如果由于Kubernetes节点故障或Liveness探针故障而重新启动了容器,并且initialDelaySeconds
参数的时间不够长,则可能会导致永远无法启动该应用程序,因为该应用程序会在完全启动之前反复被杀死并重新启动。 initialDelaySeconds
参数应大于容器的最大初始化时间。为避免这些动态变化随时间变化带来的意外,在一定程度上定期重启Pod很有好处-单个Pod一次支持服务运行数周或数月不一定是目标。重要的是,定期运行和评估部署,重新启动和故障是运行可靠服务的一部分。
所以建议如下:
- 避免在Liveness探针中检查服务的依赖项。Liveness探针应该简单并且具有最小的响应时间。
- 保守地设置Liveness探针超时,以便系统临时或永久更改,而不会导致Liveness探针故障过多。考虑将活动探针超时设置为与客户端超时相同的幅度。
- 保守地设置
initialDelaySeconds
参数,以便即使启动动态随时间变化,也可以可靠地重新启动容器。
结论
KubernetesLiveness 和 Readiness 探针可以极大地提高服务的健壮性和弹性,并提供出色的最终用户体验。但是,如果您不仔细考虑如何使用这些探针,特别是如果您不考虑异常的系统动态(无论多么罕见),则有可能使服务的可用性变差,而不是变好。