服务启动即崩:InetAddress.getLocalHost() 抛 UnknownHostException 排查记
摘要 :新环境机器上服务启动第一秒就崩,堆栈指向
InetAddress.getLocalHost(),报UnknownHostException: 主机名: Name or service not known。代码一行没改,同样的包在别的机器跑得好好的。本文讲清这行代码的真实机制------它是"取主机名 + 解析主机名"两步,任何一步失败都会崩;给出三类高发场景、运维侧与代码侧的根治方案,最后补一个更隐蔽的姊妹坑:不抛错、但静默拿错 IP。适合读者:在 Linux 服务器/容器上部署 Java 服务的开发和运维。这个报错的搜索量常年居高不下,而大部分答案只给一句"改 hosts",不讲为什么。
一、报错现场
服务初始化时拿本机 IP,塞进系统属性供日志格式、监控上报和服务注册使用------非常常见的写法:
java
// 启动初始化代码(示意)
System.setProperty("ip", InetAddress.getLocalHost().getHostAddress());
新机器上启动即崩:
text
java.net.UnknownHostException: game_review-02: game_review-02: Name or service not known
at java.net.InetAddress.getLocalHost(InetAddress.java:1506)
at com.example.server.ServerBootstrap.initConfig(ServerBootstrap.java:148)
at com.example.server.ServerBootstrap.init(ServerBootstrap.java:59)
at com.example.server.GameMain.main(GameMain.java:21)
特定机器必现,代码与其他机器完全相同。报错信息里有两个细节:主机名出现了两次 ,是异常被包了两层(内层查找失败的消息已含主机名,外层 getLocalHost() 捕获后又拼一次重抛);Name or service not known 是 glibc EAI_NONAME 的标准输出,意思是这个名字在所有解析来源里都找不到------JVM 只是转述了操作系统的判决。
启动即崩某种意义上是好事------后文会讲"不崩"的版本更危险。
二、getLocalHost() 到底做了什么
很多人以为它就是"读一下本机 IP",不会失败。实际是两步操作 (堆栈行号 1506 正对应 JDK 8 getLocalHost() 里的重抛代码块,不同 8u 小版本会浮动一两行):
text
第一步 gethostname() 取内核维护的主机名(启动时从 /etc/hostname 加载)
第二步 解析这个名字 ← 本次事故的失败点
按 /etc/nsswitch.conf 的顺序(通常 files → dns):
查 /etc/hosts → 查不到问 DNS → 都没有 = UnknownHostException
成功的结果被 JVM 缓存 5 秒(见 §4.3)
关键认知:getLocalHost() 要求"本机主机名是可解析的",而操作系统只保证主机名存在,不保证它能被解析。 hostnamectl set-hostname 会更新内核主机名和 /etc/hostname,但不会 往 /etc/hosts 里写任何东西;DNS 上也未必有这台机器的记录。主机名与解析记录之间这条"没人担保的缝隙",就是本类事故的全部生存空间。
对照本案:主机名是部署时自定义的(game_review-02),改了名但没动 hosts,DNS 里更不会有它------第二步必然失败。
三、为什么解析会失败:三类高发场景
场景一:自定义主机名,但没有对应的解析记录(本案)
云上机器、测试/审核环境最常见:运维用 hostnamectl set-hostname xxx 起了个好认的名字,然后忘了 /etc/hosts。此后这台机器上所有 调用 getLocalHost() 的 Java 进程都活不了。同类还有:镜像克隆带来的模板主机名、改名后没同步 hosts。
顺带一提,本案主机名里有下划线 (..._review-02):内核层面合法,但不符合 RFC 952/1123。DNS 协议本身(RFC 2181)不限制标签字符集,_dmarc、_acme-challenge 遍地都是------但那些是 TXT 记录,不在校验范围;BIND 的 check-names 只管"按规范应当是主机名"的位置(A/AAAA/MX 的 owner name,以及 NS/SOA/MX/SRV 记录里的目标名),所以主机名想建 A 记录默认就会被拒。再加上云厂商校验器、TLS 证书与 SNI、Kerberos 的 host/FQDN 全都对不上,给机器起名请只用字母、数字和连字符。
场景二:容器环境(Docker / K8s)
- 默认桥接网络 :Docker 会把"容器 IP + 容器主机名"写进容器的
/etc/hosts,一般不出事; --network=host+--hostname=自定义名:--hostname只改容器的 UTS 主机名,而 Docker 生成的那份 hosts 文件是宿主机/etc/hosts的拷贝 (libnetwork 的OptionOriginHostsPath("/etc/hosts")),里面没有这个新名字 → 必然查不到。因为是创建时的拷贝而非挂载,事后改宿主机 hosts 不会同步进已存在的容器,只能修好后重建;- 想用
--add-host "$(hostname):127.0.0.1"补一条?注意副作用 :host 网络下一旦带了--add-host,libnetwork 就不再拷贝宿主机 hosts,改走自己生成的路径------里面只剩 Docker 内置的几行 localhost 加你显式传的条目,宿主机 hosts 上的其他映射(内部服务、数据库)会在容器里静默消失; - K8s
hostNetwork: true:kubelet 给这类 Pod 生成的 hosts 文件是节点/etc/hosts的拷贝 (首行带# Kubernetes-managed hosts file (host network).标记),而 Pod 的主机名是节点名 ------节点名不在节点自己的 hosts 里、集群 DNS 又没记录 → 同样炸。这里hostAliases是生效的(API 注释里那句 "only valid for non-hostNetwork pods" 本来就是错的,已从源码删掉),可以直接拿它补这条映射。
容器化改造后"原来好好的服务突然起不来",优先怀疑这一类。
场景三:镜像/环境裁剪过度
坑不在"镜像里的 /etc/hosts 被删了"------运行时会重新生成它,改镜像无效。真正的坑是解析链路缺件 :Alpine/musl 压根不读 /etc/nsswitch.conf(固定 files → dns),glibc 上验证过的方案搬过来行为可能不一致;distroless 连 getent/dig 都没有,现场难取证;CI 动态生成 hosts 漏了本机。
一个重要推论:不用这个 API 不代表安全
即便业务代码从不调 getLocalHost(),很多基础库会在内部调它:日志框架取主机名、注册中心客户端取 IP、监控 agent、Hadoop/Kafka 生态组件......所以这个报错经常出现在你从没写过的调用栈里。修机器(让主机名可解析)是治本,改自己代码只是治标。
四、修复:把主机名放回解析链里
4.1 应急修复:补一行 hosts(本案的解法)
text
# /etc/hosts
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
127.0.0.1 localhost localhost.localdomain
10.0.24.15 game_review-02 ← 新增:本机IP + 主机名
映射到 127.0.0.1 还是真实网卡 IP?看用途:
| 用途 | 映射到 | 原因 |
|---|---|---|
只是让 getLocalHost() 不抛错、日志里显示 IP |
127.0.0.1 |
最稳,不依赖网卡状态 |
| 取到的 IP 要注册给外部(注册中心、对端回连) | 真实网卡 IP | 别人要能连通这个地址 |
本案的 IP 只用于日志和监控标记,按上表选即可。注意同一主机名别在 hosts 里写多行 :glibc 返回全部匹配项,getLocalHost() 取第一个,而顺序由 RFC 6724 地址排序规则决定、不是你写的行序。
4.2 运维侧根治:让这条规则"必然存在"
手工修一台没问题,机器多了就是概率问题。把规则固化进镜像模板 / 云主机初始化脚本 / Ansible:
bash
#!/bin/bash
# 幂等(需 root)。用法:ensure_hosts.sh <本机IP>
# 要注册给外部的 IP 必须传真实网卡地址;传 127.0.0.1 就是在亲手制造 §6 那个故障。
set -euo pipefail
hn="$(hostname)"
ip="${1:?必须显式传入要写入 /etc/hosts 的本机 IP}"
# 按字段精确匹配:主机名不当正则用,容忍行尾注释与 CRLF,跳过注释行,
# 也不会把 web1 误判成 web10 / web1.example.com
if awk -v hn="$hn" '{ sub(/\r$/, "") } /^[[:space:]]*#/ { next }
{ for (i = 2; i <= NF; i++) if ($i == hn) { found = 1; exit } }
END { exit !found }' /etc/hosts
then exit 0; fi
printf '%s\t%s\n' "$ip" "$hn" >> /etc/hosts
配套两条规范:新机器命名只用字母数字连字符(见 §3 场景一);改主机名的流程里,"同步 /etc/hosts"作为必选项写进 SOP。
4.3 生效与缓存
改完没立刻生效,按顺序排这三层:
- glibc:改完立即生效,这层不用管;
- 缓存服务(最容易漏) :跑了 nscd / systemd-resolved 时,失败结果也会被缓存 ,有 nscd 的老 CentOS 上是必现而非"极端情况"------
nscd -i hosts(或重启服务),systemd-resolved 用resolvectl flush-caches; - JVM :
getLocalHost()有一个它独有的 5 秒缓存 (从 JDK 8 GA 到 21 都是 5 秒),刻意绕过通用 DNS 缓存、失败也不进缓存。所以运行中的进程等 5 秒就能拿到新结果,不必重启;崩在启动阶段的,重启即生效。
别和通用 DNS 缓存(
networkaddress.cache.ttl/negative.ttl)搞混,那套管的是getByName()和建连接。"hosts 明明改对了、进程还在报同一个错",卡住的多半是第 2 层。
五、代码侧防御:不要再裸调 getLocalHost()
"机器修好了"不等于"下次新机器不会再犯"。把"获取本机 IP"封装成防御性工具,三层递进:
java
// import 省略:java.net.{Inet4Address,InetAddress,NetworkInterface,SocketException,UnknownHostException}
// java.util.{Collections,Enumeration} java.util.logging.{Level,Logger}
public final class LocalIpUtil {
// 换成你项目自己的日志框架
private static final Logger LOG = Logger.getLogger(LocalIpUtil.class.getName());
private static final String FALLBACK = "127.0.0.1";
public static String getLocalIp() {
// 一、显式配置优先:IP 是部署信息,不是探测出来的
String configured = System.getProperty("app.ip");
if (configured != null && !configured.isEmpty()) return configured;
// 二、常规路径,但只信"非回环、非通配"的结果
try {
InetAddress addr = InetAddress.getLocalHost();
if (!addr.isLoopbackAddress() && !addr.isAnyLocalAddress()) return addr.getHostAddress();
LOG.warning("getLocalHost() 返回回环/通配地址 " + addr.getHostAddress() + ",降级到网卡遍历");
} catch (UnknownHostException | SecurityException e) {
// 绝不向上抛------拿 IP 这件事不该有资格搞崩服务
LOG.log(Level.WARNING, "getLocalHost() 失败,降级到网卡遍历", e);
}
// 三、遍历网卡兜底:先内网 IPv4,再放宽到任意可用 IPv4
try {
String ip = pickIpv4(true);
if (ip == null) ip = pickIpv4(false);
if (ip != null) return ip;
} catch (SocketException e) {
LOG.log(Level.WARNING, "遍历网卡失败", e);
}
LOG.severe("无法确定本机 IP,降级到 " + FALLBACK);
return FALLBACK;
}
/** 取一个可用 IPv4;siteLocalOnly 时只认内网段(10/172.16-31/192.168) */
private static String pickIpv4(boolean siteLocalOnly) throws SocketException {
Enumeration<NetworkInterface> nis = NetworkInterface.getNetworkInterfaces();
if (nis == null) return null; // 无网络栈时返回 null
for (NetworkInterface ni : Collections.list(nis)) {
if (!ni.isUp() || ni.isLoopback() || isVirtualByName(ni.getName())) continue;
for (InetAddress addr : Collections.list(ni.getInetAddresses())) {
if (!(addr instanceof Inet4Address)) continue;
// 回环、通配、链路本地(169.254/16)三类无条件排除
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()
|| addr.isLinkLocalAddress()) continue;
if (siteLocalOnly && !addr.isSiteLocalAddress()) continue;
return addr.getHostAddress();
}
}
return null;
}
/** 按名字挡虚拟网卡是打不完的地鼠,新增一种 CNI 就得补一条,只作辅助手段 */
private static boolean isVirtualByName(String name) {
return name.startsWith("docker") || name.startsWith("veth") || name.startsWith("br-")
|| name.startsWith("virbr") || name.startsWith("cali") || name.startsWith("flannel")
|| name.equals("cni0"); // 按需再补 lxc/vnet/tap/tunl/kube-* ...
}
}
三个设计要点:
- 显式配置永远优先。多网卡机器上"本机 IP"本身就是歧义问题(内网卡还是公网卡?),启发式探测只能兜底;迁移时记得把 §1 那行遗留代码的读取方一起改掉。
- 失败要降级,但不要静默。它是辅助信息,降级到回环地址比炸掉启动流程好得多;日志这一步别省,否则故障会在几小时后换个面目出现。
- 判断回环别用字符串比较 。
"127.0.0.1".equals(ip)挡不住::1,保留InetAddress对象调isLoopbackAddress()才能一次覆盖两种栈。
六、姊妹坑:不抛错,但静默拿错 IP
UnknownHostException 是这个 API 最"体面"的失败方式------启动即崩,一眼定位。更贵的是它成功返回、但返回了错误的 IP,而且不止一条路:
- 选中虚拟网卡 :宿主机装了 Docker,出现
docker0网桥(172.17.0.1)。hosts 里主机名恰好映射到它,或兜底逻辑没过滤虚拟网卡 → 服务拿172.17.0.1注册到 ZooKeeper/Nacos/Eureka,其他服务按这个地址回连全部失败。服务"看起来启动成功",故障在半小时后以"服务间调用超时"的面目出现,排查路径完全想不到是 IP 问题; - 拿到链路本地地址 :云主机 DHCP 失败、网卡降级时接口上挂着
169.254.x.x。比172.17.0.1更难查------它看起来完全"像个正常内网 IP",注册进注册中心也不会有人觉得不对; - 拿到回环还不报错 :
getLocalHost()里有一句catch (SecurityException e) { return impl.loopbackAddress(); }------装了 SecurityManager 而缺SocketPermission "<主机名>", "resolve"(checkConnect(host, -1)校验的正是 resolve 动作)时,它不抛异常,直接返回127.0.0.1。hosts 只有::1 主机名映射而 JVM 走 IPv6 栈时同理。
防御手段:
- 注册地址入库前做合法性校验:非回环、非通配、非
169.254.0.0/16、非172.17.0.0/16(自家内网真用了 172.17/16 的就去掉这条),(可选)再确认本机确实持有该地址; - 明确期望 IPv4 的场景加
-Djava.net.preferIPv4Stack=true,并在解析代码里过滤Inet4Address。
一句话:这个 API 的两种失败------崩溃和说谎------说谎的那种更贵。
七、排查手册
拿到 UnknownHostException: <主机名> 后的五分钟定位流程:
| 步骤 | 命令 | 看什么 |
|---|---|---|
| 1 | hostname / hostnamectl |
确认报错里的名字 = 系统主机名(通常是) |
| 2 | getent hosts $(hostname) |
关键一步:与 JVM 同走 glibc NSS,结论可互推;无输出 = 复现故障,问题在机器不在代码 |
| 3 | cat /etc/nsswitch.conf |
确认 hosts: files dns 顺序(files 优先,改 hosts 才有效)。文件不存在也别意外------Alpine/musl 压根不读它 |
| 4 | cat /etc/hosts |
有没有主机名映射、localhost 条目是否完整、主机名里有没有下划线 |
| 5 | nslookup <主机名> / dig |
验证 DNS 路径(这两个工具绕过 hosts 直连 DNS;容器/裁剪环境常见两条路都断) |
修复后同样用 getent hosts $(hostname) 验证:有输出,Java 就能起来。开发机上,Windows 看 C:\Windows\System32\drivers\etc\hosts,macOS 改名后常发同类问题,解法相同。
一句话总结 :getLocalHost() 表面上在问"我的 IP 是什么",实际在问"我的主机名解析得出来吗"------前者天经地义,后者取决于 /etc/hosts 里有没有那一行,而这行字,只有你自己会写。