场景是这样的:几台机器跑在同一个局域网里,开发服务器、NAS、树莓派都有。我需要它们自动发现彼此。不要中心服务器,不要手动配 IP 列表,不要"改完 YAML 重启一下"。
启动就能找到。
最后用了三层方案叠加:mDNS、一个从 90 年代 Novell NetWare 偷来的 UDP 广播协议、以及暴力子网扫描兜底。全部加起来大概 300 行 TypeScript。
mDNS:理论上很好
第一版只用 mDNS。典型的网络拓扑:
scss
笔记本 (Wi-Fi) → 办公室 AP → 路由器 → 跑服务的 server
理论上 mDNS 能搞定。实际上在家里的 MacBook 没问题,小办公室也行,到了真实网络环境就崩了。路由器静默丢弃组播包。AP 开了客户端隔离。有个公司网络直接禁了组播,IT 部门自己都不知道为什么。
Node.js 里用 mDNS 的几个坑:
- 没有内置支持,得装
multicast-dns这个 npm 包 - macOS 有 Bonjour 原生支持,Linux 要装 avahi,Windows 看运气
- 5353 端口容易跟已经在跑的 avahi-daemon 冲突
- 失败的时候没有任何提示。查询发出去,石沉大海
从 90 年代 NetWare 偷的思路
Novell NetWare 有个叫 SAP(Service Advertising Protocol)的东西。每个服务器定期往整个子网广播:"我在这儿,我能干这些事"。不需要组播组,不需要特殊基础设施。
我在 UDP/IP 上实现了一样的机制。两种消息类型:
typescript
interface DiscoverMessage {
type: "discover";
version: 1;
}
interface AnnounceMessage {
type: "announce";
version: 1;
instance_id: string;
display_name: string;
port: number;
tls: boolean;
}
节点启动后广播 discover,已经在跑的实例回复 announce。同时有个定时器周期性广播 announce,这样后加入的节点也能被发现。
Socket 初始化
typescript
import * as dgram from "node:dgram";
import * as os from "node:os";
const DISCOVERY_PORT = 17891;
async function startDiscovery(): Promise<dgram.Socket> {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket("udp4");
socket.on("error", (err) => {
if (!socket.address()) {
reject(err); // bind 阶段失败
}
});
socket.bind(DISCOVERY_PORT, () => {
socket.setBroadcast(true); // 千万别忘了这行
resolve(socket);
});
});
}
这个地方我浪费了两个小时。跑 tcpdump,盯 Wireshark 抓包,换端口,怀疑防火墙。
最后发现是忘了 socket.setBroadcast(true)。Node.js 不加这个就不能往广播地址发包------不报错,不警告,包直接消失。
广播地址计算
多网卡的机器上不能只用 255.255.255.255,得算每个子网的广播地址:
typescript
function getBroadcastTargets(): string[] {
const targets: string[] = [];
const ifaces = os.networkInterfaces();
for (const [name, addrs] of Object.entries(ifaces)) {
if (!addrs || isVirtualInterface(name)) continue;
for (const addr of addrs) {
if (addr.family === "IPv4" && !addr.internal) {
targets.push(calcBroadcast(addr.address, addr.netmask));
}
}
}
return targets;
}
// 广播地址 = IP | ~掩码
function calcBroadcast(ip: string, mask: string): string {
const ipParts = ip.split(".").map(Number);
const maskParts = mask.split(".").map(Number);
return ipParts
.map((b, i) => (b | (~maskParts[i]! & 0xff)))
.join(".");
}
192.168.1.42 配上掩码 255.255.255.0,算出来就是 192.168.1.255。IP 跟反转掩码做按位或。
过滤虚拟网卡
Docker 加上 WireGuard VPN 一跑,我的服务就开始"发现"一堆不存在的节点。往 Docker 桥接网络和 VPN 隧道广播,收回来的全是垃圾。
typescript
function isVirtualInterface(name: string): boolean {
const n = name.toLowerCase();
return (
n.startsWith("wg") || // WireGuard
n.startsWith("tun") || // TUN (OpenVPN)
n.startsWith("tap") || // TAP
n.startsWith("docker") || // Docker
n.startsWith("br-") || // Docker bridge
n.startsWith("veth") || // Docker veth
n.startsWith("virbr") || // KVM
n.startsWith("vmnet") || // VMware
n.startsWith("vboxnet") // VirtualBox
);
}
硬编码前缀列表,没什么好办法。os.networkInterfaces() 不告诉你哪个是物理网卡哪个是虚拟的。Linux 上可以解析 sysfs,但 macOS 和 Windows 上不行。
TCP 探测再注册
UDP 是不可靠的。收到 announce 不能直接信,先探测一下:
typescript
async function handleAnnounce(
data: AnnounceMessage,
rinfo: dgram.RemoteInfo,
): Promise<void> {
// 自己的广播回来了------是的这会发生
if (isSelfIp(rinfo.address)) return;
// 不在本子网?拒绝
if (!isInLocalSubnet(rinfo.address)) return;
// 先 TCP 连一下再说
const alive = await tcpProbe(rinfo.address, data.port);
if (!alive) return;
registerPeer({
id: data.instance_id,
address: rinfo.address,
port: data.port,
source: "broadcast",
});
}
可能收到 30 秒前已经挂掉的服务的 announce。TCP 健康检查加 2 秒超时,过滤掉这些幽灵节点。
还有一个问题:自己发的广播会回到自己这里。我发现的时候是因为服务一直在"发现"自己。
广播抖动
不加随机化的话,10 个同时启动的实例会永远在同一秒广播:
typescript
const ANNOUNCE_INTERVAL = 60_000;
const ANNOUNCE_JITTER = 10_000;
function scheduleNextAnnounce(socket: dgram.Socket): void {
const delay = ANNOUNCE_INTERVAL + Math.random() * ANNOUNCE_JITTER;
setTimeout(() => {
broadcastAnnounce(socket);
scheduleNextAnnounce(socket);
}, delay);
}
60 秒基础间隔加 0-10 秒随机抖动。NTP 也是这么干的。
mDNS 还是得用
虽然上面吐了一堆槽,mDNS 我还是保留了,跟广播协议并行跑。家庭网络上 mDNS 没问题,而且别的程序也能通过 mDNS 发现你的服务。
typescript
const mDNS = require("multicast-dns");
const mdns = mDNS();
const SERVICE_TYPE = "_my-service._tcp.local";
setInterval(() => {
mdns.query({
questions: [{ name: SERVICE_TYPE, type: "PTR" }],
});
}, 30_000);
mdns.on("response", (response, rinfo) => {
const allRecords = [
...response.answers,
...response.additionals,
];
const srvRecords = allRecords.filter((r) => r.type === "SRV");
const aRecords = allRecords.filter((r) => r.type === "A");
for (const srv of srvRecords) {
const port = srv.data.port;
const target = srv.data.target;
const aRecord = aRecords.find((r) => r.name === target);
const ip = aRecord ? String(aRecord.data) : rinfo.address;
console.log(`Found peer: ${ip}:${port}`);
}
});
有个坑:很多服务绑定 127.0.0.1 然后通过 mDNS 广告这个地址。你连上去发现连的是自己。
解决办法------A 记录是 loopback 的时候,用 UDP 包的源 IP:
typescript
const aRecordIp = aRecord ? String(aRecord.data) : "";
const isLoopback =
aRecordIp === "127.0.0.1" ||
aRecordIp === "::1" ||
aRecordIp.startsWith("127.");
const address = aRecordIp && !isLoopback
? aRecordIp
: rinfo.address;
mDNS 我做成了软依赖------multicast-dns 没装的话就退化成纯广播模式:
typescript
let mdns = null;
try {
const mDNS = require("multicast-dns");
mdns = mDNS();
} catch {
console.warn("mDNS 不可用,仅使用广播模式");
}
最后手段:子网扫描
被动发现都不好使的时候,还有暴力扫描:
typescript
const CONCURRENCY = 50;
const TIMEOUT = 2_000;
async function scanSubnet(subnet: string, port: number) {
const targets = [];
for (let i = 1; i <= 254; i++) {
targets.push({ host: `${subnet}.${i}`, port });
}
const queue = [...targets];
const worker = async () => {
while (queue.length > 0) {
const target = queue.shift()!;
const alive = await probeHost(target.host, target.port);
if (alive) console.log(`Found: ${target.host}:${target.port}`);
}
};
// 50 个 worker 并发跑
const workers = Array.from(
{ length: Math.min(CONCURRENCY, targets.length) },
() => worker(),
);
await Promise.allSettled(workers);
}
50 并发探测,2 秒超时。一个 /24 子网大概 10 秒扫完。我把它做成按需调用的 API 接口。
回头看
虚拟网卡过滤应该第一天就做。这个问题浪费了一整个晚上------我一直以为广播协议有 bug,直到发现 Docker 桥接网络在愉快地回复我的广播包。
UDP 消息应该用二进制头而不是 JSON。有个坏掉的 mDNS 响应者发了非法数据,直接把 JSON.parse 炸了。我知道它可能抛异常,但没包 try-catch。
Windows 上要早点测。防火墙会静默屏蔽自定义端口的 UDP。最后加了 netsh advfirewall 规则创建。
整个方案只依赖 Node 内置的 dgram 和 os 模块,外加一个可选的 multicast-dns。