0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CoreDNS の Kubernetes プラグインで、Service のドメイン名がクラスタ内で名前解決できるまで

0
Posted at

はじめに

前回の記事 Deployment を apply してから Pod が Ready になるまで では、Deployment を kubectl apply してから、Pod が Ready になり、Service 経由でトラフィックを受けられる状態になるまでの流れを整理しました。

この記事はその続編として、Service が Ready な Pod を Endpoint として持つようになった後、クラスタ内の Pod からその Service のドメイン名 (<service>.<namespace>.svc.cluster.local のような名前) で名前解決できるようになるまでの流れを整理します。

  1. kubelet が Pod の DNS 設定 (/etc/resolv.conf 相当) をどう組み立てるか
  2. CoreDNS がプラグインチェーンとしてどう構成され、どの順序で実行されるか
  3. CoreDNS の kubernetes プラグインが Service の情報からどう A レコードを組み立てるか
  4. クラスタ外のドメインへのクエリが、どのようにして上流 DNS へ転送されるか
  5. 実際の運用クラスタでよく使われる Corefile の設定例と、それぞれの設定値が何のためにあるか
  6. Pod から接続先を指定する際に、名前解決のクエリ数を抑えるための Tips

対象コンポーネントとバージョン

コンポーネント バージョン リポジトリ 本記事での役割
Kubernetes v1.37.1 https://github.com/kubernetes/kubernetes kubelet による Pod への DNS 設定注入、kubeadm による kube-dns Service・CoreDNS Deployment の生成
CoreDNS v1.14.6 https://github.com/coredns/coredns プラグインチェーンの構築、kubernetes プラグインによる名前解決、forward/cache 等の周辺プラグイン
kind v0.33.0 https://github.com/kubernetes-sigs/kind 検証環境。ノードイメージは kindest/node:v1.37.0

kind は各ノードコンテナ内で実際に kubeadm init を実行してクラスタを構築します。

// pkg/cluster/internal/create/actions/kubeadminit/init.go
args := []string{
	// init because this is the control plane node
	"init",
	// specify our generated config file
	"--config=/kind/kubeadm.conf",
	"--skip-token-print",
	// increase verbosity for debugging
	"--v=6",
}

