50张图,掌握Kubernetes中优雅且零停机部署的实现

前言

在本文中,您将了解如何在Pod启动或关闭时防止连接异常,并将学习如何以优雅的方式关闭长时间运行的任务。

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

当您执行滚动更新,扩展部署,每个新发行版,每个作业和cron作业等时,都会创建Pod。

但是在节点被驱逐之后,Pods也会被删除并重新创建—例如,当您将节点标记为不可调度时。

这些Pod的生命是如此短暂,那么当Pod在响应请求的过程中却被告知关闭时会发生什么?

请求在关闭之前是否已完成?

接下来的请求又如何呢?

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

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

pod.yaml

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. 确定哪个节点最适合运行它。

在过程结束时:

  • 在etcd中将Pod标记为Scheduled

  • 为Pod分配了一个节点。

  • Pod的状态存储在etcd中。

但是Pod仍然不存在。

  1. 当您使用kubectl apply -f提交一个Pod时,YAML被发送到kubernetes api。

  1. API将Pod保存在数据库etcd中。

  1. 调度程序为这个Pod分配最佳节点,并且Pod的状态更改为Pending。pod只存在于etcd中。

先前的任务发生在控制平面中,并且状态存储在数据库中。

那么谁在您的节点中创建Pod?

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地址)。

例如,Docker创建虚拟以太网对并将其连接到网桥,而AWS—CNI将Pods直接连接到虚拟私有云(VPC)。

当容器网络接口完成其工作时,Pod已连接到网络,并分配了有效的IP地址。

还有一个问题。

Kubelet知道IP地址(因为它调用了容器网络接口),但是控制平面却不知道。

没有人告诉主节点,该Pod已分配了IP地址,并准备接收流量。

就控制平面而言,仍在创建Pod。

Kubelet的工作是收集Pod的所有详细信息(例如IP地址)并将其报告回控制平面。

您可以想象检查etcd不仅可以显示Pod的运行位置,还可以显示其IP地址。

  1. Kubelet轮询控制平面以获取更新。

  2. 当一个新的Pod分配给它的节点时,kubelet会检索详细信息

  3. Kubernetns不会自己创建pod。它依赖于三个组件:容器运行时接口、容器网络接口和容器存储接口。

  4. 一旦所有三个组件都成功完成,Pod就在您的节点中运行并分配了一个IP地址。

  5. kubelet向控制平面报告IP地址。

如果Pod不是任何服务的一部分,那么任务将结束。

Pod已创建并可以使用。

如果Pod是服务的一部分,则还需要执行几个步骤。

Pods和Services

创建服务时,通常需要注意以下两条信息:

  1. selector — 用于指定将接收流量的Pod。

  2. targetPort — 通过pod使用的端口接收的流量。

服务的典型YAML定义如下所示:

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  ports:
  - port: 80
    targetPort: 3000
  selector:
    name: app

将Service提交给集群时kubectl apply,Kubernetes会找到所有具有与selector(name: app)相同标签的Pod,并收集其IP地址 — 但前提是它们已通过Readiness探针。

然后,对于每个IP地址,它将IP地址和端口连接在一起。

如果IP地址是10.0.0.3和,targetPort3000,Kubernetes将两个结果连接起来并称为endpoint。

IP address + port = endpoint
---------------------------------
10.0.0.3   + 3000 = 10.0.0.3:3000

endpoint存储在etcd的另一个名为Endpoint的对象中。

是否有点疑惑?

Kubernetes中定义:

  • endpoint是IP地址+端口对(10.0.0.3:3000)(在本文和Learnk8s资料中称为小写eendpoint)。

  • Endpoint是endpoint的集合(在本文和Learnk8s材料中,被称为大写Eendpoint)。

Endpoint对象是Kubernetes中的真实对象,对于每个服务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列表刷新Endpoint对象:

  1. 创建一个Pod。

  2. Pod已删除。

  3. 在Pod上修改了标签。

因此,您可以想象,每次创建Pod并在kubelet将其IP地址发布到主节点后,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,172.17.0.8:80
endpoints/my-service-2   192.168.99.100:8443

