Skip to content

DNSリゾルバ

実装: network/dns/ / 実行: go test ./network/dns/

ドメイン名から IP アドレスを引く DNS は、読めるテキストではなく詰まったバイナリを、接続の手続きなしに1発で投げ合う(UDP)。理由は 512 バイトという上限にある。同じ名前を回答ごとに書き直すと、名前が長い環境では1つの応答に 6 件しか入らない。名前を「さっきの場所を見て」と指す形にすると回答1件が 16 バイト固定になり、同じ名前で 27 件入る。入らなければ TCP へ落ちる。

この章で作るもの

HTTP サーバは「IP アドレスが分かった後」の通信だった。 その手前で、ドメイン名から IP アドレスを引くのが DNS。example.com に アクセスする前に、必ずこの解決が走っている。

ブラウザ"example.com" の IP は?
リゾルバUDP で問い合わせ
DNS サーバ8.8.8.8 など
応答93.184.216.34
名前解決の流れ。ブラウザは通信の前に、まず名前の IP をリゾルバ経由で DNS サーバに問い合わせる

問い合わせのバイト列を手で組み立てて送り、返ってきたバイト列から IP を取り出す。 HTTP のような読めるテキストではなく、詰まったバイナリになっている。

運び方には UDP を使う。TCP のような接続の確立をせず、ひと固まりのメッセージを 1発投げて1発受け取るだけの送り方で、届く保証はない代わりに往復が最少で済む。

なぜバイナリなのかは、数字1つで説明がつく。512 バイト。UDP で1発投げて1発 受け取る、という設計を選んだ時点で、メッセージ全体がこの中に収まらなければならなく なった。フォーマットのほとんどは、この制約から出てきている。

順に作る。

  1. 区切り文字ではなく長さを置く: . で切るのをやめて、ラベルごとに長さを前に置く
  2. 同じ名前は指す: 名前をポインタで参照すると、回答1件が名前の長さによらず16バイトになる
  3. 入らなければ TCP へ落ちる: 512 を超えたら TC を立てて、1往復という前提そのものを諦める

① 区切り文字ではなく長さを置く

www.example.com[3]www[7]example[3]com[0] という並びになる。 各ラベルの前に「そのラベルの長さ」を1バイト置き、最後を 0 で終える。

go
// encodeName はドメイン名を DNS のラベル形式にする。
// "www.example.com" → [3]www[7]example[3]com[0]。
// 各ラベルの前に「そのラベルの長さ」を1バイト置き、最後を 0 で終える。
func encodeName(name string) []byte {
	var buf []byte
	for _, label := range strings.Split(name, ".") {
		buf = append(buf, byte(len(label)))
		buf = append(buf, label...)
	}
	buf = append(buf, 0) // ルート(空ラベル)で終端
	return buf
}

// BuildQuery は A レコードの問い合わせメッセージを組み立てる。
// 構成: 12バイトのヘッダ + 質問セクション(名前 + QTYPE + QCLASS)。
func BuildQuery(id uint16, name string) []byte {
	msg := make([]byte, 12)
	binary.BigEndian.PutUint16(msg[0:2], id)     // ID: 応答を照合するための番号
	binary.BigEndian.PutUint16(msg[2:4], 0x0100) // フラグ: RD=1(再帰的に解決してほしい)
	binary.BigEndian.PutUint16(msg[4:6], 1)      // QDCOUNT: 質問1つ
	// ANCOUNT/NSCOUNT/ARCOUNT は 0 のまま。

	msg = append(msg, encodeName(name)...)
	msg = binary.BigEndian.AppendUint16(msg, 1) // QTYPE = A(IPv4 アドレス)
	msg = binary.BigEndian.AppendUint16(msg, 1) // QCLASS = IN(インターネット)
	return msg
}

