结论先行: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 就是解析不到。
二、根因: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返回。
五、结论与可复用清单
结论:K8s 里 Service 域名是动态的,JDK 的 DNS 负缓存(默认 10s,我们误配成 30s)会把 CoreDNS 的瞬时抖动放大 3–4 倍。正确姿势是正缓存 30s + 负缓存 2s,让 JVM 的 DNS 行为对齐 Go/Python 的「每次解析」语义。
可复用清单(建议贴进 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 告警,把「负缓存命中」变成可观测事件; - [ ] 排查 DNS 问题时,同时跑
dig和java -cp . DNSProbe,两条路径都验证; - [ ] 如果用了 Sidecar(Envoy / Istio)做服务发现,确认 Sidecar 的 DNS 缓存 TTL 也在合理范围(Envoy 默认 30s,
dns_refresh_rate可调); - [ ] CoreDNS 加 GC 监控(
jstat -gcutil或cgroup memory.events),Full GC > 5s 即告警——它是 DNS 抖动的常见上游。
留一个开放问题想听听大家:你们 K8s 集群里,Java 服务的 networkaddress.cache.negative.ttl 到底设的是多少?有没有人遇到过「同一时刻一半 Pod 报错一半正常」的 DNS 问题?欢迎把你们的 java.security 配置贴出来对一对。