api.example.com を 1 回引いただけなのに、なぜ Pod は DNS に 4 回も問い合わせるのか?
Kubernetes 上のアプリから外部の決済 API を呼ぶとき、コードに書くのは https://api.example.com/... の 1 行だけだ。ところが CoreDNS のログを開くと、api.example.com.default.svc.cluster.local のような存在しない名前への問い合わせと NXDOMAIN(該当なし)の応答が何行も並んでいる。誰がこんな名前を作り、なぜ失敗するとわかっている問い合わせを繰り返しているのだろうか。
- 名前解決はアプリ内のリゾルバが
/etc/resolv.confを読んで行い、点の数がndots未満の名前にはsearchリストのドメインを順に付けて先に試す。 - kubelet は Pod の
resolv.confに search ドメイン 3 つとndots:5を書くため、点が 2 つのapi.example.comも「短い名前」扱いになり、3 回の空振りの後でやっと本来の名前が引かれる。 - 末尾ドット付きの FQDN、
dnsConfigでのndots調整、NodeLocal DNSCache で空振りのコストを減らせるが、ndotsを下げるとクラスタ内の短縮名の引き方も変わる。
名前解決は「どの名前を試すか」から始まる
アプリが getaddrinfo() などで名前を引くとき、実際に DNS サーバーへ問い合わせるのはアプリのプロセス内にあるスタブリゾルバ(libc などに組み込まれた、問い合わせを組み立てて DNS サーバーに投げる部品)だ。スタブリゾルバは問い合わせの前に /etc/resolv.conf を読み、どの DNS サーバーに、どの名前を、どの順で聞くかを決める。
順序を決めるのが 2 つの設定だ。search は、短い名前に補って試すドメインの一覧(search リスト)。options ndots:N は、名前に含まれる点が N 個未満なら search リストを先に試し、N 個以上なら最初から名前そのものを試す、という切り替えの閾値だ。glibc の resolv.conf(5) によれば ndots のデフォルトは 1 で、上限 15 に丸められる。
デフォルトの ndots:1 なら、点を 1 つでも含む名前はまず「そのまま」引かれる。社内ネットワークで search corp.example を設定しておけば、ssh build01 と打つだけで build01.corp.example を探してくれる。search リストはもともと、このような人間向けの省略入力のための仕組みだ。
kubelet が Pod に書く resolv.conf
Kubernetes では、kubelet が Pod ごとに /etc/resolv.conf を生成する。dnsPolicy を指定しない Pod には ClusterFirst が適用され(名前は紛らわしいが、Default はデフォルトではない)、公式ドキュメントの例では次の内容になる。
nameserver 10.32.0.10 search <namespace>.svc.cluster.local svc.cluster.local cluster.local options ndots:5
nameserver はクラスタ DNS(多くの場合 CoreDNS)の Service の IP だ。search リストが 3 段あるおかげで、同じ Namespace の Service は data、別 Namespace なら data.prod と書くだけで data.prod.svc.cluster.local に補完される。コードや設定ファイルに長い FQDN(ルートまで省略せずに書いた完全な名前)を書かなくて済むのは、この search リストのおかげだ。
ndots:5 という大きな値を選んだ理由は公式ドキュメントに明記されていない。ただ、Service の SRV レコード名は _port._proto.my-svc.my-ns.svc の形まで省略でき、この時点で点が 4 つある。点 4 つまでの名前をすべて「クラスタ内の短縮名かもしれない」と扱うには閾値を 5 にする必要がある、と考えると数字の辻褄は合う。
外部ドメインも「短い名前」扱いになる
search リストはクラスタ内の名前のためにあるのだから、外部ドメインには関係ないように思える。しかしリゾルバが見ているのは点の数だけだ。api.example.com の点は 2 つで 5 未満なので、default Namespace の Pod では次の順に問い合わせが進む。
Pod (ns: default) CoreDNS │ api.example.com + │ │.default.svc.cluster.local│ ├─────────────────────────▶│ │◀──────────── NXDOMAIN ───┤ │.svc.cluster.local │ ├─────────────────────────▶│ │◀──────────── NXDOMAIN ───┤ │.cluster.local │ ├─────────────────────────▶│ │◀──────────── NXDOMAIN ───┤ │ api.example.com │ ├─────────────────────────▶│──▶ 上流 DNS │◀──────────── IP ─────────┤ ▼ ▼
1 回の名前解決が 4 往復になる。しかも glibc はデフォルトで IPv4 用の A レコードと IPv6 用の AAAA レコードを並行して問い合わせる(resolv.conf(5) の single-request の説明より)ため、パケット数はその 2 倍になる。クラウドによっては kubelet がノード側の search ドメインも後ろに足すので、公式のデバッグ手順に載っている GKE の例のように search リストが 5 段になり、空振りがさらに増える。
1 回あたりの空振りは CoreDNS がクラスタ内の情報だけで即答するため、通常は速い。問題になるのは量と、まれな失敗だ。外部 API を毎リクエスト呼ぶサービスでは CoreDNS への問い合わせが数倍に膨らむ。さらに DNS は通常 UDP で運ばれるので、パケットが 1 つ落ちるとリゾルバは timeout(resolv.conf(5) によるデフォルト 5 秒)を待ってから再送する。往復が 4 倍あれば、この「たまに 5 秒遅い」に当たる機会も 4 倍になる。
大企業の社内郵便係が「宛名の区切りが 5 つ未満なら、まず社内の住所録を自部署 → 本社 → グループ全体の順に引く」という規則で働いている状況だ。「田中」宛の手紙なら規則どおり自部署の田中さんに一発で届く。しかし「東京都千代田区丸の内」宛の社外向け手紙も区切りが少ないので、係は 3 冊の社内住所録を順にめくって「該当なし」を確かめてから、ようやく郵便局に回す。住所の末尾に「(社外)」と書き添えれば住所録は飛ばされる。これが末尾ドットの役割にあたる。
空振りを減らす 3 つの手と、その副作用
最初に試すべきは、外部ドメインを末尾ドット付きの FQDN で書くことだ。api.example.com. のように最後に点を付けた名前は「これで完結している」という意味になり、リゾルバは search リストを使わずに 1 回だけ問い合わせる。アプリ側の変更だけで済むが、HTTP クライアントやライブラリによっては Host ヘッダや証明書の名前照合で末尾ドットの扱いが揺れることがあるため、通信先ごとに動作を確かめてから使うほうがよい。
次の手は、Pod 単位で ndots を下げることだ。dnsConfig.options に書いた値は kubelet が生成する resolv.conf にそのまま反映される。
spec:
dnsConfig:
options:
- name: ndots
value: "2"
ndots:2 にすると、点が 2 つ以上の api.example.com は最初から本来の名前で引かれ、空振りが消える。一方、data.prod(点 1 つ)は従来どおり search リストで補完されるが、data.prod.svc(点 2 つ)は先に外部へ問い合わせて NXDOMAIN を受けてから search リストに戻る。名前解決自体は成功するが、今度はクラスタ内の名前で空振りが起きる。ndots を下げるのは、その Pod が使う名前の形を把握してからにする。
3 つ目は、クラスタ側で NodeLocal DNSCache(各ノードに DaemonSet として置く DNS キャッシュ)を導入することだ。問い合わせの回数自体は変わらないが、Pod は同じノード上のキャッシュに聞くため、kube-proxy の DNAT や conntrack(Linux カーネルの接続追跡表)を経由しなくなる。公式ドキュメントは、これにより conntrack の競合で UDP パケットが落ちる問題が減り、キャッシュから CoreDNS への通信を TCP に切り替えることでタイムアウト起因のテールレイテンシも減ると説明している。
典型的な入口は「外部 API 呼び出しのレイテンシ p99 だけが 5 秒前後に張り付く」「CoreDNS の CPU 使用率が Pod 数のわりに高い」「CoreDNS のログが NXDOMAIN だらけ」の 3 つだ。どれも Pod 内で cat /etc/resolv.conf を見て、呼び出し先ドメインの点の数と ndots を比べるところから調べ始める。対策は影響範囲の小さい順に、①外部ドメインを末尾ドット付きで書く、②外部呼び出しの多い Pod だけ dnsConfig で ndots を下げる、③クラスタ全体で NodeLocal DNSCache を入れる、と進めるのが無難だ。アプリ側で解決結果をキャッシュする HTTP クライアントやコネクションプールを使っていれば、名前解決の回数自体も減る。
kind で作ったローカルクラスタの Pod から、search リストによる空振りを目で見る。dig は標準では search リストを使わないので、+search で resolv.conf の設定に従わせ、+showsearch で途中の問い合わせも表示させる。
kind create cluster --name dns-lab kubectl run dnstest --image=nicolaka/netshoot --restart=Never -- sleep 3600 kubectl wait --for=condition=Ready pod/dnstest --timeout=120s kubectl exec dnstest -- cat /etc/resolv.conf # 末尾ドットなし: search リストを順に試す kubectl exec dnstest -- dig +search +showsearch +noall +comments +question api.github.com | grep -E 'status:|^;api' # 末尾ドットあり: 1 回で済む kubectl exec dnstest -- dig +search +showsearch +noall +comments +question api.github.com. | grep -E 'status:|^;api' kind delete cluster --name dns-lab
1 つ目の dig では、api.github.com.default.svc.cluster.local. などの問い合わせに status: NXDOMAIN が返った後、最後の api.github.com. で status: NOERROR になる様子が見えるはずだ。2 つ目は NOERROR の 1 組だけになる。resolv.conf の search リストの段数は、ホスト側の設定によって増えることがある。
- search リストはクラスタ内の短い名前にしか使われない — リゾルバは名前の意味ではなく点の数しか見ない。
ndots:5の下では、点が 4 つ以下の外部ドメインもすべて search リストを先に試す。 - クラスタ全体で
ndots:1にすれば副作用なく解決する —data.prodのような点を含む短縮名が、まず外部 DNS へ問い合わされてから補完されるようになる。外部に同名のドメインが実在すると、意図しない宛先に解決される余地も生まれる。 - DNS は UDP の 1 往復なので、回数が増えても誤差の範囲だ — 平常時は速いが、UDP パケットが 1 つ落ちるとリゾルバはタイムアウト(デフォルト 5 秒)まで待つ。往復の回数が増えるほど、まれな数秒の遅延に当たる確率も増える。
- スタブリゾルバ
- libc などに組み込まれ、
/etc/resolv.confに従って DNS サーバーへ問い合わせを投げる部品。 - search リスト
- 短い名前に補って試すドメインの一覧。resolv.conf の
search行で指定する。 - ndots
- 名前の点の数がこの値未満なら search リストを先に試し、以上なら名前そのものを先に試す閾値。
- FQDN
- ルートまで省略せずに書いた完全な名前。末尾にドットを付けると search リストの対象外になる。
- NXDOMAIN
- 「その名前は存在しない」を表す DNS の応答コード。
- NodeLocal DNSCache
- 各ノードに DaemonSet として DNS キャッシュを置き、Pod の問い合わせを同じノード内で受ける Kubernetes の仕組み。
- DNS for Services and Pods (Kubernetes Docs) — Pod の resolv.conf、
dnsPolicyの 4 種類、dnsConfigの書き方と上限が一次情報でまとまっている。 - resolv.conf(5) - Linux manual page —
ndots・timeout・attempts・single-requestのデフォルト値と挙動の原典。 - Using NodeLocal DNSCache in Kubernetes Clusters — conntrack と UDP がなぜ DNS のテールレイテンシを生むのか、ノードローカルキャッシュでどう避けるのか。
- Debugging DNS Resolution (Kubernetes Docs) — クラウドごとに search リストが伸びる例と、Pod 内での名前解決の調べ方。