長さを前に置く形は、読む側が楽になる。. を探して走査する必要がなく、 「1バイト読んで、その数だけ飛ぶ」を繰り返せば終わる。そしてこの形にしておくと、 後から出てくるポインタ圧縮が成立する。長さバイトの上位2bitが 11 かどうかで、 「これは長さか、それとも参照か」を1バイト読んだ時点で判別できる。 区切り文字だけで組んでいたら、この分岐は入る場所がなかった。

ヘッダの先頭2バイトは ID。同時に複数の問い合わせを投げるので、 どの応答がどの問い合わせのものかを ID で照合する。次の2バイトはフラグで、 RD=1(Recursion Desired)は「あなたが再帰的に最後まで解決して、答えだけください」の意味。

② 同じ名前は指す

DNS サーバは、同じフォーマットに回答セクションを足して返す。

go
// ParseResponse は応答メッセージから A レコードの IP を取り出す。
// wantID は送った問い合わせの ID。一致しなければ別の問い合わせの応答なので弾く。
func ParseResponse(msg []byte, wantID uint16) ([]string, error) {
	if len(msg) < 12 {
		return nil, fmt.Errorf("dns: response too short (%d bytes)", len(msg))
	}
	id := binary.BigEndian.Uint16(msg[0:2])
	if id != wantID {
		return nil, fmt.Errorf("dns: id mismatch: got %#x, want %#x", id, wantID)
	}
	qdcount := binary.BigEndian.Uint16(msg[4:6])
	ancount := binary.BigEndian.Uint16(msg[6:8])

	// 質問セクションを読み飛ばす(名前 + QTYPE 2B + QCLASS 2B)。
	off := 12
	for i := 0; i < int(qdcount); i++ {
		n, err := skipName(msg, off)
		if err != nil {
			return nil, err
		}
		off = n + 4 // QTYPE + QCLASS
	}

	// 回答レコードを読む。
	var ips []string
	for i := 0; i < int(ancount); i++ {
		n, err := skipName(msg, off) // 回答の名前(たいていポインタ圧縮)
		if err != nil {
			return nil, err
		}
		off = n
		if off+10 > len(msg) {
			return nil, fmt.Errorf("dns: truncated answer header")
		}
		typ := binary.BigEndian.Uint16(msg[off : off+2])
		rdlength := int(binary.BigEndian.Uint16(msg[off+8 : off+10]))
		off += 10
		if off+rdlength > len(msg) {
			return nil, fmt.Errorf("dns: truncated rdata")
		}
		if typ == 1 && rdlength == 4 { // A レコード = 4バイトの IPv4
			ip := msg[off : off+4]
			ips = append(ips, fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]))
		}
		off += rdlength
	}
	return ips, nil
}

// skipName は off から名前を読み飛ばし、その次のオフセットを返す。
// DNS はメッセージを縮めるため「名前をポインタで参照する」圧縮を使う(上位2bitが 11)。
// ポインタに当たったら、そこで名前は終わり(2バイトぶん進めて返す)。
func skipName(msg []byte, off int) (int, error) {
	for {
		if off >= len(msg) {
			return 0, fmt.Errorf("dns: name runs past end of message")
		}
		length := int(msg[off])
		switch {
		case length == 0:
			return off + 1, nil // ルートラベル。名前の終わり
		case length&0xc0 == 0xc0:
			// 圧縮ポインタ(2バイト)。ここで名前は終端とみなす。
			return off + 2, nil
		default:
			off += 1 + length // 通常ラベル: 長さ + 中身を飛ばす
		}
	}
}

ここで問題になるのが、応答には質問と同じドメイン名がもう一度出てくること。 しかも回答が複数あれば、その数だけ繰り返す。そのままだと名前が長い環境で効いてくる。

DNS は「さっき出た名前は、メッセージの N バイト目を見て」という2バイトのポインタで これを潰す。skipName がその分岐を持っていて、上位2bitが 11 なら 「そこで名前は終わり」と判断して2バイト進める。

