mDNS 服务发现——让 IoT 设备零配置找到后端的那三重降级

IoT 项目有个很实际的问题:后端的 IP 地址怎么告诉设备?

Web 应用有域名,配个 DNS 就完事。IoT 设备跑在内网,没域名,IP 还是 DHCP 动态分配的。你要在每台传感器、每个智能插座上硬编码后端地址?下次路由器重启 IP 一变,全部失联。

mDNS(Multicast DNS)就是干这个的——设备在局域网里喊一声”谁是 iot-backend?“,后端自动应答”我是,IP 是 192.168.1.100,端口 8800”。苹果的 Bonjour、安卓的 NsdManager、Linux 的 Avahi,全支持。

听起来很美好。但真正写代码注册 mDNS 服务的时候,坑比想象的多。

最简版:十行代码能跑

用 Python 的 zeroconf 库,注册一个 mDNS 服务就这点代码:

1
2
3
4
5
6
7
8
9
10
11
from zeroconf import Zeroconf, ServiceInfo
import socket

zeroconf = Zeroconf()
info = ServiceInfo(
type_="_http._tcp.local.",
name="iot-backend._http._tcp.local.",
addresses=[socket.inet_aton("192.168.1.100")],
port=8800,
)
zeroconf.register_service(info)

开发机上跑,没问题。部署到现场——崩了。

第一关:IP 地址怎么拿

硬编码 IP 显然不行。得自动检测本机的局域网 IP。

直觉是 socket.gethostbyname(socket.gethostname()),但这个方法在 Docker 容器里返回 127.0.0.1,在 macOS 上返回 127.0.0.1,在有些 Linux 上直接报错。

最后写了个三级探测链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _detect_local_ipv4():
# 第一级:通过主机名解析
local = _detect_interface_ipv4()
if local:
return local

# 第二级:UDP 探测 8.8.8.8
probes = [("8.8.8.8", 53), ("1.1.1.1", 53)]
for host, port in probes:
ip = _detect_outbound_ipv4(host, port)
if ip:
return ip

return ""

第一级 gethostname / getfqdn 解析:在正常 Linux 服务器上管用。

第二级 UDP 探测是最靠谱的——创建一个 UDP socket,connect 到 8.8.8.8:53,然后 getsockname() 取本端地址。这个技巧妙在:UDP 的 connect 不会真的发包,只是让操作系统选一个出口网卡和 IP。所以即使在完全没网的环境里也不会卡住。

1
2
3
4
5
6
7
8
9
10
11
def _detect_outbound_ipv4(target_host, target_port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.5)
try:
sock.connect((target_host, target_port))
candidate = sock.getsockname()[0]
return _normalize_ipv4(candidate)
except Exception:
return ""
finally:
sock.close()

拿到的 IP 还得过 _normalize_ipv4 的验证——排除 0.0.0.0127.0.0.1169.254.x.x(link-local)、组播地址,这些都不是合法的广播 IP:

1
2
3
4
5
def _normalize_ipv4(value):
ip_obj = ipaddress.ip_address(value)
if ip_obj.is_unspecified or ip_obj.is_loopback or ip_obj.is_multicast or ip_obj.is_link_local:
return ""
return str(ip_obj)

8.8.8.8 不通就试 1.1.1.1。两个都不通?那这个机器大概率没网,mDNS 注册了也没用,返回空。

第二关:Zeroconf 注册,三种姿势

拿到 IP 后,调用 zeroconf.register_service()。但在不同版本的 zeroconf 库和不同操作系统上,这个方法的行为不一致:

  • 有些版本要求 strict=True(严格模式,检查服务名合法性),有些没有这个参数
  • 有些版本 Zeroconf(ip_version=IPVersion.V4Only) 正常,有些抛 EventLoopBlocked
  • Linux 上 IPv6 组播可能没权限,macOS 上 IPv4-only 模式偶尔卡住

所以写了三重降级策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
strategies = [
{"label": "v4_strict", "ip_version": IPVersion.V4Only, "strict": True},
{"label": "v4_relaxed", "ip_version": IPVersion.V4Only, "strict": False},
{"label": "default_relaxed", "ip_version": None, "strict": False},
]

for strategy in strategies:
try:
self.stop()
self._zeroconf = self._create_zeroconf(strategy["ip_version"])
self._service_info = ServiceInfo(...)
self._register_service(self._zeroconf, self._service_info, bool(strategy.get("strict", True)))
return True
except Exception as exc:
self.stop()

第一种:IPv4-only + 严格模式。最规范,绝大多数 Linux 服务器走这条路。

第二种:IPv4-only + 宽松模式。有些 zeroconf 版本的 strict 检查太严格(比如服务名里有下划线就报错),放宽后能过。

第三种:默认 + 宽松。放弃 IPv4-only 限制,让 zeroconf 自己选协议。这招在 macOS 上最管用——苹果的网络栈对 IPv4-only 组播有限制。

