[译]优雅退出和零停机部署

2023/09/17 20:31 pm

本文翻译自:https://learnk8s.io/graceful-shutdown

在Kubernetes中,创建和删除Pod是最常见的任务之一。

当执行滚动更新、扩展部署、发布新版本、执行作业和定时作业等操作时,会创建Pod。

但是,在发生驱逐事件后,例如将节点标记为不可调度,Pod也会被删除并重新创建。

如果这些Pod的特性是如此短暂,那么当一个Pod正在响应请求时被告知关闭会发生什么?

请求在关闭之前是否完成?那么后续的请求会被重定向到其他地方吗?

在讨论Pod被删除时会发生什么之前,有必要讨论一下Pod被创建时会发生什么。

假设您想在集群中创建以下Pod:

apiVersion: v1

kind: Pod

metadata:
  name: my-pod

spec:
  containers:
    - name: web
      image: nginx
      ports:
        - name: web
          containerPort: 80

您可以使用以下命令将YAML定义提交到集群:

kubectl apply -f pod.yaml

一旦您输入该命令,kubectl会将Pod定义提交给Kubernetes API。这就是旅程的开始。

将集群状态保存到数据库中

API接收并检查Pod定义,然后将其存储在数据库(etcd)中。Pod也会被添加到调度器的队列中。

调度器:

  1. 检查定义
  2. 收集有关工作负载的详细信息,例如CPU和内存请求
  3. 决定最适合运行Pod的节点(通过Filters和Predicates等过程)。

在此过程结束时:

  • Pod在etcd中被标记为Scheduled。
  • Pod被分配给一个节点。
  • Pod的状态被存储在etcd中。

但是,Pod此时并不实际存在。

当您使用kubectl apply -f命令提交一个Pod的时候,YAML文件会被发送到Kubernetes API。

API会将Pod保存在数据库(etcd)中。

调度器会为该Pod分配最合适的节点,并将Pod的状态更改为Pending。此时,Pod仅存在于etcd中。

kubelet — Kubernetes 代理

kubelet 的工作是轮询控制平面以获取更新。

你可以想象 kubelet 不断地向主节点询问:“我负责管理工作节点 1,有没有新的 Pod 给我?”

当有一个 Pod 时,kubelet 就会创建它。

kubelet 并不是直接创建 Pod。相反,它将工作委托给其他三个组件:

  1. 容器运行时接口(CRI) —— 用于为 Pod 创建容器的组件。
  2. 容器网络接口(CNI) —— 用于将容器连接到集群网络并分配 IP 地址的组件。
  3. 容器存储接口(CSI) —— 用于在容器中挂载卷的组件。

在大多数情况下,容器运行时接口(CRI)的工作类似于:

docker run -d <my-container-image>

容器网络接口(CNI)更有趣一些,因为它负责:

  1. 为 Pod 生成有效的 IP 地址。
  2. 将容器连接到网络的其余部分。

正如你可以想象的,有多种方法可以将容器连接到网络并分配有效的 IP 地址(可以选择 IPv4 或 IPv6,或者分配多个 IP 地址)。

如果你对 Linux 网络命名空间和 CNI 感兴趣,可以查看这篇关于追踪 Kubernetes 网络流量路径的文章。

例如,Docker 创建虚拟以太网对并将其连接到桥接器,而 AWS-CNI 则直接将 Pod 连接到虚拟私有云(VPC)的其余部分。

当容器网络接口完成其工作后,Pod 就会连接到网络的其余部分,并被分配一个有效的 IP 地址。

但有一个问题,**kubelet 知道 IP 地址(因为它调用了容器网络接口),但控制平面不知道。**没有人告诉主节点该 Pod 已经分配了一个 IP 地址,并且准备好接收流量。

在控制平面看来,该 Pod 仍在创建中。**kubelet 的工作是收集 Pod 的所有细节,例如 IP 地址,并将它们报告给控制平面。**你可以想象,检查 etcd 将不仅会显示 Pod 运行的位置,还会显示其 IP 地址。

Kubelet会定期向控制平面轮询更新。

当一个新的Pod被分配到节点上时,kubelet会检索相关的详细信息。

kubelet本身不会创建Pod,它依赖于三个组件:容器运行时接口(CRI)、容器网络接口(CNI)和容器存储接口(CSI)。

当这三个组件都成功完成后,Pod将在节点上处于Running状态,并被分配了一个IP地址。

kubelet会将IP地址报告给控制平面。

