はじめに — dig は通るのにアプリケーションだけ名前を見つけられません
障害対応で最も人を混乱させる組み合わせがあります。
dig +short api.internal.example.com
10.20.4.31
DNS は正常に見えます。ところが同じホストでアプリケーションはこう落ちます。
java.net.UnknownHostException: api.internal.example.com
ここでたいていは「DNS サーバーは問題ないのにアプリケーションがおかしい」という結論に進みます。そしてアプリケーションのコードやライブラリを漁り始めます。この方向はほぼ常に徒労に終わります。
原因は単純です。dig とアプリケーションは、そもそも別の経路で名前を解決します。dig が成功したという事実は、アプリケーションが成功する根拠にはなりません。逆方向も同じです。dig が失敗してもアプリケーションはうまく動いている場合があります。
この記事では、アプリケーションが実際にたどる解決経路を順にたどりながら、各段階で何が食い違いうるのかをまとめます。
アプリケーションが名前を解決する実際の経路
C、Python、Ruby、PHP、Node の既定の解決器、そして Linux 上の Java まで、ほとんどは結局 glibc の getaddrinfo() を呼びます。glibc は次の順序をたどります。
/etc/nsswitch.confの hosts 行を読み、どのソースをどの順序で使うかを決めます。- files モジュールなら
/etc/hostsを見ます。 - dns モジュールなら
/etc/resolv.confを読み、nameserver、search、options を適用して問い合わせを送ります。 - resolve モジュールなら D-Bus で systemd-resolved に尋ねます。
- myhostname や mdns4_minimal のような特殊モジュールが途中に挟まっていることがあります。
最初の段階から確認します。
grep '^hosts' /etc/nsswitch.conf
hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname
この一行には落とし穴が二つあります。
mdns4_minimal [NOTFOUND=return] は、名前が .local で終わるときは mDNS でのみ解決し、失敗したらその場で止まれという意味です。後ろにある dns モジュールまで進みません。社内ドメインを cluster.local や svc.local のように名付けている組織がこの罠によくかかります。dig は nsswitch を見ないので正常な応答を返し、アプリケーションだけが失敗します。
もう一つは resolve モジュールです。
hosts: files resolve [!UNAVAIL=return] dns myhostname
この設定だと、アプリケーションは D-Bus を通じて systemd-resolved に尋ねます。resolv.conf の nameserver 値を直接は使いません。つまり /etc/resolv.conf を直しても、アプリケーションの動作が変わらないことがあります。
resolv.conf も確認します。
cat /etc/resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad
search ap-northeast-2.compute.internal
dig と nslookup は /etc/hosts を見ません
この記事で最も実用的な事実です。dig、nslookup、host は DNS プロトコル専用のツールです。nsswitch.conf も、/etc/hosts も、systemd-resolved のリンク別設定も見ません。resolv.conf から nameserver のアドレスだけを取り出し、UDP 53 に問い合わせを投げます。
そのため、次のような状況が何の矛盾もなく成立します。
# /etc/hosts にしかない名前 — dig は見つけられない
dig +short legacy-billing.internal
(空の出力)
# アプリケーションと同じ経路 — 正常に見つかる
getent hosts legacy-billing.internal
10.20.9.14 legacy-billing.internal
逆方向もあります。誰かがデバッグ中に /etc/hosts へ一時的に入れた古い IP が残っていると、dig は新しい IP を返すのにアプリケーションは古い IP へ接続し続けます。この場合の症状は DNS エラーではなく connection refused やタイムアウトとして現れるので、原因を DNS に求めなくなってしまいます。
したがって、診断の基本ツールを変える必要があります。
# アプリケーションと同一の経路をそのまま再現する
getent hosts api.internal.example.com
# IPv4 と IPv6 の両方を、そして試行順序まで見せてくれる
getent ahosts api.internal.example.com
10.20.4.31 STREAM api.internal.example.com
10.20.4.31 DGRAM
10.20.4.31 RAW
getent ahosts の出力順序は、アプリケーションが実際に試す順序と同じです。AAAA が先に出るのに IPv6 経路がなければ、最初の接続試行が失敗するという意味であり、これが先に述べた「Network is unreachable」の原因になります。
まとめるとこう使います。dig は DNS サーバーの答えを見るツールで、getent はアプリケーションが受け取る答えを見るツールです。二つの結果が違えば、その差そのものが原因の位置を教えてくれます。
systemd-resolved と 127.0.0.53 が生む混乱
Ubuntu をはじめとする最近のディストリビューションでは、resolv.conf の nameserver はたいてい 127.0.0.53 です。これは systemd-resolved が立てたスタブリゾルバであり、実際の上位サーバーは別にあります。
resolvectl status
Global
Protocols: -LLMNR -mDNS +DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub
Link 2 (ens5)
Current Scopes: DNS
Protocols: +DefaultRoute +DNSSEC=no/unsupported
Current DNS Server: 10.0.0.2
DNS Servers: 10.0.0.2
DNS Domain: ap-northeast-2.compute.internal
Link 5 (tun0)
Current Scopes: DNS
Protocols: +DNSOverTLS
Current DNS Server: 10.60.0.53
DNS Servers: 10.60.0.53
DNS Domain: ~corp.example.com ~internal.example.com
ここで二つが重要です。
一つめ、リンクごとに DNS サーバーとドメインが別々にあります。上の例では corp.example.com で終わる名前は VPN インターフェースの 10.60.0.53 へ行き、残りは 10.0.0.2 へ行きます。これをスプリット DNS と呼びます。ところが dig @10.0.0.2 vault.corp.example.com のように上位サーバーを直接指定すると、このルーティングを飛ばしてしまうので失敗します。VPN を有効にした状態で「dig は失敗するのにブラウザは開く」という状況がここから生まれます。
二つめ、dig の既定の宛先は 127.0.0.53 なので、スタブのキャッシュを見ることになります。上位サーバーがすでに修正されていても、スタブのキャッシュのせいで古い答えが出続けることがあります。
三つの層をそれぞれ分けて確認するのが正確です。
# 1) アプリケーション経路そのまま
resolvectl query vault.corp.example.com
# 2) スタブリゾルバに直接
dig @127.0.0.53 vault.corp.example.com
# 3) 上位サーバーに直接
dig @10.60.0.53 vault.corp.example.com
vault.corp.example.com: 10.60.4.7 -- link: tun0
-- Information acquired via protocol DNS in 2.1ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: yes
resolvectl query がリンク名まで教えてくれる点が便利です。どのインターフェースの DNS へ行ったのかがそのまま出ます。
キャッシュが疑わしいときは層ごとに空にします。
resolvectl flush-caches
resolvectl statistics
DNSSEC verdicts
Secure: 0
Insecure: 0
Bogus: 0
Indeterminate: 0
Transactions
Current Transactions: 0
Total Transactions: 184213
Cache
Current Cache Size: 0
Cache Hits: 156402
Cache Misses: 27811
キャッシュの階層はここで終わりではありません。アプリケーション内部のキャッシュが別にあります。Java が代表例です。セキュリティマネージャーがインストールされていない既定の環境では、JVM は成功した解決結果を30秒キャッシュし、失敗した解決は networkaddress.cache.negative.ttl の既定値である10秒キャッシュします。セキュリティマネージャーを使う古い配備では、成功した結果を永久にキャッシュします。フェイルオーバーで IP が変わったのに特定のサービスだけが古い IP をつかみ続けているなら、この値をまず見るべきです。
# Java 実行時に明示的に指定
java -Dsun.net.inetaddr.ttl=30 -Dsun.net.inetaddr.negative.ttl=0 -jar app.jar
# または java.security ファイルで
# networkaddress.cache.ttl=30
# networkaddress.cache.negative.ttl=0
まとめると、キャッシュは四層です。アプリケーション内部、スタブリゾルバ、再帰リゾルバ、そして権威サーバーが伝えてきた TTL です。どの層を空にしたのかを明確にしないと、「キャッシュを消したのに変わらない」という報告が繰り返されます。
search ドメインと ndots — Kubernetes が遅くなる本当の理由
Kubernetes の Pod 内の resolv.conf はこうなっています。
kubectl exec -it deploy/api -- cat /etc/resolv.conf
search prod.svc.cluster.local svc.cluster.local cluster.local ap-northeast-2.compute.internal
nameserver 10.96.0.10
options ndots:5
ndots:5 は「問い合わせる名前のドットが5個より少なければ、絶対名として試す前に search リストを先に付けて試せ」という意味です。この設定のおかげで、Pod 内から api や api.prod のような短い名前でサービスを呼べます。
問題は外部ドメインです。api.stripe.com はドットが2個なので5より少ないです。そのため次の順序で問い合わせが出ていきます。
kubectl exec -it deploy/api -- sh -c 'tcpdump -nn -i any port 53' &
kubectl exec -it deploy/api -- curl -sS -o /dev/null https://api.stripe.com/v1/charges
10:11:02.512331 IP 10.244.1.9.41822 > 10.96.0.10.53: 41955+ A? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.512402 IP 10.244.1.9.41822 > 10.96.0.10.53: 8271+ AAAA? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.513880 IP 10.96.0.10.53 > 10.244.1.9.41822: 41955 NXDomain 0/1/0 (148)
10:11:02.513991 IP 10.96.0.10.53 > 10.244.1.9.41822: 8271 NXDomain 0/1/0 (148)
10:11:02.514552 IP 10.244.1.9.55110 > 10.96.0.10.53: 12043+ A? api.stripe.com.svc.cluster.local. (50)
10:11:02.514601 IP 10.244.1.9.55110 > 10.96.0.10.53: 39218+ AAAA? api.stripe.com.svc.cluster.local. (50)
10:11:02.516001 IP 10.96.0.10.53 > 10.244.1.9.55110: 12043 NXDomain 0/1/0 (143)
10:11:02.516120 IP 10.96.0.10.53 > 10.244.1.9.55110: 39218 NXDomain 0/1/0 (143)
10:11:02.516644 IP 10.244.1.9.39004 > 10.96.0.10.53: 55129+ A? api.stripe.com.cluster.local. (46)
10:11:02.516702 IP 10.244.1.9.39004 > 10.96.0.10.53: 21008+ AAAA? api.stripe.com.cluster.local. (46)
10:11:02.518233 IP 10.96.0.10.53 > 10.244.1.9.39004: 55129 NXDomain 0/1/0 (139)
10:11:02.518344 IP 10.96.0.10.53 > 10.244.1.9.39004: 21008 NXDomain 0/1/0 (139)
10:11:02.518881 IP 10.244.1.9.60122 > 10.96.0.10.53: 3311+ A? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.518944 IP 10.244.1.9.60122 > 10.96.0.10.53: 61190+ AAAA? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.523115 IP 10.96.0.10.53 > 10.244.1.9.60122: 3311 NXDomain 0/1/0 (157)
10:11:02.523240 IP 10.96.0.10.53 > 10.244.1.9.60122: 61190 NXDomain 0/1/0 (157)
10:11:02.523774 IP 10.244.1.9.44911 > 10.96.0.10.53: 9042+ A? api.stripe.com. (32)
10:11:02.523821 IP 10.244.1.9.44911 > 10.96.0.10.53: 55403+ AAAA? api.stripe.com. (32)
10:11:02.531002 IP 10.96.0.10.53 > 10.244.1.9.44911: 9042 3/0/0 A 34.111.87.9 ... (98)
外部ドメイン一つを解決するために問い合わせが10個出ていき、そのうち8個は NXDOMAIN をもらうためだけに出たものです。Pod が一つではなく数百個あって毎秒数十回これをやると、CoreDNS の負荷の大半が失敗確定の問い合わせで埋まります。そして UDP パケットの損失が少しでもあると、失われた問い合わせごとに5秒の再試行タイムアウトが付いて、遅延が目に見えて跳ねます。
ここでよく出回っている処方を正しておく必要があります。「ndots を1に下げればよい」という助言はクラスターを壊します。ndots が1だと、ドットが1個以上ある名前は search を付けずに絶対名として先に問い合わせます。すると payments.prod のように名前空間を付けて呼ぶクロス名前空間の呼び出しがすべて壊れます。ドットが1個なので search が適用されないからです。
安全な下限は2です。ndots が2なら api と payments.prod は引き続き search を経由し、ドットが2個以上ある外部ドメインはただちに絶対名として問い合わせます。
spec:
dnsConfig:
options:
- name: ndots
value: '2'
アプリケーションを直せるなら、もっと確実な方法があります。外部ドメインの末尾にドットを付けて絶対名にすれば、search リストをまるごと飛ばせます。
# 末尾のドット一つが search ドメイン4個を消す
curl -sS -o /dev/null -w '%{time_namelookup}\n' https://api.stripe.com./v1/charges
NodeLocal DNSCache を使うのも良い緩和策です。失敗する問い合わせがノードローカルのキャッシュで終わり CoreDNS まで届かなくなりますし、UDP 損失による5秒の再試行も大きく減ります。
NXDOMAIN、SERVFAIL、そして空の NOERROR
応答コードをひとまとめにして「DNS が動かない」と報告すると、原因の範囲が絞れません。dig 出力の status フィールドを必ず確認すべきです。
dig api.internal.example.com A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 51204
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
ANSWER が0なのに status は NOERROR です。この組み合わせが最も頻繁に誤診されます。
| 応答コード | dig で見える姿 | 実際の意味 | よくある原因 | 次にすること |
|---|---|---|---|---|
| NOERROR + ANSWER 1個以上 | 正常なレコードが出力される | 名前もレコードタイプも両方存在する | 正常 | 次の階層へ移る |
| NOERROR + ANSWER 0個 | AUTHORITY だけで答えがない | 名前はあるがそのタイプのレコードがない | A はあるが AAAA はない、CNAME だけで対象が未作成 | 問い合わせタイプを変えて再確認 |
| NXDOMAIN | status: NXDOMAIN | 権威サーバーがその名前自体が存在しないと確定 | 打ち間違い、未作成のレコード、search ドメイン付きの名前 | 権威サーバーに直接問い合わせて確定 |
| SERVFAIL | status: SERVFAIL | リゾルバが答えを作れなかった | DNSSEC 検証失敗、上位サーバー無応答、ゾーン読み込み失敗 | リゾルバのログと DNSSEC の検査 |
| REFUSED | status: REFUSED | サーバーがこの問い合わせを扱う意思がない | 再帰非許可、ACL 遮断、ゾーンを持っていない | 問い合わせ先のサーバーが正しいか確認 |
空の NOERROR が出る代表的な状況は IPv6 です。AAAA レコードだけがなく、アプリケーションが AAAA を先に探す場合、ログには「名前が見つからない」として残ることがあります。問い合わせタイプを明示して確認します。
dig +noall +answer api.internal.example.com A AAAA CNAME
api.internal.example.com. 300 IN CNAME api-lb.internal.example.com.
api-lb.internal.example.com. 60 IN A 10.20.4.31
SERVFAIL はリゾルバ自身の問題です。DNSSEC の検証失敗が原因かどうかを素早く切り分ける方法があります。
# 検証を切ってもう一度尋ねる。ここで成功すれば DNSSEC の問題だ
dig +cd api.internal.example.com
# 権威サーバーまで委任経路をたどる
dig +trace api.internal.example.com
NXDOMAIN を見たときは、問い合わせた名前をそのまま読むべきです。tcpdump で api.stripe.com.prod.svc.cluster.local のように search ドメインが付いた名前に対する NXDOMAIN であれば、それは正常な動作であり問題の原因ではありません。
512 バイト、TCP フォールバック、そして大きな応答だけ失敗する場合
DNS はもともと UDP で512バイトを超える応答を送れませんでした。超えるとサーバーが TC(truncated)フラグを立て、クライアントは同じ問い合わせを TCP 53 で送り直します。EDNS0 は、クライアントがより大きな UDP バッファを受け取れると伝えてこの往復を減らす拡張です。
# EDNS0 を切って512バイトに制限してみる
dig +notcp +bufsize=512 TXT _dmarc-report.example.com
;; Truncated, retrying in TCP mode.
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 40912
;; flags: qr tc rd ra; QUERY: 1, ANSWER: 8, AUTHORITY: 0, ADDITIONAL: 0
フラグに tc が見えるのが切り詰めの証拠です。
ここで実務上の事故が起きます。古いファイアウォール設定は UDP 53 だけを開けて TCP 53 を塞いでいることが多いのです。普段はまったく問題ありません。応答が小さいからです。ところがレコードが増えたり DNSSEC の署名が付いたりして応答が512バイトを超えた瞬間、その名前だけが解決されなくなります。「特定のドメイン一つだけ通らない」という申告の原因がここにあることがあります。
# TCP 53 が開いているか直接確認
dig +tcp @10.0.0.2 example.com
# EDNS0 経路がミドルボックスに塞がれていないか確認
dig +bufsize=4096 @10.0.0.2 dnssec-failed.org
# 応答サイズそのものを確認
dig +noall +stats example.com
;; Query time: 12 msec
;; SERVER: 10.0.0.2#53(10.0.0.2) (UDP)
;; MSG SIZE rcvd: 512
MSG SIZE rcvd が512や1232の近くにぴったり張り付いていて、その名前だけが間欠的に失敗するなら、経路上のサイズ制限を疑う根拠は十分です。ちなみに最近のリゾルバは IP フラグメンテーションを避けるため、EDNS バッファの推奨値を1232バイトに下げて使います。
おわりに — 名前解決は dig で終わりません
覚えることは一文です。dig は DNS サーバーの答えを見せ、getent はアプリケーションが受け取る答えを見せます。二つの結果が違うなら、その差がそのまま原因の位置です。
実戦では次の順序で絞れば、たいてい数分で終わります。まず getent hosts と dig +short を並べて実行し、結果が分かれるかを見ます。分かれるなら、nsswitch.conf の hosts 行と /etc/hosts、そして systemd-resolved のリンク別設定を確認します。結果が同じなら問題はリゾルバより上なので、dig の status フィールドで NXDOMAIN、SERVFAIL、空の NOERROR を切り分け、必要なら +trace で委任経路をたどります。答えは正しいのに時間がかかるなら、tcpdump で実際に出ていく問い合わせの数を数えてみます。search ドメインと ndots が生む不要な問い合わせが、そこにそのまま現れます。
DNS 障害のかなりの部分は、サーバーではなくクライアント側の解決順序で生じます。順序を先に確認すれば、サーバーを漁る必要はなくなります。
현재 단락 (1/164)
障害対応で最も人を混乱させる組み合わせがあります。