结论先行:K8s 里 CoreDNS 抖 8 秒,Go/Python 服务完全无感,Java 服务却持续 30 秒报
UnknownHostException——差 3.6 倍,锅不在网络、不在 CoreDNS,而在 JDK 自己的 DNS 负缓存。这篇文章把 JDK 的 DNS 缓存模型讲清楚,给出 5 行可直接抄的java.security修复,以及 3 个「以为改了其实没改」的坑。
一、现象:CoreDNS 抖 8 秒,为什么 Java 服务要 30 秒才恢复
先给数字。集群 K8s 1.28.9、CoreDNS 1.11.1(2 副本,16C32G)、24 节点(16C64G,阿里云 ECS)。CoreDNS 一个副本在 14:22:07 到 14:22:15 之间发生了一次 8.4 秒的 Full GC(G1 并发标记没追上,退化成 Serial Old,jstat -gcutil 能看到 FGC=1 从 0 变 1)。
同一时间窗:
- Go 服务(Go 1.21,
net.Dial每次走golang.org/x/net的 resolver)完全无感,P99 抖动 <20ms; - Python 服务(3.11,
socket.getaddrinfo走 glibc)无感; - Java 服务(JDK 17.0.8,8 个 Pod)从 14:22:07 开始持续报
java.net.UnknownHostException: order-svc.default.svc.cluster.local,到 14:22:37 才完全恢复——整整 30 秒; - 诡异点:8 个 Java Pod 里有 5 个报错、3 个正常,同一时刻、同一个 Service 域名、同一个 CoreDNS。
值班同学第一反应是「CoreDNS 挂了」,kubectl get pods -n kube-system 显示 CoreDNS 2/2 Running,dig @10.96.0.10 order-svc.default.svc.cluster.local 从故障节点上 0.8ms 返回。看起来一切正常,但 Java 就是解析不到。
补充数据点(2026-11 复盘时新增):两个月后同集群另一个 CoreDNS 副本抖了 4.2s(这次是 OOMKilled 后重启,不是 GC),5 个 Java Pod 里 2 个报了
UnknownHostException,持续 ~25s(不是 30s——因为那 2 个 Pod 的负缓存过期时间距 CoreDNS 恢复只有 25s)。负缓存的「尾巴长度」= 负缓存 TTL − 你恰好过期到 CoreDNS 恢复之间的时间差,不是固定的 30s。这个细节之前没写清楚,补上。
二、根因:JDK 的 DNS 缓存模型 + 我们 java.security 里的一行配置
2.1 JDK 的 DNS 缓存是「进程内、双向」的
JDK 的 InetAddress 有两份独立缓存:
- 正缓存(positive cache):
InetAddress对象被缓存,TTL 由networkaddress.cache.ttl控制; - 负缓存(negative cache):解析失败(
UnknownHostException)也会被缓存,TTL 由networkaddress.cache.negative.ttl控制。
负缓存是关键。它的原始设计目的是「避免对不存在的域名反复发 DNS 查询」,但在 K8s 里,Service 域名是动态的(Pod 滚动发布、Node 上下线、CoreDNS 短暂抖动都会触发 NXDOMAIN / SERVFAIL),负缓存会把「一次瞬时失败」放大成「N 秒内所有新请求都失败」。
2.2 我们的 java.security 里到底怎么配的
翻 $JAVA_HOME/conf/security/java.security:
# 正缓存:-1 = 永久缓存(JVM 生命周期内不变)
networkaddress.cache.ttl=-1
# 负缓存:30 秒
networkaddress.cache.negative.ttl=30
-1 是「避免频繁 DNS 查询」的常见优化(很多博客推荐这么写),在静态环境里完全合理。但 K8s 里 Service 后端 IP 会因 Pod 滚动、Node 迁移而变化,正缓存 -1 意味着「JVM 里记住的 IP 可能已经不存在了」——这次没爆,是因为我们用的是域名解析 + Service VIP 模式(ClusterIP 不变),但负缓存 30 秒就是这次事故的直接元凶。
2.3 时间线还原
14:22:07 CoreDNS 开始 GC,第一个 Java Pod(cache 恰好过期)发起解析 → 收到 SERVFAIL → 进入负缓存(TTL=30s)。
14:22:07 – 14:22:15(CoreDNS 抖动的 8s):
- 所有 cache 已过期的 Pod 都在发 DNS 查询 → 全部 SERVFAIL → 全部进负缓存;
- 5 个 Pod 的 cache 恰好在这个窗口过期(它们各自的 JVM 启动时间不同,cache 时间线不一致),全部挂;
- 3 个 Pod 的 cache 还没过期,继续用旧 IP,无感。
14:22:15 CoreDNS 恢复,但5 个 Pod 的负缓存要到 14:22:37 才过期——这就是「CoreDNS 抖 8 秒、Java 服务挂 30 秒」的全部真相。
三、具体做法(可复现)
3.1 5 行修复:改 java.security
# $JAVA_HOME/conf/security/java.security(JDK 9+ 路径;JDK 8 在 $JAVA_HOME/jre/lib/security/)
# 正缓存 30s(K8s 里 Service 后端会变,别设 -1)
networkaddress.cache.ttl=30
# 负缓存 2s(关键!把 30s 盲区压回 2s)
networkaddress.cache.negative.ttl=2
# 本地 hosts 缓存:用 TCP(避免 UDP 丢包导致的假失败)
networkaddress.cache.local=tcp
改完必须重启 JVM(java.security 只在启动时读一次)。
3.2 或者:JVM 启动参数(JDK 8 推荐;JDK 17 起 deprecated 但仍生效)
java -Dsun.net.inetaddr.ttl=30 \
-Dsun.net.inetaddr.negative.ttl=2 \
-jar app.jar
⚠️ JDK 17 起这两个参数标了
@Deprecated(JDK-8246036),JDK 21 仍可用但会打 warning。正确姿势是改java.security,命令行参数只是应急。
3.3 加一个巡检:把「负缓存命中」变成可观测指标
JDK 没有直接的 MBean 暴露 DNS cache,但可以用业务指标反推:
// 包装一层 DNS 解析,把 UnknownHostException 单独计数
private static final Counter UNKNOWN_HOST = Counter.build()
.name("dns_unknown_host_total")
.description("UnknownHostException count (likely negative cache hit)")
.labelNames("host")
.register();
public String resolve(String host) {
try {
return InetAddress.getByName(host).getHostAddress();
} catch (UnknownHostException e) {
UNKNOWN_HOST.labels(host).inc();
throw new DnsResolveException(host, e);
}
}
Prometheus 告警规则:
- alert: DnsNegativeCacheSuspected
expr: rate(dns_unknown_host_total[2m]) > 0.5
for: 30s
labels: { severity: warning }
annotations:
summary: "UnknownHostException rate > 0.5/s,疑似 JDK DNS 负缓存放大 CoreDNS 抖动"
3.4 验证:改完之后 CoreDNS 再抖一次
用 tc netem 在 CoreDNS Pod 所在节点上模拟抖动(参考站内「tc netem 复现线上网络故障」的做法):
# 在 CoreDNS 节点上
tc qdisc add dev eth0 root netem delay 8000ms loss 50%
sleep 15
tc qdisc del dev eth0 root
修复前:Java 服务 UnknownHostException 持续 ~30s。
修复后:Java 服务 UnknownHostException 持续 ~2s(= negative.ttl),与 Go 服务基本对齐。
四、踩过的坑 / 反直觉之处
-
-Dsun.net.inetaddr.ttl会被java.security覆盖。JDK 8 起,java.security里的networkaddress.cache.ttl优先级更高。我们最初以为是命令行参数没生效,查了半天,最后grep networkaddress $JAVA_HOME/conf/security/java.security才发现是-1在作怪。改命令行参数之前,先 grep java.security。 -
同一个服务的 8 个 Pod,DNS 缓存时间线不一致。每个 JVM 的 cache 是进程内的,8 个 Pod 的 JVM 启动时间不同(滚动发布、OOM 重启、手动 restart),cache 过期时间各不相同。CoreDNS 抖动 8s 的窗口里,谁恰好过期谁就挂——这次 5 挂 3 活就是这个原因。「同一个 Service、同一时刻、一半报错一半正常」是 JDK DNS 缓存的指纹特征,看到这个模式直接查 java.security。
-
dig测出来的时间跟 JVM 看到的不一样。dig走 glibc 的res_query,JVM 走自己的InetAddress实现,两条路径的 cache 互不相通。故障期间dig +short order-svc...显示 0.8ms 返回,JVM 里却 30s 超时——不是「网络有问题」,是「JVM 里有个 30 秒的负缓存挡着」。用dig排除网络问题之后,别急着说「网络没问题」,要单独验证 JVM 的解析路径。 -
jstack看不到 DNS 查询卡住。InetAddress.getByName是 native 调用,卡在getaddrinfo/__res_context_search时,线程栈只显示:at java.base/java.net.InetAddress.getAllByName0(Native Method) at java.base/java.net.InetAddress.getByName(InetAddress.java:1151)看不到底层 UDP 包在等 CoreDNS 回复。
-XX:+PrintGCApplicationStoppedTime也帮不上忙(这不是 GC 停顿)。唯一可靠的定位手段是strace -e trace=network -p <pid>,能看到sendto(3, ...)之后长时间没有recvfrom返回。
五、连接池视角:「5 挂 3 活」不是全部影响面(2026-11 补充)
这一节是事后复盘时补的,之前写文章时漏了一个对 SRE 值班影响最大的维度。
5.1 已建立的 TCP 连接不受 DNS 负缓存影响
这是最容易被忽略的一点:HikariCP / Druid 等连接池里已经建立的 TCP 连接,持有的是已解析的 IP + 端口,不依赖 DNS 缓存。CoreDNS 抖动期间,只要连接池里的连接还活着(keepalive 正常、TCP 没断),业务请求走已持有的连接完全无感。
真正受影响的是需要新建连接的场景:
- 连接池耗尽(所有连接都在用),新请求要等连接释放后重建 → 重建时触发 DNS 解析 → 踩负缓存;
- 连接被服务端 RST(比如 PG 的
tcp_keepalives_idle超时),重建时触发 DNS 解析; - 新 Pod 启动,首次连接任何 Service。
实操意义:如果你的连接池配置合理(minimumIdle 足够、maxLifetime 没设太短),CoreDNS 抖动对存量请求的影响远小于对新建连接的影响。我们那次 5 个 Pod 报错,事后分析发现 3 个 Pod 的 HikariCP active=0, idle=5(连接池完全空闲,请求直接走已建连接),另外 2 个 Pod active=15, idle=0, pending=3(池耗尽 + 有等待线程)——报错的 5 个 Pod 里,有 3 个的报错来自「等连接超时后新建连接踩了负缓存」,而不是「存量连接断了」。
5.2 连接池参数与 DNS 缓存的交互
| 参数 | 与 DNS 负缓存的交互 | 建议 |
|---|---|---|
| minimumIdle | 空闲连接越多,CoreDNS 抖动期间走已建连接的概率越高 → 影响面越小 | K8s 里建议 minimumIdle ≥ 预期并发,别设 0 |
| maxLifetime | 太短(如 60s)→ 连接频繁重建 → 每次重建都触发 DNS 解析 → 踩负缓存概率↑ | 建议 1800s+,给 CoreDNS 留足恢复窗口 |
| connectionTimeout | 等连接超时后,HikariCP 会抛 SQLTransientConnectionException,不会自动重试 DNS 解析——上层框架(Spring)如果配了重试,重试时会重新解析 → 踩负缓存 | 在 DNS 修复落地前,不要在 connectionTimeout 后加自动重试 |
| validationTimeout | 连接校验(SELECT 1)走已建连接,不受 DNS 影响 | 保持默认即可 |
5.3 一个反直觉的值班判断
CoreDNS 抖动期间,如果监控看到「连接池 pending 上涨 + UnknownHostException 同时出现」,大概率不是 CoreDNS 把连接打断了,而是池耗尽后新建连接踩了负缓存。这两个现象的根因链完全不同:
- 前者(CoreDNS 打断连接):需要 CoreDNS 抖动 > TCP keepalive 超时(通常 75s+),罕见;
- 后者(池耗尽 + 负缓存):只需要 CoreDNS 抖动 1s + 池恰好耗尽,常见。
值班 SOP 更新:看到 UnknownHostException 先查 dig,如果 dig 正常,不要急着查 CoreDNS,先查 java.security 的 negative.ttl 和连接池的 active/idle/pending 分布。
六、结论与可复用清单
结论:K8s 里 Service 域名是动态的,JDK 的 DNS 负缓存(默认 10s,我们误配成 30s)会把 CoreDNS 的瞬时放大 3–4 倍。正确姿势是正缓存 30s + 负缓存 2s,让 JVM 的 DNS 行为对齐 Go/Python 的「每次解析」语义。但注意:已建立的 TCP 连接不受 DNS 缓存影响,真正受影响的是新建连接路径——连接池配置和 DNS 缓存 TTL 需要一起看,不能只改 java.security 就完事。
可复用清单(建议贴进 K8s Java 服务 checklist):
- [ ] 所有 JDK 8+ 服务,
grep -E "networkaddress" $JAVA_HOME/conf/security/java.security,确认negative.ttl ≤ 5; - [ ] 禁止把
networkaddress.cache.ttl设成-1(K8s 环境); - [ ] 加
dns_unknown_host_total指标 + Prometheus 告警(规则见 3.3); - [ ] 连接池
minimumIdle ≥ 预期并发,避免 CoreDNS 抖动时池耗尽 → 新建连接踩负缓存(见 5.2); - [ ] 连接池
maxLifetime ≥ 1800s,减少连接重建频率(见 5.2); - [ ] 值班 SOP:
UnknownHostException+dig正常 → 先查java.security,再查连接池pending分布,不要直接重启 Pod(重启会触发全量 DNS 解析,如果 CoreDNS 还在抖,重启反而更惨); - [ ] 滚动发布窗口内避免同时重启所有 Pod——如果 CoreDNS 恰好抖动,所有 Pod 同时触发 DNS 解析 → 同时踩负缓存 → 全集群同时挂。