
深夜两点半被值班电话叫醒的那种心情做过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_IP169.254.20.10 TEST_DOMAINkubernetes.default.svc.cluster.local START$(date %s%N) timeout 2 getent hosts ${TEST_DOMAIN} /dev/null 21 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写探测开始出现偶发超时清理节点日志目录中的归档文件但严格限制清理范围极小L2Node 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时忽略了PDBL3动作设计的时候我一开始直接调用了驱逐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的次数很少但每一次触发的现场都让人觉得这套防线建得值。