
LAN 内に存在するデバイスを見つけたい、という要求はよくある。 一方で、実際にそれを「ちゃんと」実装しようとすると、想像以上に設計要素が多いことに気づく。
- 同一ネットワークにあるはずなのに見つからない
- レスポンスは返るが、それが本当に対象か分からない
- 複数台存在した場合の扱い
- discovery が不安定なときの UX
本記事では、ある LAN 内デバイス制御ツールを作る過程で実装した Zeroconf の思想に基づく mDNS discovery について整理する。
なお、デバイスの操作・制御部分には踏み込まない。 あくまで「どう見つけるか」という設計に焦点を当てる。
Zeroconf と mDNS の関係を整理する
まず用語を整理しておく。
-
Zeroconf (Zero Configuration Networking) 概念・思想。 人手による設定なしにネットワーク上でサービスを発見するための考え方。
-
mDNS (Multicast DNS) Zeroconf を構成する技術要素のひとつ。 UDP/5353 を用いて、デバイスが自身の情報を LAN 内に広告する仕組み。
つまり、
| Zeroconf の思想を、具体的に実現するための中核技術が mDNS
という関係になる。

今回の discovery は、Zeroconf の思想に従い、mDNS を使って実装したものと位置づけるのが一番正確だ。
なぜ Zeroconf / mDNS を選んだのか
LAN 内 discovery の手法はいくつかある。
- IP レンジスキャン
- SSDP / UPnP
- mDNS
今回 mDNS を選んだ理由はシンプルで、
- こちらから能動的にスキャンしない
- デバイスが「公開している情報」だけを扱える
- 仕組みとして正規で、IoT 機器に広く使われている
- 無闇にポートを叩かない
という点が大きい。
特に、
| 探しに行くのではなく、広告されているものを受け取る
という点は、Zeroconf の思想そのものでもある。
mDNS は「見つかる」だけでは足りない
mDNS を使うと、確かに色々なレスポンスが返ってくる。 しかし、実装して最初に直面するのは次の問題だ。
| 見つかるが、それが使いたいデバイスとは限らない
実際には、次のようなものが平然と混ざる。
- 同一ベンダの別機器
- 無関係な IoT デバイス
- 名前が似ているだけの別物
- キャッシュされた古い情報

mDNS では各レコードに TTL が設定されており、またデバイスがネットワークから離脱する際にはGoodbye packet が送信されることもある。
しかし実装や環境によっては、これらが必ずしも期待通りに機能するとは限らない。
そのため、discovery(発見)と selection(選別)を明確に分けて設計した。
Discovery の全体像(抽象)

