打破Kubernetes中的零停机时间部署

256 阅读10分钟

DeepSource,我们真正关心所有生产问题的根本原因分析。从调试PV的错误配置和解密回溯到测量查询延迟和低磁盘吞吐量,我们对携带我们的侦探镜头感到自豪,并使用它来排除问题,直至其根本原因。当然,为了进行这种水平的根源分析,需要在基础设施的每个角落建立深度监控水平,在对系统进行故障排除时,使一切都成为公开的案例。可观察性是一个可靠系统的重要本质,没有它,你只是在盲目飞行。

最近,我们被一个纠缠不休的生产问题所困扰,它对用户产生了恶劣的影响。简单地说,在Kubernetes部署过程中,任何客户端请求都会遇到502 NGINX错误。

Kubernetes的部署在本质上是默认滚动的,并保证零停机时间,但有一个转折。正是这个转折表现为一个恼人的问题。为了用Kubernetes实现真正的零停机部署,不破坏或丢失任何一个飞行中的请求,我们需要扩大我们的游戏规模,并拿出侦探镜头进行深入的根本原因分析。

破解根源

让我们来谈谈滚动更新。默认情况下,Kubernetes部署会通过滚动更新策略推出Pod版本更新。这个策略的目的是通过在执行更新的任何时间点上保持至少一些实例的正常运行来防止应用停机。只有在新部署版本的新吊舱启动并准备好处理流量后,旧吊舱才会被关闭。

我们指定了Kubernetes在更新期间如何处理多个副本的确切方式。根据工作负载和可用的计算资源,我们必须配置在任何时候要超额或不足配置多少个实例。例如,给定三个所需的副本,我们是否应该立即创建三个新的pod并等待它们全部启动,是否应该终止除一个以外的所有旧pod,或者逐一进行过渡?下面的代码片段显示了一个名为asgard的应用程序的Kubernetes部署定义,其默认的RollingUpdate 升级策略,以及在更新期间最多有一个超额配置的pod (maxSurge),没有不可用的pod:

kind: Deployment
apiVersion: apps/v1
metadata:
  name: asgard
spec:
  replicas: 3
  template:
    # with image docker.example.com/asgard:1
    # ...
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

asgard 部署将导致创建asgard:1 镜像的三个副本。

这种部署配置将以下列方式执行版本更新。它将一次创建一个带有新版本的pod,等待pod启动并准备好,触发一个旧pod的终止,然后继续创建下一个新pod,直到所有副本都被过渡。为了告诉Kubernetes我们的pod何时运行并准备好处理流量,我们需要配置有效性和准备性探测

下面显示了kubectl get pods ,以及新旧pod随时间变化的输出:

$ kubectl get pods
NAME                             READY     STATUS             RESTARTS   AGE
asgard-5444dd6d45-hbvql   1/1       Running            0          3m
asgard-5444dd6d45-31f9a   1/1       Running            0          3m
asgard-5444dd6d45-fa1bc   1/1       Running            0          3m
...

asgard-5444dd6d45-hbvql   1/1       Running            0          3m
asgard-5444dd6d45-31f9a   1/1       Running            0          3m
asgard-5444dd6d45-fa1bc   1/1       Running            0          3m
asgard-8dca50f432-bd431   0/1       ContainerCreating  0          12s
...

asgard-5444dd6d45-hbvql   1/1       Running            0          4m
asgard-5444dd6d45-31f9a   1/1       Running            0          4m
asgard-5444dd6d45-fa1bc   0/1       Terminating        0          4m
asgard-8dca50f432-bd431   1/1       Running            0          1m
...

asgard-5444dd6d45-hbvql   1/1       Running            0          5m
asgard-5444dd6d45-31f9a   1/1       Running            0          5m
asgard-8dca50f432-bd431   1/1       Running            0          1m
asgard-8dca50f432-ce9f1   0/1       ContainerCreating  0          10s
...

...

asgard-8dca50f432-bd431   1/1       Running            0          2m
asgard-8dca50f432-ce9f1   1/1       Running            0          1m
asgard-8dca50f432-491fa   1/1       Running            0          30s

检测可用性差距

如果我们执行从旧版本到新版本的滚动更新,并跟踪输出哪些pods是活的和准备好的,这个行为首先看起来是有效的。然而,正如我们所看到的,从旧版本到新版本的切换并不总是完全平滑的,也就是说,应用程序可能会失去一些客户的请求。

为了真正测试当一个实例脱离服务时,飞行中的请求是否会丢失,我们不得不对我们的服务进行压力测试并收集结果。我们感兴趣的重点是我们传入的HTTP请求是否被正确处理,包括HTTP保持连接。

我们使用简单的Fortio负载测试工具,用一连串的请求来围攻我们的HTTP端点。这包括50个并发连接/goroutine,每秒查询次数(或更恰当的,每秒请求次数)为500,测试超时为60秒。

fortio load -a -c 50 -qps 500 -t 60s ""

-a 选项使Fortio保存报告,以便我们可以使用HTML GUI查看。我们在进行滚动更新部署时启动了这个测试,发现有几个请求未能连接:

Fortio 1.1.0 running at 500 queries per second, 4->4 procs, for 20s
Starting at 500 qps with 50 thread(s) [gomax 4] for 20s : 200 calls each (total 10000)
08:49:55 W http_client.go:673> Parsed non ok code 502 (HTTP/1.1 502)
[...]
Code 200 : 9933 (99.3 %)
Code 502 : 67 (0.7 %)
Response Header Sizes : count 10000 avg 158.469 +/- 13.03 min 0 max 160 sum 1584692
Response Body/Total Sizes : count 10000 avg 169.786 +/- 12.1 min 161 max 314 sum 1697861
[...]