効き方を数える。回答1件は、名前のあとに TYPE 2 + CLASS 2 + TTL 4 + RDLENGTH 2 + IPv4 4 が続く形になる。名前をポインタにすると、この合計が 16 バイトで固定される

go

// UDPLimit は UDP で送れる DNS メッセージの上限(RFC 1035)。
//
// この 512 という数字が、DNS のフォーマットをほぼ全部決めている。
// テキストではなくバイナリなのも、名前をポインタで指すのも、ここに収めるため。
const UDPLimit = 512

// QuerySize は問い合わせメッセージのバイト数を返す。
// ヘッダ12 + 名前 + QTYPE 2 + QCLASS 2。
func QuerySize(name string) int {
	return 12 + len(encodeName(name)) + 4
}

// AnswerSize は A レコード1件のバイト数を返す。
//
// 名前のあとに TYPE 2 + CLASS 2 + TTL 4 + RDLENGTH 2 + IPv4 4 が続く。
// 圧縮すると名前が2バイトのポインタになるので、合計は 16 で固定になる。
// 名前をそのまま書くと、名前が長いほどレコードも太る。
func AnswerSize(name string, compress bool) int {
	n := 2 // 圧縮ポインタ
	if !compress {
		n = len(encodeName(name))
	}
	return n + 2 + 2 + 4 + 2 + 4
}

// Capacity は 512 バイトに入る A レコードの件数を返す。
//
// 圧縮ありなら 1件 16 バイト固定なので、名前が長くなっても
// 減るのは質問セクションのぶんだけになる。
func Capacity(name string, compress bool) int {
	return (UDPLimit - QuerySize(name)) / AnswerSize(name, compress)
}

長さの違う名前で、512 バイトに入る件数を測った:

名前名前のバイト数名前を指す名前を書き直す
example.com1330 件17 件
www.example.com1729 件15 件
d111111abcdef8.cloudfront.net3129 件10 件
ec2-203-0-113-25.ap-northeast-1.compute.amazonaws.com5527 件6 件

名前を4倍に伸ばしても、指すほうは 30 件から 27 件にしか減らない。減ったのは質問が 太ったぶんだけで、回答1件はずっと16バイトのままだからだ。書き直すほうは 17 件から 6 件、4割を切るところまで落ちる。テストで、この落ち方の差(片方は8割を保ち、 もう片方は4割を下回る)を固定した。

ポインタ圧縮は「少し縮む」工夫ではない。回答の大きさを名前の長さから切り離す 仕掛けで、切り離しておかないと、名前が長いというだけで載る件数が変わってしまう。 クラウド事業者が振り出す長いホスト名は、この圧縮を前提にして初めて成立している。

③ 入らなければ TCP へ落ちる

それでも入らないことはある。そのときは、入るぶんだけ載せて TC ビットを立てる。

go

// BuildResponse は回答つきの応答メッセージを組み立てる。
//
// compress が false なら、回答ごとに名前をそのまま書き直す。
// 512 バイトに収まらなくなったらそこで打ち切り、TC ビットを立てる。
// TC=1 は「入りきらなかったので TCP でやり直して」の合図になる。
func BuildResponse(id uint16, name string, ips [][4]byte, compress bool) []byte {
	msg := make([]byte, 12)
	binary.BigEndian.PutUint16(msg[0:2], id)
	binary.BigEndian.PutUint16(msg[2:4], 0x8180) // 応答, RD, RA
	binary.BigEndian.PutUint16(msg[4:6], 1)      // QDCOUNT

	// 質問の名前は必ずオフセット12から始まる。回答はここを指せばいい。
	const qnameOffset = 12
	qname := encodeName(name)
	msg = append(msg, qname...)
	msg = binary.BigEndian.AppendUint16(msg, 1) // QTYPE = A
	msg = binary.BigEndian.AppendUint16(msg, 1) // QCLASS = IN

	n := 0
	for _, ip := range ips {
		if len(msg)+AnswerSize(name, compress) > UDPLimit {
			msg[2] |= 0x02 // TC = 1
			break
		}
		if compress {
			msg = binary.BigEndian.AppendUint16(msg, 0xc000|qnameOffset)
		} else {
			msg = append(msg, qname...)
		}
		msg = binary.BigEndian.AppendUint16(msg, 1)  // TYPE = A
		msg = binary.BigEndian.AppendUint16(msg, 1)  // CLASS = IN
		msg = binary.BigEndian.AppendUint32(msg, 60) // TTL
		msg = binary.BigEndian.AppendUint16(msg, 4)  // RDLENGTH
		msg = append(msg, ip[:]...)
		n++
	}
	binary.BigEndian.PutUint16(msg[6:8], uint16(n)) // ANCOUNT
	return msg
}