如果该 Pod 不是任何 Service 的一部分,这就是任务的结束。Pod 已经创建并准备好使用。当 Pod 是 Service 的一部分时,还需要进行一些额外的步骤。

Pod 和 Service

当你创建一个 Service 时,通常需要注意两个信息:

  1. label 选择器,用于指定将接收流量的 Pod。
  2. targetPort —— Pod 用于接收流量的端口。

Service 的典型 YAML 定义如下:

apiVersion: v1

kind: Service

metadata:
  name: my-service

spec:
  ports:
  - port: 80
    targetPort: 3000
  selector:
    name: app

当你使用 kubectl apply 将 Service 提交到集群时,Kubernetes 会找到与选择器(name: app)相同标签的所有 Pod,并收集它们的 IP 地址 —— 但仅当它们通过了就绪探针

然后,对于每个 IP 地址,它将 IP 地址和端口连接起来。如果 IP 地址是 10.0.0.3targetPort3000,Kubernetes 将这两个值连接起来,并称之为一个端点。

IP 地址 + 端口 = 端点
---------------------------------
10.0.0.3   + 3000 = 10.0.0.3:3000

这些端点存储在 etcd 中,另一个名为 Endpoint 的对象中。

Kubernetes 中的术语区分如下:

  • endpoint(在本文和 Learnk8s 材料中以小写的 e 表示)是 IP 地址 + 端口对(10.0.0.3:3000)。
  • Endpoint(在本文和 Learnk8s 材料中以大写的 E 表示)是一组端点。

Endpoint 对象是 Kubernetes 中的一个真实对象,对于每个 Service,Kubernetes 自动创建一个 Endpoint 对象。

你可以通过以下命令验证:

kubectl get services,endpoints

NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
service/my-service-1   ClusterIP   10.105.17.65   <none>        80/TCP

service/my-service-2   ClusterIP   10.96.0.1      <none>        443/TCP

NAME                     ENDPOINTS

endpoints/my-service-1   172.17.0.6:80,172.17.0.7:80

endpoints/my-service-2   192.168.99.100:8443

Endpoint 收集了来自 Pod 的所有 IP 地址和端口。

每当发生以下情况时,Endpoint 对象会使用新的端点列表进行刷新:

  1. 创建一个 Pod。
  2. 删除一个 Pod。
  3. 修改 Pod 上的标签。

因此,每次创建一个 Pod 并在 kubelet 将其 IP 地址提交给主节点后,Kubernetes 都会更新所有的端点以反映这些变化:

kubectl get services,endpoints

NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
service/my-service-1   ClusterIP   10.105.17.65   <none>        80/TCP

service/my-service-2   ClusterIP   10.96.0.1      <none>        443/TCP

NAME                     ENDPOINTS

endpoints/my-service-1   172.17.0.6:80,172.17.0.7:80,172.17.0.8:80

endpoints/my-service-2   192.168.99.100:8443

在这张图片中,您的集群中部署了一个Pod。该Pod属于一个Service。如果您检查etcd,您会发现Pod和Service的详细信息。

当部署一个新的Pod时会发生什么?

Kubernetes需要跟踪Pod及其IP地址。Service应该将流量路由到新的端点,因此IP地址和端口应该被传播。

当部署另一个Pod时会发生什么?

完全相同的过程。在数据库中创建一个新的Pod记录,并传播端点。

但是,当删除一个Pod时会发生什么?

Service会立即删除该端点,并最终从数据库中删除该Pod。

Kubernetes对您的集群中的每一个小变化都做出反应。

端点存储在控制平面中,并且 Endpoint 对象已经更新了。你准备好开始使用你的 Pod 了吗?

在Kubernetes中使用终端点

终端点在Kubernetes中被多个组件使用。

Kube-proxy使用终端点在节点上设置iptables规则。因此,每当终端点(对象)发生更改时,kube-proxy会检索新的IP地址和端口列表,并编写新的iptables规则。

让我们考虑一个有两个Pod和没有Service的三节点集群。Pod的状态存储在etcd中。

当您创建一个Service时会发生什么?

Kubernetes会创建一个Endpoint对象,并从Pod中收集所有端点(IP地址和端口对)。

kube-proxy守护进程订阅对Endpoint的更改。

当一个Endpoint被添加、删除或更新时,kube-proxy会检索新的端点列表。

kube-proxy使用这些端点在集群中的每个节点上创建iptables规则。

Ingress控制器也使用相同的终端点列表。Ingress控制器是集群中将外部流量路由到集群的组件。当您设置Ingress清单时,通常会将服务指定为目标:

apiVersion: networking.k8s.io/v1

