2026 年 3 月一个周四下午 16:18,运维同事在 K8s 平台值班群里贴了一张监控截图:"过去 1 小时,集群内部服务之间的 P99 调用延迟从正常的 80ms 飙到 1.2s,但目标服务自身 P99 还是 60ms,网络流量正常,没有任何 Pod 重启或扩缩容动作。"这种"中间发生了什么我也不知道"的现象在 K8s 网络层面太常见了。我盯着这条消息看了 5 秒,凭着第六感打了 5 个字:"是不是 DNS?"
2 小时后答案揭晓:确实是 DNS。但根因比"DNS 慢"复杂得多——一条看似无害的 K8s 服务调用,在 glibc 的 resolver 库 + ndots:5 默认配置 + IPv4/IPv6 双栈串行查询 + iptables conntrack race 这五个因素叠加下,会以稳定概率 hang 5 秒(UDP 重传超时),导致 P99 灾难性变差。这篇是完整复盘,涵盖 Linux DNS 解析的内部机制、K8s 默认 ndots 的"原罪"、conntrack race 的根本原因、NodeLocal DNSCache 的工作原理,以及落地的《K8s DNS 优化清单》。
服务背景:这个被 DNS 玩死的微服务集群
| 维度 | 数值 |
|---|---|
| 集群规模 | K8s 1.28,180 个 Node,约 2400 个 Pod |
| CNI | Calico 3.27 + IPv4/IPv6 双栈 |
| DNS | CoreDNS 1.11,3 副本 |
| 服务网格 | 无(裸 K8s Service + Endpoint) |
| 主要业务 | SaaS B 端 ERP,Java/Python/Go 多语言并存 |
| 事故现象 | 服务间调用偶发 5 秒延迟,影响 ~5% 请求,主要是 Java + Go 服务 |
| P99 影响 | 从 80ms → 1.2s,持续 1-2 小时后又自动好,无规律 |
这个集群从 2024 年底搭建,跑了一年多,DNS 问题一直零星冒出但都被归因为"网络抖动"。这次因为大客户业务高峰加剧,5% 的 5 秒延迟变成显眼的 P99 拉高,才被认真追究。
说到"网络抖动"这个词,它在我们团队复盘后被列入了"禁用归因清单"。所谓"网络抖动"通常是排查不下去的托词,真实根因往往是某个具体的协议层细节(如 conntrack 满、TCP retrans、DNS race、TLS 握手抖动)。我们规定任何监控告警分析,不允许出现"原因:网络抖动"这种模糊归因——必须给到协议层 + 具体配置 + 复现方式,否则视为未结案。这条纪律让我们后续半年至少避免了 3 次类似的"长期偶发"事故。
另外补充一句:这个集群里跑的 Java 服务是大头(占 60% Pod),其次是 Go(25%)和 Python(15%)。Java 的影响最大是因为 JVM 默认会启用 IPv6 双栈查询,且 InetAddress 没有应用层缓存(默认 networkaddress.cache.ttl=-1 是"永久缓存"而不是"不缓存",理解错了反而是坑),所以每次新建 HttpClient 连接都会触发完整的 DNS 解析链路,踩 race 概率最高。
事故复盘后我们做了一次"全量 Pod DNS 配置体检",发现集群里 78% 的 Deployment 完全没显式配 dnsConfig,11% 配了但参数不全,只有 11% 是按团队规范配置的。这个比例和事故影响范围(主要集中在 Java 服务)高度吻合——配置不全的 Deployment 几乎都是 Java。这个体检后来被脚本化,纳入 CI 检查项,任何新 Deployment yaml 必须满足 dnsConfig 规范才能合并。
事故时间线:从"是不是 DNS"到根因的 2 天
| 时刻 | 事件 |
|---|---|
| 03-12 16:18 | P99 飙升告警,我猜 DNS |
| 03-12 16:30 | 进一个客户端 Pod,跑 time dig service.namespace.svc.cluster.local,正常 1-3ms;但跑 10 次有 1 次 hang 5 秒 |
| 03-12 17:00 | 用 tcpdump -i any port 53 抓 DNS 包,看到 5 秒延迟的查询是 IPv6 (AAAA) 查询超时,5 秒后客户端重试改用 TCP 才成功 |
| 03-12 17:30 | 同时跑 cat /etc/resolv.conf,看到 ndots:5(K8s 默认) |
| 03-13 上午 | 读 glibc resolver 源码 + 翻 Calico + iptables conntrack 文档,理解 conntrack race |
| 03-13 下午 | 本地集群复现:写一个高并发解析脚本,稳定触发 5 秒 hang |
| 03-14 上午 | 方案:Pod 级 ndots:1 + 部署 NodeLocal DNSCache + 应用层 DNS 缓存 |
| 03-14 下午 | 预发跑 24 小时压测,5 秒 hang 完全消失 |
| 03-15 | 分批 rollout 到生产,P99 稳定在 80ms |
第一反应:"DNS 应该挺快的吧"
大多数应用开发者对 DNS 的认知停留在"它是个查域名拿 IP 的服务,毫秒级"。这个理解在大多数场景对,但在 K8s 容器化环境下,DNS 是性能问题的高发区,而且性能问题的形式经常不是"慢一点",而是"卡 5 秒"或者"卡 10 秒"这种数量级的崩塌。原因都和 Linux 的 UDP DNS 客户端的"超时"机制有关——glibc resolver 默认 timeout 5 秒,query 不返回就死等。
第一个关键证据是 dig 偶发 5 秒:
# 在客户端 Pod 里跑 100 次
for i in $(seq 1 100); do
/usr/bin/time -f "%e" dig +short user-service.default.svc.cluster.local 2>&1
done | sort -rn | head -10
# 输出 (节选)
5.04
5.03
5.02
0.012
0.011
0.010
...
100 次里有 6 次正好 5.0X 秒,其余都是毫秒级。5 秒这个魔法数字直接说明是 DNS 客户端的 UDP 超时——能稳定踩到这个数字,意味着 query 包发出去后没收到响应,5 秒后重试(改 TCP 或换 server)才成功。
5 个因素叠加的因果链
把这次事故的根因画成因果图,是 5 个机制串起来的复合事件:
这条链有意思的地方是每一环单看都"合理":ndots:5 是为方便,glibc 双栈并查是为延迟,Calico + iptables 是标准 CNI,UDP 5 秒超时是 POSIX 默认。但在 K8s 大规模容器化环境下,这 5 个"合理"叠加就是稳定的 P99 杀手。
真凶 1:ndots:5 + 不带 . 的域名 = 6 次 DNS 查询
K8s 默认给每个 Pod 的 /etc/resolv.conf 是这样:
nameserver 10.96.0.10 # CoreDNS Service IP
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
关键是 ndots:5。它告诉 glibc resolver:"如果你要查的域名里点的数量小于 5,先尝试加上每个 search 域,最后才作为完整域名查"。看上去合理(对 K8s 内服务调用方便),但代价巨大。
举个例子,当 Java 应用调用 customer-service.default.svc.cluster.local 时(看起来是 FQDN):
| 步骤 | 查询的域名 | 是否会被查 |
|---|---|---|
| 1 | customer-service.default.svc.cluster.local.default.svc.cluster.local | ✅(因为点数 4 < 5) |
| 2 | customer-service.default.svc.cluster.local.svc.cluster.local | ✅ |
| 3 | customer-service.default.svc.cluster.local.cluster.local | ✅ |
| 4 | customer-service.default.svc.cluster.local(原始 FQDN) | ✅ |
而且每个域名又会被分别查 A 和 AAAA(IPv4 / IPv6)——所以一次"我以为是直接查 FQDN"的调用,实际会触发8 个 DNS 查询包(4 域名 × 2 record type)。任何一个包丢失或对端 race,都会触发 5 秒重传。
对于不带任何点的短域名(比如 customer-service),glibc 会先尝试 3 个 search 域,只有都失败才查 FQDN——好的方面是命中 search 域很快(就是用户想要的同 namespace 调用),坏的方面是每次都至少跑完所有 search 域才结束,网络包数翻倍。
真凶 2:glibc 双栈串行 + 同源端口冲突
更深的坑在 glibc resolver 的实现。glibc 在解析时:
- 同一个查询的 A 和 AAAA 用同一个 UDP socket 并行发出(同源端口)
- 等待两个响应都回来再返回结果
- 响应没回来就走 timeout(默认 5 秒)
"同源端口"这个细节是灾难的根源。在 K8s 网络栈里,Pod 发出的 UDP 包要经过 iptables/netfilter 的 conntrack(连接跟踪)做 NAT。conntrack 表里以 (源 IP, 源端口, 目的 IP, 目的端口, 协议) 五元组为 key——同源端口的两个并发包(A 和 AAAA 各一个)会被 conntrack 视为同一连接。
这就引出了著名的 conntrack race condition:
| 顺序 | 事件 |
|---|---|
| T1 | glibc 同时发出 A 查询和 AAAA 查询,源端口都是 X |
| T2 | 第一个包到达 netfilter,创建 conntrack entry |
| T3 | 第二个包几乎同时到达,看到 conntrack entry 不存在(还没插入完),尝试创建一个 |
| T4 | 两个包都在尝试创建相同五元组的 conntrack entry → 内核检测到冲突,丢弃其中一个 |
| T5 | 被丢弃的包对应的查询永远收不到响应 |
| T6 | glibc 等 5 秒,timeout,重试(通常用 TCP)成功 |
这就是为什么正好 5 秒 hang——5 秒是 glibc 默认 timeout,然后重试 TCP 才成功。在 Calico CNI + IPv4/IPv6 双栈的环境下,这个 race 的概率约 1%-5%,QPS 高的话能稳定触发。
这个 race 在内核态可以通过 conntrack -S 看到丢包计数:
# 在 K8s Node 上看 conntrack 状态
sudo conntrack -S
# 关键字段:
# insert_failed: 多少次插入冲突被丢弃
# search_restart: race 检测后的重启搜索次数
# drop: 因 conntrack 满或冲突丢的包数
# 我们事故期间这个 Node 的数据:
# insert_failed=18742 (24h)
# 平均每个 Node 每小时丢 780 个包
这些 insert_failed 数字平时根本没人看,但事故复盘后它成了"K8s Node 健康分"的关键指标——任何 Node 这个值持续增长就说明有 conntrack race 在发生。我们的告警阈值是 insert_failed 24h 增长超过 1000 即告警。
顺便说一下,这个 race 并不是 Linux 内核的 bug,而是"同源端口并发"和"conntrack 五元组唯一性"的语义冲突——内核选择"宁可丢一个包也不能让 conntrack 表错乱",这是正确决策,只是没考虑到 glibc resolver 会刻意制造这种并发。glibc 自己也有意识到这个问题,所以才提供了 single-request-reopen 和 single-request 两个 option 给用户选——但默认不开,留给应用自己决定。
真凶 3:CoreDNS 容量 + 缓存策略
除了上面两个根因,我们的 CoreDNS 部署也有几个加剧问题的因素:
| 问题 | 表现 |
|---|---|
| CoreDNS 只 3 副本 | 整个集群 2400 Pod 共用 3 个 CoreDNS,QPS 高时 CPU 100% |
| 没启用 cache 插件优化 | 同样的查询反复打到 CoreDNS,放大 ndots:5 的伤害 |
| 客户端无应用层 DNS 缓存 | 每次 HTTP 调用都重新解析 |
| 没部署 NodeLocal DNSCache | 每次解析都走集群 Service IP → kube-proxy → CoreDNS Pod,链路长 |
这些不是根因但是放大器——前两个根因让 DNS 容易丢包,后面几个让丢包发生的频率更高、影响更大。
修法:四层组合优化
修法 1:dnsConfig 调小 ndots
K8s 允许 Pod 级别覆盖 DNS 配置。对所有"主要做集群内 FQDN 调用"的服务,把 ndots 改成 1 或 2:
# Deployment spec
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "2" # K8s 内服务调用 FQDN(4 个点)直接走原域名, 不再 search
- name: single-request-reopen
value: "" # 关键: 让 IPv4/IPv6 用不同源端口, 避免 conntrack race
- name: timeout
value: "1" # 单次查询超时缩短到 1 秒
- name: attempts
value: "2" # 重试 2 次
dnsPolicy: ClusterFirst
关键的 single-request-reopen:它告诉 glibc resolver "每个查询包关闭 socket 重新打开",这样 IPv4 和 IPv6 查询会用不同源端口,从根本上避免 conntrack race。还有一个更激进的 single-request 选项,直接禁用并行查询,改成串行——延迟略升但完全无 race。
对短域名调用为主的服务(很多 Python / 内部脚本喜欢写短名),ndots 保持 5(K8s 默认),但配 single-request-reopen。
修法 2:NodeLocal DNSCache
K8s 官方提供的 NodeLocal DNSCache 是 daemonset,每个 Node 上跑一个 169.254.20.10 监听的 DNS 缓存。Pod 的 DNS 请求先走 NodeLocal,缓存命中直接返回,miss 才转发到 CoreDNS。
# 部署 NodeLocal DNSCache (官方 yaml, 略)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/kubernetes/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml
# 部署后, Pod 的 /etc/resolv.conf 自动指向 NodeLocal:
nameserver 169.254.20.10
NodeLocal 用的是 TCP 转发到 CoreDNS,避开了 UDP conntrack race;同时本地缓存让大量重复查询不出 Node,延迟从毫秒级压到微秒级。
装完 NodeLocal DNSCache 后,我们 CoreDNS 的 QPS 从 80k 直接降到 8k——90% 的查询被 NodeLocal 缓存吃掉。CoreDNS 副本可以缩到 2 个反而绰绰有余。
修法 3:Java 服务关掉 IPv6
Java 默认会尝试 IPv6,且 InetAddress.getByName 会触发 A + AAAA 查询。如果业务不需要 IPv6,加 JVM 参数:
-Djava.net.preferIPv4Stack=true # 完全禁用 IPv6, 只查 A
-Dsun.net.inetaddr.ttl=60 # JVM 层 DNS 缓存 60 秒(默认是 forever, 反而是坑)
-Dsun.net.inetaddr.negative.ttl=10 # 失败结果缓存 10 秒
JVM 的 DNS 缓存默认是永久缓存(networkaddress.cache.ttl=-1),这本身就是 K8s 环境的反模式(参考 026 那篇 HttpClient 文章)。一定要显式设。
修法 4:Go 服务用 PureGo Resolver
Go 默认混用 cgo resolver (用 glibc) 和 Go 原生 resolver。glibc 有上面所有问题,Go 原生 resolver 自己实现 DNS,行为更可控。强制用原生:
// 在 main 里
import _ "net"
func init() {
// 强制使用 Go 原生 resolver, 绕开 glibc
os.Setenv("GODEBUG", "netdns=go")
}
// 或者编译时
GODEBUG=netdns=go go build ...
Go 原生 resolver 默认不会做 AAAA 查询(除非显式开 IPv6),也用不同源端口发查询,直接避免 conntrack race。
验证:24 小时混沌测试
| 指标 | 修复前 | 修复后 |
|---|---|---|
| DNS 查询 P99 | 120ms(其中 5% 是 5 秒) | 0.4ms |
| DNS 查询 P99.9 | 5042ms | 1.2ms |
| 服务间调用 P99 | 1.2s | 78ms |
| CoreDNS QPS | 80k | 8k |
| CoreDNS Pod CPU | 3 副本各 80%+ | 2 副本各 15% |
| 5 秒 hang 出现频率 | 5% 请求 | 0% |
决策树:K8s DNS 优化怎么选
修复过程中,我们整理出一张 "DNS 优化优先级图",做容量评估时直接照着选:
这张图最有价值的判断是 "NodeLocal DNSCache 优先",原因是它一招把 conntrack race 问题彻底绕开(改 TCP),还顺便缓存了 90% 的查询。其他修法都是补丁,NodeLocal 是结构性解。
横向对比:其他云原生方案怎么处理 DNS
这次复盘前我们对 K8s DNS 的认知是 "默认就行",事后做了横向对比:
| 方案 | DNS 处理方式 | 是否有 conntrack race | 典型 P99 |
|---|---|---|---|
| 裸 K8s + CoreDNS | UDP 走 kube-proxy + iptables | 有 | 80ms ~ 5s |
| K8s + NodeLocal DNSCache | 本地 169.254 缓存 + TCP 转发 | 无(TCP) | 0.5ms ~ 5ms |
| K8s + Cilium eBPF | 用 eBPF map 替代 iptables conntrack | 无 | 1ms ~ 8ms |
| Istio sidecar | Envoy 拦截 DNS, 应用层缓存 | 无(sidecar 缓存) | 0.3ms ~ 3ms |
| Nomad + Consul DNS | 每节点 Consul agent | 无 | 2ms ~ 10ms |
| 裸物理机 + systemd-resolved | 本地缓存 + 直连 upstream | 无(无 conntrack) | 0.5ms ~ 5ms |
结论:"应用 + Pod + iptables conntrack + UDP DNS" 这条链路是 K8s 默认架构的脆弱点,任何打破其中一环的方案都能消除问题。NodeLocal DNSCache 通过用 TCP 转发打破"UDP + conntrack",Cilium eBPF 通过替换 conntrack 实现打破,Istio 通过 sidecar 拦截 DNS 打破——三种思路殊途同归。
5 天里被否决的"看似合理"方案
| 方案 | 否决原因 |
|---|---|
| 把 CoreDNS 副本加到 20 个 | 问题不在 CoreDNS 容量, 是 conntrack 丢包, 加副本不解决 |
| 所有应用统一用 IP 直连绕过 DNS | 失去 Service 发现机制, Pod IP 变化时全挂, 工程上不可接受 |
| 统一升级到 systemd-resolved | 大多数容器镜像没装 systemd, 工作量太大且改变基础镜像 |
| 关闭 IPv6 模块 | 关掉宿主机 IPv6 模块影响其他系统调用, 风险面太大 |
| 把 conntrack table 调到无限大 | 问题是 race 不是容量, 调大表不能消除并发插入冲突 |
| 把 timeout:5 改成 timeout:1 | 缓解症状但不解根因, 而且短超时会增加重试压力, 风险更高 |
顺手发现的其他几个 K8s 网络坑
| 问题 | 表现 | 修法 |
|---|---|---|
| kube-proxy iptables 模式延迟高 | 大集群 Service 数量多时, iptables 链路长 | 切 IPVS 或者 Cilium eBPF |
| Pod 内 DNS resolv.conf 不会随 ConfigMap 更新 | 修改 ndots 需要重建 Pod | 滚动重启 |
| HostNetwork Pod 的 DNS 配置和普通 Pod 不同 | 访问集群 Service 失败 | 显式 dnsPolicy: ClusterFirstWithHostNet |
| 跨命名空间调用没用 FQDN | 触发更多 search 域查询 | 统一用 service.namespace.svc.cluster.local |
| 大流量服务用 ClusterIP 模式 | kube-proxy 转发成瓶颈 | 用 Headless Service 或 LoadBalancer |
修法 5:Python / Node.js 服务的应用层优化
除了 Java 和 Go,Python 和 Node.js 也有专属坑。Python 的 requests 库默认不缓存 DNS,每次 HTTP 都解析一次。我们对高频调用的内部 HTTP client 加了 cachetools 缓存层:
from cachetools import TTLCache
import socket
import threading
_dns_cache = TTLCache(maxsize=10000, ttl=60)
_dns_lock = threading.Lock()
_orig_getaddrinfo = socket.getaddrinfo
def cached_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
key = (host, port, family, type, proto, flags)
with _dns_lock:
if key in _dns_cache:
return _dns_cache[key]
res = _orig_getaddrinfo(host, port, family, type, proto, flags)
_dns_cache[key] = res
return res
socket.getaddrinfo = cached_getaddrinfo
这个 monkey patch 让 Python 进程内所有 DNS 解析自动走 60 秒缓存,对高 QPS 的内部 RPC 非常有效。我们的 Python 网关服务接入后,DNS 调用数从 12k/s 降到 200/s。
Node.js 默认也不缓存 DNS,且 Node 的 DNS 模块有 dns.lookup(走 glibc, 同步阻塞 event loop)和 dns.resolve(走 c-ares, 异步)两种 API。生产环境必须强制用 c-ares,且加缓存:
// 用 cacheable-lookup 包做透明缓存
const CacheableLookup = require('cacheable-lookup');
const cacheable = new CacheableLookup({
maxTtl: 60, // 缓存 60 秒
errorTtl: 5, // 失败缓存 5 秒, 防止穿透
});
cacheable.install(http.globalAgent);
cacheable.install(https.globalAgent);
装完这个,Node.js 服务的 DNS 查询数下降 95%,5 秒 hang 完全消失。这两个语言级修法配合 NodeLocal DNSCache,是真正的"应用 + 平台"双重保险。
额外一个 Python 的注意点是 requests 库的 connection pool 默认不会复用,每次新建 Session 都重新 DNS 解析。建议改用 requests.Session() 全局实例,配合上面的 monkey patch,DNS 调用还能再减一个数量级。Node.js 这边,Axios 默认也会创建新的 http.Agent,记得显式传入复用的 globalAgent,否则前面的 cacheable-lookup 安装不生效。
对于 Rust(我们也跑了几个 Rust 服务)的 hyper / reqwest,默认用的是 tokio 的 trust-dns-resolver,行为更可控,不会触发 glibc 那一套坑,但也建议显式设缓存 TTL 60 秒,避免 DNS 抖动放大成应用层抖动。这部分是事故后我们做技术栈横扫时整理的,顺便把所有语言的 HTTP client DNS 配置规范化了。
整个"语言级 DNS 优化"花了我们一周时间梳理出 6 套语言模板(Java / Go / Python / Node.js / Rust / .NET),后续新业务上线时直接套用,从根上避免了应用层 DNS 配置不一致的问题。这套模板和 NodeLocal DNSCache 的组合是我们目前 K8s DNS 架构的最终形态,稳定运行 3 个月零事故,可以说彻底把 DNS 这个老大难从我们的告警列表里抹掉了,腾出的运维注意力转去搞 Service Mesh 升级和成本优化,这种"修一次永远不用再修"的体验真是难得,毕竟运维大多数时候是按下葫芦浮起瓢的循环游戏。
立的《K8s DNS 优化清单》
- 所有 K8s 集群必须部署 NodeLocal DNSCache,无例外。这是性价比最高的单项优化。
- 所有 Java 服务必须设 java.net.preferIPv4Stack=true(除非确实用 IPv6)+ sun.net.inetaddr.ttl=60。
- 所有 Go 服务必须设 GODEBUG=netdns=go。
- 所有 Pod 的 dnsConfig 必须显式配 single-request-reopen,这是修 conntrack race 的根本解。
- 主要做 K8s 内 FQDN 调用的服务,ndots 改成 2;调用习惯不规范的服务保持 ndots:5 但靠 NodeLocal 兜底。
- CoreDNS 副本数 ≥ Node 数 / 100,最少 2 个,且配 PodAntiAffinity 分散在不同 Node。
- 必须监控 DNS 查询延迟和错误率:CoreDNS 自带 metrics,P99 > 100ms 告警,SERVFAIL 比例 > 0.1% 告警。
- 禁止在生产环境用 hostNetwork: true 同时 dnsPolicy: Default——会拿到节点的 DNS 配置,集群内服务调用全部失效。
- 跨命名空间调用必须用完整 FQDN(service.namespace.svc.cluster.local),避免每次都走 search 域。
给读者的几条自查清单
- 进任一 Pod 跑
cat /etc/resolv.conf,看 ndots 是几。是 5 但你的应用大量调 FQDN,改 2。 - 跑 100 次
time dig +short service.namespace.svc.cluster.local,看有没有 5.0X 秒的。有就是 conntrack race。 - 用
tcpdump -i any port 53抓 60 秒,看有没有"发出去没回响"的查询。 - 检查 CoreDNS 副本数和 CPU 使用率。Pod 数 / CoreDNS 副本数 > 1000 是高风险。
- 看你的集群有没有 NodeLocal DNSCache:
kubectl get ds -n kube-system | grep node-local-dns。没有的话部署它。 - Java / Go / Python 应用层的 DNS 缓存策略:Java 默认 forever 是坑,Python requests 默认不缓存。
- 如果用了 IPv6 双栈,且业务不需要 IPv6,在所有应用层强制 IPv4(节省一半 DNS 查询)。
事故后这 3 个月的长期收益
| 指标 | 事故前 | 修复后 3 个月 |
|---|---|---|
| 服务间调用 P99 走势 | 偶发 1.2s 毛刺 | 稳定 70-80ms |
| 客服收到"系统卡"投诉 | 15/周 | 1-2/周 |
| CoreDNS 集群 CPU 总和 | 240% (3 副本各 80%) | 30% (2 副本各 15%) |
| DNS 相关 P3+ 告警 | 每周 4-6 次 | 近 3 个月 0 次 |
| 5 秒 hang 报告数量 | 每天 1000+ 次 | 0 |
| 新业务上线时 DNS 配置问题 | 每次都要踩坑 | 统一模板, 零配置上线 |
更意外的收益是整个团队对 K8s 网络栈的理解上了一个台阶。这次复盘后我组织了两场内部分享,把 ndots、conntrack、glibc resolver、NodeLocal 这些概念讲透,后续团队在排查任何 K8s 网络问题(不止 DNS)时,第一步都会先想"我的请求会经过哪些层、每层有什么默认行为"。这种"反操作系统直觉"的能力,是 K8s 工程师的核心竞争力。
这次复盘让我们更新的 K8s 工程认知
(1) "默认配置 = 反优化" 同样适用于 K8s 网络:K8s 的默认 DNS 配置(ndots:5、UDP)、kube-proxy 默认 iptables 模式、CoreDNS 默认副本数、Pod 默认 dnsPolicy——每一项都是为"通用易用"设计的,对大规模生产环境都需要调。我们把 "K8s 网络治理 checklist" 加进了集群上线 SOP,新集群必装 NodeLocal + Cilium 评估 + dnsConfig 模板。
(2) "应用层 + 平台层"边界要重新画:之前我们把 DNS 问题当作 "平台问题",应用同学不需要懂。这次事故后我们把 "K8s DNS 行为" 列为后端工程师必学知识,因为修法 (Java preferIPv4Stack、Go GODEBUG、dnsConfig) 必须从应用侧配合,不是平台单方面能搞定。
(3) "偶发性问题"必须比"稳定问题"更重视:稳定的 P99 高大家会盯,但"偶发 5 秒"很容易被归因为"网络抖动"过滤掉。我们的教训是 偶发性 5 秒延迟在 K8s 里几乎都是 DNS 问题,以后再遇到不能拖。监控加了"P99 突增告警"——只要 P99 比前 1 小时平均上涨 50%,就触发自动诊断脚本(包括 dig 测试、tcpdump、CoreDNS 状态)。
(4) "可观测性必须涵盖网络栈每一层":我们之前的 APM 只看应用层调用链,DNS 这一层是黑盒。修复后接入了 CoreDNS metrics + NodeLocal metrics + conntrack 表满率 + iptables drop 计数,这些指标和应用 P99 关联起来分析,任何 DNS 异常都能秒级定位。
这次事故让我对 K8s 网络又多了一层"敬畏感":它的复杂度大头不在 CNI 路由,而在那些"看似无关的默认配置"——ndots:5、glibc 同端口并发、UDP timeout 5 秒、conntrack race,这些在物理机时代根本不存在的问题,在容器化网络栈下会以惊人的方式叠加。如果你的 K8s 集群偶发"5 秒延迟"或者"P99 偶发飙升",不要去找应用层,先看 DNS——这是最高概率的命中。
另一个感悟是K8s 默认配置的某些选择,在大规模生产环境下不太合理。ndots:5 是为了让"短域名 + 同 namespace 调用"方便,但代价是放大了所有 FQDN 调用的开销。这种"为开发方便而牺牲性能"的默认值,在大集群下必须显式覆盖。这也是为什么生产 K8s 必须有"网络治理"这个角色——不只是搭起来,还要把这些默认值一一调对。
最后给所有还没部署 NodeLocal DNSCache 的团队一个建议:今天就装,装完一晚上的 P99 改善会让你后悔为什么没早装。这是 K8s 网络优化里 ROI 最高的单项工作,部署成本一个下午,后续 3 个月可以解决 80% 的偶发"慢"问题。我们的实际数据:服务间调用 P99 从 1.2s 降到 78ms,CoreDNS 副本从 3 缩到 2,客服投诉减少 90%——这些就是一个 daemonset 带来的全部收益。
这次事故还让我重新审视了一个工程价值观:"我们一直这么用都没事"不是稳定性的论据,而是"运气好还没爆"的别名。K8s 默认配置在小集群、低 QPS 时确实没事,但规模一旦上来,所有"理论上的小问题"都会变成"生产事故"。预防的成本是几小时调研 + 几天测试,事故的成本是几天加班 + 客户信任损失,后者贵 100 倍。
—— 别看了 · 2026