実装上の流れは大まかに以下。
- mDNS query を送信
- 一定時間レスポンスを収集
- raw なレスポンスを内部モデルに変換
- 条件に合うものだけを候補として残す
- 最低限の情報に正規化
ここで重要なのは、mDNS レスポンスを信頼しすぎないこと。
service type と選別の考え方
mDNS では、サービスは _http._tcp.local のような service type として広告される。
これにより、
- どのプロトコルを前提としているか
- TCP / UDP の別
- 利用されるポート
といった情報を、問い合わせ段階である程度絞り込める。
実装では service type をそのまま信用するのではなく、「候補を狭めるためのヒント」として扱った。
TXT レコードも同様に、識別のヒントとして使えることは多いが、それ単体で確定判断を下すことは避けている。
IPv4 / IPv6 の扱い
mDNS レスポンスには IPv4 と IPv6 の両方が含まれることがある。 どちらを使うかは、実装上の判断が必要になる。
今回は以下の優先順位で選択した。
- IPv4 アドレスを優先
- IPv6 の場合はリンクローカル(
fe80::)を除外
let ip = addresses
.iter()
.find(|a| a.is_ipv4())
.or_else(|| addresses.iter().find(|a| {
if let IpAddr::V6(v6) = **a {
// 簡易的な判定。厳密には is_unicast_link_local() を使うべきだが、
// 実用上 fe80:: 以外のリンクローカルはほぼ存在しない
!v6.to_string().starts_with("fe80")
} else {
true
}
}));
リンクローカル IPv6 は同一セグメント内でしか到達できず、ルーティングされない。 そのため、LAN 内であっても扱いが面倒になることが多い。
| IPv4 があれば IPv4、なければグローバル IPv6
という単純なルールで十分だった。
TXT レコードの活用
mDNS の TXT レコードには、デバイス固有の情報が含まれることが多い。 ただし、これらのキーは標準化されていない。
よく見られるキーの例:
| キー | 意味 |
|---|---|
fn, name |
フレンドリー名 |
md, model |
モデル名 |
id, uuid |
一意識別子 |
let friendly_name = properties
.iter()
.find(|p| p.key() == "fn" || p.key() == "name")
.map(|p| p.val_str().to_string());
実装では、複数の候補キーを試す形にしている。
存在しなければ None のまま持ち、無理に補完しない。
| TXT レコードはあくまで「ヒント」
と考えて扱うのが安全だ。
複合条件による判定
単一条件での判定は、ほぼ確実に誤検出を生む。 実装では以下を複合的に評価した。
let is_target =
name.to_lowercase().contains("keyword")
|| hostname.to_lowercase().contains("keyword")
|| properties.iter().any(|p| {
p.key().to_lowercase().contains("keyword")
|| p.val_str().to_lowercase().contains("keyword")
});
// さらに service type も確認
if is_target || info.get_type() == SERVICE_TYPE {
// 候補として採用
}
フルネーム、ホスト名、TXT レコード、service type のいずれかにマッチすれば候補とする。
一見すると緩い条件に見えるが、service type で browse している時点である程度絞られているため、これで十分機能する。
| 厳しすぎる条件は、正当なデバイスを弾くリスクがある
このバランスは、実際に動かしながら調整した。
実装の一部(簡略化)
以下は、mDNS レコードを内部表現に変換する部分の一例。 再現性を下げるため、意図的に簡略化している。
// mDNS レスポンスを抽象化した構造体
struct MdnsRecord {
addr: Option<IpAddr>,
hostname: Option<String>,
txt: Vec<(String, String)>,
}
// discovery 段階で扱う最小限の内部表現
struct DiscoveredDevice {
addr: IpAddr,
hostname: Option<String>,
model: Option<String>,
}
fn normalize(record: MdnsRecord) -> Option<DiscoveredDevice> {
// IP アドレスが取得できないものは候補から除外する
let addr = record.addr?;
let hostname = record.hostname;
// TXT レコードなどからモデル情報らしきものを抽出する
let model = extract_model_hint(&record.txt);
Some(DiscoveredDevice {
addr,
hostname,
model,
})
}

ここで意識している点は:
- 情報が欠けていれば
Noneのまま持つ - 無理に補完しない
- この段階では「確定」させない
Zeroconf 的には、確実でないものを確定情報にしないことが重要だ。
重複レスポンスの扱い
mDNS は UDP マルチキャストのため、同一デバイスから複数回レスポンスが届くことがある。
これは異常ではなく、仕様上想定される動作だ。
実装では IP アドレスをキーとした HashMap で管理し、後から届いた情報で上書きする形で対応した。
let devices: HashMap<String, Device> = HashMap::new();
// イベント受信ループ内
devices.insert(ip, device); // 同じ IP なら上書き
これにより、重複は自然に排除され、常に最新の情報が保持される。
非同期での discovery ループ
mDNS の受信は継続的に行いつつ、一定時間で打ち切る必要がある。
Rust + tokio の場合、tokio::select! で実現できる。
let deadline = Instant::now() + timeout;
loop {
tokio::select! {
_ = sleep_until(deadline) => {
break; // タイムアウト
}
event = receive_mdns_event() => {
// イベント処理
}
}
}
select! は複数の Future を競合させ、最初に完了したものを処理する。
これにより、
- イベントが来れば即座に処理
- タイムアウトに達すればループを抜ける
という動作が自然に書ける。
なお、mDNS ライブラリが同期的な場合は、spawn_blocking でブロッキング操作を別スレッドに逃がす必要がある。
let event = tokio::task::spawn_blocking(move || {
receiver.recv_timeout(Duration::from_millis(100))
}).await;
非同期と同期の境界は、discovery のような I/O 処理では特に意識する必要がある。
タイムアウト設計は UX の話
discovery はネットワーク I/O なので、必ず「待ち」が発生する。
- 長く待てば見つかる可能性は上がる
- しかし CLI としての体験は悪くなる
最終的には、
- 数秒で打ち切る
- 見つからなければ「存在しない」と判断する
という割り切りにした。
| 必ず見つけるより、すぐ終わる
という判断も、Zeroconf を扱う上では重要だと感じている。
おわりに
Zeroconf / mDNS を使った LAN 内 discovery は、「とりあえず動いた」で終わらせるには惜しい題材だ。
- どこまで信じるか
- どこで諦めるか
- 何を確定情報として扱うか
これらを考える過程そのものが、ネットワークソフトウェア設計の練習になる。
もし mDNS を使う機会があれば、「見つかった」その先を一段考えてみると、意外と奥が深くて面白いかもしれない。
コメント
コメントを投稿