kind: Ingress

metadata:
  name: my-ingress

spec:
  rules:
  - http:
      paths:
      - backend:
          service:
            name: my-service
            port:
              number: 80
        path: /
        pathType: Prefix

实际上,流量不会路由到服务。相反,Ingress控制器设置一个订阅,以便在服务的终端点发生更改时得到通知。**Ingress直接将流量路由到Pod,跳过了服务。**可以想象,每当终端点(对象)发生更改时,Ingress会检索新的IP地址和端口列表,并重新配置控制器以包括新的Pod。

在这张图片中,有一个带有两个副本的Deployment和一个Service的Ingress控制器。

如果您想通过Ingress将外部流量路由到Pod,您应该创建一个Ingress清单(一个YAML文件)。

当您使用kubectl apply -f ingress.yaml命令时,Ingress控制器会从控制平面检索文件。

Ingress YAML具有serviceName属性,用于描述应该使用哪个Service。

Ingress控制器会从Service中检索端点列表并跳过它。流量直接流向端点(Pod)。

当创建一个新的Pod时会发生什么?

您已经知道Kubernetes如何创建Pod并传播端点。\n

Ingress控制器会订阅对端点的更改。由于有一个新的更改,它会检索新的端点列表。

Ingress控制器将流量路由到新的Pod。

还有更多的Kubernetes组件示例订阅终端点的更改。CoreDNS是集群中的DNS组件的另一个示例。如果您使用的是无头服务(Headless)类型的服务,CoreDNS将需要订阅终端点的更改,并在添加或删除终端点时重新配置自身。

同样的终端点也被服务网格(如Istio或Linkerd)使用,被云提供商用于创建type: LoadBalancer类型的服务,以及无数的操作者。

您必须记住,有多个组件订阅终端点的更改,并且它们可能在不同的时间接收到有关终端点更新的通知

快速回顾一下创建Pod时发生的情况:

  1. Pod被存储在etcd中。
  2. 调度器分配一个节点。它将节点写入etcd。
  3. kubelet收到新的已调度Pod的通知。
  4. kubelet委托容器的创建给容器运行时接口(CRI)。
  5. kubelet委托将容器附加到容器网络接口(CNI)。
  6. kubelet委托将容器中的卷挂载到容器存储接口(CSI)。
  7. 容器网络接口分配一个IP地址。
  8. kubelet将IP地址报告给控制平面。
  9. IP地址被存储在etcd中。

如果您的Pod属于一个服务:

  1. kubelet等待成功的就绪探测。
  2. 所有相关的终端点(对象)都会收到更改的通知。
  3. 终端点将新的终端点(IP地址+端口对)添加到它们的列表中。
  4. Kube-proxy收到终端点更改的通知。Kube-proxy在每个节点上更新iptables规则。
  5. Ingress控制器收到终端点更改的通知。控制器将流量路由到新的IP地址。
  6. CoreDNS收到终端点更改的通知。如果服务是无头服务类型,DNS条目将被更新。
  7. 云提供商收到终端点更改的通知。如果服务是type: LoadBalancer类型,新的终端点将被配置为负载均衡器池的一部分。
  8. 安装在集群中的任何服务网格都会收到终端点更改的通知。
  9. 订阅终端点更改的任何其他操作者也会收到通知。

对于一个看似常见的任务-创建Pod来说,这个列表如此之长。Pod处于运行状态。现在是讨论删除Pod时会发生什么的时候了。

删除Pod

您可能已经猜到了,但是当删除Pod时,您需要按相同的步骤反向操作。

首先,应该从终端点(对象)中删除终端点。

这次忽略就绪探测,并立即从控制平面中删除终端点。

这反过来会触发kube-proxy、Ingress控制器、DNS、服务网格等所有事件。

这些组件将更新其内部状态,并停止将流量路由到该IP地址。

由于这些组件可能正在忙于其他任务,无法保证从其内部状态中删除IP地址需要多长时间

如果您使用kubectl delete pod命令删除一个Pod,该命令首先会到达Kubernetes API。

该消息会被控制平面中的特定控制器(Endpoint控制器)拦截。

Endpoint控制器会向API发出命令,从Endpoint对象中删除IP地址和端口。

谁监听Endpoint的更改?kube-proxy、Ingress控制器、CoreDNS等都会收到更改的通知。

一些组件(如kube-proxy)可能需要一些额外的时间来进一步传播更改。

对于某些组件而言,可能只需要不到一秒钟,而对于其他组件可能需要更长时间。

同时,etcd中Pod的状态被更改为Terminating(终止)。

