何が起きた?
私物のKubernetesクラスタ(kubesprayで構築)のCiliumを1.15.11から1.16.5へ更新したところ、クラスタ内の名前解決が一斉にタイムアウトするようになりました。
CoreDNSサービス自体は正常に応答を返しているようでした。デバッグした結果、各ノードのkubeletが配る resolv.conf
にはnode-local-dnsがリッスンしているリンクローカルアドレスが書かれており、このアドレスへの接続に問題が発生したことが分かりました。
悲しいことにこれによってArgoCDが機能不能になり、Ciliumの簡単なロールバックはできない状態になったためどうにか現状で復旧するしかありませんでした*1。最終的にnode-local-dnsを無効にしてノードを順番にdrain→uncordonして復旧しました。
再現手順
node-local-dns自体のマニフェストはkubernetesのドキュメントにも載っています。
しかしこれを適用するだけでは配られる resolv.conf
にリンクローカルアドレスが記載される状態にはなりません。kubesprayでnode-local-dnsのアドオンを有効化するとこの状態を作るようになります。
このような構成は上記の公式ドキュメントでも推奨されているものであり、特段に不審な構成ではありません。
NodeLocal DNSキャッシュのローカルリッスン用のIPアドレスは、クラスター内の既存のIPと衝突しないことが保証できるものであれば、どのようなアドレスでもかまいません。例えば、IPv4のリンクローカル範囲169.254.0.0/16やIPv6のユニークローカルアドレス範囲fd00::/8から、ローカルスコープのアドレスを使用することが推奨されています。
1. VMを作る
kubesprayでkubernetesクラスタを構築するため、VMを用意します。ここではmultipassを使っていますが、何でも良いです。
OSはUbuntu 24.04を問題になったクラスタで利用していたため、ここでも採用しました。他のOSの挙動は検証していませんが、おそらく同じだと思います。
GITHUB_USER=pddg # cloud-initのconfigを記述する cat << EOF > cloud-config.yaml allow_public_ssh_keys: true ssh_import_id: ["gh:${GITHUB_USER}"] EOF # VMを起動する multipass launch \ --name k8s-master \ --memory 2G \ --disk 30G \ --cloud-init cloud-config.yaml \ 24.04 multipass launch \ --name k8s-worker \ --memory 2G \ --disk 30G \ --cloud-init cloud-config.yaml \ 24.04
2. kubesprayでクラスタを構築する
作成したクラスタとホストOSは普通にIPアドレスで通信できるようになっているので、これを元にkubesprayで使うためのAnsible Inventoryファイルを用意します。ユーザが ubuntu
であることに注意してください(一敗)。
mkdir -p inventory/multipass MASTER_IPADDR=$(multipass info --format json| jq -r '.info | to_entries[] | select(.key == "k8s-master") | .value.ipv4[]') WORKER_IPADDR=$(multipass info --format json| jq -r '.info | to_entries[] | select(.key == "k8s-worker") | .value.ipv4[]') cat << EOF > inventory/multipass/hosts.yaml all: hosts: k8s-master: ansible_user: ubuntu ansible_host: ${MASTER_IPADDR} ip: ${MASTER_IPADDR} access_ip: ${MASTER_IPADDR} k8s-worker: ansible_user: ubuntu ansible_host: ${WORKER_IPADDR} ip: ${WORKER_IPADDR} access_ip: ${WORKER_IPADDR} children: kube_control_plane: hosts: k8s-master: kube_node: hosts: k8s-master: k8s-worker: etcd: hosts: k8s-master: k8s_cluster: children: kube_control_plane: kube_node: EOF
このファイルを使って対象のホストがアクセス可能であることを確かめます。ここでは簡単のためdockerコンテナを利用しています。
# pingが成功することを確認する docker run --rm -it \ --mount type=tmpfs,dst=/.ansible \ --mount type=bind,source=$(pwd)/inventory,dst=/kubeproxy/inventory \ --mount type=bind,source=${HOME}/.ssh/id_ed25519,dst=/root/.ssh/id_rsa \ quay.io/kubespray/kubespray:v2.26.0 \ ansible -i /kubeproxy/inventory/multipass/hosts.yaml -m ping all
うまくいけば各ホストからpongが返ってくるはずです*2。
❯ docker run --rm \ --mount type=tmpfs,dst=/.ansible \ --mount type=bind,source=$(pwd)/inventory,dst=/kubeproxy/inventory \ --mount type=bind,source=${HOME}/.ssh/id_ed25519,dst=/root/.ssh/id_rsa \ quay.io/kubespray/kubespray:v2.26.0 \ ansible -i /kubeproxy/inventory/multipass/hosts.yaml -m ping --private-key /root/.ssh/id_rsa all WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested [WARNING]: Skipping callback plugin 'ara_default', unable to load k8s-worker | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3" }, "changed": false, "ping": "pong" } k8s-master | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3" }, "changed": false, "ping": "pong" }
kubesprayの設定ファイルを書き、クラスタを構築します。
mkdir -p inventory/multipass/group_vars cat << EOF > inventory/multipass/group_vars/all.yaml --- kube_version: v1.30.4 container_manager: containerd # 後からciliumを自分で入れる kube_network_plugin: cni # kube-proxyは削除しておく kube_proxy_remove: true # 構築したクラスタへアクセスするためのKUBECONFIGをローカルに配置する kubeconfig_localhost: true dns_mode: coredns # node-local-dnsを有効化 enable_nodelocaldns: true # node-local-dnsがリッスンするアドレス(デフォルト) nodelocaldns_ip: 169.254.25.10 EOF docker run --rm \ --mount type=tmpfs,dst=/.ansible \ --mount type=bind,source=$(pwd)/inventory,dst=/kubeproxy/inventory \ --mount type=bind,source=${HOME}/.ssh/id_ed25519,dst=/root/.ssh/id_rsa \ quay.io/kubespray/kubespray:v2.26.0 \ ansible-playbook -i /kubeproxy/inventory/multipass/hosts.yaml --become cluster.yml
しばらく待つと構築が完了するので、 .envrc
に KUBECONFIG
の設定を書いてここで使うkubernetesクラスタの情報を設定します。
cat << 'EOF' > .envrc export KUBECONFIG=$KUBECONFIG:inventory/multipass/artifacts/admin.conf EOF direnv allow .
いくつかのpodはpendingですが、Kubernetesクラスタが構築出来たことを確認します。
❯ kubectl get po -n kube-system NAME READY STATUS RESTARTS AGE coredns-776bb9db5d-jsnjw 0/1 Pending 0 2m36s dns-autoscaler-6ffb84bd6-9xt7x 0/1 Pending 0 2m34s kube-apiserver-k8s-master 1/1 Running 0 3m46s kube-controller-manager-k8s-master 1/1 Running 1 3m45s kube-scheduler-k8s-master 1/1 Running 1 3m45s nginx-proxy-k8s-worker 1/1 Running 0 2m49s nodelocaldns-75h97 1/1 Running 0 2m33s nodelocaldns-jhpmt 1/1 Running 0 2m33s
3. helmでCilium 1.16.4をインストール
まずはCIlium 1.16.4をインストールします。実際に壊れた環境では1.15.12→1.16.5への更新で壊れたのですが、再現環境を作って試していると1.16.4まではこの構成でも普通に動作していました。
helm repo add cilium https://helm.cilium.io/ cat << EOF > cilium-values.yaml # kube-proxyがない環境でmasterにアクセスできる必要がある k8sServiceHost: ${MASTER_IPADDR} k8sServicePort: 6443 # kube-proxyレスモードでインストールする kubeProxyReplacement: true ipam: mode: kubernetes securityContext: privileged: true # この設定を入れると再現する bpf: masquerade: true EOF helm install cilium cilium/cilium \ --version 1.16.4 \ --values cilium-values.yaml \ --namespace kube-system # cilium agentがreadyになるまで待つ kubectl wait --timeout=90s --for=condition=Ready -n kube-system \ pods -l k8s-app=cilium # ciliumインストール前から存在し、hostNetwork以外で動くpodをdeleteして作り直す kubectl get pods \ --all-namespaces \ -o custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name,HOSTNETWORK:.spec.hostNetwork \ --no-headers=true \ | grep '<none>' \ | awk '{print "-n "$1" "$2}' \ | xargs -L 1 -r kubectl delete pod
4. 名前解決出来ることを確認する
公式ドキュメントの以下で紹介されているイメージを使うことにします。 kubernetes.io
cat << EOF > dnsutil.yaml apiVersion: v1 kind: Pod metadata: name: dnsutils namespace: default spec: containers: - name: dnsutils image: registry.k8s.io/e2e-test-images/agnhost:2.39 imagePullPolicy: IfNotPresent restartPolicy: Always EOF kubectl apply -f dnsutil.yaml # PodがReadyになるまで待つ kubectl wait --timeout=90s --for=condition=Ready pods/dnsutils
1.16.4ではこの状態で名前解決に成功します。
❯ kubectl exec -i -t dnsutils -- nslookup kubernetes.default Server: 169.254.25.10 Address: 169.254.25.10#53 Name: kubernetes.default.svc.cluster.local Address: 10.233.0.1
resolv.confにはnode-local-dnsのリッスンするリンクローカルアドレスが書かれています。
❯ kubectl exec -i -t dnsutils -- cat /etc/resolv.conf search default.svc.cluster.local svc.cluster.local cluster.local flets-east.jp iptvf.jp nameserver 169.254.25.10 options ndots:5
各kubernetesノードには以下の様なデバイスが生えるようです。
❯ ssh ubuntu@$WORKER_IPADDR ip a show dev nodelocaldns 3: nodelocaldns: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default link/ether 6e:a5:52:ac:8a:b2 brd ff:ff:ff:ff:ff:ff inet 169.254.25.10/32 scope global nodelocaldns valid_lft forever preferred_lft forever
hubbleを使って通信を観測してみます。
# 対象のpodが乗っているノードのcilium-agentを出力する便利スクリプト wget -O k8s-get-cilium-pod.sh \ https://raw.githubusercontent.com/cilium/cilium/refs/tags/v1.16.4/contrib/k8s/k8s-get-cilium-pod.sh chmod +x k8s-get-cilium-pod.sh kubectl exec -ti pod/$(./k8s-get-cilium-pod.sh dnsutils default) -n kube-system -c cilium-agent \ -- hubble observe --since 1m --pod default/dnsutils
手元で実行した記録
❯ kubectl exec -ti pod/$(./k8s-get-cilium-pod.sh dnsutils default) -n kube-system -c cilium-agent \ -- hubble observe --since 1m --pod default/dnsutils Dec 22 07:51:41.852: 127.0.0.1:39065 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP) Dec 22 07:51:41.852: default/dnsutils:50642 (ID:32060) -> 169.254.25.10:53 (world) to-stack FORWARDED (UDP) Dec 22 07:51:41.853: default/dnsutils:50642 (ID:32060) <- 169.254.25.10:53 (world) to-endpoint FORWARDED (UDP) Dec 22 07:51:41.853: 169.254.25.10:53 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP) Dec 22 07:51:41.853: default/dnsutils:60221 (ID:32060) -> 169.254.25.10:53 (world) to-stack FORWARDED (UDP) Dec 22 07:51:41.853: default/dnsutils:60221 (ID:32060) <- 169.254.25.10:53 (world) to-endpoint FORWARDED (UDP) Dec 22 07:51:41.853: 169.254.25.10:53 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP) Dec 22 07:51:41.855: default/dnsutils:51283 (ID:32060) -> 169.254.25.10:53 (world) to-stack FORWARDED (UDP) Dec 22 07:51:41.856: default/dnsutils:51283 (ID:32060) <- 169.254.25.10:53 (world) to-endpoint FORWARDED (UDP) Dec 22 07:51:41.856: 169.254.25.10:53 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP)
5. Cilium 1.16.5に更新する
helm upgrade cilium cilium/cilium \ --version 1.16.5 \ --namespace kube-system \ --values cilium-values.yaml # cilium agentがreadyになるまで待つ kubectl wait --timeout=90s --for=condition=Ready -n kube-system \ pods -l k8s-app=cilium
dnsutils Podでのnslookupがタイムアウトするようになります。
❯ kubectl exec -i -t dnsutils -- nslookup kubernetes.default ;; connection timed out; no servers could be reached command terminated with exit code 1
corednsのClusterIPを指定すると通るので、node-local-dnsとの通信だけが途絶えている事が分かります。
❯ kubectl get svc -n kube-system NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE cilium-envoy ClusterIP None <none> 9964/TCP 6m56s coredns ClusterIP 10.233.0.3 <none> 53/UDP,53/TCP,9153/TCP 56m hubble-peer ClusterIP 10.233.15.182 <none> 443/TCP 6m56s ❯ kubectl exec -i -t dnsutils -- nslookup kubernetes.default 10.233.0.3 Server: 10.233.0.3 Address: 10.233.0.3#53 Name: kubernetes.default.svc.cluster.local Address: 10.233.0.1
hubble observeすると虚空に消えて返って来ないようです。
❯ kubectl exec -ti pod/$(./k8s-get-cilium-pod.sh dnsutils default) -n kube-system -c cilium-agent \ -- hubble observe --since 1m --pod default/dnsutils Dec 22 07:45:18.830: 127.0.0.1:45439 (world) <> default/dnsutils (ID:32060) pre-xlate-rev TRACED (UDP) Dec 22 07:45:18.830: default/dnsutils:55914 (ID:32060) -> 169.254.25.10:53 (world) to-network FORWARDED (UDP) Dec 22 07:45:28.849: default/dnsutils:55914 (ID:32060) -> 169.254.25.10:53 (world) to-network FORWARDED (UDP)
試したこと
masqLinkLocal: false
bpf.masquerade: true
にすると問題が発生することから、IPマスカレードの仕組みのどこかでおかしくなっているのではと考えました。
In addition, if the
masqLinkLocal
is not set or set to false, then169.254.0.0/16
is appended to the non-masquerade CIDRs list.
node-local-dnsのリンクローカルアドレスは169.254.25.10なので、なにかの設定変更でIPマスカレードの対象になりおかしくなったのかもしれません。 下記の設定を追加してみましたが効果はありませんでした。
ipMasqAgent: enabled: true masqLinkLocal: false
hostLegacyRouting: true
以下のissueは起きた現象としては似ているなと思ったので参考にし、この設定を入れてcilium agentを再起動したところ接続出来るようになりました。
メンテナの見解では、これはBPF Host Routingが正しく動作するようになった結果なので、このオプションを有効化するのが正しいとのこと(できればChangelogに書いて欲しかったなぁ)。
https://github.com/cilium/cilium/issues/36761#issuecomment-2559123347
Ciliumにおける正しい(?)node-local-dnsのアーキテクチャ
node-local-dnsと言えばhost networkで動作させるものだと思っていました。しかしCiliumではpod networkで動作させ、 CiliumLocalRedirectPolicy
リソースでCoreDNS Cluster IPあての通信をnode-local-dnsサービスにルーティングするという構成が想定されているようです。
自分のクラスタはこの構成に移行することにし、kubesprayのaddonとしてのnode-local-dnsは無効にしました。
まとめ
stagingとかdevみたいなクラスタは用意しないポリシーだったんですが、やっぱりあった方が良いかもと言う気持ちになりました。
通常のKubernetesでのデファクトな構成と、自分が利用しているコンポーネントの想定する構成が一致するとは限りません。ちゃんとドキュメントは確認しましょう。