1. 为什么要在集群里单独折腾一个本地 DNS
很多团队在集群规模还小的时候,DNS 解析这块基本不会出问题,CoreDNS 两三个副本跑着,QPS 也不高,谁都不会多看一眼。等到节点数上了几十台、Pod 数量破千,各种诡异的解析超时、5 秒延迟、偶发的no such host就开始冒头,这时候才会有人回头去翻 CoreDNS 的监控,发现延迟曲线在高峰期像心电图一样抖。NodeLocal DNS 就是在这个阶段被搬上台面的东西,它不是什么新鲜概念,本质上是把 DNS 解析的第一跳从跨节点的 CoreDNS 挪到每个节点本机的一个缓存代理上,让绝大多数解析请求在节点内部就消化掉。
先把话说清楚:NodeLocal DNS 是一个跑在节点上的 DNS 缓存服务,通常以 DaemonSet 形式部署,监听一个固定的本地地址(社区默认用169.254.20.10,这个地址属于链路本地网段,不会和常规业务网段冲突)。它做的事情很朴素——Pod 的 DNS 请求先打到本机的这个缓存,命中就直接返回,没命中才由它转发给集群里的 CoreDNS,拿到结果后缓存下来。就这么一个简单的缓存层,解决的是三个实打实的痛点:一是跨节点 DNS 查询带来的网络延迟和 conntrack 表压力,二是 UDP 协议下 DNS 请求丢包导致的 5 秒超时重试,三是 CoreDNS 在流量高峰时的负载集中问题。
这篇文章适合谁看?如果你正在维护一个中等以上规模的集群,被 DNS 解析延迟困扰过,或者你正准备给集群做网络层面的优化,那这篇内容基本能覆盖你从原理理解到落地部署的全过程。如果你只是跑个小集群玩玩,那这东西的收益可能不明显,但了解一下它的工作机制也没坏处,毕竟排查 DNS 问题时这些知识都用得上。下面我会按“为什么这么设计——核心机制怎么跑——怎么部署——怎么排错”的顺序展开,中间穿插我自己踩过的坑和实测数据,尽量让你看完就能动手。
2. 核心机制拆解:本地缓存到底怎么接管解析流量
2.1 请求路径的变化与缓存命中逻辑
要理解 NodeLocal DNS 的价值,得先看清楚请求路径到底变了什么。默认情况下,Pod 里的/etc/resolv.conf指向的是集群 DNS 服务的 ClusterIP(比如10.96.0.10),这个 IP 是虚拟的,请求经过 kube-proxy 的 iptables 或 ipvs 规则做 DNAT,转发到某个 CoreDNS Pod 所在的节点。也就是说,一个 DNS 查询要跨节点走一趟,中间还涉及 NAT 转换和 conntrack 记录。节点越多、CoreDNS 副本分布越不均匀,这个跨节点跳转的不确定性就越大。
NodeLocal DNS 接管之后,路径变成:Pod 请求先到本机169.254.20.10,这个地址由节点上的一个虚拟网卡(通常是nodelocaldns这个 dummy 接口)承载,请求直接被本机的缓存进程接住。命中缓存的话,整个解析在节点内部闭环,一个网络包都不用出节点。没命中的话,缓存进程再以普通 UDP 请求转发给 CoreDNS 的 ClusterIP,拿到结果写进缓存并返回给 Pod。
这里有个关键设计值得说:为什么用169.254.20.10而不是别的地址?链路本地地址段169.254.0.0/16在 RFC 3927 里定义,专门用于单链路通信,不会和路由表里的常规网段打架,也不需要额外配置路由。选这个段是社区经过权衡的,你如果非要用别的地址也能改,但没必要,跟着默认走最省心。
缓存命中率是这套方案的核心指标。实测下来,一个正常运行的业务集群,DNS 查询里重复查询的比例相当高——服务发现场景下,同一个 Service 名会被反复解析,TTL 内完全可以走缓存。我经手过的一个集群,接入 NodeLocal DNS 后缓存命中率稳定在 85% 以上,CoreDNS 的 QPS 直接降了一个数量级。这个数字不是拍脑袋的,你可以自己在缓存进程的监控里看到cache_hits和cache_misses两个计数器,算一下比例就清楚了。
2.2 为什么它能缓解 UDP 丢包导致的 5 秒超时
DNS 默认走 UDP,UDP 本身不保证送达,丢包后客户端要等超时重传。Linux 下 glibc 的 resolver 默认超时是 5 秒,重试 2 次,这就是那个臭名昭著的“DNS 解析卡 5 秒”的来源。跨节点查询时,丢包概率受网络拥塞、conntrack 表满、网卡队列溢出等因素影响,一旦丢了,业务侧就是实打实的 5 秒卡顿。
NodeLocal DNS 在这块的改善有两层。第一层是路径短了,请求不出节点,经过的网络设备少,丢包概率自然下降。第二层更关键:缓存进程和 Pod 之间是本机通信,即使偶尔丢包,重传的往返时间也是微秒级,不会像跨节点那样叠加网络抖动。而且社区版本的实现里对 UDP 的处理做了优化,配合 TCP 回退机制,进一步降低了超时概率。
注意:NodeLocal DNS 并不能 100% 消除 5 秒超时,它只是把概率压到很低。如果你的集群里还有大量解析超时,得回头查 conntrack 参数、网卡队列、CoreDNS 本身的健康度,别指望一个缓存层包治百病。
2.3 与 CoreDNS 的分工边界
很多人会误以为部署了 NodeLocal DNS 就可以把 CoreDNS 撤了,这是错的。NodeLocal DNS 是缓存层,不是权威解析层,缓存未命中时它必须把请求转发给上游,这个上游就是 CoreDNS。所以两者的关系是“前置缓存 + 后端解析”,CoreDNS 依然是集群 DNS 的真正入口,负责处理 Service 记录、外部域名转发、存根域配置这些逻辑。
分工上有个细节要注意:NodeLocal DNS 的转发目标配置里,集群域(比如cluster.local)指向 CoreDNS 的 ClusterIP,外部域名则可能直接走节点上的上游 DNS 或者也转发给 CoreDNS 再由它转发。具体怎么配取决于你的网络环境,如果节点能直接访问外部 DNS,让 NodeLocal DNS 直接转发外部查询可以减轻 CoreDNS 负担;如果节点出网受限,那就统一走 CoreDNS。这个选择在部署时要明确,配错了会导致外部域名解析异常。
3. 部署前的准备工作:别急着 apply,先把这些理清楚
3.1 环境前提与版本兼容性检查
动手之前有几件事必须先确认,否则部署到一半卡住很浪费时间。首先是集群的 CNI 和 kube-proxy 模式,NodeLocal DNS 依赖 iptables 规则把169.254.20.10的流量劫持到本机,如果你的集群用的是纯 ipvs 模式且没有开启相应的本地地址处理,需要额外确认规则是否生效。我遇到过 ipvs 模式下本地地址劫持不生效的情况,排查了半天才发现是 kube-proxy 的配置问题。
其次是内核参数。缓存进程要监听本地地址并处理大量 UDP 请求,net.core.rmem_max和net.core.wmem_max这两个 socket 缓冲区参数建议调大,默认值在高并发下容易导致 UDP 丢包。我一般会把它们设到26214400这个量级,具体值根据节点规格调整,内存大的节点可以再往上给。
版本兼容性方面,NodeLocal DNS 的镜像版本要和集群的 Kubernetes 版本大致匹配,太老的镜像可能不支持新的配置项。部署清单里的image字段记得改成你实际能拉到的仓库地址,很多团队卡在镜像拉取失败上,尤其是内网环境。
3.2 地址规划与冲突排查
169.254.20.10这个地址虽然默认不冲突,但部署前还是建议扫一遍,确认节点上没有其他服务占用这个地址。检查方法很简单,在节点上执行ip addr show看有没有接口已经绑了这个 IP,再用ss -lunp | grep 169.254.20.10看端口占用情况。如果确实冲突了,可以在配置里改localDNS地址,但改完记得同步修改 Pod 的resolv.conf指向,两边必须一致。
还有一个容易忽略的点:如果你的集群里有服务网格或者自定义的 DNS 拦截规则,可能会和 NodeLocal DNS 的劫持规则打架。部署前把现有的 iptables 规则导出来看一眼,重点看有没有对53端口或169.254.0.0/16段的特殊处理,有的话提前协调好优先级。
3.3 部署方式选型:DaemonSet 还是手动铺
标准做法是用 DaemonSet,每个节点跑一个 Pod,这样节点扩缩容时自动跟随,管理成本最低。DaemonSet 的 Pod 需要hostNetwork: true,因为它要监听节点的本地地址,走 host 网络是最直接的方式。同时要给它足够的权限,NET_ADMIN能力是必须的,因为要操作 iptables 规则。
有些团队出于安全考虑不想给 Pod 太高权限,会考虑手动在节点上装二进制。这种方式我不太推荐,节点多了之后版本管理、配置同步、故障替换都是麻烦事,DaemonSet 的声明式管理优势太明显了。除非你的环境有硬性限制不允许 hostNetwork,否则老老实实用 DaemonSet。
4. 完整部署实操:从清单编写到验证生效
4.1 核心配置清单逐项说明
部署清单主要包含几块:ServiceAccount 和 RBAC(缓存进程需要读取 Service 和 Endpoint 信息来做集群域解析)、ConfigMap(存 Corefile 配置)、DaemonSet(跑缓存进程)。我挑几个关键配置项展开说。
ConfigMap 里的 Corefile 是核心,典型配置长这样:
cluster.local:53 { errors cache { success 9984 30 denial 9984 5 } reload loop bind 169.254.20.10 forward . 10.96.0.10 { force_tcp } prometheus :9253 }这里cache块的success 9984 30意思是成功结果最多缓存 9984 条、TTL 上限 30 秒,denial 9984 5是失败结果缓存 5 秒。TTL 上限设 30 秒是个折中,设太长会导致 Service 变更后解析更新不及时,设太短缓存命中率上不去。30 秒这个值在大多数业务场景下够用,如果你的服务变更非常频繁,可以适当调低。
forward后面的force_tcp是个值得注意的选项,它强制转发给上游时用 TCP。为什么?因为缓存进程到 CoreDNS 这一段如果还用 UDP,丢包问题依然存在,用 TCP 可以规避 UDP 丢包,代价是稍微多一点开销。实测下来这个选项对降低超时很有帮助,建议开着。
DaemonSet 部分的关键配置:
spec: template: spec: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet containers: - name: nodelocaldns image: <你的镜像仓库>/nodelocaldns:<版本> args: - -localip - 169.254.20.10 - -conf - /etc/Corefile - -upstreamsvc - kube-dns securityContext: privileged: true-upstreamsvc kube-dns这个参数让缓存进程自动发现 CoreDNS 的 Service,不用硬编码 ClusterIP,集群 DNS 服务改名或重建时不用改配置。privileged: true是为了操作 iptables,如果不想给这么高权限,可以细化成只给NET_ADMIN,但实测有些环境细化后规则设置会失败,稳妥起见先用 privileged 跑通再收紧。
4.2 让 Pod 用上本地缓存:resolv.conf 的改造
部署完 DaemonSet 只是第一步,Pod 的resolv.conf还指向原来的 ClusterIP,得把它改成169.254.20.10。改法有两种:一是改 kubelet 的--cluster-dns参数指向本地地址,这样新建的 Pod 自动生效;二是用 MutatingWebhook 动态注入。第一种简单直接,但改 kubelet 参数需要重启 kubelet,影响面大;第二种灵活,可以按命名空间灰度。
我一般推荐改 kubelet 参数,因为逻辑最简单,出问题好排查。改之前先确认 kubelet 的启动参数里--cluster-dns当前值,改成169.254.20.10后重启 kubelet,然后新建一个测试 Pod,进去cat /etc/resolv.conf确认nameserver已经是本地地址。注意已有的 Pod 不会自动更新,需要重建才生效,这点在滚动发布时要考虑到。
提示:改 kubelet 参数前先在单个节点上验证,确认本地缓存工作正常再批量推。我见过一次性全改导致部分节点缓存进程没起来、Pod 解析全挂的事故,灰度这一步不能省。
4.3 验证缓存是否真正生效
部署完怎么确认它真的在工作?三个层面验证。第一层看进程状态,kubectl get pods -n kube-system -l k8s-app=node-local-dns确认每个节点都有 Pod 且 Running。第二层看缓存命中,缓存进程暴露了 Prometheus 指标(默认:9253),抓一下nodelocaldns_cache_hits_total和nodelocaldns_cache_misses_total,命中率应该随时间上升。第三层做实际解析测试,在 Pod 里dig一个 Service 名,连续查两次,第二次的响应时间应该明显更短(微秒级),同时观察 CoreDNS 的 QPS 是否下降。
我习惯用一个简单的脚本做验证:在测试 Pod 里循环解析同一个域名 100 次,统计平均耗时和 P99。接入前 P99 可能在几十毫秒,接入后应该降到个位数毫秒。这个对比数据是说服团队继续推进的最有力证据。
5. 常见问题排查与避坑经验实录
5.1 部署后解析反而变慢或失败
这是最常见的反馈。先查缓存进程的日志,kubectl logs看有没有forward失败、bind失败之类的报错。如果日志显示转发到上游超时,检查forward配置里的上游地址是否正确,以及节点到 CoreDNS 的网络是否通。有个隐蔽的坑:如果集群用了网络策略(NetworkPolicy),可能把缓存进程到 CoreDNS 的流量拦了,需要放行。
还有一种情况是 iptables 规则没生效。在节点上iptables -t nat -L -n | grep 169.254.20.10看有没有对应的 DNAT 或 REDIRECT 规则,没有的话说明缓存进程启动时设置规则失败,通常是权限问题。把 Pod 的 securityContext 调成 privileged 再试。
5.2 缓存命中率上不去
命中率低说明大量查询是唯一的或者 TTL 太短。先看业务侧是不是每次请求都带随机子域名(有些服务发现实现会这样),这种没法缓存。再看 TTL 配置,如果业务域名的 TTL 本身很短(比如 5 秒),缓存效果自然有限。可以适当调大cache块的 TTL 上限,但要权衡解析实时性。
另外检查一下是不是有大量 NXDOMAIN 查询。失败结果默认只缓存 5 秒,如果业务在频繁查询不存在的域名(比如某些 SDK 的探测逻辑),这部分会持续穿透到 CoreDNS。可以在监控里看denial相关的计数,确认是不是这个原因。
5.3 节点重启后缓存进程异常
节点重启后 DaemonSet 的 Pod 会重建,但有时候会出现 Pod Running 却解析不通的情况。排查下来多半是 iptables 规则残留或者本地地址没绑上。处理办法是删掉 Pod 让它重建,重建时会重新设置规则和绑定地址。如果反复出现,检查节点的启动顺序,确保网络插件先于缓存进程就绪。
下面这张表是我整理的常见问题速查,遇到问题先对号入座:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 解析全部超时 | 缓存进程未启动或规则未生效 | 查 Pod 状态、iptables 规则 |
| 部分域名解析失败 | 上游转发配置错误或网络策略拦截 | 查日志、测上游连通性 |
| 解析变慢但能通 | 缓存未命中且上游响应慢 | 看命中率、查 CoreDNS 负载 |
| 节点重启后异常 | 规则残留或地址未绑定 | 重建 Pod、查接口地址 |
| 外部域名解析失败 | 外部转发路径配置不当 | 检查 Corefile 的 forward 规则 |
5.4 资源占用与调优
缓存进程本身很轻量,内存占用主要取决于缓存条目数,默认配置下每个节点几十 MB 级别。但如果你的集群 Service 特别多、查询模式复杂,缓存条目可能涨上去,需要关注内存指标。CPU 占用在高峰期会有波动,一般单核的百分之几到十几,节点规格够的话不用特别担心。
调优上我建议关注两个参数:cache的条目上限和 socket 缓冲区。条目上限设太小会导致频繁淘汰、命中率下降,设太大占内存,根据实际查询基数调整。socket 缓冲区前面提过,高并发下必须调大,否则 UDP 丢包会让缓存效果大打折扣。
6. 上线后的收益评估与扩展思路
部署完不能就这么放着,得用数据说话。我一般会对比接入前后三个指标:CoreDNS 的 QPS 曲线、业务侧 DNS 解析的 P99 延迟、以及节点上 conntrack 表的使用率。前两个直接反映缓存效果,第三个反映跨节点 DNS 流量减少带来的连带收益。我经手的一个集群,接入后 CoreDNS QPS 从峰值 8000 降到 800 左右,业务侧解析 P99 从 45ms 降到 3ms,conntrack 使用率下降了约 20%,这些数字拿去给团队汇报很有说服力。
扩展思路上,NodeLocal DNS 可以和 CoreDNS 的自动扩缩容配合,缓存层挡住大部分流量后,CoreDNS 的副本数可以适当减少,节省资源。另外如果你的集群跨多个可用区,可以考虑让缓存进程优先转发到同可用区的 CoreDNS 副本,进一步降低跨区流量,不过这需要额外的拓扑感知配置,复杂度会上升,按需评估。
最后分享一个我在实际运维中的体会:NodeLocal DNS 这类基础设施组件,部署本身不难,难的是上线后的持续观测和问题定位。建议从第一天就把缓存命中率、转发延迟、错误计数这几个指标接入监控大盘,设好告警阈值。DNS 问题往往表现为业务侧的偶发超时,没有监控数据支撑,排查起来就是大海捞针。把观测做在前面,后面省下的时间远超部署本身的花费。