Kubernetes と名前解決

2023/03/22に公開

tl;dr

  • 外部サービスのホスト名の末尾に . (ドット) を必ず指定しましょう。
    • ✅ google.com.
    • ❌ google.com
  • 末尾にドットを指定できない (e.g. SDK 組み込み) かつ大量の名前解決が発生している場合は、Pod の DNS Config の options で ndots: 1 を指定しましょう。
  • Kubernetes の名前解決の仕組みを理解していないと、各ノードの conntrack テーブルが溢れてパケットが破棄され、サービスに影響が出ることがあります。

背景

アプリケーションが外部のサービスを呼び出す場合、ホスト名を IP アドレスに変換 (= 名前解決) してから接続するのが一般的です。Kubernetes 上の名前解決には ndots: 5 という魔法がかかっており、それを知らずに運用していると思わぬ障害に繋がることがあります。

検証

ローカル環境に Kubernetes を起動します。今回は Lima で起動した仮想マシン上に Kind でクラスタを起動します。

brew install lima kind docker docker-buildx

Docker が同梱された仮想マシンを起動します。

limactl start --name=docker template://docker

プロンプトに表示された docker context コマンドを 1 度実行して登録しておくと楽です。

Kind でシングルノードのクラスタを起動します。Kind 0.17.0 の場合、デフォルトで v1.25.3 の Kubernetes クラスタが起動します。

kind create cluster

システムの Pod が全て起動していることを確認して下さい。

❯ kubectl get pods -n kube-system
NAME                                         READY   STATUS    RESTARTS   AGE
coredns-565d847f94-gx7kf                     1/1     Running   0          47s
coredns-565d847f94-hzbgq                     1/1     Running   0          47s
etcd-kind-control-plane                      1/1     Running   0          61s
kindnet-4l949                                1/1     Running   0          47s
kube-apiserver-kind-control-plane            1/1     Running   0          61s
kube-controller-manager-kind-control-plane   1/1     Running   0          61s
kube-proxy-xpg6s                             1/1     Running   0          47s
kube-scheduler-kind-control-plane            1/1     Running   0          61s

名前解決のパケットを覗き見るために使用するデバッグ用のコンテナイメージを生成します。

mkdir my-gadgets
cd my-gadgets

# tcpdump がインストールされたコンテナイメージ
cat - <<EOF > Dockerfile
FROM debian:11.6-slim

RUN apt-get update && apt-get install --no-install-recommends -y iproute2 tcpdump

ENTRYPOINT ["/bin/bash"]
EOF

docker buildx build -t my-gadgets:0.1.0 .
# Kind クラスタ内にイメージを読み込み
kind load docker-image my-gadgets:0.1.0

準備が整ったので、Kubernetes 内での名前解決がどのように行われるのか確認していきます。まずは、curl 公式のイメージを Entrypoint を書き換えて起動します。

kubectl run -it --rm curl --image=curlimages/curl:7.88.1 -- sh

別のウィンドウを開いて、エフェメラルコンテナを起動します。

kubectl debug curl -it --image=my-gadgets:0.1.0

curl コンテナとエフェメラルコンテナは同じ network namespace を共有しているので、tcpdump で eth0 のインターフェイスに対する 53 番ポートでの送受信の通信を覗きます。

tcpdump -n -i eth0 port 53

この状態で、curl のコンテナから google.com に問い合わせます。

curl https://google.com

tcpdump で以下のようなパケットがキャプチャできたと思います。