// Truncated は TC ビットが立っているかを返す。
// 立っていたら、同じ問い合わせを TCP で投げ直すことになる。
func Truncated(msg []byte) bool {
	return len(msg) >= 12 && msg[2]&0x02 != 0
}

TC=1 は「入りきらなかったので TCP でやり直して」の合図になる。受け取った側は 同じ問い合わせを TCP で投げ直す。つまり接続確立の往復を省くという、UDP を選んだ 理由そのものが消える

さっきの長い名前で、8件の回答を返そうとするとこうなった:

メッセージ長載った件数TC
名前を指す199 バイト8 件立たない
名前を書き直す485 バイト6 件立つ

8件はロードバランスでよくある数だが、書き直すほうはそこに届かない。 テストで、同じ8件が片方では収まり、片方では TC が立つことを固定した。 また、載った件数が Capacity の計算と一致することも固定した。

動かす

下のデモは、打ち込んだ名前でそのまま同じ計算をする。名前を長くすると、 バイト列の緑の部分が伸び、その下の2本の棒が離れていく。

デモDNS 問い合わせメッセージ29 バイト
ドメイン名:これを UDP で 8.8.8.8:53 に送る
ヘッダ (12B)ID, フラグ, 各セクションの件数
12·34401·00·00·01·00·00·00·00·00·00·
名前[長さ][ラベル]... を 0 で終端
07·65e78x61a6dm70p6cl65e03·63c6fo6dm00·
QTYPE/QCLASSA レコード / IN(インターネット)
00·01·00·01·
この問い合わせに対する応答が 512 バイトに収まる件数(A レコード)
名前を指す(圧縮あり)30/ 1件 16B
名前を書き直す17/ 1件 27B

名前を長くすると差が開く。指すほうは 1 件 16 バイトのまま動かないので、減るのは質問が太ったぶんだけ。書き直すほうは、名前がそのままレコードの大きさになる。

実際に問い合わせる

組み立てた問い合わせを UDP ソケットで送り、応答を受け取る:

go
// Resolver は DNS サーバに UDP で問い合わせて名前を解決する。
type Resolver struct {
	// Server は問い合わせ先(例: "8.8.8.8:53" = Google Public DNS)。
	Server  string
	Timeout time.Duration
	// nextID は問い合わせIDの採番用。応答を照合するために毎回変える。
	nextID uint16
}

// NewResolver は指定した DNS サーバを使うリゾルバを返す。
func NewResolver(server string) *Resolver {
	return &Resolver{Server: server, Timeout: 3 * time.Second, nextID: 1}
}

// Resolve は name の A レコード(IPv4)を引く。
// 1. UDP ソケットを開く 2. 問い合わせを送る 3. 応答を受け取る 4. パースする。
func (r *Resolver) Resolve(name string) ([]string, error) {
	id := r.nextID
	r.nextID++

	conn, err := net.Dial("udp", r.Server)
	if err != nil {
		return nil, fmt.Errorf("dns: dial %s: %w", r.Server, err)
	}
	defer conn.Close()
	conn.SetDeadline(time.Now().Add(r.Timeout))

	query := BuildQuery(id, name)
	if _, err := conn.Write(query); err != nil {
		return nil, fmt.Errorf("dns: send query: %w", err)
	}

	// DNS 応答は 512 バイト以内(UDP の古い上限)に収まる前提で受ける。
	buf := make([]byte, 512)
	n, err := conn.Read(buf)
	if err != nil {
		return nil, fmt.Errorf("dns: read response: %w", err)
	}
	return ParseResponse(buf[:n], id)
}