(参考: init.go#L72-L78)

そのため、1.2節で説明する kubeadm の DNS アドオン生成ロジックは、kind クラスタでの検証結果とそのまま対応します。

TL;DR

  • kubelet は、Pod ごとに /etc/resolv.conf を組み立てて注入します。 nameserver はクラスタ DNS の ClusterIP に固定され、ndots:5 がデフォルトで設定されるため、ドット数の少ない短い名前のクエリは search ドメインを総当たりします
  • CoreDNS は Caddy をベースにしたプラグインチェーン型の DNS サーバーです。 実行順序は Corefile に書いた記述順ではなく、CoreDNS 本体のビルド時に固定された順序で決まります
  • kubernetes プラグインは Service・EndpointSlice を informer で watch し、A レコードを組み立てます。 通常の Service は ClusterIP を1件返し、Headless Service は EndpointSlice の各アドレスを列挙します
  • クラスタ内のゾーンにマッチしないクエリは forward プラグインで上流 DNS へ転送されます。 CoreDNS 自身も dnsPolicy: Default で動くため、loop プラグインが自己転送ループを検知する安全装置になっています
  • 接続先の指定方法によって、クラスタ内の名前解決にかかるクエリ数は大きく変わります。 別 Namespace の Service へは完全な名前より <service>.<namespace> の方がクエリ数を抑えられ、外部ドメインしか解決しない Pod では dnsPolicy: Default にして CoreDNS を経由しない選択肢もあります

CoreDNS の概要

Kubernetes が定めているのは、Service/Pod のドメイン名をどう解決すべきかという DNS ベースのサービスディスカバリの仕様であり、それを満たす実装自体は特定の1つに限定していません。

This document is a specification for DNS-based Kubernetes service discovery. While service discovery in Kubernetes may be provided via other protocols and mechanisms, DNS is very commonly used and is a highly recommended add-on. The actual DNS service itself need not be provided by the default Kube-DNS implementation.

(参考: kubernetes/dns: DNS-Based Service Discovery)

この仕様を満たす実装として、かつては kube-dns が使われていましたが、Kubernetes 1.11 以降は CoreDNS が広く使われるようになりました。CoreDNS は Kubernetes プロジェクトが管理する実装ではなく、Kubernetes とは別に CNCF がホストする独立したプロジェクト (graduated project) です。

CoreDNS is sister CNCF project and is the successor to SkyDNS, on which kube-dns is based... As an independent project, it is more actively developed than kube-dns...

(参考: KEP-427: CoreDNS)

CoreDNS がデフォルトになったのも Kubernetes プロジェクト全体の決定ではなく、kubeadm チームが 1.11 から選んだ結果です。

In fact, the kubeadm team chose to make it the default option starting with Kubernetes 1.11.

(参考: CoreDNS GA for Kubernetes Cluster DNS)

この記事の対象である Kubernetes v1.37 のリリースでは、kube-dns サブプロジェクト自体の retire (引退) が正式にアナウンスされています。

CoreDNS has been the default cluster DNS add-on since Kubernetes v1.13, and kube-dns has not kept pace since then; features like EndpointSlices and dual-stack Services aren't available in it. Kubernetes has already retired the kube-dns subproject... It is expected that no new packages will be built for kube-dns after v1.40.

(参考: Kubernetes v1.37: Garhwal)

CoreDNS 自体は、Go で書かれたプラグインチェーン方式の DNS サーバーです。README では次のように説明されています。

CoreDNS is a DNS server/forwarder, written in Go, that chains plugins. Each plugin performs a (DNS) function.

(参考: coredns/coredns README)

ファイルベースのゾーン配信・キャッシュ・ロードバランシング・DNSSEC・etcd や Kubernetes をバックエンドにした動的なレコード生成など、多数の機能をプラグインとして提供しています。クラスタ内の名前解決に使われるのは、このうちの kubernetes プラグインです。

設定ファイルは Corefile と呼ばれ、どのプラグインをどのゾーンに適用するかを記述します。この Corefile のパースとプラグインチェーンの構築には、Web サーバー Caddy からフォークされた github.com/coredns/caddy が使われており (go.mod#L19)、1.3節で見る「プラグインの実行順序が固定である」という仕組みもここに由来します。この記事では、kubeadm が CoreDNS をデプロイする場合の構成 (Corefile を含む ConfigMap と Deployment を kube-system Namespace に coredns という名前で生成する構成) を前提に説明します。

公式ドキュメントでも、kubelet が Service 名で名前解決できるように Pod の DNS を設定し、CoreDNS がその情報を元に応答することが説明されています。

Kubernetes publishes information about Pods and Services which is used to program DNS. kubelet configures Pods' DNS so that running containers can look up Services by name rather than IP.

(参考: DNS for Services and Pods)

以降の章では、この「kubelet が Pod の DNS を設定する」部分と、「CoreDNS がその情報を元に応答する」部分を、それぞれソースコードから確認していきます。

第1章: kubernetes プラグインが Service のドメイン名を解決できるようになるまで

この章では、kubelet が Pod に DNS 設定を注入してから、CoreDNS の kubernetes プラグインが Service のドメイン名を A レコードとして返すまでの流れを説明します。

1.1 kubelet が Pod に DNS 設定を注入する

kubelet は Pod の dnsPolicy (デフォルトは ClusterFirst) を見て、コンテナに渡す DNS 設定の種別を決めます。dnsPolicy には次の4種類があります。

dnsPolicy 概要
ClusterFirst (デフォルト) hostNetwork でない Pod はクラスタ DNS を優先して使う。hostNetwork の Pod では Default にフォールバックする
ClusterFirstWithHostNet hostNetwork の Pod でもクラスタ DNS を優先して使う
Default kubelet が稼働するノードの /etc/resolv.conf をそのまま使う
None DNS 設定を空にする。nameserver・search 等は Pod の dnsConfig フィールドで明示的に指定する
// staging/src/k8s.io/api/core/v1/types.go
const (
	// DNSClusterFirstWithHostNet indicates that the pod should use cluster DNS
	// first, if it is available, then fall back on the default
	// (as determined by kubelet) DNS settings.
	DNSClusterFirstWithHostNet DNSPolicy = "ClusterFirstWithHostNet"

	// DNSClusterFirst indicates that the pod should use cluster DNS
	// first unless hostNetwork is true, if it is available, then
	// fall back on the default (as determined by kubelet) DNS settings.
	DNSClusterFirst DNSPolicy = "ClusterFirst"

	// DNSDefault indicates that the pod should use the default (as
	// determined by kubelet) DNS settings.
	DNSDefault DNSPolicy = "Default"

	// DNSNone indicates that the pod should use empty DNS settings. DNS
	// parameters such as nameservers and search paths should be defined via
	// DNSConfig.
	DNSNone DNSPolicy = "None"
)

(参考: types.go#L3963-L3982)

この記事では、通常の Pod (hostNetwork でない) がデフォルトの ClusterFirst で動作する場合を扱います。dnsPolicy からどの DNS 設定を使うかを判定する実装は次の通りです。

// pkg/kubelet/network/dns/dns.go
func (c *Configurer) getPodDNSType(pod *v1.Pod) (podDNSType, error) {
	dnsPolicy := pod.Spec.DNSPolicy
	switch dnsPolicy {
	case v1.DNSNone:
		return podDNSNone, nil
	case v1.DNSClusterFirstWithHostNet:
		return podDNSCluster, nil
	case v1.DNSClusterFirst:
		if !kubecontainer.IsHostNetworkPod(pod) {
			return podDNSCluster, nil
		}
		// Fallback to DNSDefault for pod with hostNetwork and DNSClusterFirst
		fallthrough
	case v1.DNSDefault:
		return podDNSHost, nil
	}
	...
}

(参考: dns.go#L304-L323)

DNSClusterFirst (hostNetwork でない通常の Pod) は podDNSCluster に分類され、GetPodDNS の中でクラスタ DNS 向けの設定が組み立てられます。

// pkg/kubelet/network/dns/dns.go
case podDNSCluster:
	if len(c.clusterDNS) != 0 {
		// For a pod with DNSClusterFirst policy, the cluster DNS server is
		// the only nameserver configured for the pod. The cluster DNS server
		// itself will forward queries to other nameservers that is configured
		// to use, in case the cluster DNS server cannot resolve the DNS query
		// itself.
		dnsConfig.Servers = []string{}
		for _, ip := range c.clusterDNS {
			dnsConfig.Servers = append(dnsConfig.Servers, ip.String())
		}
		dnsConfig.Searches = c.generateSearchesForDNSClusterFirst(dnsConfig.Searches, pod)
		dnsConfig.Options = defaultDNSOptions
		break
	}
	// clusterDNS is not known. Pod with ClusterDNSFirst Policy cannot be created.
	...
	// Fallback to DNSDefault.
	fallthrough
case podDNSHost:

(参考: dns.go#L402-L422)

ポイントは、nameserver をノード本来の /etc/resolv.conf に書かれたものに追加するのではなく、c.clusterDNS (クラスタ DNS の ClusterIP) だけで 置き換えている ことです。コメントにある通り、クラスタ DNS 自身が外部ドメインへのクエリを上流に転送する前提になっています。

search ドメインは generateSearchesForDNSClusterFirst で組み立てます。

// pkg/kubelet/network/dns/dns.go
func (c *Configurer) generateSearchesForDNSClusterFirst(hostSearch []string, pod *v1.Pod) []string {
	if c.ClusterDomain == "" {
		return hostSearch
	}

	nsSvcDomain := fmt.Sprintf("%s.svc.%s", pod.Namespace, c.ClusterDomain)
	svcDomain := fmt.Sprintf("svc.%s", c.ClusterDomain)
	clusterSearch := []string{nsSvcDomain, svcDomain, c.ClusterDomain}

	return omitDuplicates(append(clusterSearch, hostSearch...))
}

(参考: dns.go#L165-L175)

<namespace>.svc.<cluster-domain> → svc.<cluster-domain> → <cluster-domain> の順で search リストを作り、ノード本来の search ドメイン (hostSearch) をその後ろに付け足しています。ndots:5 はデフォルト値として別途定義されており、これがそのまま dnsConfig.Options に代入されます。

// pkg/kubelet/network/dns/dns.go
var (
	// The default dns opt strings.
	defaultDNSOptions = []string{"ndots:5"}
)

(参考: dns.go#L42-L45)

なぜ5なのかについては、Kubernetes の共同創業者である Tim Hockin 氏が、このデフォルト値の由来を議論した issue で段階的に説明しています。

  1. Services are the vast majority of lookups so class "svc" must be in the search path and ndots must be >= 1 (e.g. name must resolve)
    ...
  2. Because of (4), we don't really want apps to hardcode the FQDN (bad for portability), so svc.$zone must be in the search path and ndots must be >= 2 (e.g. kubernetes.default must resolve)
  3. Because of (2) and (4), same-namespace and cross-namespace lookups of non-service names should be easy. Therefore $zone must be in the search path and ndots must be >= 3 (e.g. name.namespace.svc must resolve).
  4. Because of (1) and (4), and the fact that petsets have per-endpoint names, local and cross-namespace petnames should be easy. Given the previous search paths and ndots >= 4, we can ensure that petname.service.namespace.svc resolves.
  5. We also support SRV records of the form _$port._$proto.$service.$namespace.svc.$zone. Given (6) and (2), we must enable _$port._$proto.$service.$namespace.svc to resolve. That requires ndots = 5.

This explains how we got to ndots = 5.

(参考: kubernetes/kubernetes#33554)

同じ namespace の Service 名のみでの解決 (ndots >= 1) から始まり、svc.$zone の省略 (ndots >= 2)、$zone の省略 (ndots >= 3)、StatefulSet (コメント当時の名称は petset) の Pod 単位の名前解決 (ndots >= 4)、最後に SRV レコードの解決 (ndots = 5) まで、サポートしたい省略形を1つずつ積み上げた結果として5に行き着いたという経緯です。同じコメントでは、この先の _$port._$proto.$petname.$service.$namespace.svc.$zone という最悪ケースまで含めると本来は ndots = 6 が必要になる点や、それでも6まで上げなかった理由にも触れられています。

クラスタ DNS の IP アドレスとクラスタドメインは、kubelet 起動時のフラグ (--cluster-dns、--cluster-domain) から渡されます。

// cmd/kubelet/app/options/options.go
fs.StringVar(&c.ClusterDomain, "cluster-domain", c.ClusterDomain, "Domain for this cluster.  If set, kubelet will configure all containers to search this domain in addition to the host's search domains")
...
fs.StringSliceVar(&c.ClusterDNS, "cluster-dns", c.ClusterDNS, "Comma-separated list of DNS server IP address.  This value is used for containers DNS server in case of Pods with \"dnsPolicy=ClusterFirst\". Note: all DNS servers appearing in the list MUST serve the same set of records otherwise name resolution within the cluster may not work correctly. There is no guarantee as to which DNS server may be contacted for name resolution.")

(参考: options.go#L434、options.go#L437)

これらは KubeletConfiguration のフィールドとしてバインドされ、

// pkg/kubelet/apis/config/types.go
	// clusterDomain is the DNS domain for this cluster. If set, kubelet will
	// configure all containers to search this domain in addition to the
	// host's search domains.
	ClusterDomain string
	// clusterDNS is a list of IP addresses for a cluster DNS server. If set,
	// kubelet will configure all containers to use this for DNS resolution
	// instead of the host's DNS servers.
	ClusterDNS []string

(参考: types.go#L203-L211)

kubelet 起動時に IP としてパースされたうえで dns.Configurer に渡されます。

// pkg/kubelet/kubelet.go
clusterDNS := make([]net.IP, 0, len(kubeCfg.ClusterDNS))
for _, ipEntry := range kubeCfg.ClusterDNS {
	ip := netutils.ParseIPSloppy(ipEntry)
	if ip == nil {
		logger.Info("Invalid clusterDNS IP", "IP", ipEntry)
	} else {
		clusterDNS = append(clusterDNS, ip)
	}
}

(参考: kubelet.go#L606-L613、dns.NewConfigurer の呼び出しは kubelet.go#L642)

公式ドキュメントも同様の仕組みを説明しており、ここでの /etc/resolv.conf の例は実装とそのまま対応します。

DNS queries may be expanded using the Pod's /etc/resolv.conf. kubelet configures this file for each Pod. For example, a query for just data may be expanded to data.test.svc.cluster.local. The values of the search option are used to expand queries.

nameserver 10.32.0.10
search <namespace>.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

(参考: DNS for Services and Pods#Namespaces of Services)

1.2 kube-dns Service が CoreDNS の前段に立つ

kubelet に渡す --cluster-dns の値は、CoreDNS の前段に立つ kube-dns という名前の Service の ClusterIP です。この Service や CoreDNS の Deployment/ConfigMap 自体は Kubernetes 本体 (kube-controller-manager など) が管理するものではなく、kubeadm のようなクラスタ構築ツールが生成します。kind も内部で kubeadm init を使うため (対象コンポーネントとバージョンの節を参照)、ここで見るロジックは kind クラスタにもそのまま適用されます。

// cmd/kubeadm/app/constants/constants.go
// GetDNSIP returns a dnsIP, which is 10th IP in svcSubnet CIDR range
func GetDNSIP(svcSubnetList string) (net.IP, error) {
	svcSubnetCIDR, err := GetKubernetesServiceCIDR(svcSubnetList)
	...
	dnsIP, err := netutils.GetIndexedIP(svcSubnetCIDR, 10)
	...
	return dnsIP, nil
}

(参考: constants.go#L676-L690)

kubeadm は ClusterIP を Service CIDR の固定10番目のアドレスとして機械的に計算します。デフォルトの --service-cluster-ip-range=10.96.0.0/12 であれば 10.96.0.10 になります。Service 名が coredns ではなく kube-dns で固定されているのは、CoreDNS の概要で触れた通り kubeadm が Kubernetes 1.11 から kube-dns アドオンに代えて CoreDNS をデフォルトにした経緯による互換名で、--cluster-dns に渡す Service 名・ClusterIP を変えずに移行できるようにするためのものです。

kubeadm が生成する Corefile のゾーンには、kubelet 側の --cluster-domain と一致するクラスタドメイン (デフォルト cluster.local) が指定されています。

kubernetes cluster.local in-addr.arpa ip6.arpa {
    pods insecure
    fallthrough in-addr.arpa ip6.arpa
}

(参考: manifests.go#L163-L192)

1.3 CoreDNS のプラグインチェーンが構築される仕組み

CoreDNS のプラグインは、共通の Handler インターフェースを実装します。

// plugin/plugin.go
Handler interface {
    ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error)
    Name() string
}

(参考: plugin.go#L51-L54)

各プラグインの setup.go は、Corefile のパース時に AddPlugin で自分自身をチェーンに登録します。kubernetes プラグインの例です。

// plugin/kubernetes/setup.go
dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
    k.Next = next
    return k
})

(参考: setup.go#L53-L56)

ポイントは、ここで登録される順序が Corefile に記述した順序ではない ということです。実行順序は CoreDNS のビルド時に固定される Directives というスライスで決まっており、ソースコードのコメントにもその意図が明記されています。

// core/dnsserver/zdirectives.go
// Directives are registered in the order they should be
// executed.
//
// Ordering is VERY important. Every plugin will
// feel the effects of all other plugin below
// (after) them during a request, but they must not
// care what plugin above them are doing.
var Directives = []string{

(参考: zdirectives.go#L5-L12、全体は zdirectives.go#L1-L72)

このファイルはリポジトリ直下の plugin.cfg から自動生成されるもので、真の実行順序の定義元は plugin.cfg の行順です。つまり、Corefile 内でユーザーがどの順番にプラグインを並べて書いても、CoreDNS 内部での実行順序は変わりません。

登録されたプラグインは、NewServer の中で 末尾から 折り畳まれてチェーンになります。

// core/dnsserver/server.go
// compile custom plugin for everything
var stack plugin.Handler
for i := len(site.Plugin) - 1; i >= 0; i-- {
	stack = site.Plugin[i](stack)
	// register the *handler* also
	site.registerHandler(stack)
	...
}
site.pluginChain = stack

(参考: server.go#L110-L136)

site.Plugin は Directives の順序どおりに並んでいるため、末尾から畳み込むことで「最初に実行されるべきプラグイン (errors など) が一番外側のハンドラになり、残りのチェーンを Next として保持する」という入れ子構造が作られます。実際にクエリを受けたときは、このチェーンの先頭を1回呼ぶだけです。

// core/dnsserver/server.go
if r.Question[0].Qtype != dns.TypeDS {
	rcode, _ := h.pluginChain.ServeDNS(ctx, w, r)
	if !plugin.ClientWrite(rcode) {
		errorFunc(s.Addr, w, r, rcode)
	}
	return
}

(参考: server.go#L335-L341)

あとは各プラグインが、自分の処理が終わったタイミングで Next.ServeDNS (共通ヘルパーの plugin.NextOrFailure) を呼び出し、次のプラグインに処理を委譲していく、という仕組みです。

// plugin/plugin.go
func NextOrFailure(name string, next Handler, ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
	if next != nil {
		...
		pw := &pluginWriter{ResponseWriter: w, plugin: next.Name()}
		return next.ServeDNS(ctx, pw, r)
	}
	return dns.RcodeServerFailure, Error(name, errors.New("no next plugin found"))
}

(参考: plugin.go#L76-L89)

1.4 Corefile 内の主なプラグイン (ディレクティブ) の一覧

実際の運用クラスタでよく使われる Corefile には、だいたい次のようなプラグインが並びます。これは一般的に Corefile へ書くときの並び順 (3章で見る設定例もこの順) であり、前節で見た通り、CoreDNS 内部での実際の実行順序とは別物です。詳しい設定値の解説は3章で扱うので、ここでは一覧として概要だけ押さえます。

プラグイン (ディレクティブ) 概要
errors 処理中に発生したエラーを標準出力にログとして出力する
health /health エンドポイントで liveness probe に応答する
ready /ready エンドポイントで readiness probe に応答する (他プラグインの準備状況を集約する)
kubernetes Kubernetes の Service・EndpointSlice・Namespace の情報から DNS レコードを動的に生成する
prometheus :9153 で Prometheus 形式のメトリクスを公開する
forward 自身のゾーンにマッチしないクエリを上流の DNS サーバーへ転送する
cache 応答をメモリ上にキャッシュする
loop 自分自身への転送ループを検知してプロセスを終了させる
reload Corefile の変更を検知し、無停止で設定を再読込する
loadbalance 複数のレコードがある場合に応答の順序をランダム化する
autopath search ドメインの展開を CoreDNS 側で肩代わりする (3.11節で解説するオプション機能)
log クエリのログを標準出力へ出力する (3.12節で解説するオプション機能)

実際の実行順序 (plugin.cfg の行順) は、この書き方の並びとは一致しません。

reload:reload
ready:ready
health:health
prometheus:metrics
errors:errors
log:log
loadbalance:loadbalance
cache:cache
autopath:autopath
kubernetes:kubernetes
loop:loop
forward:forward

(参考: plugin.cfg)

特に cache と autopath が kubernetes より先に実行される点は、それぞれ3.7節の disable denial/disable success と3.11節の autopath の動作に直結するので覚えておいてください。

1.5 kubernetes プラグインが Service のドメイン名を A レコードに変換する

kubernetes プラグインの ServeDNS は、まずクエリされた名前が自分の担当ゾーン (Corefile で指定した cluster.local など) にマッチするかを確認します。

// plugin/kubernetes/handler.go
func (k Kubernetes) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
	state := request.Request{W: w, Req: r}
	qname := state.QName()
	zone := plugin.Zones(k.Zones).Matches(qname)
	if zone == "" {
		return plugin.NextOrFailure(k.Name(), k.Next, ctx, w, r)
	}
	...

(参考: handler.go#L13-L91、ゾーンマッチ判定は L17-L20)

マッチした場合、クエリ名は parseRequest で <service>.<namespace>.svc.<zone> のようなパターンに分解されます。コメントに書かれている通り、パターンは4種類あります。

// plugin/kubernetes/parse.go
// 4 Possible cases:
// 1. _port._protocol.service.namespace.pod|svc.zone
// 2. (endpoint): endpoint.service.namespace.pod|svc.zone
// 3. (service): service.namespace.pod|svc.zone
// 4. (endpoint multicluster): endpoint.cluster.service.namespace.pod|svc.zone

(参考: parse.go#L31-L89、4パターンの説明は L32-L36)

ゾーンに近い側のラベルから pod/svc → namespace → service の順に剥がしていき、残りがあれば port/protocol (SRV クエリ) または endpoint として解釈します。パースした結果から実データを引くのが Records で、Pod 向けなら findPods、Service 向けなら findServices が呼ばれます。

// plugin/kubernetes/kubernetes.go
func (k *Kubernetes) Records(ctx context.Context, state request.Request, exact bool) ([]msg.Service, error) {
	...
	if isPod {
		...
		return k.findPods(r, zone)
	}
	...
	return k.findServices(r, zone)
}

(参考: kubernetes.go#L332-L363)

Service の情報は、kubernetes プラグインが自前で持つ informer から取得します。Service・Pod・EndpointSlice・Namespace のそれぞれに対して、CoreDNS 起動時に informer が登録されます。

// plugin/kubernetes/controller.go
dns.svcLister, dns.svcController = object.NewIndexerInformer(
	cache.ToListWatcherWithWatchListSemantics(
		&cache.ListWatch{
			ListFunc:  serviceListFunc(ctx, dns.client, api.NamespaceAll, dns.selector),
			WatchFunc: serviceWatchFunc(ctx, dns.client, api.NamespaceAll, dns.selector),
		},
		kubeClient,
	),
	&api.Service{},
	cache.ResourceEventHandlerFuncs{AddFunc: dns.Add, UpdateFunc: dns.Update, DeleteFunc: dns.Delete},
	cache.Indexers{svcNameNamespaceIndex: svcNameNamespaceIndexFunc, svcIPIndex: svcIPIndexFunc, svcExtIPIndex: svcExtIPIndexFunc},
	object.DefaultProcessor(object.ToService, nil),
)

(参考: controller.go#L142-L154、EndpointSlice の watch は controller.go#L174-L190)

EndpointSlice を直接 watch しているため、古い Endpoints API を経由せず、Service の変更はほぼリアルタイムで CoreDNS 側のローカルキャッシュに反映されます。

レコードの組み立ては findServices の中で、ClusterIP 型と Headless 型で明確に分岐しています。

// plugin/kubernetes/kubernetes.go
// Endpoint query or headless service
if svc.Headless() || r.endpoint != "" {
	if endpointsList == nil {
		endpointsList = endpointsListFunc()
	}
	for _, ep := range endpointsList {
		if object.EndpointsKey(svc.Name, svc.Namespace) != ep.Index {
			continue
		}
		for _, eps := range ep.Subsets {
			for _, addr := range eps.Addresses {
				...
				s := msg.Service{Host: addr.IP, Port: int(p.Port), TTL: k.ttl}
				...
				services = append(services, s)
			}
		}
	}
	continue
}

// ClusterIP service
for _, p := range svc.Ports {
	...
	for _, ip := range svc.ClusterIPs {
		s := msg.Service{Host: ip, Port: int(p.Port), TTL: k.ttl}
		...
		services = append(services, s)
	}
}

(参考: kubernetes.go#L509-L560)

通常の ClusterIP 型 Service は、svc.ClusterIPs (固定の仮想 IP、通常1件) をそのまま A レコードとして返すだけです。一方 Headless Service では EndpointSlice の各アドレスを1レコードずつ列挙するため、Pod の数だけ A レコードが返ります。公式ドキュメントでも同様の違いが説明されています。

Headless Services (without a cluster IP) are also assigned DNS A and/or AAAA records, with a name of the form my-svc.my-namespace.svc.cluster-domain.example. Unlike normal Services, this resolves to the set of IPs of all of the Pods selected by the Service.

(参考: DNS for Services and Pods#A/AAAA records)

Pod 向けクエリ (findPods) では、Corefile の pods オプションで挙動が変わります。デフォルトの insecure モードは、Pod の実在を一切確認せず、クエリ名からハイフン区切りの IP アドレスを機械的に組み立てて返すだけです。

// plugin/kubernetes/kubernetes.go
const (
	// podModeDisabled is the default value where pod requests are ignored
	podModeDisabled = "disabled"
	// podModeVerified is where Pod requests are answered only if they exist
	podModeVerified = "verified"
	// podModeInsecure is where pod requests are answered without verifying they exist
	podModeInsecure = "insecure"
	...

(参考: kubernetes.go#L76-L82)

insecure と verified で決定的に違うのは、API への Pod watch の有無です。Pod informer (podController) は verified モードのときだけ起動されます。

// plugin/kubernetes/kubernetes.go
k.opts.initPodCache = k.podMode == podModeVerified

(参考: kubernetes.go#L290、Pod informer の起動は controller.go#L443-L451 で initPodCache が真の場合のみ実行されます)

つまり insecure (もしくは disabled) モードでは、クラスタ全体の Pod への API watch 自体が張られません。これが insecure モードが verified モードよりコストの低い実装である理由です。

1.6 実地検証: kind クラスタで Service のドメイン名を解決する

実際に kind でクラスタを作り、Deployment と Service を apply して、Pod からの名前解決を確認します。

kind create cluster --name coredns-demo --image kindest/node:v1.37.0

前回記事と同じ内容の Deployment と、そのための Service を apply します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: default
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          readinessProbe:
            httpGet:
              path: /
              port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: default
spec:
  selector:
    app: nginx
  ports:
    - port: 80
      targetPort: 80

Service が Ready になると、EndpointSlice には Ready な Pod の IP が2件登録されます。

apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: nginx-8dcnm
  namespace: default
  labels:
    kubernetes.io/service-name: nginx
addressType: IPv4
ports:
  - name: ""
    port: 80
    protocol: TCP
endpoints:
  - addresses: ["10.244.0.5"]
    conditions: {ready: true, serving: true, terminating: false}
    targetRef: {kind: Pod, name: nginx-deployment-56b9f4956-4vj5r, namespace: default}
  - addresses: ["10.244.0.2"]
    conditions: {ready: true, serving: true, terminating: false}
    targetRef: {kind: Pod, name: nginx-deployment-56b9f4956-ll45d, namespace: default}

別の Pod を立てて、Service の ClusterIP と /etc/resolv.conf を確認します。dig コマンドを使うため、nicolaka/netshoot イメージを使います。

$ kubectl run dnsutils --image=nicolaka/netshoot --restart=Never -- sleep 3600
$ kubectl get svc nginx -o jsonpath='{.spec.clusterIP}'
10.96.251.146
$ kubectl exec dnsutils -- cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

1.1節で確認した generateSearchesForDNSClusterFirst / defaultDNSOptions の実装どおり、search には3つのクラスタ用ドメインが並び、nameserver は kube-dns の ClusterIP だけに固定されています。

ここで、短い Service 名 nginx の A レコードを dig で問い合わせてみます。dig は OS の resolver ライブラリを経由せず、/etc/resolv.conf の search/ndots を自前で解釈する実装を持っており、+search オプションで有効にできます。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN	A	10.96.251.146

Corefile に一時的に log プラグインを追加してクエリログを確認すると、CoreDNS が受けたクエリは1回だけでした。

$ kubectl -n kube-system logs coredns-55c7f56bc5-rw2kv
[INFO] 10.244.0.9:45819 - 52547 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000791553s

nginx (ドット数0) は ndots:5 未満なので、search リストの先頭 (default.svc.cluster.local) を付加した名前を最初に試し、そこで一致したため1回で完結しています。

search リストのどのエントリにも一致しない名前では挙動が変わります。存在しない名前 doesnotexist を問い合わせてみます。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A doesnotexist
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
;; ->>HEADER<<- ... status: NXDOMAIN
[INFO] 10.244.0.9:51748 - 43574 "A IN doesnotexist.default.svc.cluster.local. udp 79 false 1232" NXDOMAIN qr,aa,rd 149 0.000185618s
[INFO] 10.244.0.9:35606 - 63632 "A IN doesnotexist.svc.cluster.local. udp 71 false 1232" NXDOMAIN qr,aa,rd 141 0.000223409s
[INFO] 10.244.0.9:54743 - 6397 "A IN doesnotexist.cluster.local. udp 67 false 1232" NXDOMAIN qr,aa,rd 137 0.000049374s
[INFO] 10.244.0.9:41978 - 30757 "A IN doesnotexist. udp 53 false 1232" NXDOMAIN qr,rd,ra 30 0.004721411s

今度は4回のクエリが発生しています。search リストの3つのエントリ (default.svc.cluster.local、svc.cluster.local、cluster.local) を順に試し、いずれも kubernetes プラグインが権威を持って (aa フラグ付き) NXDOMAIN を返した後、最後に絶対名としてそのまま試し、forward プラグイン経由で上流に転送されています (aa なし、ra フラグ付き)。これが ndots:5 による無駄なクエリの実態です。

最初から Service の完全な名前 (nginx.default.svc.cluster.local) を指定すれば、この総当たりを避けられるか確認します。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx.default.svc.cluster.local
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN	A	10.96.251.146
[INFO] 10.244.0.9:53934 - 25649 "A IN nginx.default.svc.cluster.local.default.svc.cluster.local. udp 98 false 1232" NXDOMAIN qr,aa,rd 168 0.000245658s
[INFO] 10.244.0.9:59436 - 62868 "A IN nginx.default.svc.cluster.local.svc.cluster.local. udp 90 false 1232" NXDOMAIN qr,aa,rd 160 0.00007454s
[INFO] 10.244.0.9:38202 - 29221 "A IN nginx.default.svc.cluster.local.cluster.local. udp 86 false 1232" NXDOMAIN qr,aa,rd 156 0.000101372s
[INFO] 10.244.0.9:41656 - 7194 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000086122s

意外にも、こちらも4回のクエリになりました。nginx.default.svc.cluster.local はドット数が4つで、やはり ndots:5 (デフォルト5) 未満です。そのため search リストの各エントリを末尾に付加した名前がまず試され (いずれも NXDOMAIN)、最後に指定した名前そのもの (絶対名としての nginx.default.svc.cluster.local) を試してようやく成功しています。**Service の完全な名前のつもりで指定しても、末尾にドットを付けない限り ndots:5 の対象から外れるわけではありません。**次の節で、この問題を避ける方法を見ていきます。

1.7 ndots:5 によるクエリ増幅を減らす

ndots オプションの仕様上、クエリ名に含まれるドットの数が ndots 以上であれば、resolver は search リストを展開せず、クエリ名をそのまま (絶対名として) 問い合わせます。

Resolver queries having fewer than ndots dots (default is 1) in them will be attempted using each component of the search path in turn until a match is found.

(参考: resolv.conf(5))

1.6節で見た通り、ドット数だけでは ndots:5 の対象から外れません。nginx.default.svc.cluster.local のドット数は4つで、デフォルトの ndots:5 を下回るため、末尾にドットを付けない限り search 展開の対象になります。確実に展開を避けるには、末尾にドットを付けた絶対名で問い合わせます。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN	A	10.96.251.146
[INFO] 10.244.0.9:44887 - 63099 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000108205s

今度は1回のクエリだけで完結しています。末尾のドットで絶対名・相対名を区別するという表記自体は、DNS のマスターファイル形式を定めた RFC 1035 で定義されています。

Domain names that end in a dot are called absolute, and are taken as complete. Domain names which do not end in a dot are called relative; the actual domain name is the concatenation of the relative part with an origin specified in a $ORIGIN, $INCLUDE, or as an argument to the master file loading routine.

(参考: RFC 1035 Section 5.1)

これを踏まえると、次のような問い合わせ方でクエリ数を減らせます。

  • 外部ドメインは、末尾にドットを付けた絶対名 (example.com. のような表記) で問い合わせる。ドット数に関わらず search リストの展開を避けられます
  • クラスタドメイン内で、かつ同じ Namespace の Service であれば、Service 名のみで問い合わせる。1.6節で確認した通り、search リストの先頭が <namespace>.svc.cluster.local なので、最初の展開でヒットします

第2章: クラスタ外ドメインへのクエリが上流 DNS へ転送されるまで

この章では、cluster.local にマッチしないクエリが、どのようにして上流の DNS サーバーへ転送されるかを説明します。

2.1 ゾーンにマッチしないクエリは forward プラグインへ回る

1.5節で見た通り、kubernetes プラグインの ServeDNS は冒頭でゾーンマッチを判定します。

// plugin/kubernetes/handler.go
zone := plugin.Zones(k.Zones).Matches(qname)
if zone == "" {
	return plugin.NextOrFailure(k.Name(), k.Next, ctx, w, r)
}

(参考: handler.go#L17-L20)

kubernetes.io のような外部ドメインは、Corefile で指定した cluster.local、in-addr.arpa、ip6.arpa のいずれにもマッチしません。そのため、kubernetes プラグインは最初から処理をせず、チェーン内の次のプラグインに委譲します。この仕組みは、1.5節で触れた fallthrough (ゾーンにはマッチしたが名前が見つからなかった場合に次へ委譲する) とは別のものです。ゾーンマッチの判定自体で素通りするのが外部ドメイン、ゾーンにはマッチしたうえで NXDOMAIN になったクエリを次へ渡すかどうかを制御するのが fallthrough、という違いになります。

kubernetes プラグインより後に Corefile で登録されているプラグインのうち、実際にクエリに応答するのは forward です。

2.2 forward プラグインがノードの resolv.conf を読んで上流に転送する

forward . /etc/resolv.conf という設定は、TO 引数にファイルパスを渡した場合の構文です。CoreDNS はこのファイルを resolv.conf 形式としてパースし、nameserver 行から上流サーバーの一覧を得ます。

// plugin/pkg/parse/host.go
func tryFile(s string) ([]string, error) {
	c, err := dns.ClientConfigFromFile(s)
	...
	for _, s := range c.Servers {
		servers = append(servers, net.JoinHostPort(stripZone(s), c.Port))
	}
	return servers, nil
}

(参考: host.go#L87-L99、呼び出し元は resolve.go#L33-L63)

max_concurrent オプションを指定すると、同時並行で扱うアップストリームへのクエリ数に上限を設けられます。超過した場合はキューイングせず即座に REFUSED を返します。

// plugin/forward/forward.go
if f.maxConcurrent > 0 {
	count := atomic.AddInt64(&(f.concurrent), 1)
	defer atomic.AddInt64(&(f.concurrent), -1)
	if count > f.maxConcurrent {
		maxConcurrentRejectCount.Add(1)
		return dns.RcodeRefused, f.ErrLimitExceeded
	}

(参考: forward.go#L119-L124)

2.3 CoreDNS 自身も dnsPolicy: Default で動いている

CoreDNS の Pod 自体は、他の一般的な Pod と違って dnsPolicy: Default で動作します。これはノード本来の /etc/resolv.conf をそのまま使うという設定です。CoreDNS 自身がクラスタ DNS であるため、ClusterFirst で自分自身を nameserver に指定するわけにはいきません。

この構成には副作用があります。ノードの /etc/resolv.conf に、ループバックアドレス (systemd-resolved が使う 127.0.0.53 など) が nameserver として書かれていると、forward . /etc/resolv.conf が実質的に CoreDNS 自身へ転送するループになってしまいます。これを防ぐのが loop プラグインです。起動時に自分自身宛てのランダムなプローブクエリを送り、一定回数以上観測されたら転送ループと判断してプロセスを終了させます。

// plugin/loop/loop.go
if state.Name() == l.qname {
	l.inc()
}
if l.seen() > 2 {
	log.Fatalf(`Loop (%s -> %s) detected for zone %q, ...`, ...)
}

(参考: loop.go#L33-L57)

プロセスが終了すると Kubernetes が CrashLoopBackOff として検知し、Pod を再起動します。無限ループによる CPU・メモリの枯渇を未然に防ぐための安全装置です。README の「Troubleshooting Loops In Kubernetes Clusters」でも、この構成上の注意点が説明されています。

(参考: CoreDNS loop plugin#Troubleshooting Loops In Kubernetes Clusters)

2.4 実地検証: 外部ドメインへのフォワード

kind クラスタのノード (コンテナ) 自体の /etc/resolv.conf を見ると、CoreDNS の Pod が実際に使っている上流サーバーを確認できます。

$ docker exec coredns-demo-control-plane cat /etc/resolv.conf
nameserver 192.168.5.2
options ndots:0

この状態で kubernetes.io の A レコードを問い合わせると、1.6節で見た doesnotexist と同じく search 展開が働きます。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A kubernetes.io
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; ANSWER SECTION:
kubernetes.io.		5	IN	A	3.33.186.135
kubernetes.io.		5	IN	A	15.197.167.90
[INFO] 10.244.0.9:42449 - 56539 "A IN kubernetes.io.default.svc.cluster.local. udp 80 false 1232" NXDOMAIN qr,aa,rd 150 0.000148246s
[INFO] 10.244.0.9:50373 - 65120 "A IN kubernetes.io.svc.cluster.local. udp 72 false 1232" NXDOMAIN qr,aa,rd 142 0.000084414s
[INFO] 10.244.0.9:34883 - 29654 "A IN kubernetes.io.cluster.local. udp 68 false 1232" NXDOMAIN qr,aa,rd 138 0.000044665s
[INFO] 10.244.0.9:46833 - 20811 "A IN kubernetes.io. udp 54 false 1232" NOERROR qr,rd,ra 89 0.009308609s

search リストの3つのエントリは、いずれも kubernetes プラグインが権威を持って (aa フラグ付き) NXDOMAIN を返しています。最後に絶対名として試されたクエリだけが NOERROR になっており、フラグも qr,aa,rd ではなく qr,rd,ra (aa なし、ra あり) に変わっています。これは、kubernetes プラグインのゾーンにマッチしなかったため forward プラグインへ処理が渡り、実際にネットワーク越しに上流サーバーへ問い合わせて非権威応答を返しているためです。応答時間も 0.0093s と、kubernetes プラグインが権威応答を返していた 0.00005〜0.0003s 台より一桁遅くなっています。

1.7節で見た通り、末尾にドットを付けた絶対名で問い合わせれば、この search 展開を避けられます。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A kubernetes.io.
;; ANSWER SECTION:
kubernetes.io.		5	IN	A	3.33.186.135
kubernetes.io.		5	IN	A	15.197.167.90
[INFO] 10.244.0.9:57891 - 60795 "A IN kubernetes.io. udp 54 false 1232" NOERROR qr,rd,ra 89 0.003405158s

1回のクエリで完結しています。

第3章: 主なプラグインの一覧と参考の設定

この章で紹介するプラグインの一覧は次の通りです。各プラグインの概要・設定値・採用理由は、3.1節以降でそれぞれ解説します。

サブ章 プラグイン 概要
3.1 errors エラーを標準出力へログ出力する
3.2 health liveness probe (/health) に応答する
3.3 ready readiness probe (/ready) に応答する
3.4 kubernetes Service/EndpointSlice から DNS レコードを生成する
3.5 prometheus Prometheus 形式のメトリクスを公開する
3.6 forward ゾーンにマッチしないクエリを上流 DNS サーバーへ転送する
3.7 cache 応答をキャッシュする (serve_stale/prefetch による上流障害対策を含む)
3.8 loop 自己転送ループを検知してプロセスを終了する
3.9 reload Corefile の変更を無停止で再読込する
3.10 loadbalance 複数レコードの応答順をランダム化する
3.11 autopath search ドメインの展開を CoreDNS 側で肩代わりする
3.12 log クエリのログを標準出力へ出力する

実際の運用クラスタでは、公式の coredns.yaml.base をベースにしつつ、いくつかの設定をカスタマイズした Corefile がよく使われます。次の例は、3.7節で解説する serve_stale/prefetch も含めて、ここまでに登場したプラグインを組み合わせた設定です。

.:53 {
    errors
    health {
        lameduck 10s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
    }
    prometheus :9153
    forward . /etc/resolv.conf
    cache 30 {
        disable denial cluster.local
        disable success cluster.local
        prefetch 5 60s 10%
        serve_stale 1h verify
    }
    loop
    reload
    loadbalance
}

3.11節で解説する autopath を使うには kubernetes プラグインを pods verified にする必要があり、3.4節で説明したデフォルトの pods insecure とは両立しません。そのため autopath まで含める場合は、cluster.local 向けのブロックを . (それ以外の全ゾーン) のブロックと分ける必要があります。

cluster.local in-addr.arpa ip6.arpa {
    errors
    health {
        lameduck 10s
    }
    ready
    autopath @kubernetes
    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods verified
        fallthrough in-addr.arpa ip6.arpa
    }
    prometheus :9153
    cache 30 {
        disable denial cluster.local
        disable success cluster.local
        prefetch 5 60s 10%
        serve_stale 1h verify
    }
    loop
    reload
    loadbalance
}

.:53 {
    errors
    forward . /etc/resolv.conf
    cache 30
    loop
    reload
    loadbalance
}

3.1 errors

処理中に発生したエラーを標準出力へログとして出力するだけのプラグインです。設定可能なオプションはありません。

errors
// plugin/errors/errors.go
func (h errorHandler) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
	rcode, err := plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r)
	if err != nil {
		...
	}
	return rcode, err
}

(参考: errors.go#L94-L122)

Any errors encountered during the query processing will be printed to standard output.

(参考: CoreDNS errors plugin)

3.2 health

/health (デフォルトポート 8080) で liveness probe に応答するプラグインです。ハンドラの実装は常に 200 を返すだけです (参考: CoreDNS health plugin)。この例での設定は次の通りです。

設定 値 概要
lameduck 10s SIGTERM を受けてから実際にプロセスを終了するまでの猶予時間
health {
    lameduck 10s
}
// plugin/health/health.go
h.mux.HandleFunc(h.healthURI.Path, func(w http.ResponseWriter, _r *http.Request) {
	// We're always healthy.
	w.WriteHeader(http.StatusOK)
	io.WriteString(w, http.StatusText(http.StatusOK))
})

(参考: health.go#L60-L64)

// plugin/health/health.go (OnFinalShutdown)
time.Sleep(h.lameduck)

(参考: health.go#L82-L101)

この猶予の間も /health は 200 を返し続けます。kube-apiserver 公式の coredns.yaml.base ではデフォルト値が 5s ですが、この設定例では 10s に延ばしています。Pod が Terminating になってから、その変更が EndpointSlice に反映され、kube-proxy のルールが更新されるまでには前回記事で見た通り非同期の遅延があります。lameduck を長めに取ることで、その間も liveness probe を失敗させずに Pod を生かしたまま、既存のコネクションを処理し切る時間を確保しています。

3.3 ready

/ready (デフォルトポート 8181) で readiness probe に応答するプラグインです。health との違いは、各プラグインが実装する Readiness インターフェースを集約して判定する点です。設定可能なオプションはありません。

ready
// plugin/ready/readiness.go
type Readiness interface {
	Ready() bool
}

(参考: readiness.go#L4-L7)

登録された各プラグインの Ready() を呼び、1つでも false であれば /ready は 503 を返します。

will return 200 OK, when all plugins that are able to signal readiness have done so

(参考: CoreDNS ready plugin)

health が無条件に 200 を返すのに対し、ready は kubernetes プラグインの informer の初回同期が終わっているかなど、実際に名前解決ができる状態かどうかを反映します。

3.4 kubernetes

1.5節・1.6節で見た通り、Service/EndpointSlice/Namespace の情報から DNS レコードを組み立てるプラグインです (参考: CoreDNS kubernetes plugin)。この例での設定は次の通りです。

設定 値 概要
pods insecure Pod への API watch を行わず、クエリ名から IP を機械的に組み立てて応答する軽量なモード
fallthrough in-addr.arpa ip6.arpa PTR レコード (逆引き) を解決できない場合に、次のプラグインへ処理を委ねる
kubernetes cluster.local in-addr.arpa ip6.arpa {
    pods insecure
    fallthrough in-addr.arpa ip6.arpa
}

pods insecure は、Pod の実在を一切確認せず、クエリ名からハイフン区切りの IP アドレスを機械的に組み立てて返すだけのモードです。クラスタ全体の Pod を verified モードで watch すると、Pod 数や churn に比例してメモリ・CPU のコストが増えるため、通常のクラスタ内通信で Pod 単位の名前解決を積極的に使わないのであれば、insecure (または disabled) で十分です。

fallthrough in-addr.arpa ip6.arpa は、PTR レコードのクエリについて、kubernetes プラグインが持つ情報で解決できなかった場合に、エラーを確定させず次のプラグイン (forward) に処理を委ねる設定です。

// plugin/pkg/fall/fall.go
func (f F) Through(qname string) bool {
	return plugin.Zones(f.Zones).Matches(qname) != ""
}

(参考: fall.go#L27-L31)

in-addr.arpa/ip6.arpa はクラスタ全体の逆引きゾーンであり、kubernetes プラグインが把握しているのは自身が管理する Service/Pod の IP だけです。それ以外の IP (ノード自体や外部サービスなど) への逆引きクエリも飛んでくるため、fallthrough を指定しないと、kubernetes プラグインが分からない IP への逆引きがすべて NXDOMAIN で打ち切られてしまいます。

3.5 prometheus

:9153 で Prometheus 形式のメトリクスを公開するだけのプラグインです。Corefile 上のディレクティブ名は prometheus ですが、実装は plugin/metrics というディレクトリにあり、coredns.io 上のページ名も metrics です (参考: CoreDNS metrics plugin)。

設定 値 概要
ADDR :9153 メトリクスのバインドアドレス。ポートのみ指定すると全インターフェースにバインドする
prometheus :9153
// plugin/metrics/setup.go
func init() { plugin.Register("prometheus", setup) }
...
const defaultAddr = "localhost:9153"

(参考: setup.go#L29、setup.go#L142)

デフォルトのバインドアドレスは localhost:9153 ですが、Corefile で :9153 とポートだけを指定すると、全インターフェースにバインドされます。これは Pod 外 (Prometheus など) からスクレイプできるようにするためです。

3.6 forward

2.2節で見た通り、ノードの /etc/resolv.conf をパースして得た上流サーバーへそのままクエリを転送するプラグインです (参考: CoreDNS forward plugin)。

設定 値 概要
FROM . 全ゾーンを対象にする
TO /etc/resolv.conf ノードの resolv.conf を読み込み、そこに書かれた上流サーバーへ転送する
max_concurrent (未設定) 同時並行クエリ数の上限を設けない
forward . /etc/resolv.conf

公式の coredns.yaml.base では max_concurrent 1000 が付与されていますが、この設定例ではあえて外しています。max_concurrent を設定すると、上限を超えた同時並行クエリは待たされずに即座に REFUSED として打ち切られます。一部のアプリケーションの Pod が単位時間あたり非常に多くの DNS クエリを発行する環境では、この上限によって REFUSED が多発するよりも、conntrack テーブルを逼迫させてでも上流への転送を試みさせたい、という判断から上限を外している例があります。これは良し悪しというより、どちらのリスクを許容するかというトレードオフです。

3.7 cache

応答をキャッシュするプラグインです (参考: CoreDNS cache plugin)。cache 30 の 30 は TTL の上限 (キャップ) であり、レコード本来の TTL をこの値より延長するものではありません。この例での設定は次の通りです。

設定 値 概要
TTL 上限 30 成功応答・否定応答ともにキャップする上限秒数
disable denial cluster.local cluster.local 向けの否定応答のキャッシュを無効化する
disable success cluster.local cluster.local 向けの肯定応答のキャッシュを無効化する
prefetch 5 60s 10% アクセス頻度の高いエントリを TTL 失効前にバックグラウンドで先読み更新する
serve_stale 1h verify 上流が応答不能なとき、TTL 切れの古いエントリを最大1時間まで返し続ける
cache 30 {
    disable denial cluster.local
    disable success cluster.local
    prefetch 5 60s 10%
    serve_stale 1h verify
}
// plugin/cache/cache.go
func computeTTL(msgTTL, minTTL, maxTTL time.Duration) time.Duration {
	ttl := min(max(msgTTL, minTTL), maxTTL)
	...
}

(参考: cache.go#L135-L136、デフォルトの上限は成功応答が3600秒・否定応答が1800秒で、cache.go#L358-L363 で定義)

disable denial <zone>/disable success <zone> は、指定したゾーンについてキャッシュそのものを無効化するオプションです。

// plugin/cache/cache.go
case response.NoError, response.Delegation:
	if plugin.Zones(w.pexcept).Matches(m.Question[0].Name) != "" {
		// zone is in exception list, do not cache
		return
	}
	...
case response.NameError, response.NoData, response.ServerError:
	if plugin.Zones(w.nexcept).Matches(m.Question[0].Name) != "" {
		return
	}

(参考: cache.go#L279-L317)

cluster.local に disable denial/disable success を設定している理由は、1.3節・1.4節で見た CoreDNS のプラグイン実行順序に由来します。cache プラグインは kubernetes プラグインより先に実行されるため、本来 kubernetes プラグインが権威を持つはずの cluster.local の応答まで cache プラグインが先にキャッシュしてしまいます。これは、権威を持つゾーンのデータはキャッシュされたデータより常に優先されるべきという RFC 1035 の原則に反する構成です。

authoritative data in zones will always "hide", and hence take precedence over, cached data.

(参考: RFC 1035 Section 6.1.2)

kubeadm がこの設定を追加した際の議論でも、Service の追加・削除や EndpointSlice の更新 (Pod の再作成・再スケジュールなど) の反映が、cache のキャッシュによって本来のタイミングより最大で TTL 分遅れて伝わってしまう点が、実務上の懸念として挙げられています。TTL を 0 にする対処法もありますが、TTL 0 の応答を正しく扱えないクライアントが存在するため、disable denial/disable success によるキャッシュの無効化のほうが望ましいとされています。

(参考: kubernetes/kubernetes#128359: disable caching for authoritative zone to comply with rfc-1035 section 6.1.2)

forward . /etc/resolv.conf は、上流 DNS が一時的に応答不能になると、そのままクラスタ内の名前解決にも影響します。これを緩和するのが prefetch と serve_stale です。

serve_stale [DURATION] [REFRESH_MODE] は、上流が応答不能なときに TTL 切れの古いエントリを返して名前解決を継続させるオプションです。REFRESH_MODE は immediate (デフォルト。先に stale なエントリを返しつつ裏側で更新を試みる) と verify (上流から取得できないことを確認してから stale を返す) の2種類があります。

// plugin/cache/setup.go
case "serve_stale":
	...
	ca.staleUpTo = 1 * time.Hour      // デフォルトDURATION
	...
	ca.verifyStale = false            // デフォルトはimmediate
	...
	ca.verifyStale = mode == "verify"

(参考: setup.go#L173-L210)

prefetch AMOUNT [[DURATION] [PERCENTAGE%]] は、アクセス頻度の高いエントリを TTL 失効前にバックグラウンドで先読み更新するオプションです。デフォルトは無効で、AMOUNT に先読み対象とみなす最低アクセス回数を指定すると有効になります。

// plugin/cache/cache.go
prefetch:   0,                 // 無効 (AMOUNT 指定時のみ有効)
duration:   1 * time.Minute,   // DURATION デフォルト
percentage: 10,                // PERCENTAGE デフォルト

(参考: cache.go#L74-L76)

serve_stale を有効にする場合は、過去に SERVFAIL が肯定キャッシュを上書きしてしまう不具合 (coredns/coredns#8003) が報告されていたため、修正済みのバージョンを使っているか確認しておくとよいと思います。また serve_stale は、上流の IP 変更があった場合に古い IP を stale window の間だけ返し続けるリスクがあるため、immediate よりリスクの低い verify モードを選ぶか、coredns_cache_* のメトリクスと名前解決の失敗率を監視しておくのが良いと思います。

3.8 loop

2.3節で見た通り、自分自身への転送ループを検知してプロセスを終了させる安全装置です。設定可能なオプションはありません。

loop

dnsPolicy: Default の CoreDNS 自身がノードの /etc/resolv.conf を使う構成では、ノード側の DNS 設定次第でこのプラグインが重要な役割を果たします。

3.9 reload

Corefile の変更を検知し、無停止 (DNS 応答を止めずに) でプロセス内の設定を再読込するプラグインです (参考: CoreDNS reload plugin)。この例では値を指定しておらず、デフォルト値がそのまま使われます。

設定 デフォルト値 概要
INTERVAL 30s Corefile の変更をチェックする間隔
JITTER 15s INTERVAL に加えるランダムな揺らぎ
reload
// plugin/reload/setup.go
const (
	minJitter       = 1 * time.Second
	minInterval     = 2 * time.Second
	defaultInterval = 30 * time.Second
	defaultJitter   = 15 * time.Second
)

(参考: setup.go#L85-L90)

Corefile の SHA512 ハッシュを定期的に計算して比較し、差分があれば再起動する実装です。ConfigMap の更新が Pod 内のファイルへ反映されるまでの遅延を考慮しても、通常は数十秒から1分程度で新しい設定に切り替わります。

3.10 loadbalance

複数の A/AAAA/MX レコードがある場合に、応答の順序をランダム化するプラグインです。設定可能なオプションはありません。

loadbalance

loadbalance will act as a round-robin DNS load balancer by randomizing the order of A, AAAA, and MX records

(参考: CoreDNS loadbalance plugin)

クライアント側の多くの実装は、DNS が返した複数レコードのうち先頭のものを優先して使う傾向があります。Headless Service のように複数の Pod IP が A レコードとして返る場合、loadbalance がないと特定の Pod に負荷が偏る可能性があります。

3.11 autopath

ndots:5 によって短い名前のクエリが search ドメインを総当たりする点は1.6節で確認した通りです。autopath は、この search ドメインの展開を CoreDNS 側で肩代わりし、クライアントのクエリ回数を減らすプラグインです。

設定 値 概要
autopath @kubernetes kubernetes プラグインの Pod キャッシュから Namespace を引き、search パスをサーバー側でたどる
pods (kubernetes プラグイン側) verified autopath が動作するために必須となる Pod watch モード
cluster.local {
    autopath @kubernetes
    kubernetes {
        pods verified
    }
}
// plugin/kubernetes/autopath.go
func (k *Kubernetes) AutoPath(state request.Request) []string {
	...
	if !k.opts.initPodCache {
		return nil   // pods verified でなければ機能しない
	}
	ip := state.IP()
	pod := k.podWithIP(ip)
	if pod == nil { return nil }
	...
	search = append(search, pod.Namespace+".svc."+zone, "svc."+zone, zone)

(参考: autopath.go#L11-L59)

autopath @kubernetes は、クライアントの送信元 IP から kubernetes プラグインの Pod キャッシュで Namespace を引き、その Pod の search パスをサーバー側でたどって、最初に NXDOMAIN 以外になった名前への CNAME を1回の応答にまとめて返します。この実装は k.opts.initPodCache (pods verified モードのときだけ真になる) を前提にしており、insecure/disabled モードでは動作しません。

メリットは、1.6節・1.7節で見た search 展開によるクエリ増幅を、クライアント側の設定を変えずに回避できる点です。クライアントは1回のクエリで結果を受け取れます。

デメリットとしては、まず pods verified が前提になるため、1.5節で見た insecure モードと比べて Pod 数や churn に比例したメモリ・CPU のコストが増えます。加えて、README には次のような制約も明記されています。

In Kubernetes, autopath can derive the wrong namespace of a client Pod (and therefore wrong search path) in the following case. ... In the case that a Pod is deleted, and its IP is immediately provisioned to a Pod in another namespace, and that new Pod make a DNS lookup before the API watch can notify CoreDNS of the change, autopath will resolve the IP to the previous Pod's namespace.

In Kubernetes, autopath is not compatible with Pods running from Windows nodes.

If the server side search ultimately results in a negative answer (e.g. NXDOMAIN), then the client will fruitlessly search all paths manually, thus negating the autopath optimization.

(参考: CoreDNS autopath plugin)

autopath はクラスタ側 (CoreDNS) の変更だけで完結する分、デメリットで見た制約やコストをまるごと引き受けることになります。まずは第4章で紹介する、接続先の指定方法や dnsPolicy の見直しといったクライアント側の Tips で search 展開そのものを避けられないか検討し、それでも避けられないクエリが残る場合に autopath の導入を検討するのが良いと思います。

3.12 log

1.6節・2.4節の実地検証で、クエリ内容を確認するために一時的に有効化していたプラグインです。クエリごとに1行のログを標準出力へ出力します (参考: CoreDNS log plugin)。これまでの常用設定例には含めていませんでした。

log

引数なしで指定すると、全ゾーン・全応答クラスが対象になり、デフォルトのログフォーマットが使われます。

// plugin/log/log.go
CommonLogFormat = `{remote}:{port} ` + replacer.EmptyValue + ` {>id} "{type} {class} {name} {proto} {size} {>do} {>bufsize}" {rcode} {>rflags} {rsize} {duration}`
...
DefaultLogFormat = CommonLogFormat

(参考: log.go#L84-L89)

このフォーマットは、1.6節・2.4節で見た実際のログ行とそのまま対応しています。

[INFO] 10.244.0.9:45819 - 52547 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000791553s

{remote}:{port} が送信元、間に挟まる - は値を持たないフィールドの固定プレースホルダ (replacer.EmptyValue) で、{>id} がクエリ ID、"{type} {class} {name} {proto} {size} {>do} {>bufsize}" がクエリ内容 (レコードタイプ・DNS クラス・クエリ名・プロトコル・サイズなど)、{rcode} {>rflags} {rsize} {duration} が応答内容 (ステータスコード・フラグ・応答サイズ・処理時間) にそれぞれ対応します。

class オプションを指定すると、ログ対象を success(正常応答)・denial(NXDOMAIN/NODATA)・error(SERVFAIL など)の組み合わせに絞り込めます。指定しない場合はデフォルトの all (すべての応答) になります。公式ドキュメントにもある通り、クエリ数の多いサーバーで常時有効にすると性能への影響があるため、検証・デバッグ時に一時的に有効化するか、class で対象を絞って使うのが良いと思います。

Note that for busy servers logging will incur a performance hit.

(参考: CoreDNS log plugin)

第4章: Pod からの名前解決を効率化する Tips

ここまでの章で確認した内容を踏まえ、Pod から接続先を指定する際に名前解決のクエリ数を抑えるための Tips をまとめます。

4.1 クラスタ内の別 Namespace の Service へは <service>.<namespace> で接続する

1.6節で見た通り、短い Service 名 (nginx) は同じ Namespace の Service であれば search リストの先頭で1回のクエリで解決できます。一方、別の Namespace の Service を完全な名前 (<service>.<namespace>.svc.<cluster-domain>、末尾ドットなし) で指定すると、1.6節で確認した通り4回のクエリが発生してしまいます。

<service>.<namespace> (ドット数1) の形式で問い合わせると、この中間になります。この節の検証は1.6節とは別のタイミングで同じ構成のクラスタに対して行っているため、ClusterIP の値は1.6節と異なります。

$ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx.default
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN	A	10.96.246.29
[INFO] 10.244.0.9:51581 - 53396 "A IN nginx.default.default.svc.cluster.local. udp 80 false 1232" NXDOMAIN qr,aa,rd 150 0.000385803s
[INFO] 10.244.0.9:35642 - 36072 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000097245s

search リストの先頭 (default.svc.cluster.local) を付加した名前はまず失敗しますが、2番目のエントリ (svc.cluster.local) を付加した名前で一致するため、2回のクエリで完結します。ここまでに確認したクラスタ内の接続先指定とクエリ数を整理すると、次のようになります。

接続先の指定方法 ドット数 クエリ回数
<service> (同じ Namespace) 0 1回
<service>.<namespace> 1 2回
<service>.<namespace>.svc.<cluster-domain> (末尾ドットなし) 4 4回
<service>.<namespace>.svc.<cluster-domain>. (絶対名) - 1回

別 Namespace の Service へ接続する場合、アプリケーションの設定で接続先のドメインを直接指定できるのであれば、完全な名前を末尾ドットなしで指定するよりも、<service>.<namespace> の形式か、1.7節で見た絶対名 (末尾ドット) を使う方が無駄なクエリを抑えられます。

4.2 クラスタ外のドメインへは絶対名で接続する

1.7節・2.4節で見た通り、外部ドメインへの接続先を設定できる場合は、末尾にドットを付けた絶対名 (example.com. のような表記) にしておくと、search リストの展開そのものを避けられます。

4.3 クラスタ外のドメインしか解決しない Pod は dnsPolicy を見直す

1.1節で見た通り、Pod の dnsPolicy のデフォルトは ClusterFirst で、CoreDNS (kube-dns Service) を唯一の nameserver として使います。クラスタ内の Service を名前で解決する必要がなく、外部ドメインしか解決しない Pod であれば、dnsPolicy: Default にしてノードの /etc/resolv.conf に書かれた上流 DNS サーバーを直接使う選択肢もあります。

apiVersion: v1
kind: Pod
metadata:
  name: external-only-pod
spec:
  dnsPolicy: Default
  containers:
    - name: app
      image: example-app

ClusterFirst のままだと、外部ドメインへのクエリは Pod → CoreDNS → forward プラグイン → 上流 DNS サーバーという流れになり、2.1節・2.2節で見た通り CoreDNS 側でもゾーンマッチの判定や forward の処理が挟まります。dnsPolicy: Default にすれば、この CoreDNS を経由する分を省略でき、CoreDNS 側が転送する分の負荷も減らせます。ただし、この Pod はクラスタ内の Service を名前では解決できなくなる点に注意してください。

ClusterFirst のままであれば、外部ドメインへの応答も3.7節で見た cache プラグインによって CoreDNS 側でキャッシュされ、クラスタ内の複数 Pod から同じドメインへのクエリが来ても上流への問い合わせは間引かれます。dnsPolicy: Default にすると、この CoreDNS 側の共有キャッシュを経由しなくなるため、アプリケーション自体が名前解決の結果をキャッシュしない構成だと、各 Pod が TTL を無視して毎回上流 DNS サーバーへ直接問い合わせることになり、むしろ上流側への負荷が増えてしまう場合があります。このオプションを選ぶ際は、アプリケーション (または利用している DNS ライブラリ) 側に名前解決結果をキャッシュする仕組みがあるかどうかも合わせて確認するのが良いと思います。

4.4 クエリ数が多い環境では conntrack テーブルの逼迫にも注意する

ここまでの Tips はいずれも CoreDNS 側の負荷を減らす観点でしたが、ndots:5 によるクエリ増幅は、ノード側の conntrack テーブルを圧迫する形でも現れます。DNS クエリは UDP で送られるため、1回の問い合わせごとに1つの conntrack エントリが (短命ながら) 作られ、テーブルが溢れると DNS に限らず別のトラフィックのパケットも破棄されてしまいます。クエリ数自体が多い環境では、node-exporter などで conntrack テーブルの使用率を監視しておくのが良いと思います (参考: Kubernetes と名前解決)。

おわりに

マネージドサービスで提供されている Kubernetes クラスタを利用している場合は、ここまで知らなくても大丈夫ですが、オンプレミスで構築する場合は、ここで記載している内容に含めて、上流となる Kubernetes のクラスタ外に存在している DNS の構成も含めて理解して設計する必要があるので結構大変です。

また、マネージドサービスを使っている場合でも、性能に対してシビアの要件を持つアプリケーションを実行したい場合は、名前解決の回数を減らしたりするために、ndots:5 の仕組みを理解して対応する必要があります。

この辺は公式ドキュメントだけ見ても理解するのが難しいと思うので、このドキュメントが助けになれば幸いです。

参考

参考リンク

本文で登場する順に並べています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?