08:46:01.748820 IP 10.244.0.5.52111 > 10.96.0.10.53: 48427+ A? google.com.default.svc.cluster.local. (54)
08:46:01.748907 IP 10.244.0.5.52111 > 10.96.0.10.53: 48719+ AAAA? google.com.default.svc.cluster.local. (54)
08:46:01.749344 IP 10.96.0.10.53 > 10.244.0.5.52111: 48719 NXDomain*- 0/1/0 (147)
08:46:01.749413 IP 10.96.0.10.53 > 10.244.0.5.52111: 48427 NXDomain*- 0/1/0 (147)
08:46:01.749487 IP 10.244.0.5.33806 > 10.96.0.10.53: 12481+ A? google.com.svc.cluster.local. (46)
08:46:01.749536 IP 10.244.0.5.33806 > 10.96.0.10.53: 12772+ AAAA? google.com.svc.cluster.local. (46)
08:46:01.749764 IP 10.96.0.10.53 > 10.244.0.5.33806: 12772 NXDomain*- 0/1/0 (139)
08:46:01.749830 IP 10.96.0.10.53 > 10.244.0.5.33806: 12481 NXDomain*- 0/1/0 (139)
08:46:01.749851 IP 10.244.0.5.45661 > 10.96.0.10.53: 53609+ A? google.com.cluster.local. (42)
08:46:01.749882 IP 10.244.0.5.45661 > 10.96.0.10.53: 53776+ AAAA? google.com.cluster.local. (42)
08:46:01.750027 IP 10.96.0.10.53 > 10.244.0.5.45661: 53776 NXDomain*- 0/1/0 (135)
08:46:01.750100 IP 10.96.0.10.53 > 10.244.0.5.45661: 53609 NXDomain*- 0/1/0 (135)
08:46:01.750162 IP 10.244.0.5.41689 > 10.96.0.10.53: 37111+ A? google.com. (28)
08:46:01.750192 IP 10.244.0.5.41689 > 10.96.0.10.53: 37277+ AAAA? google.com. (28)
08:46:01.772189 IP 10.96.0.10.53 > 10.244.0.5.41689: 37111 1/0/0 A 142.250.196.110 (54)
08:46:02.755488 IP 10.96.0.10.53 > 10.244.0.5.41689: 37277 0/0/0 (28)

A / AAAA レコードのセット (IPv4 / IPv6) が 4 つ表示されています。最初の 3 つは全て NXDomain (存在しないドメイン) の返事が返ってきています。

  • google.com.default.svc.cluster.local.
  • google.com.svc.cluster.local.
  • google.com.cluster.local.
  • google.com.

ホスト名に余計なドメインを補完して無駄にクエリを投げていることが分かります。最後の google.com. のクエリのみ発行されるべきですが、期待と違った結果になったと思います。

では、ドットの数を変えるとどうなるでしょうか。ここからは架空のホスト名に対して名前解決します。

curl http://a.b.c.d.e.f

ドットの数が 5 個以上の場合、名前解決のクエリの数が期待通りの数に減ることが分かります。

08:49:57.883638 IP 10.244.0.5.37238 > 10.96.0.10.53: 23312+ A? a.b.c.d.e.f. (29)
08:49:57.883738 IP 10.244.0.5.37238 > 10.96.0.10.53: 23479+ AAAA? a.b.c.d.e.f. (29)
08:49:57.911115 IP 10.96.0.10.53 > 10.244.0.5.37238: 23312 NXDomain 0/1/0 (104)
08:49:58.888317 IP 10.96.0.10.53 > 10.244.0.5.37238: 23479 0/0/0 (29)

最後に、ドットの数が 5 未満でもホスト名の末尾にドットを付けてみるとどうなるでしょうか。

curl https://google.com.

DNS クエリの数が減ります。

06:40:07.081227 IP 10.244.0.5.53681 > 10.96.0.10.53: 3609+ A? google.com. (28)
06:40:07.081332 IP 10.244.0.5.53681 > 10.96.0.10.53: 3942+ AAAA? google.com. (28)
06:40:07.098550 IP 10.96.0.10.53 > 10.244.0.5.53681: 3609 1/0/0 A 142.251.222.46 (54)
06:40:08.086274 IP 10.96.0.10.53 > 10.244.0.5.53681: 3942 0/0/0 (28)

原因

Kubernetes が Pod にマウントされるリゾルバの設定を書き換えているからです。厳密に言うと、Pod の DNS ポリシーが ClusterFirst (デフォルト) の場合に、Kubernetes が Pod にマウントされる /etc/resolver.conf を書き換えています。