很好,endpoint存储在控制平面中,并且endpoint对象已更新。

  1. 在此图中,集群中部署了一个Pod。Pod属于服务。如果您要检查etcd,则可以找到Pod的详细信息以及服务。

  2. 当部署新pod后会发生什么?

  3. Kubernetes必须跟踪Pod及其IP地址。服务应该将流量路由到新的endpoint,因此应该传播IP地址和端口。

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

  5. 完全相同的过程。在数据库中为Pod创建一个新的“记录”,并传递给endpoint。

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

  7. 服务会立即删除endpoint,最后,Pod也会从数据库中删除。

  8. Kubernetes会对集群中的每一个小变化做出反应。

您准备好开始使用Pod了吗?

在Kubernetes中使用Endpoint

endpoint由Kubernetes中的多个组件使用。

Kube-proxy使用endpoint在节点上设置iptables规则。

因此,每当endpoint(对象)发生变化时,kube-proxy就会检索新的IP地址和端口列表,并编写新的iptables规则。

  1. 让我们考虑具有两个Pod且不包含Service的三节点群集。Pod的状态存储在etcd中。

  2. 创建服务时会发生什么?

  3. Kubernetes创建了一个endpoint对象,并从pod收集所有endpoint(IP地址和端口对)。

  4. Kube-proxy守护进程监听endpoint的更改。

  5. 当添加、删除或更新endpoint时,kube proxy检索endpoint的新列表。

  6. Kube-proxy使用endpoint在集群的每个节点上创建iptables规则。

Ingress controller使用相同的endpoint列表。

Ingress controller是群集中将外部流量路由到群集中的那个组件。

设置Ingress清单时,通常将Service指定为目标:

ingress.yaml

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: my-ingress
spec:
  rules:
  - http:
      paths:
      - backend:
          serviceName: my-service
          servicePort: 80
        path: /

实际上,流量不会路由到服务。

取而代之的是,Ingress controller设置了一个订阅,每次该服务的endpoint更改时都将收到通知。

Ingress会将流量直接路由到Pod,从而跳过服务。

可以想象,每次更改endpoint(对象)时,Ingress都会检索IP地址和端口的新列表,并将控制器重新配置为包括新的Pod。

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

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

  3. 一旦你运行了kubectl apply -f ingress.yaml,入口控制器从控制平面检索文件。

  4. Ingress YAML有一个serviceName属性,该属性描述它应该使用哪个服务。

  5. 入口控制器从服务检索Endpoint列表并跳过它。流量直接流向endpoint(pod)。

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

  7. 您已经知道Kubernetes如何创建Pod并通告endpoint。

  8. 入口控制器正在订阅对endpoint的更改。因为有一个变更的通知,它检索新的Endpoint列表。

  9. 入口控制器将流量路由到新的Pod。

有更多的Kubernetes组件示例订阅了对endpoint的更改。

集群中的DNS组件CoreDNS是另一个示例。

如果您使用Headless类型的服务,则每次添加或删除endpoint时,CoreDNS都必须订阅对endpoint的更改并重新配置自身。

相同的endpoint被istio或Linkerd之类的服务网格所使用,云提供商也创建了type:LoadBalancer

您必须记住,有几个组件订阅了对endpoint的更改,它们可能会在不同时间收到有关endpoint更新的通知。

够了吗,还是在创建Pod之后有什么事发生?

这次您完成了!

快速回顾一下创建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等待成功的Readiness探针。

  2. 通知所有相关的endpoint(对象)更改。

  3. Endpoint将新endpoint(IP地址+端口对)添加到其列表中。

  4. Endpoint更改将通知Kube-proxy。Kube-proxy更新每个节点上的iptables规则。

  5. 通知Endpoint变化的入口控制器。控制器将流量路由到新的IP地址。

  6. CoreDNS通知Endpoint更改。如果服务的类型为Headless,则更新DNS条目。

  7. 向云提供商通知Endpoint更改。如果服务为type: LoadBalancer,则将新Endpoint配置为负载均衡器池的一部分。

  8. Endpoint更改将通知群集中安装的所有服务网格。

  9. 订阅Endpoint更改的其他运营商也会收到通知。

如此长的列表令人惊讶地只是一项常见任务 — 创建Pod。

Pod正在运行。现在是时候讨论删除它时会发生什么。

删除pod

您可能已经猜到了,但是删除Pod时,必须遵循相同的步骤,但要相反。

首先,应从endpoint(对象)中删除endpoint。

这次将忽略“Readiness”探针,并立即从控制平面移除endpoint。

依次触发所有事件到kube-proxy,Ingress控制器,DNS,服务网格等。

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

