本文翻译自: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也会被添加到调度器的队列中。
调度器:
在此过程结束时:
「但是,Pod此时并不实际存在。」
当您使用kubectl apply -f
命令提交一个Pod的时候,YAML文件会被发送到Kubernetes API。
API会将Pod保存在数据库(etcd)中。
调度器会为该Pod分配最合适的节点,并将Pod的状态更改为Pending。此时,Pod仅存在于etcd中。
「kubelet 的工作是轮询控制平面以获取更新。」
你可以想象 kubelet 不断地向主节点询问:“我负责管理工作节点 1,有没有新的 Pod 给我?”
当有一个 Pod 时,kubelet 就会创建它。
kubelet 并不是直接创建 Pod。相反,它将工作委托给其他三个组件:
在大多数情况下,容器运行时接口(CRI)的工作类似于:
docker run -d <my-container-image>
容器网络接口(CNI)更有趣一些,因为它负责:
正如你可以想象的,有多种方法可以将容器连接到网络并分配有效的 IP 地址(可以选择 IPv4 或 IPv6,或者分配多个 IP 地址)。
例如,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 的一部分时,还需要进行一些额外的步骤。
当你创建一个 Service 时,通常需要注意两个信息:
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.3
,targetPort
是 3000
,Kubernetes 将这两个值连接起来,并称之为一个端点。
IP 地址 + 端口 = 端点
---------------------------------
10.0.0.3 + 3000 = 10.0.0.3:3000
这些端点存储在 etcd 中,另一个名为 Endpoint 的对象中。
Kubernetes 中的术语区分如下:
e
表示)是 IP 地址 + 端口对(10.0.0.3:3000
)。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 对象会使用新的端点列表进行刷新:
因此,每次创建一个 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中被多个组件使用。」
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时发生的情况:
如果您的Pod属于一个服务:
type: LoadBalancer
类型,新的终端点将被配置为负载均衡器池的一部分。对于一个看似常见的任务-创建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收到更改通知并进行以下操作:
换句话说,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中不太可能立即从所有组件中删除终端点,您可以:
「你应该等待多久?」
「默认情况下,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 都迁移到新版本。
Kubernetes 只有在新 Pod 准备好接收流量(也就是通过了就绪检查)后,才会重复每个周期。
Kubernetes 是否会等待 Pod 被删除后再进行下一个操作?
「不会。」
如果你有 10 个 Pod,并且 Pod 需要 2 秒才能准备就绪,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。你可以在长时间运行的任务完成后手动删除旧的部署。或者你可以自动将部署的副本数缩减为零,以自动化这个过程。