如您所知,在 Kubernetes 中部署一个基本可行的应用程序配置是轻而易举的事。另一方面,试图使您的应用程序尽可能地可用和容错不可避免地会带来大量的障碍和陷阱。在本文中,我们分解了我们认为在 Kubernetes 中部署高可用性应用程序并以简洁的方式共享它们时最重要的规则。
请注意,我们不会使用任何开箱即用的功能。我们也不会做的是锁定特定的 CD 解决方案,我们将省略模板/生成 Kubernetes 清单的问题。在本文中,我们只讨论 Kubernetes manifests 在部署到集群时的最终结构。
您至少需要两个副本才能将应用程序视为最低可用。但是,您可能会问,为什么单个副本还不够?问题是 Kubernetes 中的许多实体(Node、Pod、ReplicaSet 等)都是短暂的,即在某些条件下,它们可能会被自动删除/重新创建。显然,Kubernetes 集群和在其中运行的应用程序必须考虑到这一点。
例如,当自动缩放器缩减您的节点数量时,其中一些节点将被删除,包括在它们上运行的 Pod。如果您的应用程序的唯一实例正在要删除的节点之一上运行,您可能会发现您的应用程序完全不可用,尽管这通常是短暂的。一般来说,如果你只有一个应用程序的副本,任何异常终止都会导致停机。换句话说,您必须至少有两个正在运行的应用程序副本。
副本越多,在某些副本失败的情况下,应用程序的计算能力下降的幅度就越小。例如,假设您有两个副本,其中一个由于节点上的网络问题而失败。应用程序可以处理的负载将减半(只有两个副本中的一个可用)。当然,新的副本会被调度到一个新的节点上,完全恢复应用的负载能力。但在那之前,增加负载会导致服务中断,这就是为什么必须保留一些副本的原因。
上述建议适用于没有使用 HorizontalPodAutoscaler 的情况。对于具有多个副本的应用程序,最好的替代方案是配置 HorizontalPodAutoscaler 并让它管理副本的数量。我们将在下一篇文章中重点介绍 HorizontalPodAutoscaler。
Deployment 的默认更新策略需要减少旧 + 新 ReplicaSet Pod 的数量,其 Ready
状态为更新前数量的 75%。因此,在更新期间,应用程序的计算能力可能会下降到其正常水平的 75%,这可能会导致部分故障(应用程序性能下降)。该 strategy.RollingUpdate.maxUnavailable
参数允许您配置在更新期间不可用的 Pod 的最大百分比。因此,要么确保你的应用程序在 25% 的 Pod 不可用的情况下也能顺利运行,要么降低 maxUnavailable
参数。请注意,该 maxUnavailable
参数已向下舍入。
默认更新策略 ( RollingUpdate
) 有一个小技巧:应用程序将暂时不仅有几个副本,而且还会同时运行两个不同的版本(旧版本和新版本)。因此,如果由于某种原因并行运行不同副本和不同版本的应用程序是不可行的,那么您可以使用 strategy.type: Recreate
. 在该 Recreate
策略下,所有现有的 Pod 都会在新的 Pod 被创建之前被杀死。这会导致短暂的停机时间。
其他部署策略(蓝绿、金丝雀等)通常可以提供比 RollingUpdate 策略更好的替代方案。但是,我们没有在本文中考虑它们,因为它们的实现取决于用于部署应用程序的软件。这超出了本文的范围(这是我们推荐的关于该主题的 精彩文章 ,非常值得一读)。
如果您有应用程序的多个副本,那么跨不同节点分发应用程序的 Pod 非常重要。为此,您可以指示调度程序避免在同一节点上启动同一 Deployment 的多个 Pod:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
app: testapp
topologyKey: kubernetes.io/hostname
最好使用 preferredDuringSchedulingaffinity
而不是 requiredDuringScheduling
. 如果新 Pod 所需的节点数大于可用节点数,后者可能会导致无法启动新 Pod。尽管如此, requiredDuringScheduling
当预先知道节点和应用程序副本的数量并且您需要确保两个 Pod 不会最终在同一个节点上时,亲和性可能会派上用场。
priorityClassName
代表您的 Pod 优先级。调度程序使用它来决定首先调度哪些 Pod,如果节点上没有剩余 Pod 空间,则应该首先驱逐哪些 Pod。
您将需要添加几个 PriorityClass类型的资源并使用 priorityClassName
. 以下是如何 PriorityClasses
变化的示例:
设置优先级将帮助您避免突然驱逐关键组件。此外,如果缺乏节点资源,关键应用程序将驱逐不太重要的应用程序。
中指定的信号 STOPSIGNAL
(通常是 TERM
信号)被发送到进程以停止它。但是,某些应用程序无法正确处理它并且无法正常关闭。在 Kubernetes 中运行的应用程序也是如此。
例如,为了正确关闭 nginx,你需要一个 preStop
像这样的钩子:
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -ec
- |
sleep 3
nginx -s quit
此清单的简要说明:
sleep 3
防止可能由删除端点引起的竞争条件。nginx -s quit
正确关闭 nginx。更新的图像不需要此行,因为 STOPSIGNAL: SIGQUIT
默认情况下在此处设置了该参数。(您可以在我们的另一篇文章 中了解更多关于与 PHP-FPM 捆绑的 nginx 的优雅关闭 。)
处理方式 STOPSIGNAL
取决于应用程序本身。在实践中,对于大多数应用程序,您必须以 Google 的方式 STOPSIGNAL
进行处理。如果信号处理不当, preStop
钩子可以帮助您解决问题。另一种选择是替换 STOPSIGNAL
为应用程序可以正确处理的信号(并允许它正常关闭)。
terminationGracePeriodSeconds
是关闭应用程序的另一个重要参数。它指定应用程序正常关闭的时间段。如果应用程序没有在此时间范围内(默认为 30 秒)终止,它将收到一个 KILL
信号。因此,如果您认为运行 preStop
钩子和/或关闭应用程序 STOPSIGNAL
可能需要超过 30 秒的时间,则需要增加terminationGracePeriodSeconds 参数。例如,如果来自您的 Web 服务客户端的某些请求需要很长时间才能完成(例如涉及下载大文件的请求),您可能需要增加它。
值得注意的是, preStop
钩子有一个锁定机制,即 STOPSIGNAL
只有在钩子运行完成后才可以发送 preStop
。同时,在 preStop 钩子执行 terminationGracePeriodSeconds
期间倒计时 继续。所有钩子引发的进程,以及在容器中运行的进程,都会在结束后被 ed 。 KILL
terminationGracePeriodSeconds
此外,某些应用程序具有特定设置,用于设置应用程序必须终止的截止日期(例如, --timeout
Sidekiq 中的选项)。因此,在每种情况下,您都必须确保如果应用程序具有此设置,则它的值略低于 terminationGracePeriodSeconds
.
调度程序使用 Pod resources.requests
来决定将 Pod 放置在哪个节点上。例如,如果节点上没有足够的空闲(即 非请求)资源来覆盖该 Pod 的资源请求,则无法将 Pod 调度到该节点上。另一方面, resources.limits
允许您限制 Pod 的资源消耗大大超过其各自的请求。一个好的提示是设置与 requests 相等的限制。将限制设置为远高于请求可能会导致某些节点的 Pod 无法获得请求的资源的情况。这可能会导致节点上的其他应用程序(甚至节点本身)出现故障。Kubernetes 分配一个 QoS 类根据每个 Pod 的资源方案。然后,K8s 使用 QoS 类来决定应该从节点中驱逐哪些 Pod。
因此,您必须同时为 CPU 和内存设置请求和限制。 如果Linux 内核版本早于 5.4(在 EL7/CentOS7 的情况下,内核版本必须早于 3.10.0-1062.8.1.el7),您唯一可以/应该忽略的是 CPU 限制。
此外,某些应用程序的内存消耗往往以无限的方式增长。一个很好的例子是用于缓存的 Redis 或基本上“独立”运行的应用程序。为了限制它们对节点上其他应用程序的影响,您可以(并且应该)设置要消耗的内存量的限制。唯一的问题是 KILL
当达到这个限制时应用程序将被编辑。应用程序无法预测/处理此信号,这可能会阻止它们正确关闭。这就是为什么除了 Kubernetes 限制之外,我们强烈建议使用特定于应用程序的机制来限制内存消耗,使其不超过(或接近)Pod limits.memory
参数中设置的数量。
这是一个 Redis 配置,可以帮助您解决这个问题:
maxmemory 500mb # if the amount of data exceeds 500 MB...
maxmemory-policy allkeys-lru # ...Redis would delete rarely used keys
至于 Sidekiq,您可以使用 Sidekiq 工人杀手:
require 'sidekiq/worker_killer'
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
# Terminate Sidekiq correctly when it consumes 500 MB
chain.add Sidekiq::WorkerKiller, max_rss: 500
end
end
很明显,在所有这些情况下,都 limits.memory
需要高于触发上述机制的阈值。
在下一篇文章中,我们将讨论使用 VerticalPodAutoscaler 自动分配资源。
在 Kubernetes 中,探针(健康检查)用于确定是否可以将流量切换到应用程序( 就绪探针)以及是否需要重新启动应用程序( 活性探针)。它们在更新 Deployment 和启动新 Pod 方面发挥着重要作用。
首先,我们想为所有探针类型提供一般建议:为参数设置一个较高的值 。一秒的默认值太低了,它会对 readinessProbe 和 livenessProbe 产生严重影响。如果太低,应用程序响应时间的增加(由于服务负载平衡,这通常同时发生在所有 Pod 上)可能会导致这些 Pod 从负载平衡中移除(在就绪探测失败的情况下),或者,更糟糕的是,在级联容器重新启动时(在活性探测失败的情况下)。 timeoutSeconds
timeoutSeconds
在实践中,活性探针的使用并不像您想象的那么广泛。其目的是在例如应用程序被冻结时重新启动容器。然而,在现实生活中,这样的应用程序死锁是一个例外而不是规则。如果应用程序出于某种原因展示了部分功能(例如,它无法在数据库中断后恢复与数据库的连接),您必须在应用程序中修复它,而不是“发明”基于 livenessProbe 的解决方法。
虽然您可以使用 livenessProbe 来检查这些状态,但我们建议不要默认使用 livenessProbe或仅执行一些基本的 liveness-testing,例如测试 TCP 连接(请记住设置高超时值)。这样,应用程序将重新启动以响应明显的死锁,而不会陷入重新启动循环的陷阱(即重新启动它无济于事)。
与配置不当的 livenessProbe 相关的风险非常严重。在最常见的情况下,livenessProbe 失败的原因是应用程序的负载增加(它根本无法在 timeout 参数指定的时间内完成)或由于当前正在检查的外部依赖项的状态(直接或间接)。在后一种情况下,所有容器都将重新启动。在最好的情况下,这不会产生任何结果,但在最坏的情况下,这会使应用程序完全不可用,可能是长期的。如果大多数 Pod 的容器在短时间内重新启动,可能会导致应用程序(如果它有大量副本)长期完全不可用。一些容器可能比其他容器更快地准备好,并且整个负载将分布在这个有限数量的运行容器上。这最终会导致 livenessProbe 超时,这将触发更多的重启。
此外,如果您的应用程序对已建立的连接数有限制并且已达到该限制,请确保 livenessProbe 不会停止响应。通常,您必须将单独的应用程序线程/进程专用于 livenessProbe 以避免此类问题。例如,如果您的应用程序有 11 个线程(每个客户端一个线程),您可以将客户端数量限制为 10 个,确保有空闲线程可用于 livenessProbe。
当然,不要向 livenessProbe 添加任何外部依赖项检查。
有关活性探测问题以及如何预防这些问题的更多信息,请参阅 本文。
事实证明,readinessProbe 的设计并不是很成功。readinessProbe 结合了两个不同的功能:
在实践中,绝大多数情况下都需要第一个函数,而第二个函数只需要和 livenessProbe 一样频繁。配置不当的 readinessProbe 可能会导致类似于 livenessProbe 的问题。在最坏的情况下,它们还可能最终导致应用程序长期不可用。
当 readinessProbe 失败时,Pod 停止接收流量。在大多数情况下,这种行为几乎没有用,因为流量通常在 Pod 之间或多或少地均匀分布。因此,通常情况下,readinessProbe 要么在任何地方工作,要么不能同时在大量 Pod 上工作。在某些情况下,这种行为可能有用。但是,根据我的经验,这在很大程度上是在特殊情况下。
尽管如此,readinessProbe 还具有另一个关键特性:它有助于确定新创建的容器何时可以处理流量,以免将负载转发到尚未准备好的应用程序。这个 readinessProbe 功能,相反,在任何时候都是必要的。
换句话说,readinessProbe 的一个特性需求量很大,而另一个根本不需要。随着 startupProbe 的引入,这个困境得到了解决。它首次出现在 Kubernetes 1.16 中,在 v1.18 中成为 beta 版,在 v1.20 中稳定。因此,您最好使用 readinessProbe 来检查应用程序是否在 1.18 以下的 Kubernetes 版本中准备就绪,但在 Kubernetes 版本 1.18 及更高版本中是 startupProbe。再说一次,如果您需要在应用程序启动后停止到各个 Pod 的流量,您可以在 Kubernetes 1.18+ 中使用 readinessProbe。
startupProbe 检查容器中的应用程序是否准备就绪。然后它将当前 Pod 标记为准备好接收流量或继续更新/重新启动部署。与 readinessProbe 不同,startupProbe 在容器启动后停止工作。我们不建议使用 startupProbe 来检查外部依赖关系:它的失败会触发容器重启,最终可能导致 Pod 运行 CrashLoopBackOff
。在这种状态下,尝试重新启动失败容器之间的延迟可能高达五分钟。这可能会导致不必要的停机,因为尽管应用程序已 准备好重新启动,但容器会继续等待,直到该 CrashLoopBackOff
时间段结束,然后再尝试重新启动。
如果您的应用程序接收到流量并且您的 Kubernetes 版本是 1.18 或更高版本,则应该使用 startupProbe。
另外,请注意,增加 failureThreshold
而不是设置 initialDelaySeconds
是配置探测器的首选方法。这将允许容器尽快变得可用。
如您所知,readinessProbe 通常用于检查外部依赖项(例如数据库)。虽然这种方法有权存在,但建议您将检查外部依赖项的方法与检查 Pod 中的应用程序是否满负荷运行(并切断向其发送流量)的方法分开也是个好主意)。
您可以 initContainers
在运行主容器的 startupProbe/readinessProbe 之前检查外部依赖项。很明显,在这种情况下,您将不再需要使用 readinessProbe 检查外部依赖项。 initContainers
不需要更改应用程序代码。您不需要嵌入额外的工具来使用它们来检查应用程序容器中的外部依赖关系。通常,它们相当容易实现:
initContainers:
- name: wait-postgres
image: postgres:12.1-alpine
command:
- sh
- -ec
- |
until (pg_isready -h example.org -p 5432 -U postgres); do
sleep 1
done
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
cpu: 50m
memory: 50Mi
- name: wait-redis
image: redis:6.0.10-alpine3.13
command:
- sh
- -ec
- |
until (redis-cli -u redis://redis:6379/0 ping); do
sleep 1
done
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
cpu: 50m
memory: 50Mi
总而言之,这是一个无状态应用程序的生产级部署的完整示例,其中包含上面提供的所有建议。
您将需要 Kubernetes 1.18 或更高版本以及内核版本为 5.4 或更高版本的基于 Ubuntu/Debian 的节点。
apiVersion: apps/v1
kind: Deployment
metadata:
name: testapp
spec:
replicas: 10
selector:
matchLabels:
app: testapp
template:
metadata:
labels:
app: testapp
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
app: testapp
topologyKey: kubernetes.io/hostname
priorityClassName: production-medium
terminationGracePeriodSeconds: 40
initContainers:
- name: wait-postgres
image: postgres:12.1-alpine
command:
- sh
- -ec
- |
until (pg_isready -h example.org -p 5432 -U postgres); do
sleep 1
done
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
cpu: 50m
memory: 50Mi
containers:
- name: backend
image: my-app-image:1.11.1
command:
- run
- app
- --trigger-graceful-shutdown-if-memory-usage-is-higher-than
- 450Mi
- --timeout-seconds-for-graceful-shutdown
- 35s
startupProbe:
httpGet:
path: /simple-startup-check-no-external-dependencies
port: 80
timeoutSeconds: 7
failureThreshold: 12
lifecycle:
preStop:
exec:
["sh", "-ec", "#command to shutdown gracefully if needed"]
resources:
requests:
cpu: 200m
memory: 500Mi
limits:
cpu: 200m
memory: 500Mi
还有几个其他重要主题需要解决,例如 PodDisruptionBudget
、 HorizontalPodAutoscaler
和 VerticalPodAutoscaler
。我们将在本文的第 2 部分中讨论它们(更新: 第 2 部分已发布!) ——订阅我们下面的博客和/或关注 我们的 Twitter不要错过它!请分享您部署应用程序的最佳实践(或者,如果需要,您可以更正/补充上面讨论的那些)。