net.Dial("udp", ...) で UDP の口を開き、問い合わせを Write、応答を ReadTCP と違って接続確立の往復がないので、これで1往復になる。 テストではローカルの UDP サーバを立ててこの経路を検証している。8.8.8.8 に 向ければ本物の名前解決になる。

設計の観点

  • 上限からフォーマットを決める: 512 という数字が先にあって、バイナリも圧縮も後から出てきた。逆順ではない
  • 繰り返す部分を指せる形にしておく: 長さプレフィックスにしたおかげで、参照との判別が1バイトで済んだ
  • 可変長を固定長に変える: 回答の大きさを名前から切り離すと、載る件数が入力に左右されなくなる
  • 収まらない場合の道を用意する: TC と TCP フォールバックが無ければ、大きい応答は返しようがない
  • 問い合わせと応答を結び付ける: UDP は誰でも投げ込めるので、ID を照合しない実装は偽の応答を受け入れる
  • 壊れた入力で落ちない: 長さもオフセットも相手が書いた数字になる。境界は毎回確かめる

対照と実例

運び方形式大きさの上限名前の重複
DNS(UDP)接続なし・1往復バイナリ512 バイトポインタで指す
DNS(TCP)接続ありバイナリ2バイトの長さ前置同じ
DNS over HTTPSTCP + TLS + HTTP/2同じバイナリを包む実質なし同じ
HTTP接続ありテキストヘッダは実装依存ヘッダ圧縮(HPACK)がある
gRPC接続ありバイナリ(protobuf)実装依存フィールド番号で短くする

裏どり:

  • 512 の出どころ: RFC 1035 4.2.1 が「UDP メッセージは 512 バイト以下」と決めている。RFC 6891 の EDNS(0) が、問い合わせ側から「もっと大きくても受け取れる」と申告する仕組みを後から足した
  • 圧縮の規定: 同じく RFC 1035 4.1.4。先頭2bitが 11 のときポインタ、00 のとき長さ、と決めてあるので、ラベル長は 63 が上限になる。上位2bitを分岐に使った代償がそこに出ている
  • ID だけでは足りなかった: 2008年の Kaminsky の指摘で、16bit の ID を推測して偽の応答を先に届ける攻撃が現実的だと分かった。RFC 5452 が送信元ポートもランダムにすることを求め、推測すべき空間を 2^16 から 2^32 へ広げている
  • HTTP/2 も同じことをしている: 繰り返すヘッダを表の番号で指す(HPACK)。繰り返しを指すという手は、詰まったプロトコルではだいたい出てくる
  • TCP へ落ちる頻度: DNSSEC の署名を載せると応答が大きくなるので、EDNS(0) で上限を上げる運用が普通になった。それでも足りなければ TC が立つ

簡略化したこと

  • A レコードのみ: AAAA/CNAME/MX 等は読み飛ばすだけで、値は取り出さない
  • 再帰リゾルバ丸投げ: RD=1 で 8.8.8.8 に任せる。ルートから辿る反復解決はしない
  • キャッシュ・TTL なし: 毎回問い合わせる。DNS が速いのはキャッシュのおかげなので、実物との差は大きい
  • 圧縮は読み飛ばしのみ: ポインタを展開して名前を復元するところまではやらない
  • TCP フォールバックなし: TC を立てるところまでで、投げ直しは実装していない
  • EDNS(0)・DNSSEC・DoH/DoT なし: 上限の引き上げも、署名も、暗号化も入れていない
  • 送信元ポートは OS 任せ: ID は変えているが、ポートのランダム化は明示していない

参考資料