由于组件可能正在忙于做其他事情,因此无法保证从其内部状态中删除IP地址将花费多长时间。

对于某些人来说,可能不到一秒钟。对于其他人,可能需要更多时间。

  1. 如果您要使用删除Pod kubectl delete pod,则该命令将首先到达Kubernetes API

  2. 消息被控制平面中的特定控制器截获:Endpoint控制器。

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

  4. 谁侦听Endpoint更改?Kube-proxy、入口控制器、CoreDNS等会收到更改通知。

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

同时,etcd中Pod的状态更改为Termination

将通知kubelet更改并委托:

  1. 将全部容器卸载到容器存储接口(CSI)。

  2. 从网络上分离容器并将IP地址释放到容器网络接口(CNI)。

  3. 将容器销毁到容器运行时接口(CRI)。

换句话说,Kubernetes遵循与创建Pod完全相同的步骤,但相反。

  1. 如果您要使用删除Pod kubectl delete pod,则该命令将首先到达Kubernetes API。

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

  3. kubelet将销毁Pod委托给容器运行时接口、容器网络接口和容器存储接口。

但是,存在细微但必不可少的差异。

当您终止Pod时,将同时删除endpoint和发送到kubelet的信号。

首次创建Pod时,Kubernetes等待kubelet报告IP地址,然后开始endpoint通告。

但是,当您删除Pod时,事件将并行开始。

这可能会导致很多竞争情况。

如果在通告endpoint之前删除Pod怎么办?

  1. 删除endpoint和删除Pod会同时发生。

  2. 因此,您可以在kube-proxy更新iptables规则之前删除endpoint。

  3. 或者更幸运的是,只有在endpoint完全通告之后,Pod才会被删除。

正常关机(Graceful)

当Pod从kube-proxy或Ingress控制器中删除之前终止时,您可能会遇到停机时间。

而且,如果您考虑一下,这是有道理的。

Kubernetes仍将流量路由到IP地址,但Pod不再存在。

Ingress控制器,kube-proxy,CoreDNS等没有足够的时间从其内部状态中删除IP地址。

理想情况下,在删除Pod之前,Kubernetes应该等待集群中的所有组件具有更新的endpoint列表。

但是Kubernetes不能那样工作。

Kubernetes提供了健壮的机制来分布endpoint(即Endpoint对象和更高级的抽象功能,例如Endpoint Slices)。

但是,Kubernetes不会验证订阅endpoint更改的组件是否是集群状态的最新信息。

那么,如何避免这种竞争情况并确保在通告endpoint之后删除Pod?

你应该等一下!

当Pod即将被删除时,它会收到SIGTERM信号。

您的应用程序可以捕获该信号并开始关闭。

由于endpoint不太可能立即从Kubernetes中的所有组件中删除,因此您可以:

  1. 请稍等片刻,然后退出。

  2. 尽管有SIGTERM,仍然可以处理传入流量。

  3. 最后,关闭现有的长期连接(也许是数据库连接或WebSocket)。

  4. 关闭该过程。

你应该等多久?

默认情况下,Kubernetes将发送SIGTERM信号并等待30秒,然后强制终止该进程。

因此,您可以在最初的15秒内继续操作,以防万一。

希望该间隔应足以将endpoint删除通知到kube-proxy,Ingress控制器,CoreDNS等。

因此,越来越少的流量将到达您的Pod,直到停止为止。

15秒后,可以安全地关闭与数据库的连接(或任何持久连接)并终止该过程。

如果您认为需要更多时间,则可以在20或25秒时停止该过程。

但是,您应该记住,Kubernetes将在30秒后强行终止进程(除非您更改terminationGracePeriodSecondsPod定义中的)。

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

您可以调用脚本以等待固定的时间,然后退出应用程序。

在调用SIGTERM之前,KubernetespreStop在Pod中公开一个钩子。

您可以将preStop钩子设置为等待15秒。

让我们看一个例子:

pod.yaml

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 LifeCycle钩子之一。

建议延迟15秒吗?

这要视情况而定,但这可能是开始测试的明智方法。

以下是您可以选择的选项的概述:

  1. 您已经知道,当删除Pod时,会通知kubelet更改。

  2. 如果Pod有一个preStop钩子,则首先调用它。

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

  4. 默认情况下,进程有30秒的时间退出,这包括preStop钩子。如果进程还没有退出,kubelet发送SIGKILL信号并强制终止进程。

  5. kubelet通知控制平面pod已成功删除。

