局域网里的设备互相发现这件事,我踩过的坑比想象中多。很多人第一次听说mDNS(Multicast DNS,多播域名解析),是因为在树莓派或者开发板上访问xxx.local失败,或者两台机器明明在同一个交换机下,ping得通 IP 却死活解析不了主机名。标题里那句“亲测免费”不是噱头——mDNS 这套东西从上世纪的设计草案一路走到 RFC 6762 标准化,核心实现全是开源的,不需要任何授权费用,也不需要你搭一台中心 DNS 服务器。它干的活很朴素:让同一个局域网内的设备,不用配置、不用登记、不用维护 hosts 文件,就能互相知道“我叫什么、我在哪个 IP、我提供什么服务”。
我自己最早接触它是在一个多设备联调的现场,微信小程序、手机 App、嵌入式板子三方要互相互相找到对方,结果每换一个路由器网段就得重新改一遍 IP 白名单,改到人崩溃。后来换成 mDNS 广播加 DNS-SD 服务发现,配置文件一次没再动过。这篇内容我会把 mDNS 的安装、配置、验证、排障整条链路讲完,覆盖 Linux 守护进程方案、系统内置解析、以及应用内嵌库三种落地方式,附带可以直接抄走的配置片段和命令。刚入门网络编程的同学可以照着做,已经用过 mDNS 但被“时好时坏”折磨过的老手,第六、七节的排障和踩坑记录应该更对你的胃口。
1. 先把 mDNS 这件事讲透:它到底解决什么问题
1.1 没有中心服务器时,谁来做“报名字”的活
传统 DNS 的模型是“客户端问、服务器答”,你得先知道服务器地址,还得有人维护那份区域文件。放到家庭网络、实验室工位、展会现场这种临时组网环境里,这套模型立刻崩掉:没有服务器,没人愿意当管理员,设备插上就走。mDNS 的思路是把“回答问题的责任”下放给每台设备自己——每台主机在局域网里既是解析客户端,也是自己名字的权威服务器。
技术上它复用了 DNS 的报文格式,这一点很关键,意味着现成的解析器和抓包工具都能直接读懂。区别在于传输层:不走单播到 53 端口,改成向组播地址224.0.0.251(IPv6 是ff02::fb)的5353/UDP端口发包。任何一台设备想知道printer.local的 IP,就往这个组播组里喊一嗓子,拥有这个名字的设备听到后直接应答,其他设备顺带把答案塞进自己的缓存。整个过程没有中心节点,拔掉任何一台设备,剩下的照常工作。
这里有个容易忽略的细节:mDNS 的查询默认是“一问多答”的,而且应答方会主动做免费宣告(gratuitous announcement)。设备刚上线、IP 变了、服务重启了,它不需要等人来问,自己先广播几遍“我是谁、我在哪”。这个设计的好处是收敛快,坏处是广播风暴的风险,所以协议里塞了一堆抑制机制,后面讲配置参数时会专门说。
1.2 mDNS 负责“找 IP”,DNS-SD 负责“找服务”
很多人把这两个概念混着叫,其实分工很清楚。mDNS 解决的是名字到地址的映射,也就是nas.local对应192.168.1.20这件事;DNS-SD(DNS Service Discovery,RFC 6763)解决的是“这个网段里有哪些类型的服务、分别在哪”。后者是架在前者之上的约定,用特定的记录格式描述服务。
DNS-SD 的命名规则值得记牢,排查问题时全靠它:
- 服务类型:
_http._tcp.local.,下划线开头,格式是_服务名._传输层 - 服务实例:
我的站点._http._tcp.local.,前面那段是给人看的实例名,可以带空格和中文 - 服务类型枚举:
_services._dns-sd._udp.local.,查这个能列出网段内所有在播的服务类型 - 服务元数据:TXT 记录,键值对形式,用来携带
path=/api、version=1.0这类附加信息
一个完整的服务发布,实际会写出 PTR、SRV、TXT、A/AAAA 四类记录:PTR 把服务类型指向实例名,SRV 给出实例对应的主机名和端口,TXT 带元数据,A/AAAA 把主机名落到 IP。理解这四层嵌套,你在抓包时看到一堆记录就不会懵。我第一次用avahi-browse只看到实例名看不到 IP,就是因为只解析了 PTR 没继续追 SRV,这个坑第二节还会提。
1.3 什么场景值得上 mDNS,什么场景别硬上
mDNS 不是万能药,用错地方比不用还糟。我总结下来,它适合三类场景:第一类是终端用户设备,比如打印机、智能音箱、投屏盒子,用户不该被要求输入 IP;第二类是开发联调,前后端、移动端、嵌入式在同一网段互相找服务,省掉写死地址的麻烦;第三类是临时组网,展会、路演、应急现场,没有固定网络设施。
不适合的场景同样明确。跨网段、跨 VLAN 的发现,mDNS 的组播包默认过不了路由器,硬要过就得配反射器(reflector)或者组播代理,配置复杂度和排障难度陡增,这种时候老老实实用中心化服务注册中心更省心。设备数量超过几百台的密集网络也要谨慎,组播流量虽然单包很小,但几百台设备同时宣告和查询,交换机端口上的广播压力不小。还有一个常见误区是拿 mDNS 当安全边界,它本质上是完全公开的——同一网段任何人都能查到你在播什么服务、什么版本,把管理后台的端口通过 mDNS 暴露出去,等于给扫描器递了张地图。
2. 三套主流实现的选型对比
2.1 系统级守护进程:一次装好,全机受益
系统级方案的典型代表是 Linux 上的Avahi,它常驻后台,统一管理本机的 mDNS 响应和解析,提供avahi-browse、avahi-publish、avahi-resolve一整套命令行工具,还支持用 XML 文件声明要发布的服务。macOS 和大部分 BSD 系统内置了同类守护进程,不用额外装。Windows 相对特殊,较新的版本已经具备.local名字的解析能力,但要主动发布服务、要用dns-sd命令行工具,通常还得装额外的兼容组件。
用守护进程最大的好处是解耦:你的应用不需要关心 mDNS 协议细节,只要往/etc/avahi/services/里丢一个 XML,或者启动时调一次avahi-publish,剩下的宣告、缓存、冲突处理全交给系统。多个应用共用同一个 5353 端口也不会打架,因为端口只被守护进程独占一次。代价是多了一层依赖,容器环境、精简系统、定制固件里往往没有它,而且守护进程挂了,所有依赖它的服务一起失联。
2.2 应用内嵌库:把发现能力打进自己的进程
内嵌方案是直接在应用代码里引入 mDNS 库,自己开 socket、自己发包收包。Python 生态里python-zeroconf用得最多,Go 有hashicorp/mdns,Java 有 JmDNS,Node.js 有bonjour,Rust 也有几个成熟的 crate。这类库的 API 通常就三五个方法:注册服务、浏览服务、注销服务,学习成本很低。
内嵌的核心优势是可控:你能精确决定什么时候开始广播、广播哪些网卡、TXT 记录里放什么、收到响应后怎么处理,不需要用户去装系统包,也不受系统守护进程版本差异的影响。做桌面客户端、做嵌入式应用,这个特性非常重要。缺点是每个进程都要自己处理端口复用和网卡枚举,多网卡机器上如果没指定接口,包可能从错误的网卡发出去,导致“本机能看到、对端看不到”的诡异现象,这个坑我在第五节会详细展开。
2.3 选型决策表:按场景对号入座
| 维度 | 系统守护进程(Avahi 类) | 应用内嵌库 |
|---|---|---|
| 部署成本 | 需要 apt/yum 安装,需要 root | 随应用打包,无额外依赖 |
| 端口占用 | 独占 5353,全机共享 | 需自行处理 SO_REUSEADDR |
| 多应用共存 | 天然支持,互不干扰 | 每个进程独立,需自行协调 |
| 服务发布方式 | XML 文件或命令行 | 代码内调用 API |
| 精细控制能力 | 受配置项限制 | 完全可控,可指定网卡和 TTL |
| 容器友好度 | 差,需 host 网络 | 好,可指定接口 |
| 适合谁 | 设备厂商、运维、固定设备 | 应用开发者、跨平台客户端 |
我的实际选择习惯是:如果做的是长期在固定设备上跑的服务,直接上守护进程,配置一次管很久;如果做的是要发给别人安装的软件,一律用内嵌库,绝不要求用户去装系统包。两者也可以混用——本机用内嵌库发布,用系统的avahi-browse来验证,调试起来很方便。
3. Linux 下 Avahi 的安装与配置
3.1 安装与最小验证
Debian/Ubuntu 系一条命令搞定,顺带把客户端解析包libnss-mdns一起装上,这个包后面会反复提到:
sudo apt update sudo apt install -y avahi-daemon avahi-utils libnss-mdnsRed Hat 系对应的是avahi、avahi-tools、nss-mdns:
sudo dnf install -y avahi avahi-tools nss-mdns装完先别急着改配置,直接启动并验证守护进程活着:
sudo systemctl enable --now avahi-daemon sudo systemctl status avahi-daemon --no-pager avahi-daemon --checkavahi-daemon --check这个命令很多人不知道,它只做配置语法检查不启动服务,改完配置文件先跑一遍,能省下“服务起不来还得翻 journalctl”的时间。状态正常的话,avahi-browse -art应该能列出本机在播的主机名和网卡信息。如果这一步就报Daemon not running,八成是 systemd 单元被屏蔽了,用systemctl unmask avahi-daemon解开再试。
注意:有的发行版会把 Avahi 和
systemd-resolved一起装,两者都想监听 5353 端口。装完先跑ss -ulnp | grep 5353看一眼谁在占,只有一个进程占用才是正常状态。
3.2 配置文件逐项拆解与取舍理由
主配置在/etc/avahi/avahi-daemon.conf,默认值大部分能用,但有几个参数我建议按实际情况调:
[server] use-ipv4=yes use-ipv6=no allow-interfaces=eth0 deny-interfaces=lo ratelimit-interval-usec=1000000 ratelimit-burst=1000 [wide-area] enable-wide-area=no [publish] publish-addresses=yes publish-hinfo=no publish-workstation=yes publish-domain=no [reflector] enable-reflector=nouse-ipv6=no是我在纯 IPv4 环境里的默认选择,理由很实际:双栈环境下 IPv6 组播接口枚举容易出岔子,某些老网卡驱动对ff02::fb的处理不稳定,关掉能减少一半的“时好时坏”。如果环境确实是双栈,把它打开,同时用allow-interfaces明确列出网卡名,别让守护进程自己去猜。
allow-interfaces和deny-interfaces是排障利器。多网卡机器(有线、无线、虚拟网卡、容器网桥全都在)最常见的故障是 Avahi 从错误的网卡发包,导致对端收不到。解决办法不是关掉别的网卡,而是显式白名单:只允许业务网卡参与,其余全部拒绝。虚拟网卡(docker0、veth*、virbr0)一定要拒绝,否则容器里的临时 IP 会被广播出去,对端解析到的可能是个已经销毁的地址。
ratelimit-interval-usec和ratelimit-burst控制发包速率上限,默认值一秒内允许 1000 个包。这个限制是为了防广播风暴,正常情况下碰不到。但如果你的应用会高频注册注销服务(比如压力测试),可能会撞上限制导致部分宣告丢失,表现为“服务注册了但扫不到”。真遇到这种情况,临时把 burst 调大验证一下,确认是限流问题后,正确的做法是改业务逻辑降低注册频率,而不是长期放大限流阈值。
enable-reflector=no默认关闭是对的。反射器会把一个网卡收到的组播转发到另一个网卡,实现跨网段发现。听起来很美,实际会显著增加流量,而且如果两个网段里有同名服务,冲突处理会变得极其混乱。跨网段需求请用正经的服务注册中心,别用反射器硬扛。
3.3 用 XML 声明一个要发布的服务
Avahi 最舒服的地方是服务声明可以完全脱离代码。在/etc/avahi/services/下放一个.service后缀的 XML 文件,重启守护进程就生效:
<?xml version="1.0" standalone='no'?> <!DOCTYPE service-group SYSTEM "avahi-service.dtd"> <service-group> <name replace-wildcards="yes">%h 的演示服务</name> <service> <type>_http._tcp</type> <port>8080</port> <txt-record>path=/api</txt-record> <txt-record>version=1.0</txt-record> <txt-record>env=staging</txt-record> </service> </service-group>几个细节值得说。replace-wildcards="yes"配合%h会把主机名自动替换进实例名,多台设备用同一份配置模板部署时不会撞名。<type>必须严格符合_服务名._tcp或_服务名._udp格式,写成http._tcp少了前面那个下划线,Avahi 会静默忽略整段配置,非常隐蔽。TXT 记录的数量没有硬性上限,但每条都会塞进同一个 DNS 报文里,超过 1300 字节左右就可能被截断,实际使用时控制在五条以内比较稳妥。
改完文件不用重启整个守护进程,sudo systemctl reload avahi-daemon会重新扫描服务目录。但reload在某些版本上不触发重新宣告,稳妥起见还是restart,代价是短暂的服务中断,几十毫秒级别,业务能容忍就用 restart。
3.4 让本机也能解析 .local 名字
这是 Linux 上最高频的“我明明装了但 ping 不通”原因。Avahi 只负责发布和响应,本机的名字解析走的是 NSS 机制,必须让nsswitch.conf知道去问 mDNS。装完libnss-mdns后编辑/etc/nsswitch.conf:
hosts: files mdns4_minimal [NOTFOUND=return] dns这一行的顺序有讲究。files放最前,让/etc/hosts优先,符合直觉也方便临时覆盖。mdns4_minimal只处理.local结尾和少数单标签名字,避免所有解析请求都往组播里发一遍,性能好得多。[NOTFOUND=return]的意思是:如果 mDNS 明确回答“没有这个名字”,就直接返回失败,不要再去问后面的 DNS,这样能避免无效的单播查询拖慢解析速度。
验证命令是getent hosts 某设备名.local,注意用getent而不是ping,因为ping会走 ICMP,而getent直接调 NSS,链路更短更容易定位问题。如果getent有输出而ping不通,那问题在网络层不在解析层,方向立刻就清楚了。
4. Windows 与 macOS 的落地差异
4.1 macOS 侧基本不用配置
macOS 内置了完整的 mDNS 响应器和 DNS-SD 工具链,dns-sd命令开箱即用,这是最省心的平台。浏览服务:
dns-sd -B _http._tcp注册一个临时服务做测试:
dns-sd -R "我的测试服务" _http._tcp local 8080 path=/api version=1.0-B是浏览模式,会持续输出不退出,用Ctrl+C中断。-R是注册模式,同样会一直挂着,这个特性特别适合临时验证——不用写代码,不用改配置文件,开个终端挂在那里,另一台机器就能扫到。我在做联调时经常用它当“假服务端”,先确认发现链路通了,再换真服务,出问题时能快速判断是发现层还是业务层的问题。
解析主机名更简单,ping 某设备名.local直接可用,或者用dscacheutil -q host -a name 某设备名.local查缓存里的记录。系统会缓存一段时间,改完 IP 后如果解析结果没变,sudo dscacheutil -flushcache清一下再试。
4.2 Windows 侧先分清“能解析”和“能发布”
Windows 的情况要分两件事看。解析.local名字这件事,较新的系统版本已经原生支持,ping 某设备名.local或者 PowerShell 的Resolve-DnsName 某设备名.local通常能直接出结果,不需要额外装东西。但主动发布服务和使用dns-sd命令行工具,系统本身不带,需要安装额外的兼容组件(一般是随打印或开发工具包一起分发的那个 mDNS 响应器)。
判断当前机器处于什么状态,最快的办法就是跑一下:
Resolve-DnsName 某设备名.local -ErrorAction SilentlyContinue有结果说明解析链路通,没结果说明要么对方没在播,要么本机解析能力缺失。再看服务发现:
Get-Service | Where-Object { $_.Name -like "*Bonjour*" -or $_.Name -like "*mdns*" }服务列表为空基本可以判定没有安装发布组件。这时候的选择有两条:装兼容组件,或者干脆在你的应用里用内嵌库自己发。我一般选后者,因为给终端用户增加安装步骤是转化率杀手,而内嵌库只是多几行代码。
还有个 Windows 特有的坑:防火墙的公用网络配置文件默认会拦截入站组播。表现是服务能发出去、也能收到别人的查询,但别人发来的查询应答进不来,导致单向可见。排查时先看当前网络配置文件是“专用”还是“公用”,用Get-NetConnectionProfile查。临时验证可以临时关掉防火墙对应规则,找到原因后再精确放行,别长期裸奔。
4.3 三平台命令对照速查
| 目的 | Linux | macOS | Windows |
|---|---|---|---|
| 浏览服务类型 | avahi-browse -at | dns-sd -B _http._tcp | 用兼容组件的dns-sd -B |
| 解析完整信息 | avahi-browse -rt _http._tcp | dns-sd -L 实例名 _http._tcp | 同上 |
| 解析主机名 | avahi-resolve -n 名.local | dscacheutil -q host -a name 名.local | Resolve-DnsName 名.local |
| 临时发布服务 | avahi-publish -s 名 _http._tcp 8080 | dns-sd -R 名 _http._tcp local 8080 | 需兼容组件的dns-sd -R |
| 清缓存 | systemctl restart avahi-daemon | dscacheutil -flushcache | ipconfig /flushdns(对 mDNS 作用有限) |
最后一行要特别注意:Windows 的ipconfig /flushdns清的是 DNS Client 服务的缓存,对 mDNS 的缓存不一定生效。改完 IP 后解析还是旧值,最干脆的办法是重启对应的响应器服务或者等 TTL 自然过期。
5. 代码里怎么用:三套内嵌方案实操
5.1 Python 用 python-zeroconf 发布与发现
安装很轻,没有编译依赖:
pip install zeroconf发布侧代码,注意地址要显式指定,别指望库自动猜对网卡:
import socket from zeroconf import ServiceInfo, Zeroconf zc = Zeroconf() info = ServiceInfo( type_="_demo._tcp.local.", name="my-demo._demo._tcp.local.", addresses=[socket.inet_aton("192.168.1.50")], port=8080, properties={ "path": "/api", "version": "1.0", }, server="my-demo.local.", ) zc.register_service(info) print("服务已注册,按回车注销") input() zc.unregister_service(info) zc.close()addresses这个参数是最容易出错的地方。如果不传,库会尝试枚举本机所有网卡,多网卡机器上经常把127.0.0.1或者虚拟网卡地址填进去,导致对端拿到一个根本连不上的 IP。务必显式传入业务网卡的实际 IPv4 地址,获取方式可以用socket.gethostbyname(socket.gethostname()),但那在多网卡下同样不可靠,更稳的是读路由表找默认出口网卡的地址,或者干脆从配置里读。
发现侧代码:
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf class MyListener(ServiceListener): def add_service(self, zc, type_, name): info = zc.get_service_info(type_, name) if info: print("发现服务", name, info.parsed_addresses(), info.port) def update_service(self, zc, type_, name): print("服务更新", name) def remove_service(self, zc, type_, name): print("服务下线", name) zc = Zeroconf() browser = ServiceBrowser(zc, "_demo._tcp.local.", MyListener()) try: input("监听中,按回车退出\n") finally: zc.close()新版zeroconf的ServiceListener是抽象基类,三个方法必须全部实现,少写update_service会直接抛异常。老版本用的是一个普通对象加回调,网上很多示例还是旧写法,直接抄会报错。
5.2 Go 用 hashicorp/mdns 做服务端广播
Go 的包更底层一点,接口设计偏手工,但跨平台编译体验很好:
package main import ( "log" "os" "os/signal" "syscall" "github.com/hashicorp/mdns" ) func main() { host, err := os.Hostname() if err != nil { log.Fatal(err) } service, err := mdns.NewMDNSService( host+"-demo", "_demo._tcp", "", "", 8080, nil, []string{"path=/api", "version=1.0"}, ) if err != nil { log.Fatal(err) } server, err := mdns.NewServer(&mdns.Config{Zone: service}) if err != nil { log.Fatal(err) } defer server.Shutdown() log.Println("服务已广播") ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) <-ch }NewMDNSService的第六个参数是 IP 列表,传nil时库会自己选,还是那个问题——多网卡下不可靠。正确做法是用net.Interfaces()遍历,挑出目标网卡的地址传进去。mdns.Config里还可以设Zone、Port、IPs,做多网卡绑定的时候直接在这里收口,比你事后调试组播出口要省事得多。
发现侧用mdns.Query:
entries := make(chan *mdns.ServiceEntry, 16) go func() { for e := range entries { log.Printf("发现: %s -> %s:%d\n", e.Name, e.AddrV4, e.Port) } }() params := &mdns.QueryParam{ Service: "_demo._tcp", Domain: "local", Timeout: 5 * time.Second, Entries: entries, } if err := mdns.Query(params); err != nil { log.Fatal(err) }Query是个阻塞调用,会一直跑到Timeout结束。想长期监听就得反复调用,或者在外部套一层循环。这个设计不像 Python 的浏览器回调那么顺手,实际项目里我会自己封装一层,对外暴露成事件通道。
5.3 Node.js 用 bonjour 快速验证
如果只是想快速验证概念,Node 的方案代码量最少:
const bonjour = require('bonjour')(); bonjour.publish({ name: 'my-demo', type: 'demo', port: 8080, txt: { path: '/api', version: '1.0' }, }); const browser = bonjour.find({ type: 'demo' }, (service) => { console.log('发现服务', service.name, service.host, service.port, service.txt); }); process.on('SIGINT', () => { browser.stop(); bonjour.unpublishAll(() => bonjour.destroy()); process.exit(0); });注意type这里写demo,库内部会自动补成_demo._tcp,别自己加下划线,否则会变成__demo._tcp,扫不到还查不出原因。txt对象会被序列化成 TXT 记录,值必须是字符串,传数字在某些版本上会报错。
三个方案对比下来,Python 的 API 最直观,Go 的控制力最强,Node 上手最快。
6. 抓包与排障:5353 端口上的那些坑
6.1 抓包姿势与看得懂的关键点
出问题的时候别猜,直接抓包。最基础的:
sudo tcpdump -i eth0 -n port 5353加上-v看记录内容,-w存下来给 Wireshark 慢慢分析:
sudo tcpdump -i eth0 -nvv -s0 port 5353 -w mdns.pcap只想看组播方向:
sudo tcpdump -i eth0 -n "udp port 5353 and dst host 224.0.0.251"抓到包之后看三件事就能定位大部分问题。第一,有没有发包——如果本机一条都没发出,问题在发布侧(守护进程没起、网卡没绑对、端口被占)。第二,发给谁了——目的地址必须是224.0.0.251,如果是别的地址说明组播组加入有问题。第三,对方有没有回——如果发了查询但没有任何应答,问题在链路层(交换机、防火墙、VLAN 隔离)或者对端压根没在播。
Wireshark 里过滤mdns就能高亮解析出所有记录,PTR、SRV、TXT、A 一目了然。看 PTR 知道有哪些服务,追 SRV 拿到主机名和端口,再看 A 记录拿到 IP,这条链路走通,说明服务发现完全正常。
6.2 常见问题速查表
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
ping 名.local解析失败 | 本机缺 NSS 解析模块 | getent hosts 名.local也无输出 | 装libnss-mdns并改nsswitch.conf |
avahi-browse完全无输出 | 守护进程未运行 | systemctl status avahi-daemon | 启动并enable |
| 本机能看到,对端看不到 | 组播从错误网卡发出 | 抓包看源 IP | 配置allow-interfaces |
| 偶尔能发现,偶尔不能 | 交换机组播抑制或限流 | 多次抓包对比 | 关闭 IGMP snooping 或调整限流 |
| 服务名后面多了个 (2) | 名称冲突自动重命名 | avahi-browse -at看实例名 | 实例名加唯一后缀 |
| 解析到已经失效的旧 IP | 缓存未刷新 | avahi-resolve -n 名.local | 重启守护进程或等 TTL 过期 |
| 服务注册了但扫不到 | 发布速率被限流 | 减小注册频率后重试 | 调整 burst 或降低注册频率 |
| 容器内完全不可用 | bridge 网络不转发组播 | ip link看接口 | 改用 host 网络模式 |
| 5353 端口被占用 | 两个响应器同时在跑 | ss -ulnp | grep 5353 | 停用其中一个 |
这张表覆盖了我实际遇到的八成问题。剩下两成通常是环境级故障,比如交换机端口隔离、无线 AP 的客户端隔离功能开着、企业网的安全策略拦组播。这类问题从设备侧查不出来,得网络管理员配合,判断依据是“同一台交换机下、同一网段、抓包能看到自己的包但完全看不到别人的包”。
6.3 多网卡、容器与防火墙的处理套路
多网卡是最麻烦的一类。一台机器同时插了有线、连着无线、跑着几个容器,mDNS 守护进程默认可能从任意一个接口发包。表现是“我这台能看到别人,别人看不到我”,因为别人的应答从有线口回来,但本机的查询从无线口发出去了。解决思路就一条:把所有参与 mDNS 的接口显式列出来,其余全部拒绝。Avahi 用allow-interfaces/deny-interfaces,代码库用参数指定网卡,原则一致。
容器环境更直接。默认的 bridge 网络不转发组播,无论你配置多正确,容器里的 mDNS 就是不可用。要么用 host 网络模式(docker run --network host),让容器直接共享宿主机的网络栈;要么在宿主机上跑 mDNS 代理,把容器的服务代为广播出去。前者简单粗暴,代价是端口隔离没了;后者干净,但要额外维护一个代理进程。我做开发环境一律用 host 网络,生产环境用代理,权衡下来最省心。
防火墙这边,Linux 上如果用的是 firewalld,直接放行内置的 mdns 服务定义:
sudo firewall-cmd --permanent --add-service=mdns sudo firewall-cmd --reload这个服务定义里已经包含了 5353/UDP 和224.0.0.251的规则,比手写规则可靠。用 ufw 的话要注意,它默认对组播的处理并不完整,光开ufw allow 5353/udp可能还是收不到组播包,需要额外确认DEFAULT_FORWARD_POLICY和组播相关的内核开关,这块我踩过一次,最后是临时关掉 ufw 验证出问题在防火墙,再针对性放行的。
提示:排查顺序永远是“先关防护验证,再精确放行”。全程关着防火墙跑业务是最常见的坏习惯,验证完记得恢复。
7. 几个只有实际跑过才会知道的细节
关于 TTL 和缓存刷新,协议里的取值不是随便定的。mDNS 记录的标准 TTL 是 120 秒,缓存在 TTL 的 80% 时刻会主动发包续期,如果在 85% 之前还没收到应答,就把剩余 TTL 降到 1 秒后删除。所以设备掉线后,最长大约两分钟才会从别人的列表里消失。有人抱怨“设备都拔了怎么还能扫到”,这不是 bug 是设计,想要秒级感知得靠应用层心跳,别指望 mDNS。
关于名字冲突,mDNS 有一套探测机制:设备启动时先发几次探测查询,间隔从 250 毫秒开始翻倍,如果没人应答才正式宣告,宣告次数也是指数退避。这意味着服务注册后不是立刻可见,大概要等半秒到一秒。写自动化测试的时候如果注册完马上断言“应该能扫到”,大概率失败,加个重试或者sleep(1)会稳很多。
关于服务实例名,我现在的习惯是一律带唯一后缀,比如my-demo-7f3a。原因很实在:同名冲突时协议会自动改名成my-demo (2),这个括号和空格在 URL 里要转义,在日志里难搜,在代码里当 key 用会出各种意外。从一开始就用不可冲突的标识,能省掉一堆后续麻烦。
关于 TXT 记录,我建议只放发现阶段就需要的信息,比如版本号和接口路径。有人把整个配置 JSON 塞进 TXT,几百字节的字符串拆成多条记录,抓包看着都累,而且超过报文长度限制会被截断,接收方拿到半截数据反而更难排查。需要传大块数据,TXT 里放一个 ID,让对方拿到 IP 之后走 HTTP 去取,这才是正常做法。
最后分享一个我常用的调试组合:一台机器跑avahi-publish-service "test" _demo._tcp 8080,另一台跑avahi-browse -rt _demo._tcp实时盯着,中间用tcpdump抓包。这三件事同时开着,链路上任何一环断掉都能立刻看出来是发布问题、解析问题还是网络问题,比盲目改配置高效太多。