在任何有一定规模的Kubernetes集群中,节点维护都是一个无法回避的常规操作。无论是内核升级、硬件更换还是安全补丁,我们都需要一种机制来平滑地将节点上的工作负载迁移出去,同时保证业务的连续性和数据的完整性。Kubernetes为此提供了Cordon和Drain两个核心命令,但这背后远不止是简单的API调用。本文旨在为中高级工程师和架构师剖析其深层机制,从分布式系统原理、操作系统信号,到网络端点切换,最终探讨如何构建一套健壮的自动化节点维护体系。
现象与问题背景
想象一个场景:你需要为一个运行着关键业务(如交易系统或实时风控应用)的Kubernetes集群中的某个节点打上紧急安全补丁,这要求重启节点。一个初级工程师可能会直接在云厂商控制台或者物理机上执行重启。几秒钟后,监控系统开始告警:服务P99延迟飙升、大量HTTP 503错误、数据库连接池耗尽、甚至出现短暂的数据不一致。这是为什么?
直接重启一个节点,相当于在分布式系统中粗暴地“拔掉电源”。这会引发一系列连锁反应:
- 服务中断: Kubelet停止上报心跳后,节点状态会变为
NotReady。经过一段时间(由--pod-eviction-timeout控制,默认为5分钟),控制平面才会将该节点上的Pod标记为Terminating或Unknown,并在其他节点上重建。在这5分钟的“黑障时间”里,发往这些Pod的流量会持续失败。 - 连接风暴: 客户端与Pod的长连接(如WebSocket、gRPC stream)被非正常中断,导致大量重连请求,可能冲击整个集群的入口和后端服务。
- 状态丢失: 对于有状态应用,尤其是那些依赖本地磁盘(
emptyDir或hostPath)进行临时数据交换或缓存的Pod,未经协调的重启将导致数据全部丢失。对于依赖PV的StatefulSet,如果云盘还未被正常detach,新的Pod可能无法挂载,陷入ContainerCreating的死循环。 - 破坏服务发现: 虽然Pod被标记为终止,但Endpoint Controller将其从Service的Endpoints列表中移除需要时间。在这期间,
kube-proxy(或等效的CNI组件)的转发规则依然可能将流量导向这个已经“死亡”的节点。
这些问题的根源在于,我们破坏了分布式系统设计中的一个核心原则:节点的平滑退出(Graceful Departure)。Kubernetes的Cordon与Drain机制,正是为了解决这一问题而设计的标准化流程。
关键原理拆解
要真正理解Cordon和Drain,我们必须回归到底层的计算机科学原理。这不仅仅是Kubernetes的API,更是分布式系统、操作系统和网络协议栈协同工作的体现。
(学术派视角)
从分布式系统理论看,Kubernetes集群是一个典型的去中心化(控制平面除外)状态机。每个节点的状态(是否可调度、运行了哪些Pod)都是整个集群状态的一部分。Cordon和Drain本质上是在执行一个经过精心编排的状态转换协议。
- Cordon (隔离): 这一步对应的是分布式系统中的“隔离”或“标记待下线”。它并不改变节点上现有工作负载的状态,而是向集群的“大脑”——调度器(Scheduler)声明:“此节点不再接受新的工作单元”。这在学术上称为阻止新的任务分配,是实现系统优雅缩容的第一步。它通过修改节点对象在etcd中的一个状态字段来实现,这是一个原子性的元数据更新,保证了集群范围内的一致性。
- Drain (驱逐): 这一步是主动、有序地终止节点上的工作负载。它不是一个单一的原子操作,而是一个协调过程(Coordination Protocol)。该协议需要与多个组件交互:
- 与Pod生命周期管理器(Kubelet)协作,确保Pod按照预定义的终止流程(
preStophook,SIGTERM)关闭。 - 与副本控制器(ReplicaSet/Deployment Controller)协作,确保被驱逐的Pod能在其他健康节点上被重建,维持期望的副本数。
- 与服务可用性管理器(PodDisruptionBudget Controller)协作,确保驱逐操作不会违反服务的高可用性契约(例如,不能同时驱逐超过N%的副本)。
- 与Pod生命周期管理器(Kubelet)协作,确保Pod按照预定义的终止流程(
从操作系统层面看,Pod的终止过程直接映射到Linux的进程管理模型。当Kubelet收到驱逐Pod的指令时,它会:
- 首先执行Pod Spec中定义的
preStop钩子。这是一个给予应用自我清理、保存状态、通知其他组件自己即将下线的宝贵机会。 preStop完成后,Kubelet向容器内的主进程(PID 1)发送SIGTERM信号。这是一个可捕获的信号,应用程序应该实现信号处理器,在其中完成关闭数据库连接、刷新文件缓冲区、完成当前请求等收尾工作。- Kubelet开始等待,等待时长由
terminationGracePeriodSeconds定义。如果在此时限内进程仍未退出,Kubelet将失去耐心,发送无条件终止的SIGKILL信号,强制杀死进程。这通常是我们希望避免的,因为它剥夺了应用优雅退出的所有机会。
系统架构总览
要执行一次完整的kubectl drain,背后涉及多个Kubernetes核心组件的协同工作。这更像一出精心编排的戏剧,而不是一个简单的命令。
我们可以将整个流程的参与者和信息流描绘如下:
参与者:
- Client (kubectl): 用户命令的发起者。它是一个智能客户端,负责编排整个Drain流程,向API Server发送一系列请求。
- API Server: 集群的中枢,所有组件状态变更的唯一入口,负责认证、授权、持久化状态到etcd。
- etcd: 分布式键值存储,是集群所有状态的真理之源(Source of Truth)。
- Scheduler: 监视Pod对象,当发现有未调度的Pod时,为其选择一个合适的节点。Cordon操作的核心影响对象就是它。
- Controller Manager: 内部包含多个控制器,在Drain流程中,Deployment/ReplicaSet Controller负责重建Pod,Endpoint Controller负责更新Service的后端端点。
- Kubelet: 运行在每个节点上的代理,负责管理本节点上Pod的生命周期,是最终执行Pod终止操作的单元。
流程描述:
- 用户 在终端执行 `kubectl drain my-node-01 –ignore-daemonsets`。
- kubectl客户端 首先向 API Server 发送一个 `PATCH` 请求,将 `my-node-01` 节点的 `.spec.unschedulable` 字段设置为 `true`。这就是Cordon。
- Scheduler 在其内部缓存中感知到节点状态的变化,后续在调度新Pod时,会过滤掉 `my-node-01`。
- kubectl客户端 接着向 API Server 发送一个 `GET` 请求,列出运行在 `my-node-01` 上的所有Pod。
- kubectl客户端 在本地对Pod列表进行过滤。根据用户参数(如`–ignore-daemonsets`),它会排除掉DaemonSet管理的Pod。它也会排除不受控制器管理的“裸Pod”。
- 对于每个需要被驱逐的Pod,kubectl客户端 不会发送`DELETE`请求,而是向专门的Eviction API (`/api/v1/namespaces/{namespace}/pods/{pod-name}/eviction`)发送一个`POST`请求。
- API Server 收到Eviction请求后,会检查相关的PodDisruptionBudget (PDB)。如果驱逐这个Pod会导致服务违反PDB设定的可用性下限(如`minAvailable`),API Server会拒绝该请求,返回HTTP 429 (Too Many Requests)。
- 如果PDB检查通过,API Server 会更新etcd中该Pod对象的状态,为其添加一个`deletionTimestamp`。
- 运行在`my-node-01`上的Kubelet通过Watch机制监听到自己节点上的Pod被标记为删除。
- Kubelet开始执行上文提到的Pod终止流程:运行`preStop`钩子,发送`SIGTERM`,等待`terminationGracePeriodSeconds`,最后可能发送`SIGKILL`。
- 与此同时,Deployment/ReplicaSet Controller 也监听到Pod的删除,发现当前副本数低于期望值,于是创建一个新的Pod。
- Scheduler 挑选一个可用的新节点(非`my-node-01`),将新Pod调度上去。
- 新Pod在新节点上启动后,Endpoint Controller 会监听到Pod状态变为Running且Ready,然后更新相关Service的Endpoint对象,将新Pod的IP地址添加进去。旧Pod终止后,其IP也会被从中移除。
- kubectl客户端 会持续轮询,直到`my-node-01`上所有目标Pod都被成功删除,然后命令执行完毕。
核心模块设计与实现
(极客工程师视角)
理论说完了,来看点实在的。坑都在细节里。
Cordon的实现:一个Taint而已
Cordon操作听起来很高级,但其实现朴素得令人发指。它就是给Node对象打上一个特殊的Taint(污点)。
# 执行 kubectl cordon my-node-01 后,查看节点信息
$ kubectl describe node my-node-01
...
Taints: node.kubernetes.io/unschedulable:NoSchedule
...
当`spec.unschedulable`被设置为`true`时,Kubernetes会自动添加这个`NoSchedule`效果的Taint。调度器内部的谓词(Predicates)逻辑会检查节点的Taints,如果一个Pod没有对应的Toleration(容忍),就无法被调度到这个节点上。就是这么简单,但极其有效。解除Cordon(`kubectl uncordon`)就是移除这个Taint。
Drain的核心:Eviction API与PDB
新手最容易犯的错就是用`kubectl delete pod`来代替`drain`。千万别这么干!`delete`是无情的,它直接绕过了PDB,可能瞬间让你的服务崩溃。而`drain`命令使用的Eviction API是“有礼貌的”驱逐。
PodDisruptionBudget (PDB)是定义服务可用性契约的关键资源。来看一个例子:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: my-app-pdb
spec:
minAvailable: 2 # 或者 maxUnavailable: 1, 或者 minAvailable: "80%"
selector:
matchLabels:
app: my-app
这个PDB规定了标签为`app: my-app`的服务,在任何时候都必须至少有两个副本是可用的。当Eviction API收到对`my-app`某个Pod的驱逐请求时,它会去计算:如果我批准了这次驱逐,`my-app`的可用副本数会变成多少?如果结果小于2,请求就会被拒绝。`kubectl drain`会收到一个429错误,然后默认会重试。如果PDB配置不当(例如,一个只有2个副本的服务,`minAvailable`却设为2),那么`drain`将永远卡住,除非你手动调整PDB或强制删除Pod。
这是一个典型的“分布式信号量”实现,PDB就是那个控制并发访问(这里是并发驱逐)的信号量。
Kubelet的优雅终止逻辑
应用的优雅退出能力,直接决定了维护过程的平滑度。一个写得好的应用,其Dockerfile和Kubernetes YAML定义中应该包含这些:
首先,在应用代码层面,需要捕获`SIGTERM`。
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
server := &http.Server{Addr: ":8080"}
// 启动HTTP服务
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
fmt.Printf("HTTP server ListenAndServe: %v\n", err)
}
}()
// 监听终止信号
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
// 阻塞,直到收到信号
<-stop
fmt.Println("Shutting down server...")
// 创建一个有超时的context,用于优雅关闭
// 给服务器5秒时间处理剩余请求
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
fmt.Printf("HTTP server Shutdown: %v\n", err)
}
fmt.Println("Server gracefully stopped")
}
然后,在Pod的YAML中,合理设置`terminationGracePeriodSeconds`。这个值必须大于你应用完成优雅关闭所需的时间。例如,上面的Go代码最多需要5秒,那么这个值至少应该设置为10秒,留出一些缓冲。
apiVersion: v1
kind: Pod
metadata:
name: my-graceful-app
spec:
containers:
- name: server
image: my-app:v1
terminationGracePeriodSeconds: 10 # 给予10秒的优雅终止时间
性能优化与高可用设计
在真实的大规模集群维护中,单纯依赖`kubectl drain`是不够的,我们需要考虑更多对抗性的问题。
- Drain速度与安全性的权衡: 默认的`kubectl drain`是串行驱逐Pod,效率较低。在大型节点上(几百个Pod),这可能需要数十分钟。虽然可以通过并行执行多个`drain`任务来加速,但这会增加同时驱逐多个Pod导致违反PDB的风险。自动化工具需要设计一种智能的批处理和并行策略,例如,按服务分组,组内串行,组间并行。
- 无主的Pod和`emptyDir`数据丢失: `kubectl drain`默认会因为节点上有不受控制器管理的Pod而失败,需要加上`--force`。更危险的是`--delete-emptydir-data`参数。如果你的应用(哪怕是无状态的)使用`emptyDir`作为临时工作区,不加这个参数`drain`会失败;加了,数据就会丢失。对于需要用`emptyDir`做快速本地缓存的应用,必须确保应用层有重建缓存或从其他副本同步数据的机制。
- StatefulSet的特殊挑战: 驱逐StatefulSet的Pod尤其棘手。由于其Pod具有稳定的身份和绑定的PV,控制器不会像Deployment那样随意创建新Pod。你必须确保下层的存储类(Storage Class)支持快速的PV Detach/Attach。在公有云上,这个过程可能需要几分钟,期间应用处于不可用状态。对于数据库这类应用,最佳实践是在执行`drain`之前,通过应用自身的管理工具(如`pg_ctl`、`mysqlfailover`)先将该节点上的副本设置为备库或从集群中安全移除,然后再执行`drain`。
- DaemonSet的处理: `--ignore-daemonsets`是一个必要的“谎言”。它并不是真的忽略了DaemonSet的Pod,而是`drain`命令本身不会去删除它们,因为它们本来就应该在每个节点上运行一个。当节点被Cordon后,DaemonSet控制器不会在上面创建新Pod,但已有的Pod会继续运行直到节点关机。这通常是期望的行为(例如日志、监控代理)。
架构演进与落地路径
一个成熟的团队,其节点维护流程会经历以下几个阶段的演进:
第一阶段:纯手动操作
工程师SSH到跳板机,手动执行`kubectl cordon`和`kubectl drain`。这个阶段充满了风险,高度依赖工程师的经验和责任心。容易出现忘记参数、选错节点、在业务高峰期操作等问题。只适用于小型、非核心业务的集群。
第二阶段:脚本化半自动
使用Shell或Python脚本封装`kubectl`命令,实现批量节点的维护。脚本可以加入前置检查(如检查集群容量、PDB配置),以及后置验证(如等待节点重启后恢复Ready状态)。这降低了单次操作的失误率,但依然缺乏全局视图和闭环控制,当中断或失败时,需要人工介入。
#!/bin/bash
NODES_TO_MAINTAIN=("node-1" "node-2" "node-3")
for node in "${NODES_TO_MAINTAIN[@]}"; do
echo "--- Starting maintenance for ${node} ---"
# 1. Cordon
kubectl cordon "${node}"
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to cordon ${node}"
continue
fi
# 2. Drain
kubectl drain "${node}" --ignore-daemonsets --delete-emptydir-data --timeout=15m --force
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to drain ${node}"
# Consider adding uncordon logic here for cleanup
kubectl uncordon "${node}"
continue
fi
# 3. (Here you would trigger the actual node reboot/maintenance via cloud API or other means)
echo "Node ${node} is drained. Ready for maintenance."
sleep 300 # Simulate maintenance work
# 4. Uncordon
kubectl uncordon "${node}"
echo "--- Finished maintenance for ${node} ---"
done
上面这个脚本虽然简单,但已经体现了流程化的思想。然而,它的错误处理和状态管理能力非常薄弱。
第三阶段:平台化全自动(终极形态)
构建一个Kubernetes原生(Cloud-Native)的自动化节点维护系统。这通常是一个Kubernetes Operator。这个Operator会:
- 声明式API: 提供一个自定义资源(CRD),例如`NodeMaintenanceRequest`。运维人员不再执行命令,而是创建一个CR,声明他们希望对哪些节点(可以通过label selector)在什么时间窗口内执行维护。
- 智能调度与协调: Operator的控制循环(Reconciliation Loop)会监视这些CR。它会根据集群的当前状态、PDB约束、业务负载等因素,智能地决定一次维护多少个节点,以何种顺序进行。例如,它可以确保一个可用区内的节点不会被同时维护。
- 与外部系统集成: Operator可以调用云厂商的API来安全地重启、替换实例,或者与物理机管理系统(如Ansible, Puppet)联动。
- 闭环与可观测性: 整个过程的状态(Pending, Draining, Rebooting, Succeeded, Failed)都会被实时更新回CR的状态字段。这为监控和告警提供了坚实的基础。如果某个步骤失败,Operator可以自动重试,或者在多次失败后暂停并告警,等待人工干预。
这种模式将节点维护从一个高风险的人工任务,转变为一个稳定、可预测、无人值守的平台能力,是实现大规模、高等级SLA集群管理的必经之路。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。