_create_zeroconf_register_service 都做了参数兼容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@staticmethod
def _create_zeroconf(ip_version):
if ip_version is None:
return Zeroconf()
try:
return Zeroconf(ip_version=ip_version)
except TypeError:
return Zeroconf() # 老版本没有 ip_version 参数

@staticmethod
def _register_service(zc, info, strict):
try:
zc.register_service(info, strict=strict)
except TypeError:
zc.register_service(info) # 老版本没有 strict 参数

这种 try/except TypeError 的写法看着粗暴,但处理 optional dependency 的参数兼容特别好用——zeroconf 从 0.28 到 0.130 的 API 变了好几次,你不知道现场装的是哪个版本。

第三关:DNS 标签规范化

mDNS 的服务名必须符合 DNS 标签规范:只允许字母、数字、连字符,长度不超过 63 字节。

用户配置里可能写 IoT后端my serviceservice_name.with.dots——全是非法的。得洗一遍:

1
2
3
4
5
6
7
8
9
10
def _normalize_service_label(value):
text = str(value or "").strip().lower()
out = []
for ch in text:
if ch.isalnum() or ch == "-":
out.append(ch)
else:
out.append("-") # 非法字符替换成连字符
normalized = "".join(out).strip("-") # 去掉首尾连字符
return normalized[:63] # DNS 标签长度上限

"IoT后端""iot----""iot"(去掉尾部连字符) "my service""my-service" "service_name.with.dots""service-name-with-dots"

服务类型也要规范化,确保以 . 结尾:

1
2
3
4
5
6
7
def _normalize_service_type(value):
service_type = str(value or "").strip()
if not service_type:
return "_http._tcp.local."
if not service_type.endswith("."):
service_type += "."
return service_type

mDNS 的类型格式是 _<proto>._<transport>.local.,漏了最后的点会导致某些客户端解析失败。

客户端怎么发现

服务端注册好了,客户端怎么用?

Linux(Avahi)

1
avahi-browse -r _http._tcp.local.

macOS

1
2
dns-sd -B _http._tcp.local.
dns-sd -L iot-backend _http._tcp.local.

Python 客户端

1
2
3
4
5
6
7
8
9
10
11
from zeroconf import ZeroconfServiceBrowser, Zeroconf

def on_service_state_change(zeroconf, service_type, name, state_change):
if state_change == ServiceStateChange.Added:
info = zeroconf.get_service_info(service_type, name)
if info:
addresses = [socket.inet_ntoa(addr) for addr in info.addresses]
print(f"发现 {name}: {addresses}:{info.port}")

zeroconf = Zeroconf()
browser = ZeroconfServiceBrowser(zeroconf, "_http._tcp.local.", handlers=[on_service_state_change])

安卓(NsdManager)

1
2
val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
nsdManager.discoverServices("_http._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)

iOS(Bonjour / NetServiceBrowser)

1
2
let browser = NetServiceBrowser()
browser.searchForServices(ofType: "_http._tcp.", inDomain: "local.")

所有平台都原生支持 mDNS,不需要装额外软件。设备上线后自动发现后端,不需要配 IP。

关机时要注销

这个容易被忽略。如果后端进程直接被 kill,mDNS 记录会在局域网里残留几分钟(TTL 默认 120 秒),导致设备尝试连接一个已经不存在的地址。

1
2
3
4
5
6
7
8
9
10
11
def stop(self):
if self._zeroconf and self._service_info:
try:
self._zeroconf.unregister_service(self._service_info)
except Exception:
pass
if self._zeroconf:
try:
self._zeroconf.close()
except Exception:
pass

unregister_service 发送 goodbye 包,告诉网络”我不在了”,再 close 释放资源。try/except 兜底是因为关机时网络可能已经不可用了。

在 FastAPI 的 lifespan 里配合使用:

1
2
3
4
5
@asynccontextmanager
async def lifespan(app):
mdns_advertiser.start()
yield
mdns_advertiser.stop()

几个踩坑总结

  1. Docker 里 mDNS 不通:Docker 默认用 bridge 网络,组播包出不去。要用 --network host 或者单独配组播路由
  2. 双网卡冲突:服务器有内网和外网两块网卡,mDNS 注册的 IP 可能是外网那个。需要指定 advertise_ipv4 参数,或者依赖 UDP 探测自动选对网卡
  3. zeroconf 版本碎片化:0.28 和 0.130 的 API 差异巨大。用 try/except 兜参数,别假设用户的版本和你开发时一样
  4. mDNS 不是万能发现:它只在局域网有效。跨网段需要配 mDNS reflector(Avahi 的 enable-reflector=yes)或者上 DNS

这个 mDNS 模块代码一共 240 行,但花了两整天调通各种环境。IoT 后端不像 Web 后端跑在云上网络可控,你面对的是机房、大棚、果园——网络环境千奇百怪,代码得比环境更宽容。

欢迎关注我的其它发布渠道