宽限时间(Grace periods)和滚动更新

正常关机适用于要删除的Pod。

但是,如果不删除Pod,该怎么办?

即使您不这样做,Kubernetes也会始终删除Pod。

尤其是,每次部署较新版本的应用程序时,Kubernetes都会创建和删除Pod。

在部署中更改镜像时,Kubernetes会逐步推出更改。

pod.yaml

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 OLD
        image: nginx:1.19
        ports:
          - containerPort: 3000

如果您有三个副本,并且一旦提交新的YAML资源Kubernetes,则:

  • 用新的容器镜像创建一个Pod。

  • 销毁现有的Pod。

  • 等待Pod准备就绪。

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

Kubernetes仅在新的Pod准备好接收流量(换句话说,它通过Readiness检查)之后才重复每个周期。

Kubernetes是否在等待Pod被删除之后再移到下一个Pod?

并不会!!!

如果您有10个Pod,并且Pod需要2秒钟的准备时间和20个关闭的时间,则会发生以下情况:

  1. 创建第一个Pod,并终止前一个Pod。

  2. Kubernetes创建一个新的Pod之后,需要2秒钟的准备时间。

  3. 同时,被终止的Pod会终止20秒

20秒后,所有新的Pod均已启用(10个Pod ,在2秒后就绪),并且所有之前的10个Pod都将终止(第一个Terminated Pod将要退出)。

总共,您在短时间内将Pod的数量增加了一倍(运行10次,终止10次)。

与“Readiness”探针相比,宽限时间(graceful period)越长,您同时具有“Running(和Terminating)的Pod越多。

不好吗?

不一定,因为您要小心不要断开连接。

终止长时间运行的任务

那长期工作呢?

如果您要对大型视频进行转码,是否有其他方法可以延迟停止Pod?

假设您有一个包含三个副本的Deployment。

每个副本都分配了一个视频进行转码,该任务可能需要几个小时才能完成。

当您触发滚动更新时,Pod会在30秒内完成任务,然后将其杀死。

如何避免延迟关闭Pod?

您可以将其增加terminationGracePeriodSeconds几个小时。

但是,此时Pod的endpoint不可达。

如果公开指标以监视Pod,则检测工具将无法访问Pod。

为什么?

诸如Prometheus之类的工具依赖于Endpoints来在群集中探测Pod。

但是,一旦删除Pod,endpoint删除就会在群集中通告,甚至传播到Prometheus!

您应该考虑为每个新版本创建一个新的Deployment,而不是增加宽限时间(grace period)。

当您创建全新的deployment时,现有的deployment将保持不变。

长时间运行的作业可以照常继续处理视频。

完成后,您可以手动删除它们。

如果希望自动删除它们,则可能需要设置一个弹性伸缩,当它们用尽任务时,可以将部署扩展到零个副本。

这种Pod自动定标器的一个示例是Osiris,它是Kubernetes的通用,从零缩放的组件。

该技术有时被称为Rainbow部署,并且在每次您必须使以前的Pod运行超过宽限期的时间时很有用。

另一个很好的例子是WebSockets。

如果您正在向用户流式传输实时更新,则可能不希望在每次发布时都终止WebSocket。

如果您每天频繁发布,则可能会导致实时Feed多次中断。

为每个版本创建一个新的Deployment是一个不太明显但确是更好的选择。

现有用户可以继续流更新,而最新的Deployment服务于新用户。

当用户断开与旧Pod的连接时,您可以逐渐减少副本并退出旧的Deployment。

概要

您应该注意Pod从集群中删除,因为它们的IP地址可能仍用于路由流量。

与其立即关闭Pods,不如考虑在应用程序中等待更长的时间或设置一个preStop钩子。

仅在通告集群中的所有endpoint并将其从kube-proxy,Ingress控制器,CoreDNS等中删除后,才应删除Pod。

如果您的Pod运行诸如视频转码或使用WebSocket进行实时更新之类的长期任务,则应考虑使用Rainbow部署。

在Rainbow部署中,您为每个版本创建一个新的Deployment,并在耗尽连接(或任务)后删除上一个版本。

您可以在长时间运行的任务完成后立即手动删除较旧的Deployment。

或者,您可以自动将Deployment扩展到零副本,从而可以自动化该过程。

原文: https://learnk8s.io/graceful-shutdown

译者: 祝祥