ぽよメモ

レガシーシステム考古学専攻

Cilium 1.16.5への更新でnode-local-dnsが壊れた話

何が起きた?

私物の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のドキュメントにも載っています。

kubernetes.io

しかしこれを適用するだけでは配られる 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

しばらく待つと構築が完了するので、 .envrcKUBECONFIG の設定を書いてここで使う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マスカレードの仕組みのどこかでおかしくなっているのではと考えました。

docs.cilium.io

In addition, if the masqLinkLocal is not set or set to false, then 169.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を再起動したところ接続出来るようになりました。

github.com

メンテナの見解では、これは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サービスにルーティングするという構成が想定されているようです。

docs.cilium.io

自分のクラスタはこの構成に移行することにし、kubesprayのaddonとしてのnode-local-dnsは無効にしました。

まとめ

stagingとかdevみたいなクラスタは用意しないポリシーだったんですが、やっぱりあった方が良いかもと言う気持ちになりました。

通常のKubernetesでのデファクトな構成と、自分が利用しているコンポーネントの想定する構成が一致するとは限りません。ちゃんとドキュメントは確認しましょう。


*1:ArgoCDのhelm integrationを使っていたため、手元には生のマニフェストが無い状態でした。脱helm integrationの決意を固くしました。

*2:応答が返ってこなくなる(no route hostになったりconnection refusedになったりする)ことが一度だけあったのですが、VMを作り直したら直ったので深く追求していません