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, ServiceInfoimport 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 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.0、127.0.0.1、169.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() @staticmethod def _register_service (zc, info, strict ): try : zc.register_service(info, strict=strict) except TypeError: zc.register_service(info)
这种 try/except TypeError 的写法看着粗暴,但处理 optional dependency 的参数兼容特别好用——zeroconf 从 0.28 到 0.130 的 API 变了好几次,你不知道现场装的是哪个版本。
第三关:DNS 标签规范化 mDNS 的服务名必须符合 DNS 标签规范:只允许字母、数字、连字符,长度不超过 63 字节。
用户配置里可能写 IoT后端、my service、service_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 ]
"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, Zeroconfdef 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()
几个踩坑总结 Docker 里 mDNS 不通 :Docker 默认用 bridge 网络,组播包出不去。要用 --network host 或者单独配组播路由双网卡冲突 :服务器有内网和外网两块网卡,mDNS 注册的 IP 可能是外网那个。需要指定 advertise_ipv4 参数,或者依赖 UDP 探测自动选对网卡zeroconf 版本碎片化 :0.28 和 0.130 的 API 差异巨大。用 try/except 兜参数,别假设用户的版本和你开发时一样mDNS 不是万能发现 :它只在局域网有效。跨网段需要配 mDNS reflector(Avahi 的 enable-reflector=yes)或者上 DNS这个 mDNS 模块代码一共 240 行,但花了两整天调通各种环境。IoT 后端不像 Web 后端跑在云上网络可控,你面对的是机房、大棚、果园——网络环境千奇百怪,代码得比环境更宽容。