kubelet收到更改通知并进行以下操作:

  1. 从容器中卸载任何卷到容器存储接口(CSI)。
  2. 从网络中分离容器,并释放IP地址给容器网络接口(CNI)。
  3. 销毁容器给容器运行时接口(CRI)。

换句话说,Kubernetes按照创建Pod的相同步骤进行反向操作。然而,有一个微妙但重要的区别。当您终止一个Pod时,终端点的删除和向kubelet发出的信号同时发生

如果您使用kubectl delete pod命令删除一个Pod,该命令首先会到达Kubernetes API。

当kubelet轮询控制平面以获取更新时,它注意到Pod已被删除。

kubelet将销毁Pod的任务委托给容器运行时接口(CRI)、容器网络接口(CNI)和容器存储接口(CSI)。

当您首次创建Pod时,Kubernetes等待kubelet报告IP地址,然后开始终端点传播。**然而,当您删除Pod时,事件同时并行发生。**这可能会导致一些竞态条件。如果在终端点传播之前删除Pod会怎样?

删除端点和删除Pod同时发生。

因此,您可能会在kube-proxy更新iptables规则之前删除端点。

或者您可能更幸运,只有在端点完全传播后才删除Pod。

优雅关闭

在终端点从kube-proxy或Ingress控制器中删除之前终止Pod时,可能会出现业务中断时间。如果仔细考虑,这是有道理的。Kubernetes仍然将流量路由到IP地址,但Pod已经不存在了。

Ingress控制器、kube-proxy、CoreDNS等没有足够的时间将IP地址从内部状态中删除。理想情况下,Kubernetes应该等待集群中的所有组件都有更新的终端点列表,然后再删除Pod。

**但是Kubernetes并不是这样工作的。**Kubernetes提供了强大的原语来分发终端点(例如Endpoint对象和更高级的抽象,如Endpoint Slices)。然而,Kubernetes不会验证订阅终端点更改的组件是否与集群的状态保持同步。

那么,为了避免这种竞态条件并确保在终端点传播后删除Pod,你应该做什么呢?

你应该等待。

**当Pod即将被删除时,它会接收到一个SIGTERM信号。**您的应用程序可以捕获该信号并开始关闭。由于在Kubernetes中不太可能立即从所有组件中删除终端点,您可以:

  1. 等待更长的时间再退出。
  2. 尽管收到SIGTERM信号,仍然处理传入的流量。
  3. 最后,关闭现有的长连接(例如数据库连接或WebSockets)。
  4. 终止进程。

你应该等待多久?

默认情况下,Kubernetes会发送SIGTERM信号,并在强制终止进程之前等待30秒。

因此,您可以使用前15秒继续运行,就好像什么都没有发生。希望这个时间间隔足够传播终端点的删除到kube-proxy、Ingress控制器、CoreDNS等组件。随着时间的推移,越来越少的流量将到达您的Pod,直到停止。

在15秒之后,可以安全地关闭与数据库的连接(或任何持久连接)并终止进程。如果您认为需要更多时间,可以在20或25秒时停止进程。

但是,请记住,Kubernetes将在30秒后强制终止进程(除非您在Pod定义中更改了terminationGracePeriodSeconds)。

如果无法更改代码以等待更长时间怎么办?

您可以调用一个脚本等待固定的时间,然后让应用程序退出。在调用SIGTERM之前,Kubernetes在Pod中提供了一个preStop钩子。您可以将preStop钩子设置为等待15秒。

让我们看一个示例:

apiVersion: v1

kind: Pod

metadata:
  name: my-pod

spec:
  containers:
    - name: web
      image: nginx
      ports:
        - name: web
          containerPort: 80
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "15"]

preStop钩子是Pod生命周期钩子之一。

15秒的延迟是推荐的时间吗?

这取决于具体情况。

您已经知道,当删除一个Pod时,kubelet会收到更改的通知。

如果Pod有一个preStop钩子,它会首先被调用。

当preStop完成后,kubelet向容器发送SIGTERM信号。从那时起,容器应该关闭所有长连接并准备终止。

默认情况下,该进程有30秒的时间退出,其中包括preStop钩子。如果进程在此期间没有退出,kubelet会发送SIGKILL信号并强制终止进程。

kubelet会向控制平面通知成功删除Pod。

以下是您可以选择的选项总结。

优雅停机和滚动更新

优雅停机适用于被删除的 Pod。但如果你不删除 Pod 呢?即使你不删除,Kubernetes 也会删除 Pod。特别是,每当你部署应用程序的新版本时,Kubernetes 都会创建和删除 Pod。

