在复杂的生产环境中,对 Kubernetes 节点进行内核升级、硬件维护或安全补丁是不可避免的运维任务。然而,一个鲁莽的节点下线操作可能会像一场小规模灾难,瞬间中断服务,甚至导致数据丢失。本文旨在为中高级工程师和技术负责人彻底厘清 Kubernetes 节点维护的核心命令 Cordon 与 Drain 的工作机制。我们将从操作系统信号、分布式系统状态一致性等底层原理出发,剖析 `kubectl` 命令行工具背后的实现逻辑,探讨在真实场景中(如处理有状态服务、DaemonSet)的工程陷阱与权衡,并最终勾勒出从手动操作到全自动节点维护的架构演进路径。
现象与问题背景
想象一个典型的场景:云服务商通知你,你的一台承载着核心交易业务 Pod 的虚拟机实例(即 K8s Node)将在 48 小时后因底层硬件故障而被强制重启。或者,SRE 团队发现一个高危内核漏洞(如 Dirty COW),要求立即对集群所有节点进行滚动补丁和重启。此时,摆在你面前的问题是:如何安全、平滑地将工作负载从目标节点上迁移出去,而不引起用户可感知的服务中断?
一个初级工程师可能会直接登录到节点上执行 `reboot` 命令。这将导致灾难性的后果:kubelet 进程被粗暴终止,它所管理的所有 Pod 也会被立即杀死(等同于 `kill -9`)。对于业务进程来说,这意味着:
- 没有机会执行清理逻辑(如关闭数据库连接、将内存中的数据 flush 到磁盘)。
- 对于依赖 Leader 选举的分布式应用,可能会因为旧 Leader 未能优雅退位而导致选举风暴或双主(Split-brain)问题。
– 服务中断时间等于 Pod 被重新调度、拉取镜像、启动并完成健康检查的全部时间,这对于延迟敏感的应用是不可接受的。
为了解决这个问题,Kubernetes 提供了一套标准的、优雅的节点维护流程,其核心就是 `cordon` 和 `drain` 两个命令。它们的设计目标是在保证服务高可用的前提下,将节点“排空”,使其进入可安全维护的状态。但这套流程并非银弹,其背后涉及复杂的分布式状态协调与生命周期管理,理解其深层原理是保障生产环境稳定性的基石。
关键原理拆解:Kubernetes如何实现“优雅”
要理解 Cordon/Drain,我们必须回归到几个计算机科学和分布式系统的基础原理,Kubernetes 正是这些原理的集大成者。
1. 声明式 API 与最终一致性(Declarative API & Eventual Consistency)
作为一位教授,我会告诉你,Kubernetes 的核心是其状态机模型。用户通过 `kubectl` 或其他客户端向 API Server 提交一个“期望状态”(Desired State),例如“我需要这个 Deployment 有 3 个副本”。存储在 etcd 中的是这个期望状态。而各个控制器(Controller)则像一群不知疲倦的工人,不断地通过 **Reconciliation Loop (调谐循环)** 观察“当前状态”(Actual State)与“期望状态”的差异,并采取行动来消除这个差异。`cordon` 和 `drain` 正是利用了这一机制。
- `cordon` 修改了节点的期望状态(标记为不可调度),Scheduler 在后续的调度循环中就会遵循这个新规则。
- `drain` 则通过 Eviction API 触发 Pod 的删除,这会改变 Deployment/StatefulSet 的当前副本数,其对应的控制器会立即发现“当前副本数 < 期望副本数”,从而触发在其他可用节点上创建新 Pod 的动作。这是一个最终一致性的过程。
2. 进程信号与 Pod 优雅终止(Process Signals & Graceful Termination)
当我们删除一个 Pod 时,Kubernetes 不会直接发送 `SIGKILL` 信号。它遵循一个严谨的、类似操作系统 `shutdown` 的流程:
- Pod 状态被更新为 `Terminating`。此时,Endpoint Controller 会将其从 Service 的后端列表中移除,新的流量不会再被路由到这个即将销毁的 Pod。
- 如果定义了 `preStop` Hook,kubelet 会执行它。这给了应用一个明确的信号来开始准备关闭,例如停止接受新任务、完成当前任务的处理。
- kubelet 向容器内的主进程(PID 1)发送 `SIGTERM` 信号。这是一个可以被应用程序捕获并处理的信号。标准的应用程序(如 Nginx, Tomcat)会在此刻开始优雅关闭流程:关闭监听端口、处理完所有活动连接、保存状态等。
- kubelet 等待一个名为 `terminationGracePeriodSeconds` 的时间(默认为 30 秒)。如果在此期间容器进程正常退出(退出码为 0),则流程结束。
- 如果超过了宽限期,进程仍未退出,kubelet 就会失去耐心,发送 `SIGKILL` 信号强制终结进程。
`drain` 命令正是依赖这个优雅终止机制,来确保每个被驱逐的 Pod 都能尽可能地“体面”退出,从而保护业务逻辑和数据完整性。
3. PodDisruptionBudget (PDB):服务可用性的保险丝
在复杂的微服务架构中,节点维护不能由基础设施团队“为所欲为”。应用负责人需要一种方式来声明其服务的可用性底线。PDB 就是这个“可用性合同”。它定义了在“自愿性中断”(Voluntary Disruption,如节点维护)期间,一个应用(由某个 Label Selector 标识的一组 Pod)必须保持可用的最小副本数或允许的最大不可用副本数。`drain` 命令在驱逐 Pod 时,会通过 **Eviction API** 与 API Server 交互。API Server 在处理驱逐请求时,会检查是否会违反目标 Pod 关联的任何 PDB。如果驱逐会导致可用副本数低于 PDB 设定的阈值,API Server 会拒绝该请求(HTTP 429 Too Many Requests),`drain` 命令会因此被阻塞,直到有足够的 Pod 恢复正常。PDB 是防止因运维操作导致整个服务雪崩的关键保护机制。
Cordon与Drain:不止是API调用
现在,让我们切换到极客工程师的视角,看看 `kubectl cordon` 和 `kubectl drain` 这两条命令在底层究竟做了什么。很多人误以为它们是原子性的服务端操作,但事实并非如此,尤其是 `drain`。
`kubectl cordon `:一个简单的状态标记
`cordon` 的实现非常直白。它本质上只是向 Kubernetes API Server 发送一个 PATCH 请求,将指定 Node 对象的 `spec.unschedulable` 字段设置为 `true`。
你可以用下面的命令看到同样的效果:
# 这条命令与 `kubectl cordon my-node-01` 等价
kubectl patch node my-node-01 -p '{"spec":{"unschedulable":true}}'
执行后,该节点会获得一个 `SchedulingDisabled` 的状态。Kubernetes 调度器(kube-scheduler)在为新创建的 Pod 寻找宿主机时,会过滤掉所有 `unschedulable` 为 `true` 的节点。关键点:`cordon` 只影响新 Pod 的调度,对已经运行在该节点上的存量 Pod 没有任何影响。 这就像是在酒店门口挂上了“客满”的牌子,不再接受新客人,但已入住的客人不受影响。
`kubectl drain `:一个复杂的客户端编排逻辑
`drain` 远比 `cordon` 复杂。它不是一个单一的 API 调用,而是 `kubectl` 客户端执行的一系列精心编排的步骤。如果你以为 `drain` 只是简单地循环删除节点上的所有 Pod,那就大错特错了。它的真实逻辑如下:
- 自动执行 Cordon: `drain` 的第一步总是将目标节点标记为 `unschedulable`,防止在驱逐过程中,新的 Pod 又被调度上来。
- 获取节点上的所有 Pod: 客户端向 API Server 请求该节点上的全部 Pod 列表。
- 过滤与分类: 客户端在本地对 Pod 列表进行过滤和分类。这是 `drain` 的精髓所在,也是最容易出问题的地方。
- DaemonSet Pods: 默认情况下,`drain` 会跳过由 DaemonSet 控制器管理的 Pod。为什么?因为 DaemonSet 的契约就是要在(几乎)所有节点上都运行一个副本。即使你删除了它,DaemonSet 控制器也会立刻在同一个节点上再创建一个,驱逐它毫无意义。因此,`drain` 遇到 DaemonSet Pod 时会报错并中止,除非你显式提供了 `–ignore-daemonsets` 参数。这是99%的情况下都需要加的参数。
- 拥有本地存储的 Pod: 如果 Pod 使用了 `emptyDir` 卷,当 Pod 被删除时,卷内数据会丢失。`drain` 认为这可能是一种非预期的行为,所以默认会报错退出。你需要添加 `–delete-emptydir-data`(Kubernetes v1.26+ 中为 `–delete-local-data`)来确认你知晓并接受数据丢失。
- 不受控制器管理的 Pod: 对于那些没有被 ReplicaSet、StatefulSet 等控制器管理的“裸 Pod”,删除后它们就永远消失了。`drain` 同样会默认失败,需要 `–force` 参数来强制执行。
- 执行驱逐(Eviction): 对于通过了过滤的 Pod,`drain` 会逐个、串行地调用 Eviction API 来请求删除。它不是并行删除,以避免瞬间对控制平面和剩余节点造成过大冲击。
// kubectl drain 内部逻辑的伪代码示意 func drainNode(node *v1.Node) error { // 1. Cordon the node cordonNode(node) // 2. Get all pods on the node pods, err := getPodsOnNode(node.Name) if err != nil { return err } // 3. Filter pods (DaemonSets, local storage, etc.) podsToEvict := filterPods(pods, drainOptions) // 4. Evict pods one by one for _, pod := range podsToEvict { // 使用 Eviction API, 而不是直接 DELETE // Eviction API 会检查 PodDisruptionBudgets err := evictPod(pod, drainOptions.GracePeriod) if err != nil { // 如果因为 PDB 导致驱逐失败 (HTTP 429), 会进行重试 if isPDBError(err) { // ... wait and retry logic ... } else { return fmt.Errorf("failed to evict pod %s: %v", pod.Name, err) } } } return nil } - 等待删除完成: 每驱逐一个 Pod,`drain` 都会等待该 Pod 真正从节点上消失后再进行下一个,确保资源(如 CPU、内存)被完全释放。
这个流程清晰地表明,`drain` 是一个有状态、有检查、有重试的客户端侧工作流,而不是一个简单的服务端命令。它的设计充满了对生产环境复杂性的敬畏。
核心权衡(Trade-offs):没有银弹
在实际操作中,`drain` 流程并非总是一帆风顺。你需要理解其中的权衡,才能做出明智的决策。
- 安全 vs. 速度: PDB 是安全的保障,但也可能成为效率的瓶颈。一个过于严格的 PDB(例如,`minAvailable: 100%` 或 `maxUnavailable: 0` 对于一个单副本应用)将完全阻止 `drain` 的执行。在紧急情况下,你可能需要在应用团队的确认下,临时调整 PDB 或使用 `–disable-eviction`(不推荐)来绕过检查。
- 有状态服务与本地存储: 这是 `drain` 最大的挑战之一。对于使用网络存储(如 Ceph, EBS, NFS)的 StatefulSet,`drain` 后 Pod 会在其它节点上重建,并重新挂载原来的 PersistentVolume,数据不会丢失。但如果 StatefulSet 使用了 `local-pv`(本地磁盘),被驱逐的 Pod 将永远处于 `Pending` 状态,因为它无法在其他节点上找到那个特定的本地磁盘。此时,你需要的是更复杂的数据迁移方案,而不是简单的 `drain`。
- `–force` vs. `–grace-period=0`: 这两个参数都显得很“暴力”,但含义完全不同。`–grace-period=0` 只是将 Pod 的优雅终止时间缩短为 0,让 kubelet 立即发送 `SIGKILL`,它依然会走标准的删除流程,依然会检查 PDB。而 `–force` 则是一个“霸王条款”,它会绕过所有检查,直接删除 Pod,包括那些不受控制器管理的 Pod。`–force` 是最后的手段,在你明确知道后果(比如数据丢失、违反PDB)时才应使用。
- 客户端健壮性: `drain` 是一个长时间运行的客户端命令。如果你的 `kubectl` 进程与 API Server 的网络连接中断,`drain` 过程就会失败,可能留下一个只排空了一半的“半残”节点。这也是为什么在自动化系统中,我们倾向于使用 Operator 模式,将这个工作流的“状态”持久化在集群内部。
架构演进:从手工运维到自动化“无人机”
节点的生命周期管理,是衡量一个 Kubernetes 平台成熟度的重要标志。其演进路径通常遵循以下阶段:
阶段一:纯手工操作
运维工程师通过 SSH 登录到跳板机,手动执行 `kubectl cordon` 和 `kubectl drain`。这个阶段充满了风险:命令参数记错、忘记 `uncordon`、网络中断导致操作失败等。只适用于小规模或非生产环境。
阶段二:脚本化封装
将 cordon/drain 流程封装在一个 Bash 或 Python 脚本中。脚本可以增加一些必要的检查,例如:
- 前置检查: 确认集群是否有足够的备用容量来容纳被驱逐的 Pod。
- 参数固化: 将 `–ignore-daemonsets` 等常用参数固化在脚本中,减少人为失误。
- 后置操作: 在节点维护完成后,自动执行 `kubectl uncordon`。
- 集成告警: 在 `drain` 失败或超时后,通过 Webhook 发送告警到 Slack 或 PagerDuty。
这大大提高了操作的一致性和可靠性,是大多数中型团队采用的方案。
阶段三:自动化控制器(Operator/Controller)
这是最理想的“云原生”解决方案。我们开发一个自定义的 Kubernetes 控制器(通常称为 Node Maintenance Operator 或 Node Problem Detector 的一部分),来全权负责节点的维护生命周期。
它的工作模式是:
- 定义 CRD: 创建一个自定义资源定义(CRD),如 `NodeMaintenance`。当需要维护一个节点时,运维人员或外部系统(如监控、云厂商 API)只需创建一个 `NodeMaintenance` 对象,例如 `kubectl apply -f my-node-maintenance.yaml`。
- Watch & Reconcile: 控制器 Watch `NodeMaintenance` 资源的变化。一旦有新的对象被创建,它就会触发调谐循环。
- 执行工作流: 控制器在其调谐逻辑中,稳定、可靠地执行脚本化阶段的所有操作。但与客户端脚本不同,控制器的状态是持久化的。即使控制器 Pod 自身被重启,它也能从 `NodeMaintenance` 对象的状态中恢复,知道上次执行到了哪一步,从而实现断点续传。
- 与外部系统集成: 控制器可以直接调用云厂商的 API 来执行重启、替换实例等物理操作,形成完整的闭环。
- 完成与清理: 维护完成后,控制器更新 `NodeMaintenance` 资源的状态为 `Completed`,并自动 uncordon 节点。
像 GKE 的节点自动升级/修复功能,其底层就是一套类似的、高度成熟的自动化控制器。通过构建这样的自动化系统,节点维护可以从一个需要多人协作、小心翼翼执行的高风险任务,变成一个常规、无感的“无人机”式操作,极大地提升了整个技术团队的生产力与幸福感。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。