输出显示,并非所有的请求都被成功处理。我们运行了几个测试场景,通过不同的方式连接到应用程序,例如通过Kubernetes ingress,或直接通过服务,从集群内部。我们看到,滚动更新期间的行为是不同的,这取决于我们的测试设置的连接方式。与通过入口连接相比,从集群内部连接到服务没有经历那么多失败的连接。

了解发生了什么

现在,一百万美元的问题是,当Kubernetes在滚动更新期间重新路由流量时,从旧的pod实例版本到新的pod实例版本究竟发生了什么。让我们看一下Kubernetes是如何管理工作负载连接的。

如果我们的客户端,也就是零停机测试,直接从集群内部连接到asgard 服务,它通常使用通过集群DNS解析的服务虚拟IP,并最终到达一个Pod实例。这是通过在每个Kubernetes节点上运行的kube-proxy实现的,并更新了通往pod的IP地址的iptables。

Kubernetes将更新pods状态中的端点对象,以便它只包含可以处理流量的pods。

然而,Kubernetes ingresses以一种稍微不同的方式连接到实例。这就是为什么我们注意到在滚动更新时有不同的停机行为,而当我们的客户通过ingress资源连接到应用程序时。

NGINX ingress在其上游配置中直接包含了pod地址。它独立观察端点对象的变化。

无论我们如何连接到我们的应用程序,Kubernetes的目标是在滚动更新过程中尽量减少服务中断。

一旦一个新的pod存活并准备就绪,Kubernetes将使一个旧的pod退出服务,从而将pod的状态更新为Terminating ,将其从endpoints对象中删除,并发送一个SIGTERMSIGTERM ,导致容器关闭,以一种(希望)优雅的方式,并不接受任何新的连接。在pod从端点对象中被驱逐后,负载均衡器将把流量路由到剩余的(新的)端点对象。这就是在我们的部署中造成可用性差距的原因;在负载均衡器注意到这个变化并能更新其配置之前,或者说在这个时候,pod被终止信号所停用。这种重新配置是异步发生的,因此不能保证正确的排序,而且可能并将导致少数不幸运的请求被路由到终止的pod。

现在的任务是,我们如何加强我们的应用程序以实现(真正的)零停机迁移?

首先,实现这一目标的先决条件是我们的容器要正确处理终止信号,也就是说,在UnixSIGTERM ,进程会优雅地关闭。请看一下谷歌的构建容器的最佳实践,如何实现这一点。

下一步是包括准备好的探测器,检查我们的应用程序是否准备好处理流量。理想情况下,探针已经检查了需要预热的功能的状态,如缓存或工作者初始化。

准备就绪探测器是我们平滑滚动更新的起点。为了解决pod终止目前没有阻止和等待直到负载均衡器被重新配置的问题,我们已经包括了一个preStop 生命周期钩。这个钩子在容器终止之前被调用。

生命周期钩子是同步的,因此必须在最后的终止信号被发送到容器之前完成。在我们的案例中,我们使用这个钩子来简单地等待,然后SIGTERM ,终止应用程序的进程。同时,Kubernetes将从端点对象中移除pod,因此pod将被排除在我们的负载均衡器之外。我们的生命周期钩子的等待时间确保了负载均衡器在应用进程停止之前被重新配置。

为了实现这种行为,我们在我们的asgard 部署中定义了一个preStop 钩:

kind: Deployment
apiVersion: apps/v1beta1
metadata:
  name: asgard
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: zero-downtime
        image: docker.example.com/asgard:1
        livenessProbe:
          # ...
        readinessProbe:
          # ...
        lifecycle:
          preStop:
            exec:
              command: ["/bin/bash", "-c", "sleep 120"]
  strategy:
    # ...

这在很大程度上取决于我们选择的技术,即如何实现准备度和有效性探测以及生命周期钩子行为;后者选择了20秒的同步宽限期。在这个等待时间之后,pod的关闭过程才会继续。

当我们现在观察我们的pod在部署过程中的行为时,我们已经观察到终止的pod处于Terminating ,但在等待时间段结束前没有关闭。为了使我们的发现具体化,我们使用负载测试工具重新测试了我们的方法,并欣喜地发现失败请求的数量为零。

Fortio 1.1.0 running at 500 queries per second, 4->4 procs, for 20s
Starting at 500 qps with 50 thread(s) [gomax 4] for 20s : 200 calls each (total 10000)
[...]
Code 200 : 10000 (100.0 %)
Response Header Sizes : count 10000 avg 159.530 +/- 0.706 min 154 max 160 sum 1595305
Response Body/Total Sizes : count 10000 avg 168.852 +/- 2.52 min 161 max 171 sum 1688525
[...]

结论

Kubernetes在协调应用程序方面做得很好,考虑到了生产准备。然而,为了在生产中运行我们的企业系统,关键是我们的工程师要了解Kubernetes在引擎盖下是如何运行的,以及我们的应用程序在启动和关闭时的行为。

对这一问题进行根本原因分析是至关重要的,以确保在正在进行的部署中,用户体验不会被打乱,并确保所有请求都能100%交付给我们的后端服务。