curl のコンテナ内から /etc/resolver.conf を確認してみます。Kind の場合は、以下の結果が表示されるはずです。

/ $ cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

リゾルバの設定

それぞれのリゾルバの設定について見ていきます。

DNS のリゾルバの設定の一つで、後述する ndots と組み合わせてドメインの検索パスを追加することができます。Kubernetes では、売りの一つである DNS ベースのサービス検出で Service の VIP の名前解決で使用するドメイン名を補完しています。Kubernetes が生成した search パスに対してホストマシンで設定されている search パスを追加する実装になっています。(source)

nameserver

DNS のリゾルバが名前解決を問い合わせる先の IP アドレスが指定されています。Kubernetes では、CoreDNS (GKE の場合は kube-dns) の ClusterIP (VIP) が指定されています。kubelet は自身の設定ファイルの中からネームサーバーの接続先の IP を取得して、Pod のリゾルバの設定を生成します。

# Docker デーモン上で起動した Kind のノードに入る
docker exec -it kind-control-plane bash

root@kind-control-plane:/# cat /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
(...)
clusterDNS:
- 10.96.0.10

Pod の DNS ポリシーが ClusterFirst (デフォルト) の場合、クラスタ内の DNS サーバ (CoreDNS や kube-dns) のみが nameserver として指定されます。(source) クラスタ内の nameserver が解決できないホスト名を利用している場合は、クラスタ内の DNS サーバで処理できるようにする必要があります。例えば、kube-dns の場合は ConfigMap で stub domain が指定可能です。

ndots

ndots で指定されたドットの数未満のホスト名に対して名前解決をすると、search で指定されたパスが自動的に補完されます。

google.com は ndots が 1 で 5 未満であるため、search で指定したパスが補完されます。

  1. google.com.default.svc.cluster.local.
  2. google.com.svc.cluster.local.
  3. google.com.cluster.local.
  4. google.com.

search パスや ndots の設定は何のためにあるのでしょうか。

ホスト名 (FQDN) を指定した場合、ドメインが変わると当然動作しなくなります。ドメインに依存した書き方よりも、先頭の短い名前だけで解決できる方が可搬性・移植性が高くなります。api-gateway.default.svc.cluster.local よりも api-gateway で名前が解決できた方が嬉しい訳です。Kubernetes はクラスタ内のドメイン名 (cluster.local) を変更可能です。ホスト名を指定しまうと、あるクラスタでは動作するが、別のクラスタでは動作しない可能性があります。幸いなことに Kubernetes のユーザーのほとんどが、 cluster.local を変更せずに使っています。ただ、オンプレ環境以外でも Cloud DNS for GKE の VPC スコープの DNS など cluster.local が変更になるケースも出てきています。

Kubernetes の設計思想