当你在 Deployment 中更改镜像时,Kubernetes 会逐步滚动更新。

apiVersion: apps/v1

kind: Deployment

metadata:
  name: app

spec:
  replicas: 3
  selector:
    matchLabels:
      name: app
  template:
    metadata:
      labels:
        name: app
    spec:
      containers:
      - name: app
        # image: nginx:1.18 旧版本
        image: nginx:1.19
        ports:
          - containerPort: 3000

如果你有三个副本,并且在提交新的 YAML 资源后,Kubernetes:

  • 创建一个带有新容器镜像的 Pod。
  • 销毁一个现有的 Pod。
  • 等待 Pod 准备就绪。

它会重复上述步骤,直到所有的 Pod 都迁移到新版本。

Kubernetes 只有在新 Pod 准备好接收流量(也就是通过了就绪检查)后,才会重复每个周期。

Kubernetes 是否会等待 Pod 被删除后再进行下一个操作?

不会。

如果你有 10 个 Pod,并且 Pod 需要 2 秒才能准备就绪,20 秒才能关闭,那么情况如下:

  1. 创建第一个 Pod,并终止一个现有的 Pod。
  2. 新 Pod 需要 2 秒才能准备就绪,之后 Kubernetes 创建一个新的 Pod。
  3. 同时,正在终止的 Pod 保持终止状态 20 秒。

20 秒后,所有新的 Pod 都处于活动状态(10 个 Pod,在 2 秒后准备就绪),而之前的 10 个 Pod 正在终止中(第一个终止的 Pod 即将退出)。

总共,在短时间内你会有两倍数量的 Pod(10 个运行中,10 个终止中)。

滚动更新和优雅停机

优雅期相对于就绪探针的时间越长,你将同时拥有更多运行中(和终止中)的 Pod。

这是不好的吗?

不一定,只要你小心不丢失连接即可。

终止长时间运行的任务

那么长时间运行的任务呢?如果你正在转码一个大视频,有没有办法延迟关闭Pod?

假设你有一个包含三个副本的部署。每个副本被分配了一个需要转码的视频,这个任务可能需要几个小时才能完成。

当你触发滚动更新时,Pod在被终止之前有30秒的时间完成任务。

如何避免延迟关闭Pod呢?你可以将terminationGracePeriodSeconds增加到几个小时。然而,在这个时间点,Pod的终端点是无法访问的。

如果你暴露指标来监控你的Pod,你的监控工具将无法访问你的Pod。为什么?**诸如Prometheus之类的工具依赖于终端点来抓取集群中的Pod。**然而,一旦你删除Pod,终端点的删除就会在集群中传播 — 即使是到Prometheus!

与其增加宽限期,你应该考虑为每个新版本创建一个新的部署。

当你创建一个全新的部署时,现有的部署保持不变。

长时间运行的任务可以继续像往常一样处理视频。

一旦它们完成,你可以手动删除它们。

如果你希望自动删除它们,你可能想要设置一个自动缩放器,在任务用尽时将你的部署缩放为零副本。

这样的Pod自动缩放器的一个例子是Osiris — 一个通用的、用于Kubernetes的零副本组件。这种技术有时被称为 彩虹部署,在你需要将之前的Pod保持运行时间超过宽限期时非常有用。

另一个很好的例子是WebSockets。如果你正在向用户实时推送更新,你可能不希望每次发布时都终止WebSockets。如果你在一天内频繁发布,那可能会导致实时数据流中断多次。

为每个发布创建一个全新的部署是一个不太明显但更好的选择。 现有用户可以继续接收更新,同时最新的部署为新用户提供服务。当用户从旧的Pod断开连接时,你可以逐渐减少副本并淘汰过去的部署。

总结

你应该注意从集群中删除的 Pod,因为它们的 IP 地址可能仍然用于路由流量。与立即关闭 Pod 相比,你应该考虑在应用程序中等待更长的时间,或者设置一个 preStop 钩子。

只有在集群中的所有端点都被传播并从 kube-proxy、Ingress 控制器、CoreDNS 等中删除后,才应该删除 Pod。如果你的 Pod 运行长时间的任务,比如转码视频或使用 WebSockets 提供实时更新,你应该考虑使用彩虹部署。

在彩虹部署中,你为每个发布创建一个新的 Deployment,并在连接(或任务)被清空时删除之前的 Deployment。你可以在长时间运行的任务完成后手动删除旧的部署。或者你可以自动将部署的副本数缩减为零,以自动化这个过程。