深夜两点半被值班电话叫醒的那种心情,做过K8s生产运维的朋友应该都不陌生。那次故障的导火索是集群里好几个业务同时报DNS解析超时,context deadline exceeded刷满了服务端日志。我们沿着CoreDNS查了一圈,上游正常、副本正常、CPU内存都正常,最后才发现是某一台节点上的磁盘文件系统被系统强制置为只读。Node Local DNS进程虽然显示Running,但实际上已经彻底失去了读写能力,整个节点的服务发现链路在无声无息中瘫痪。
那次之后我花了大概两周时间,把"节点磁盘故障的自动发现与恢复"做成了一个专门的自愈系统。核心思路很直接:把Node Local DNS当作节点健康度的哨兵进程,围绕它建立一套从探测、决策到执行、审计的闭环。这篇文章我会把这个系统从设计到落地的完整思路整理出来,包括磁盘故障为什么那么难发现、自愈动作怎么分级才不会越修越坏,以及我踩过的几个差点让自愈系统变成事故源的坑。适合集群规模在几十到几百个节点、需要自己做平台稳定性建设的SRE和容器平台工程师参考。
1. 为什么把自愈系统的哨兵压在Node Local DNS上
1.1 Node Local DNS解决了很多问题,也制造了一片盲区
在标准K8s集群里,每个Pod的DNS解析默认会通过iptables转发到kube-dns/CoreDNS的ClusterIP上。流量集中意味着所有解析请求都要经过主机网络做DNAT,当集群Pod密度上来之后,conntrack表很容易被DNS请求打满,表现就是解析延迟飙升、丢包、偶发超时。Node Local DNS的思路是每个节点上跑一个本地DNS缓存,Pod直接访问169.254.20.10这样的链路本地地址,请求不再出节点,既绕开了conntrack瓶颈,也让中心CoreDNS的压力大幅下降。这是它解决的核心问题。
但这里有一个很少有人认真想过的盲区:对业务Pod而言,DNS解析路径变成了"Pod -> 本地Node Local DNS -> 中心CoreDNS"。如果节点上的Node Local DNS进程挂了,或者节点基础设施出了问题导致它无法正常工作,业务表现就是解析超时。可监控系统里看中心CoreDNS一切正常,看节点的Load、CPU也一切正常,于是很容易把排查方向引到业务自身代码上。DNS链路中间插入了一个"黑盒",而这个黑盒的健康状态恰恰决定了整条链路的可用性。
我当时就是把注意力放在了这里。想要及时发现节点级故障,与其同时盯着几十个基础指标,不如盯住一个同时满足两个条件的进程:它对节点故障极度敏感,它一挂就会直接影响业务。Node Local DNS完美符合这两个条件。
1.2 磁盘故障会在Node Local DNS身上"先走一步"
为什么是磁盘故障而不是其他?因为Node Local DNS虽然运行在hostNetwork模式下,算得上容器里比较"轻"的进程,但它依赖的节点基础设施相当多:它要在节点网络栈上监听169.254.20.10的53端口,这依赖socket的创建;它的配置从ConfigMap挂载,进程重载或者Pod重建时容器运行时要在节点上完成大量文件读写;它的运行状态和探活记录要写日志;Node Local DNS所在节点上的kubelet还要持续向API Server上报状态。
这些依赖关系里,只要节点的文件系统出问题,几乎每个环节都会先于全局故障暴露出来。比如磁盘被强制只读,Node Local DNS首当其冲无法重建socket文件,新连接全部超时,然后才是kubelet状态上报异常、节点NotReady。这给了我们一个非常宝贵的"预警窗口"——只要把探针设计到位,完全可以在节点变为NotReady之前发现磁盘异常,提前介入处理。
我最后确定的架构里,Node Local DNS不再只是一个DNS缓存,而是整个节点健康体系的哨兵。谁先被磁盘故障打倒,谁就有资格当报警器。这就是标题里"基于Node Local DNS架构"的真正含义。
2. 磁盘故障的四种形态,以及它们为什么骗过常规监控
在设计自愈系统之前,必须先搞清楚敌人长什么样。磁盘故障不是只有"磁盘满了"这一种,生产环境里我实际遇到过四种完全不同的形态,每种形态对常规监控的欺骗性都不一样。
2.1 文件系统被强制只读:df看起来一切正常
这是最阴险的一种。节点磁盘因为硬件I/O错误、文件系统日志异常等原因,被内核重新挂载为只读。这时候df -h看到的空间数字完全正常,内存CPU正常,节点状态可能还是Ready,但任何写操作都会报"Read-only file system"。
常规监控完全失效,因为我们习惯性监控的是"空间使用率"而非"可写性"。我当时能发现这个问题,完全是因为Node Local DNS突然开始疯狂报错,进到节点上随手试了一句touch /var/lib/kubelet/test && rm /var/lib/kubelet/test才确认。这个教训让我把"主动写探测"列入了探针的必选动作,并且放在所有磁盘指标的第一优先级。
2.2 inode耗尽:空间有富余,文件却一个都建不出来
本质上是小文件把inode表耗尽了。容器场景下特别容易出现,因为镜像层、容器日志、临时文件都有大量小文件。节点上df -h显示根分区还剩几十个GB,但df -i一看inode使用率已经接近100%。这个时候任何需要新建文件的操作都会失败,应用表现非常诡异,比如"File exists"、莫名Permission denied。
常规监控默认只采空间使用率,不采inode使用率,很多团队甚至根本没有这个告警。但inode耗尽对容器运行时是致命的,容器创建沙箱时会做大量临时文件操作,一旦失败,整个节点上的Pod调度和重建都会卡住。
2.3 空间满:最容易被发现,也最容易被误解
这是四种形态里唯一"比较好发现"的,但同样有坑。Kubelet本身有镜像GC和容器GC机制,默认在磁盘使用率达到85%时开始清理。可如果短时间内日志暴增、镜像拉取频繁,85%的阈值不一定来得及兜住,磁盘可能直接冲到100%。这时候容器运行时的写操作会开始失败,新Pod起不来,已有Pod如果被驱逐或重建,也会卡在ContainerCreating。
更麻烦的是,空间满的情况在Prometheus里通常能看到node_filesystem_avail_bytes的断崖下降,但告警如果叠加了五分钟持续时间的条件,等确认告警的时候业务可能已经受损了。自愈系统需要的不是"发现空间满",而是"在空间满发生前预判并自动清理"。
2.4 IO延迟飙升:所有指标正常,但所有请求都慢
磁盘老化、云盘限流、硬件故障前兆,都会导致IO延迟异常升高。df正常、磁盘空间正常、节点Load可能也不高,但是每次读写都卡几百毫秒甚至几秒。Node Local DNS表现就是偶发性解析超时,业务表现为服务间调用变慢。这种故障最难定位,因为常规监控里几乎找不到直接证据,iostat里的await和svctm是有效指标,但很少有人对这些指标设置合理阈值。
四种形态整理成表格看起来更清楚:
| 故障形态 | 常规监控表现 | 对Node Local DNS的真实影响 |
|---|---|---|
| 文件系统只读 | df正常,节点仍Ready,无异常告警 | socket重建失败、配置重载失败、解析超时 |
| inode耗尽 | 空间充足,无空间告警 | 容器运行时无法创建临时文件,Pod启动失败或挂起 |
| 空间满 | 空间告警有延迟,可能5分钟后才触发 | 日志无法写入,Pod被驱逐或无法创建 |
| IO延迟飙升 | 所有常规指标正常,节点看似健康 | DNS查询超时,进程存活但响应极慢 |
看清这四种形态之后,我确定了一件事:单靠Prometheus拉取系统指标做"被动发现"永远不够,自愈系统的检测层必须是"主动探测",站在业务视角去验证基础设施的真实可用性。这个思路贯穿了后面整个设计。
3. 系统的四层骨架:探测、决策、执行、审计
整套系统我按四个平面拆分:探测负责收集事实,决策负责做判断,执行负责动手术,审计负责留证据。这个拆分不是为了好看,是踩了几次坑之后得到的教训——如果检测和修复耦合在一个脚本里,一旦修复逻辑出Bug,连"发现故障"的能力都会一起崩掉。四层独立之后,即使决策或执行层出了问题,探针产出的原始数据依然可用,人工还能基于数据介入处理。
3.1 探测层:每个节点上的disk-probe
探测层是一个DaemonSet,名字就叫disk-probe,跑在每一个节点上。它做的事情分成三类:一是节点磁盘健康探测(空间、inode、只读、IO延迟),二是Node Local DNS业务视角的健康探测(实际发起DNS查询),三是节点基础服务状态采集(kubelet响应、容器运行时响应)。所有探测结果以Prometheus指标格式暴露,同时周期性地推送给决策层。
这一层最关键的设计原则是:"宁可探测频率低一点,也不能因为探测本身给节点增加负担。"所以写探测的默认频率是30秒一次,读探测10秒一次,DNS业务探测5秒一次,而且每一项都做了超时控制,超时立即判定为失败,不等到底层IO卡死拖垮探针自身。
3.2 决策层:一个只做状态机判断的轻量Operator
决策层是我在集群里跑的一个轻量Operator,逻辑非常简单,只做三件事:聚合探测数据、维护每个节点的健康状态、根据状态输出执行指令。它不直接操作节点,而是把指令写入一个自定义资源(我定义了一个叫NodeDiskHealth的CRD),由节点上的执行器来消费。状态机分为四个状态:Healthy、Degraded、Faulted、Fencing。
之所以做成Operator而不是简单的告警脚本,是因为状态机里有一个很重要的上下文概念:连续N次探测失败才进入Degraded,连续M次恢复才回到Healthy,不能让偶发抖动触发自愈动作。这个"连续N次"的判定逻辑,用Prometheus告警规则也能写,但跨指标、跨状态的复杂判定会有大量表达式,维护起来非常痛苦。用Operator写起来清爽得多。
3.3 执行层:幂等动作与节点侧执行器
执行层是节点上的另一个DaemonSet,负责消费NodeDiskHealth里的指令并执行具体动作。设计上所有动作必须是幂等的,重复执行不能造成额外破坏。比如"触发一次kubelet镜像清理"执行十次和一次的效果一样;"重启Node Local DNS Pod"通过删除DaemonSet管理的Pod来做,重复执行最多就是再删一个已经新建好的Pod,问题不大。
执行层和决策层都要求有"人工熔断开关"——我在CRD里放了一个字段,可以锁住整个节点或整个集群的自愈动作,人工接管时把开关置为locked即可。这个开关救过我一次,后面踩坑部分会细说。
3.4 审计层:所有动作留痕,方便事后复盘
所有探测结果、状态变化、自愈动作,都会以Kubernetes Event和日志两种形式落地。Event记录在NodeDiskHealth资源的status里,日志则输出到标准的stdout,由集群里的日志组件收集。这样每次自愈动作是什么时候触发的、当时节点的探测数据到底是什么样的、执行动作的结果如何,事后都能完整回放。
审计层的重要性在事故复盘时才会体现。有一次某个节点被自动驱逐了Pod,业务方来问为什么,我们直接拉出当时的Event,上面清清楚楚写着触发条件、探测数据以及动作等级,省去了大量扯皮时间。
4. 写到生产环境时的关键细节
骨架搭好之后,真正决定系统质量的是细节。这一章我挑几个最重要的细节展开讲,每个都是实际运行中验证过的方案。
4.1 写探测怎么设计才不会误伤磁盘
写探测是所有探测里优先级最高的,也是设计上最容易出错的一环。探测文件会真实写入磁盘,如果写得太频繁、文件选的位置不合适,可能会加剧磁盘磨损,甚至污染文件系统。我这里的方案如下:
- 探测目录单独创建,使用
/var/lib/node-probe,不放在/var/lib/kubelet下,避免和kubelet的目录管理产生交叉影响 - 探测文件常驻一个固定文件,每次写入固定内容,写完调fsync并记录耗时,不反复创建删除文件
- 每次探测之间间隔至少30秒,避免对SSD造成不必要的写放大
- 探测超时阈值设为3秒,超过即判定写探测失败
核心脚本大概长这样:
#!/bin/bash PROBE_DIR="/var/lib/node-probe" PROBE_FILE="${PROBE_DIR}/write.tmp" START=$(date +%s%N) if ! echo "disk-probe" > "${PROBE_FILE}" 2>/dev/null; then echo "disk_probe_write_status 1" > /var/lib/node-probe/metrics exit 1 fi if ! sync -f "${PROBE_FILE}" 2>/dev/null; then echo "disk_probe_write_status 2" > /var/lib/node-probe/metrics exit 1 fi END=$(date +%s%N) COST_MS=$(( (END - START) / 1000000 )) if [ "${COST_MS}" -gt 3000 ]; then echo "disk_probe_write_status 3" > /var/lib/node-probe/metrics exit 1 fi echo "disk_probe_write_status 0" > /var/lib/node-probe/metrics注意这里我用了sync -f而不是裸写一个文件,因为很多情况下写文件内容会先落在page cache里,内核还没真正落盘就返回成功,写探测就会失去意义。必须强制同步落盘,测到的延迟才接近真实IO状态。
4.2 只读文件系统的双重确认
只读文件系统不能只靠写探测的结果来判定,因为有可能探测目录所在的分区正常,但其他分区被单独设置为只读。稳妥的做法是双重确认:先读取/proc/mounts,检查所有关键挂载点(/、/var、/var/lib/kubelet等)的mount选项里有没有ro标志;然后再做一次实际的写探测。两者同时命中才判定为只读故障,降低误报率。
有个细节是/proc、/sys这类伪文件系统的mount选项里本来就可能有只读相关配置,所以只扫挂载点列表时要过滤掉这些虚拟文件系统,否则会一直误报。
4.3 用业务视角探测Node Local DNS
很多团队的Node Local DNS监控停留在"Pod Running就行",但Pod Running不代表它的DNS响应能力正常。我在disk-probe里加了一个业务视角的探测:直接在节点上发起一次真实的DNS查询,目标是集群内的一个Service域名。用dig其实有点重,我用的是getent hosts或者kdig的轻量模式,超时定为500毫秒。
#!/bin/bash DNS_IP="169.254.20.10" TEST_DOMAIN="kubernetes.default.svc.cluster.local" START=$(date +%s%N) timeout 2 getent hosts "${TEST_DOMAIN}" > /dev/null 2>&1 STATUS=$? END=$(date +%s%N) COST_MS=$(( (END - START) / 1000000 )) if [ ${STATUS} -ne 0 ] || [ "${COST_MS}" -gt 500 ]; then echo "local_dns_probe_status 1" > /var/lib/node-probe/metrics exit 1 fi echo "local_dns_probe_status 0" > /var/lib/node-probe/metrics这个探测的价值在于它验证的是整条链路,包括Node Local DNS进程本身、socket监听、本地缓存读、以及必要的上游转发能力,任何一个环节出问题都会反映到探测结果上,比单纯检查进程存活可靠得多。
4.4 怎么才算"恢复"
自愈动作执行完之后,不能简单看动作成功了就宣布恢复。我在状态机里设计了"恢复确认"阶段:执行完任何自愈动作后,至少连续10次探测(即300秒)全部通过,才把节点状态从Faulted/Degraded切回Healthy。这个"连续N次通过"的设定很重要,因为有些磁盘故障是间歇性的,比如IO延迟时好时坏,动作执行完当下可能是通的,过两分钟又卡住,如果没有恢复确认阶段,状态机就会在Healthy和Faulted之间来回抖动,不断触发自愈动作,反而把系统搞乱。
如果是经过Fencing的节点,恢复逻辑要求人工介入确认,不搞自动恢复。原因很简单,都走到Fencing这一步了,说明节点可靠度已经受到严重质疑,自动恢复的风险大于收益。
5. 自愈动作的递进策略与安全护栏
自愈动作是整个系统里最危险的部分,处理不好会从"自愈故障"变成"制造事故"。我按照"影响从小到大"的原则设计了一组递进动作,并且在动作编排上加了多层护栏。
5.1 六个等级的动作设计
| 动作等级 | 触发条件 | 执行内容 | 影响范围 |
|---|---|---|---|
| L0 | 磁盘使用率或inode超过软阈值,DNS仍正常 | 触发kubelet镜像GC和容器GC,清理悬空镜像和已停止容器 | 无业务影响 |
| L1 | 写探测开始出现偶发超时 | 清理节点日志目录中的归档文件,但严格限制清理范围 | 极小 |
| L2 | Node Local DNS业务探测连续失败 | 重启Node Local DNS DaemonSet的Pod,错峰执行且每次只处理一个节点 | DNS缓存重建,解析延迟短暂升高 |
| L3 | 磁盘异常且L2执行后仍未恢复 | cordon节点并驱逐非系统级Pod,驱逐前检查PDB | 业务Pod迁移 |
| L4 | 多次写探测失败、节点状态异常 | 节点Fencing,标记不可调度,保留现场,通知人工介入 | 节点下线 |
| L5 | 人工触发或Fencing后异常持续 | 保留现场数据并停止所有自动动作,等待人工处理 | 无 |
L0和L1的核心是"利用Kubelet的原生能力优先兜底"。Kubelet本身有镜像GC机制,对应image-gc-high-threshold和image-gc-low-threshold两个参数,默认分别是85%和80%。我们在系统里做的事情是当磁盘使用率超过75%时,主动向kubelet发出触发信号,让它提前执行一次GC,而不用等它到85%才自己触发。这样就把自愈动作的介入时间大幅提前了。
L2重启Node Local DNS是这套系统里最重要的一步,因为它是验证"节点是否还能支撑关键进程运行"的分水岭。如果Node Local DNS重启后能恢复正常,说明节点基础设施还有救;如果连它都起不来,那几乎可以确认节点级故障已经比较严重,需要进入驱逐和Fencing流程。
5.2 为什么最后一步是节点Fencing而不是重启节点
很多人会以为磁盘自愈的最后手段是重启节点,我恰恰把重启节点排除在自动动作之外。原因有两点:第一,重启节点意味着节点上的所有Pod都会失联,如果业务没有足够副本,这个操作本身就是一次事故;第二,如果故障根源是物理磁盘硬件损坏,重启只会让节点在重启后再次进入同样状态,根本起不到治疗作用,反而让故障时间被拉长。
Fencing的本质是"把坏节点从集群中隔离出去,让问题不再扩散"。操作包括给节点打上污点、cordon、驱逐非必要的Pod、保留现场日志。动作完成后,节点上的磁盘数据保留原样,等人工介入判断是换盘还是重装。生产系统里,快速隔离一个坏节点往往比试图修复一个坏节点更重要。
5.3 熔断开关与冷却机制
两个护栏不得不提。一是执行动作前必须再次确认最新探测数据,而且要连续确认3次都指向同样的故障结论,才会真正执行动作。这个是防止探测层出现瞬时抖动时,决策层作出误判。二是每个动作执行完之后,节点会进入一个冷却期,默认30分钟内不再对同一节点触发下一级动作。冷却期的存在避免了"一直坏一直修",也在多节点同时故障时降低了动作风暴的概率。
我还在CRD里放了全局和节点两个粒度的熔断开关。全局熔断开关一打开,所有自愈动作立刻停止,探针继续工作,状态继续记录,但执行层不再消费任何指令。这个开关在自愈系统自身出现Bug时是保命用的。
6. 落地踩坑:哪些坑差点让自愈系统变成事故源
这个系统从设计到落地,加上后续的调优,前后踩了不少坑。有些坑属于实现细节,有些坑差点就演变成故障,挑几个印象最深的说一下。
6.1 模板变量被当成配置渲染了
第一次部署Node Local DNS时,我直接用了官方YAML模板。这个模板里有很多__PILLAR__LOCAL_DNS__这种占位符,需要专门的流程把变量替换成实际集群的值。当时我们图省事,直接把模板扔给kubelet去apply,结果所有占位符被原样渲染进了配置文件,Node Local DNS启动之后完全无法解析。排查了很久才发现是配置里的占位符没替换。这个坑和自愈系统本身无关,但它是"部署Node Local DNS"这个前置操作里最典型的问题,提出来希望后来者别在同一个地方栽跟头。
6.2 写探测频率与SSD写放大
写探测刚上线时,我图省事把频率调成了5秒一次,结果运行两周后看节点磁盘的磨损数据,发现探针自身贡献了不少写入量。虽然影响在一般情况下不大,但如果在大量节点上运行,这个额外损耗是没有必要的。后来我把写探测改到了30秒一次,同时在写探测文件的做法上避免反复创建删除文件,用固定文件覆盖写入,磨损量立刻降下来了。对这个问题的体感是:探针本身也是生产者,它的写入量必须纳入设计考量。
6.3 驱逐Pod时忽略了PDB
L3动作设计的时候,我一开始直接调用了驱逐API把所有非系统Pod全部驱逐。上线之后第一次真正触发L3,有个只跑了一个副本的、PDB要求至少一个副本可用的服务被打挂了。原因很简单:强制驱逐时PDB只是提醒,并不阻止强制行为。后来我调整了执行逻辑:驱逐前先读取每个Pod的PDB,如果PDB要求的最小可用副本数会被破坏,就跳过这个Pod,只驱逐PDB允许驱逐的那批。自愈系统不能为了救节点而杀了业务,这个平衡必须靠PDB来兜住。
6.4 多节点同时故障时的动作风暴
某个月份我们遇到过一次可用区级别的网络抖动,一下子有好几个节点同时进入Faulted状态。决策层并行对所有故障节点执行了驱逐动作,瞬间对API Server产生了一波请求风暴,导致控制面响应变慢,反过来又加剧了节点状态上报的延迟。从那次之后,我在自愈系统里加了全局的"并发控制"逻辑:同一时刻最多只能对两个节点执行驱逐类动作,其他节点排队等待。冷却期的长度也做了限制,避免冷却期结束后一批节点同时进入下一轮动作,再次形成峰值。
6.5 Node Local DNS重启后的上游冲击
Node Local DNS的缓存是内存态的,重启之后缓存全部清空,所有本地解析请求会瞬时报到中心CoreDNS。如果一个集群上百个节点同时重启Node Local DNS,中心CoreDNS会直接被请求洪峰打穿。后来我把重启动作做成了"错峰+限速":所有节点的重启请求排队执行,同一时间最多一个节点在重启,同时在上游CoreDNS的缓存插件配置里适当调大缓存TTL和规模,给重启动后的冷缓存期提供缓冲。这个问题的教训是:任何一个自愈动作都必须考虑它在上游和下游可能引发的次生冲击,动作本身不是孤立的。
从探测到Fencing,这套系统的本质是"动作编排"
整个系统做下来,我最大的体会是:磁盘自愈系统的难点不在怎么写探针,也不在怎么收集指标,而在"动作编排"——什么时候该轻清理,什么时候该重启进程,什么时候该驱逐Pod,什么时候该放弃自动恢复把节点隔离出去,每一步的边界和分寸都需要仔细拿捏。探针只是眼睛,执行器只是手,真正的判断和克制在决策逻辑里。
最后分享一个小技巧。我给自愈系统的所有动作都做了不同的"信号灯"状态标识,并且在关键动作执行前都会向值班群推送一条包含节点信息、故障等级和即将执行动作的消息。不要小看这条消息的作用——它给了值班人员一个"叫停窗口",如果值班人员发现这个节点正在跑重要的批处理任务,可以在动作执行前手动打开熔断开关。自动化的价值不是替代人,而是把人的干预时机放到最正确的那个节点上。这个系统在线上运行了不短的时间,真正触发L3和L4的次数很少,但每一次触发的现场,都让人觉得这套防线建得值。