Tim Hockin による Kubernetes における ndots: 5 の設計思想の説明 (comment - k/k#33554) を簡単にまとめると、以下の通りです。

  1. 同じ namespace 内のアプリケーション同士での通信が一番多い
  2. 色々な種類のクラス (= Kubernetes リソース) が DNS を利用するはずなので、DNS 名の一部にクラスを含めたい
  3. Service (svc) が最もよく名前解決されるクラスなので自動補完できるようにしたい。Service 名だけで補完が効くように、必然的に ndots は 1 以上
  4. クラスタ内のドメイン名を変えたい人たちが一定数出てくるはず

ここまでで、$service から $service.$namespace.svc.$zone に補完できるように $namespace.svc.$zone が最初の search パスになる

  1. 2 番目に多い通信は異なる namespace 間でのアプリケーションの通信のはずなので、namespace を跨いだ名前解決も簡単にできると良さそう
    1. で言ったように $zone を変える人がいるので、FQDN をハードコードすると可搬性が失われる。svc.$zone は search パスで補完できるようにするべき (e.g. kubernetes.default.svc.$zone) で、必然的に ndots は 2 以上 (kubernetes.default のドット数 1 より大きい) である必要がある
    1. と 4. から Service 以外の名前解決も簡単に行えるようにしたいので、$zone も search パスで補完できるようにするべき (e.g. kubernetes.default.svc.$zone) で、必然的に ndots は 3 以上 (kubernetes.default.svc のドット数 2 より大きい) である必要がある
    1. と 4. から PetSets (その後 StatefulSets に改名) が index 毎に名前解決できる必要がある ($statefulset-0.$service.$namespace.svc.$zone) ので、必然的に ndots は 4 以上 ($statefulset-0.$service.$namespace.svc のドット数の 3 より大きい) である必要がある
  2. SRV レコードもサポートしているので、ポート名とプロトコルでも補完できるようにすべき (_$port._$proto.$service.$namespace.svc.$zone) で、必然的に ndots は 5 以上 (_$port._$proto.$service.$namespace.svc のドット数の 4 より大きい) である必要がある

以上で、ndots は 5 以上必要

    1. と 8. から StatefulSet の SRV レコード (_$port._$proto.$statefulset-0.$service.$namespace.svc) も解決可能にすべきで、ndots を 6 以上にすべきと言われるだろうけど、StatefulSet の SRV レコードって利用用途がよく分からないから考慮しない

Kubernetes の設計思想から ndots: 5 という大きな数字が選ばれましたが、世間一般のインターネット上に蔓延るホスト名が 5 未満のドット数でした。それが思わぬ副作用として働いた形です。

どうすべきか?

クラスタ内の DNS (CoreDNS, kube-dns) のメトリクスを収集できる場合は、DNS のリクエスト数などのメトリクスをもとに調整していきます。GKE の場合は、kube-dns の Pod が 10055 番ポートで公開している Prometheus 形式のメトリクスを収集するのが良さそうです。

Option #1 - Trailing dot

環境変数や設定ファイルなどで接続先のホスト名を指定するときは必ず末尾にドットを付けるようにしましょう。これ以上補完が必要ないことを明示します。

  • ✅ google.com.
  • ❌ google.com

Option #2 - Pod DNS Config

アプリケーションで使用する SDK に接続先のホスト名が埋め込まれているケースがあります。SDK を通したリクエスト数が多い場合は、Pod の DNS Config の options で ndots: 1 を指定しましょう。

Option #3 - Scaling cluster DNS

クラスタ内の DNS サーバの水平方向のスケールの設定を調整して力で解決します。DNS クエリの数が減る訳ではないので、最後の手段くらいに思うのが良いです。GKE の場合は、Scaling up kube-dns を参考に ConfigMap を調整します。

Option #4 - NodeLocal DNSCache

NodeLocal DNSCache を使用して力で解決します。DNS クエリの数が減る訳ではないので、最後の手段くらいに思うのが良いです。NodeLocal DNSCache は DaemonSet として全てのノード上で起動します。少し hacky な方法で DNS パケットを NodeLocal DNSCache に通すので、思わぬ問題 (e.g. aws/amazon-vpc-cni-k8s#1384) が発生する可能性があるため、必要ない場合は導入しない方が良いです。

その他

  • Option #2 の場合、DNSConfig を全ての Pod に設定する必要がありますが、クラスタ全体で DNSConfig を設定できる DNSClass を導入しようという動きもあります。まだ、KEP も上がって来ないし、上がったとしても sig-network の他の KEP より優先度が低いと明言されているので、本当に機能として入るか怪しいです。 (comment - k/k#116117)
  • Prometheus の node-exporter で conntrack テーブルの使用率も確認しましょう。使用率が高い割合で推移している場合は、ノード上に SSH 接続して conntrack コマンドでテーブルの中を監視するか hiveco/conntrack_exporter で Prometheus 形式のメトリクスを公開するかで原因を特定します。hiveco/conntrack_exporter は、メトリクスのカーディナリティが高いので本番環境では使用しないで下さい。 原因が分かっても対処できない場合は、DaemonSet で conntrack テーブルのサイズを増やすなど検討が必要です。(p.59-61)

参考

Discussion