[Kubernetes] Zone-Aware DNS (NodeLocalDNS) 概要を調べてみた

# About
Kubernetesにおけるクラスター内DNSのデファクトであるCoreDNSですが、クラスター内通信の最も基本的なコンポーネントであるにも関わらず単一のワークロードであり、リクエストはNode間を跨ぐ必要があるので通信的な負荷とリソース的な負荷によってボトルネックになりうる可能性が高いです。
そう言った問題に対応するために[NodeLocal DNSCache](https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/)というものが公式にリリースされています。

更に、クラウドプロバイダーの一つであるIBM Cloudにおいては、このNodeLocal DNSCacheの機能を利用・拡張し、[Zone-Aware DNS](https://cloud.ibm.com/docs/containers?topic=containers-cluster_dns#dns_zone_aware)という機能を提供しています。
各NodeにデプロイされたDNS Cacheエージェントからクラスター内のZone情報を持つCoreDNSに対する通信もAZに閉じるようにして、パフォーマンスと可用性向上を目的としております。

今回このNodeLocal DNSCacheとZone-Aware DNSについて、どのように機能を実現しているのが調べたのでその内容のメモ

## 環境
```
$ k version
Client Version: version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.12", GitCommit:"b058e1760c79f46a834ba59bd7a3486ecf28237d", GitTreeState:"clean", BuildDate:"2022-07-13T14:59:18Z", GoVersion:"go1.16.15", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"23", GitVersion:"v1.23.12+IKS", GitCommit:"53eb562c6d6b891973e77f4f96ab3569b3630596", GitTreeState:"clean", BuildDate:"2022-09-21T20:06:42Z", GoVersion:"go1.17.13", Compiler:"gc", Platform:"linux/amd64"}
```


# NodeLocal DNSCache
## 概要
公式ページでは、NodeLocal DNSCacheを用いることで以下の利点が得られるとしています。

- Node間通信の発生に伴って発生するLatencyの軽減
- `iptables`でのDNATによる、conntrack tableへエントリが蓄積されることによるリソース逼迫を軽減
- `アプリ`→ `NodeLocal DNSCacheのDNS caching agent`への通信はUDPとしたままで、`DNS caching agent`→`ClusterDNS`への通信をTCPにupgradeし、conntrackのエントリのライフサイクルを効率的（セッション終了に同期して閉じる）に管理し無用に蓄積されないようにする
- 上記の箇所でTCP通信を用いることにより、UDPパケットがドロップされた際のtail latencyを軽減
- NodeレベルでDNSのメトリクスを取得可能にする
- `Negative Cache`を用いることによるさらなる`ClusterDNS`への負荷軽減

また、`ClusterDNS`と同じIPを用いることにより、万が一`NodeLocal DNScache Pod`がダウンした場合も自動的に`ClusterDNS`にfailbackできるという利点もあります。
ではNodeの中では実際にどのようにして、Cluster DNSへの通信を`DNS Caching agent`に転送し、上記のような機能を実現しているのかについてみていきたいと思います。

## 動作内容
まず言わずもがなですが、`NodeLocal DNSCache`はクラスター上の各ノードで稼働するため、`DaemonSet`としてデプロイします。

```
$ k get deploy,ds -o json | jq -r '.items[] | select(.metadata.name | contains("dns")) | {"Kind": .kind, "name":.metadata.name}'
{
  "Kind": "Deployment",
  "name": "coredns"
}
{
  "Kind": "Deployment",
  "name": "coredns-autoscaler"
}
{
  "Kind": "DaemonSet",
  "name": "node-local-dns"
}
```
実行しているプロセスは以下のようになります。こちらの引数で渡している`-localip`に指定したIPをbindします。そのため、クラスタ内や環境内のIPと衝突して想定外の挙動を引き起こさないように[気を付ける](https://github.com/kubernetes/kubernetes/tree/master/cluster/addons/dns/nodelocaldns#nodelocaldns-addon-template)必要があります。

下の例では`169.254.20.10`が`node-local-dns`のデフォルト値、`172.21.0.10`が検証した環境の`ClusterDNS`のIPとなります。

```
$ k get ds -o json | jq -r '.items[] | select(.metadata.name | startswith("node-local-dns")) |.spec.template.spec |  {"nodeSlector":.nodeSelector, "containers":(.containers[] | {"name":.name, "image":.image, "command":.command,"args":.args}), "volumes":.volumes} '
{
  "nodeSlector": {
    "ibm-cloud.kubernetes.io/node-local-dns-enabled": "true"
  },
  "containers": {
    "name": "node-cache",
    "image": "registry.au-syd.bluemix.net/armada-master/k8s-dns-node-cache:1.22.11",
    "command": null,
    "args": [
      "-localip",
      "169.254.20.10,172.21.0.10",
      "-conf",
      "/etc/Corefile",
      "-basecorefile",
      "/etc/coredns/Corefile",
      "-upstreamsvc",
      "node-local-dns"
    ]
  },
  "volumes": [
    {
      "hostPath": {
        "path": "/run/xtables.lock",
        "type": "FileOrCreate"
      },
      "name": "xtables-lock"
    },
    {
      "configMap": {
        "defaultMode": 420,
        "name": "node-local-dns"
      },
      "name": "base-config-volume"
    },
    {
      "configMap": {
        "defaultMode": 420,
        "name": "node-local-dns-config",
        "optional": true
      },
      "name": "kube-dns-config-volume"
    }
  ]
}
```
実際にNodeの中に入り該当のプロセスに関するソケット周りの情報を見ると、`-localip`で指定したIPでbindしていることが分かります。

```
/# ps -ef | grep node-cache | grep -v grep
root      14249  14053  0 Sep27 ?        00:40:08 /node-cache -localip 169.254.20.10,172.21.0.10 -conf /etc/Corefile -basecorefile /etc/coredns/Corefile -upstreamsvc coredns
/# ss -lun4p | grep 14249
UNCONN   0         0                172.21.0.10:53              0.0.0.0:*        users:(("node-cache",pid=14249,fd=12))
UNCONN   0         0              169.254.20.10:53              0.0.0.0:*        users:(("node-cache",pid=14249,fd=10))
/# lsof -n -i @172.21.0.10:53 -i @169.254.20.10:53
COMMAND     PID USER   FD   TYPE    DEVICE SIZE/OFF NODE NAME
node-cach 14249 root    9u  IPv4 354886644      0t0  TCP 169.254.20.10:domain (LISTEN)
node-cach 14249 root   10u  IPv4 354886645      0t0  UDP 169.254.20.10:domain
node-cach 14249 root   11u  IPv4 354886646      0t0  TCP 172.21.0.10:domain (LISTEN)
node-cach 14249 root   12u  IPv4 354886647      0t0  UDP 172.21.0.10:domain
/#
```

これらのIPに対応するI/Fを作成し、自分自身からこれらのIPに対して発生する通信はlocal table上で自分自身にルーティングするようにしております。

```
/# ip a show dev nodelocaldns
2720: nodelocaldns: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default
    link/ether 02:bd:e2:79:c8:5c brd ff:ff:ff:ff:ff:ff
    inet 169.254.20.10/32 scope global nodelocaldns
       valid_lft forever preferred_lft forever
    inet 172.21.0.10/32 scope global nodelocaldns
       valid_lft forever preferred_lft forever
/# ip r show table local dev nodelocaldns
local 169.254.20.10 proto kernel scope host src 169.254.20.10
local 172.21.0.10 proto kernel scope host src 172.21.0.10
/#
```
また、これらのパケットを受け取るNetfilterでは以下のようなINPUT/OUTPUT chainが構成され、nodelocaldns I/Fに対するトラフィックの`conntrack`の無効化（conntrackエントリの削減と、NATの無効化）、およびACCEPTが行われます。

netfilter設定内容コンセプト：
https://github.com/kubernetes/enhancements/blob/master/keps/sig-network/1024-nodelocal-cache-dns/README.md#iptables-notrack
ルール本体：
https://github.com/kubernetes/dns/blob/master/cmd/node-cache/app/cache_app.go

```
# for t in $(cat /proc/net/ip_tables_names );do echo "TABLE: $t";iptables -n -t $t -L | awk 'BEGIN{p=0;s=""}{if($0~/^$/){if(p==1)print s;s="";p=0}s=s$0"\n";if($0~/udp.*169\.254\.20\.10/)p=1}';done
TABLE: security
TABLE: raw
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
cali-PREROUTING  all  --  0.0.0.0/0            0.0.0.0/0            /* cali:6gwbT8clXdHdC1b1 */
CT         udp  --  0.0.0.0/0            172.21.0.10          udp dpt:53 NOTRACK
CT         tcp  --  0.0.0.0/0            172.21.0.10          tcp dpt:53 NOTRACK
CT         udp  --  0.0.0.0/0            169.254.20.10        udp dpt:53 NOTRACK
CT         tcp  --  0.0.0.0/0            169.254.20.10        tcp dpt:53 NOTRACK


Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
cali-OUTPUT  all  --  0.0.0.0/0            0.0.0.0/0            /* cali:tVnHkvAo15HuiPy0 */
CT         tcp  --  172.21.0.10          0.0.0.0/0            tcp spt:8080 NOTRACK
CT         tcp  --  0.0.0.0/0            172.21.0.10          tcp dpt:8080 NOTRACK
CT         udp  --  0.0.0.0/0            172.21.0.10          udp dpt:53 NOTRACK
CT         tcp  --  0.0.0.0/0            172.21.0.10          tcp dpt:53 NOTRACK
CT         udp  --  172.21.0.10          0.0.0.0/0            udp spt:53 NOTRACK
CT         tcp  --  172.21.0.10          0.0.0.0/0            tcp spt:53 NOTRACK
CT         tcp  --  169.254.20.10        0.0.0.0/0            tcp spt:8080 NOTRACK
CT         tcp  --  0.0.0.0/0            169.254.20.10        tcp dpt:8080 NOTRACK
CT         udp  --  0.0.0.0/0            169.254.20.10        udp dpt:53 NOTRACK
CT         tcp  --  0.0.0.0/0            169.254.20.10        tcp dpt:53 NOTRACK
CT         udp  --  169.254.20.10        0.0.0.0/0            udp spt:53 NOTRACK
CT         tcp  --  169.254.20.10        0.0.0.0/0            tcp spt:53 NOTRACK

TABLE: nat
TABLE: mangle
TABLE: filter
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
cali-INPUT  all  --  0.0.0.0/0            0.0.0.0/0            /* cali:Cz_u1IQiXIMmKD4c */
ACCEPT     udp  --  0.0.0.0/0            172.21.0.10          udp dpt:53
ACCEPT     tcp  --  0.0.0.0/0            172.21.0.10          tcp dpt:53
ACCEPT     udp  --  0.0.0.0/0            169.254.20.10        udp dpt:53
ACCEPT     tcp  --  0.0.0.0/0            169.254.20.10        tcp dpt:53
KUBE-NODEPORTS  all  --  0.0.0.0/0            0.0.0.0/0            /* kubernetes health check service ports */
KUBE-EXTERNAL-SERVICES  all  --  0.0.0.0/0            0.0.0.0/0            ctstate NEW /* kubernetes externally-visible service portals */
KUBE-FIREWALL  all  --  0.0.0.0/0            0.0.0.0/0
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0
DROP       all  --  127.0.0.0/8          0.0.0.0/0


Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
cali-OUTPUT  all  --  0.0.0.0/0            0.0.0.0/0            /* cali:tVnHkvAo15HuiPy0 */
ACCEPT     udp  --  172.21.0.10          0.0.0.0/0            udp spt:53
ACCEPT     tcp  --  172.21.0.10          0.0.0.0/0            tcp spt:53
ACCEPT     udp  --  169.254.20.10        0.0.0.0/0            udp spt:53
ACCEPT     tcp  --  169.254.20.10        0.0.0.0/0            tcp spt:53
KUBE-SERVICES  all  --  0.0.0.0/0            0.0.0.0/0            ctstate NEW /* kubernetes service portals */
KUBE-FIREWALL  all  --  0.0.0.0/0            0.0.0.0/0
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0
```

上にリンクを張ったドキュメントで紹介されているようにnetfilterの`raw`および`filter` hookにおいて関連するルール（`53: DNS`, `8080: healthcheck`）が追加されております。
`node-local-cache`ではこれらのルールをcyclicにチェック・復元を行っております。
パケットの流れ（CNIに`Calico`、Service typeに`iptables`を使った構成の場合）を図で示すと以下のようになります。

```
     ____________     ____________     ____________     ____________  
    | raw        |   | mangle     |   | nat        |   | filter     |  
    |  OUTPUT    |   |  OUTPUT    |   |  OUTPUT    |   |  OUTPUT    |  
Pod-|---NOTRACK--|->-|---ACCEPT---|->-|------------|->-|---ACCEPT---|->-
    |____________|   |____________|   |____________|   |____________|  
     ____________     ____________     ____________ 
    | raw        |   | mangle     |   | nat        |
    |  PREROUTING|   |  PREROUTING|   |  PREROUTING|
----|---NOTRACK--|->-|---ACCEPT---|->-|------------|->---------------
    |____________|   |____________|   |____________|  
                   ____________     ____________     ____________ 
                  | mangle     |   | nat        |   | filter     |
                  |  INPUT     |   |  INPUT     |   |  INPUT     |
------------------|---ACCEPT---|->-|------------|->-|---ACCEPT---|->-NodeLocalDNS
                  |____________|   |____________|   |____________|
```
適用される各hookとdefault Chainのみ記載しています(`security hook`は完全に使われていないので割愛します)

文章で示すと
1. `raw hook`の`OUTPUT Chain` において`NOTRACK`が設定される。（そのため、その後の通信は自ホスト内であるにも関わらずI/F間の通信が `FORWARD Chain`ではなく `INPUT Chain`に送られるようになっている。）
1. （上記の理由から）`raw hook` の`PREROUTING Chain`に到着し、DNS機能宛の通信(53番ポート)はここでも`NOTRACK`が設定される（これによりconntrackの無効化。`nat hook`はnetfilterの定義より、conntrack上のステータスがNEWのものしか適用されないため、`nat hook`の `PREROUTING Chain`(Serviceで定義されるClusterIPをDNATする)、`INPUT Chain`が適用外となる）
1. `filter hook`において、`INPUT Chain`でACCEPTされる(*1)

(*1) `Cluster Net I/F` (そのWorker Node上で稼働するPod用に割り当てられたI/F）の場合はデフォルトで存在する`cali-INPUT Chain`、hostNetの場合は`node-local-dns`によって追加されたルールによって`ACCEPT`されます。


本当は各Chainの中で細かく`kube-proxy`や`calico`が作り出したルールを通過しますが、ルールを細かく追った結果を記載すると読みにくくなるのでこれも割愛。

## 動作内容まとめ

ここまでで、`NodeLocalDNS`とは
- `DaemonSet`で各Worker Node上で稼働する
- 上記のプロセスが`localhost`用のダミーデバイスとルーティングテーブルを作成する。
- 同様に`netfilter`ルールを作成し、ローカルプロセスからの受信とリソースの無駄な消費を避ける。
- Worker Node上で`DNS Caching agent`（例えば`CoreDNS`）プロセスを稼働させ、上で作成したデバイスが持つアドレスで`bind`させる。

のように稼働し、`bind`させるIPを`Cluster DNS`とすることで、Podやクラスターの設定に**手を加えることなく**（ここが肝心）`NodeLocalDNS`を通常系で用いることができ、かつ`NodeLocalDNS`のプロセスが落ちてしまったときは終了処理（SIGTERMで呼ばれる`NodeLocalDNS`内の`TeardownNetworking()`関数）によってルーティングルールが削除され、元々の`Cluster DNS`にfallbackされるという設計になっております。



# Zone-Aware DNS
## 概要

IBM Cloud Kubernetes Service(IKS)の環境では、上記で紹介した`NodeLocalDNS`の機能を少し拡張し、DNSへのリクエストが一つのAZで完結する[Zone-Aware DNS](https://cloud.ibm.com/docs/containers?topic=containers-cluster_dns#dns_zone_aware)が提供されております。
この仕組みにより、

- Zoneを跨いだ通信を発生させないことによるパフォーマンスの向上
- Zone間通信障害の影響を最小限にする構成をとれる
- 元から存在する`CoreDNS`に追加して稼働するため、冗長性が増す

といった利点が考えられます。
クラウドの`Private DNS`系のサービスを用いている場合、可用性やパフォーマンスに関わる部分でもあるのでAZを意識したロードバランシング（クライアントのAZに応じて返却するレコードを変える）といったニーズがあるかもしれません。
そのような場合、
- `NodeLocalDNS`への特定のZoneへのクエリを直接外部のDNSに転送する
- `NodeLocalDNS`がデフォルトで転送するCoreDNSを各AZに配置し、全てのクエリを同じAZ内のCoreDNSに転送する

といったいずれかのアプローチが考えられますが、`Zone-Aware DNS`は結果的に後者の形式で、DNSクライアントの問い合わせ元AZを限定することになります。


## 実装

実装というほどでもありませんが、`IBM Cloud Kubernetes Service(IKS)`において`Zone-Aware DNS`を有効化させると、以下の通り各Zone用に設定された`CoreDNS`と`NodeLocalDNS`が立ち上がり、元の`NodeLocalDNS`は0にスケールされます。

```
$ k get deploy,ds -o json | jq -r '.items[] | select(.metadata.name | contains("dns")) | {"Kind": .kind, "name":.metadata.name,"replicas(deploy)":.status.replicas, "replicas(ds)":.status.numberReady}'
{
  "Kind": "Deployment",
  "name": "coredns",
  "replicas(deploy)": 3,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-autoscaler",
  "replicas(deploy)": 1,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-autoscaler-jp-tok-1",
  "replicas(deploy)": 1,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-autoscaler-jp-tok-2",
  "replicas(deploy)": 1,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-autoscaler-jp-tok-3",
  "replicas(deploy)": 1,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-jp-tok-1",
  "replicas(deploy)": 3,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-jp-tok-2",
  "replicas(deploy)": 3,
  "replicas(ds)": null
}
{
  "Kind": "Deployment",
  "name": "coredns-jp-tok-3",
  "replicas(deploy)": 3,
  "replicas(ds)": null
}
{
  "Kind": "DaemonSet",
  "name": "node-local-dns",
  "replicas(deploy)": null,
  "replicas(ds)": 0
}
{
  "Kind": "DaemonSet",
  "name": "node-local-dns-jp-tok-1",
  "replicas(deploy)": null,
  "replicas(ds)": 2
}
{
  "Kind": "DaemonSet",
  "name": "node-local-dns-jp-tok-2",
  "replicas(deploy)": null,
  "replicas(ds)": 2
}
{
  "Kind": "DaemonSet",
  "name": "node-local-dns-jp-tok-3",
  "replicas(deploy)": null,
  "replicas(ds)": 2
}
$
```

そして、それぞれのAZにデプロイされた`NodeLocalDNS`は、それぞれのAZにいる`CoreDNS`にクエリを転送するようになっております。

```
$ k get ds -o json | jq -r '.items[] | select(.metadata.name | startswith("node-local-dns-")) |.spec.template.spec |  {"nodeSlector":.nodeSelector, "containers":(.containers[] | {"name":.name, "command":.command,"args":.args})} '
{
  "nodeSlector": {
    "ibm-cloud.kubernetes.io/zone-aware-dns-enabled": "true",
    "topology.kubernetes.io/zone": "jp-tok-1"
  },
  "containers": {
    "name": "node-cache",
    "command": null,
    "args": [
      "-localip",
      "169.254.20.10,172.21.0.10",
      "-conf",
      "/etc/Corefile",
      "-basecorefile",
      "/etc/coredns/Corefile",
      "-upstreamsvc",
      "coredns-jp-tok-1"
    ]
  }
}
{
  "nodeSlector": {
    "ibm-cloud.kubernetes.io/zone-aware-dns-enabled": "true",
    "topology.kubernetes.io/zone": "jp-tok-2"
  },
  "containers": {
    "name": "node-cache",
    "command": null,
    "args": [
      "-localip",
      "169.254.20.10,172.21.0.10",
      "-conf",
      "/etc/Corefile",
      "-basecorefile",
      "/etc/coredns/Corefile",
      "-upstreamsvc",
      "coredns-jp-tok-2"
    ]
  }
}
{
  "nodeSlector": {
    "ibm-cloud.kubernetes.io/zone-aware-dns-enabled": "true",
    "topology.kubernetes.io/zone": "jp-tok-3"
  },
  "containers": {
    "name": "node-cache",
    "command": null,
    "args": [
      "-localip",
      "169.254.20.10,172.21.0.10",
      "-conf",
      "/etc/Corefile",
      "-basecorefile",
      "/etc/coredns/Corefile",
      "-upstreamsvc",
      "coredns-jp-tok-3"
    ]
  }
}
```

上記において`-upstreamsvc`で指定した`Service`名から対応する環境変数（各Podの `.spec.enableServiceLinks: <bool>`によってPod内に自動的に定義される）より`ClusterIP`を取得し、`baseCoreFile`を書き換えて使用するようにしています。

これにより、各AZ特有の`CoreDNS`および`NodeLocalDNS`が稼働し、クエリが通常系では同一Zone内で完結するようになっております。

# まとめ
少しだけソースの方に触れつつ、主に`NodeLocalDNS`が稼働するとHost Nodeにどのような影響を及ぼすのか、どういう目的で使われるのかという観点でまとめてみました。
色々工夫されているなぁと思いつつ、結局結論としては「クラスター全体のパフォーマンス・可用性・冗長性を向上させたいなら`NodeLocalDNS`を導入せよ」というだけでした。