DNS 工作原理

在有跨节点网络(CNI)和应用多副本负载均衡(Service)后,一个应用调用另一个应用的时候肯定不能使用 IP(Service IP),就像我们手机和电脑访问网站一样,使用的都是域名,不在乎域名解析的 IP 的变化。

  • coredns 的 Service IP 部署 K8S 阶段就是写死的,例如 10.96.0.10
  • kubelet 配置 clusterDNS: [10.96.0.10],Pod 默认 DNS 策略下,kubelet 调用容器运行时(docker、containerd)创建容器的时候会附带 dns server 指定为 10.96.0.10
  • Pod 启动后,访问 test-web 向 /etc/resolv.conf 里的 nameserver 10.96.0.10 发起 DNS 解析
  • coredns 使用 RBAC role 从 K8S api 获取和缓存所有的 Service 名字和 Service IP,并响应访问到自己的 DNS 查询。
  • Pod 得到 ServiceIP 后,向 ServiceIP 发请求,从 Client 进程的角度看,和没有容器的时代一样,上图一样可以用在正常非容器环境的 DNS 解析工作流程。
  • 由于 K8S 集群的 overlay 网络,以及本机上的 DNAT 存在,所以集群内任何 node 上,不仅限于 Pod 内都可以发 DNS 查询,也可以 K8S node 上使用 dig 命令指定 dns server 来排查或者解析集群 Service 域名。

DNS options

K8S 有 namespace 隔离,而为了不影响容器解析,使用了一些 option 来实现,实际上容器内 /etc/resolv.conf 内容:

nameserver 10.96.0.10
search default.svc.cluster.local. svc.cluster.local. cluster.local.
options ndots:5

search 与 ndots

search 用于指定搜索域,也就是不是 FQDN(完全查询域名,结尾不带.)下,会依次尝试把这个未完整的域名与搜索列表内的每个域名后缀拼接起来,然后再执行 DNS 解析,直到找到匹配的域名。 而 ndots(no dot,没有点)是限定域名中包含的点(.)少于 ndots 时候,才使用 search 列表。

拿 kubernetes 这个自带的 service 测试,假如 default namespace 下有个 pod 要获取它的 Service IP:

$ kubectl -n default exec xxxx -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local. svc.cluster.local. cluster.local.
options ndots:5
$ kubectl -n default exec xxxx -- curl -k https://kubernetes
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "Unauthorized",
  "reason": "Unauthorized",
  "code": 401
}
$ kubectl exec -ti nginx -- curl -k https://kubernetes.
curl: (6) Could not resolve host: kubernetes.
command terminated with exit code 6
$ kubectl exec -ti nginx -- curl -k https://kubernetes.default.svc.cluster.local.
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "Unauthorized",
  "reason": "Unauthorized",
  "code": 401
}
# 可以其他窗口开抓包观察,使用容器网络附加上去
$ docker run --rm -ti --net container:23cc818c1800 zhangguanzhang/netshoot tcpdump -nn -i eth0 port 53
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
03:54:07.139368 IP 10.244.0.8.41893 > 10.96.0.10.53: 40318+ [1au] A? kubernetes.default.svc.cluster.local. (66)
03:54:07.139433 IP 10.244.0.8.41893 > 10.96.0.10.53: 13722+ [1au] AAAA? kubernetes.default.svc.cluster.local. (66)
03:54:07.140954 IP 10.96.0.10.53 > 10.244.0.8.41893: 13722*- 0/1/1 (162)
03:54:07.141108 IP 10.96.0.10.53 > 10.244.0.8.41893: 40318*- 1/0/1 A 10.96.0.1 (119)

只要不是特殊编程场景下,程序都会使用 libc 提供的库去做一些底层能力,例如 DNS 解析,上面的解析并没有体现出 search 使用,所以我们可以 pod 内 curl https://kubernetes1:

# 前面抓包不关
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
03:57:10.348955 IP 10.244.0.8.46982 > 10.96.0.10.53: 8279+ [1au] A? kubernetes1.default.svc.cluster.local. (67)
03:57:10.349013 IP 10.244.0.8.46982 > 10.96.0.10.53: 60481+ [1au] AAAA? kubernetes1.default.svc.cluster.local. (67)
03:57:10.350196 IP 10.96.0.10.53 > 10.244.0.8.46982: 60481 NXDomain*- 0/1/1 (163)
03:57:10.350282 IP 10.96.0.10.53 > 10.244.0.8.46982: 8279 NXDomain*- 0/1/1 (163)
03:57:10.350377 IP 10.244.0.8.46982 > 10.96.0.10.53: 40735+ [1au] A? kubernetes1.svc.cluster.local. (59)
03:57:10.350410 IP 10.244.0.8.46982 > 10.96.0.10.53: 14551+ [1au] AAAA? kubernetes1.svc.cluster.local. (59)
03:57:10.350859 IP 10.96.0.10.53 > 10.244.0.8.46982: 40735 NXDomain*- 0/1/1 (155)
03:57:10.350920 IP 10.96.0.10.53 > 10.244.0.8.46982: 14551 NXDomain*- 0/1/1 (155)
03:57:10.350987 IP 10.244.0.8.46982 > 10.96.0.10.53: 37615+ [1au] A? kubernetes1.cluster.local. (55)
03:57:10.351051 IP 10.244.0.8.46982 > 10.96.0.10.53: 9477+ [1au] AAAA? kubernetes1.cluster.local. (55)
03:57:10.351437 IP 10.96.0.10.53 > 10.244.0.8.46982: 37615 NXDomain*- 0/1/1 (151)
03:57:10.351504 IP 10.96.0.10.53 > 10.244.0.8.46982: 9477 NXDomain*- 0/1/1 (151)
03:57:10.351628 IP 10.244.0.8.46982 > 10.96.0.10.53: 47051+ [1au] A? kubernetes1. (40)
03:57:10.351666 IP 10.244.0.8.46982 > 10.96.0.10.53: 47948+ [1au] AAAA? kubernetes1. (40)
03:57:10.523232 IP 10.96.0.10.53 > 10.244.0.8.46982: 47948 NXDomain 0/1/1 (115)
03:57:10.524150 IP 10.96.0.10.53 > 10.244.0.8.46982: 47051 NXDomain 0/1/1 (115)

可以得知 ndots=5 下解析域名结尾不是 . 而 kubelet clusterDomain 为 cluster.local. 结尾会依次尝试解析:

  • Domain.NS.svc.cluster.local.
  • Domain.svc.cluster.local.
  • Domain.cluster.local.
  • Domain

所以如果集群部署完成后,或者利用自带的 Pod 探测跨节点通信正常否,由于 K8S 的 overlay 网络,可以宿主机上 dig 命令探测或者解析域名:

# 使用 FQDN 是因为宿主机 /etc/resolv.conf 并没有 search 内容,并且 dig 默认 +nosearch
$ dig @10.96.0.10 kubernetes.default.svc.cluster.local +short
10.96.0.1
# 绕过 svc,例如使用本机上的 coredns 和非本机的
$ dig @<coredns_PodIP> kubernetes.default.svc.cluster.local +short
10.96.0.1

pod 的 /etc/resolv.conf 生成机制

$ kubectl explain pod.spec.dnsPolicy
  • 默认 pod 就是 ClusterFirst 会使用 kubelet 配置的 clusterDNS 也就是 coredns 的 svc IP
  • 如果是 hostNetwork 没配置 dnsPolicy 则会使用 kubelet 配置的 resolvConf 也就是宿主机的 /etc/resolv.conf 内容,Default 也是
  • ClusterFirstWithHostNet 字面意思,就是 pod 配置 hostNetwork: true 下,设置成 coredns 的 svc IP,例如 ingress controller
  • None 就是不使用 DNS,可以配合 DNSConfig 定义 DNS 相关的参数

而 coredns 的 deploy 就设置了 dnsPolicy: Default,会使用宿主机的 /etc/resolv.conf 内容,然后结合它配置文件:


$ kubectl -n kube-system describe cm coredns 

Corefile:
----
// .匹配所有
.:53 {
    errors
    health {
        lameduck 5s
    }
    ready
    // cluster.local. 后缀请求解析到 kubernetes 插件
    kubernetes cluster.local. in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
        ttl 30
    }
    prometheus :9153
    // 没匹配到的 K8s 插件,例如pod 内访问公网域名转发到 /etc/resolv.conf 内的 nameserver
    forward . /etc/resolv.conf {
        max_concurrent 1000
    }
    cache 30
    loop
    reload
    loadbalance
}
$ dig @10.96.0.2 baidu.com +short
110.242.68.66
39.156.66.10

所以 coredns 能解析集群内域名和公网域名,需要扩展和配置 coredns 的配置,可以去看 coredns plugin 文档。例如自定义一个域名使用 hosts 插件:


...
    kubernetes cluster.local. in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
        ttl 30
    }
    hosts {
      127.0.0.1 test.com
      reload 5s
      fallthrough // 必须要,不匹配继续往下走
    }
    prometheus :9153

    forward . /etc/resolv.conf {
        max_concurrent 1000
    }

或者 zone 设置:


.:53 {
...
}

k8s.itlocal:53 {
...
}

nameserver 数量

Linux 的 libc 不可能摆脱(见 2005 年的这个 bug )只有 3 个 DNS nameserver 记录和 6 个 DNS search 记录的限制。Kubernetes 需要消耗 1 个 nameserver 记录和 3 条 search 记录。这意味着如果本地安装已经使用了 3 个 nameserver 或使用了多于 3 条 search,那么其中一些设置将会丢失。作为部分解决方法,节点可以运行 dnsmasq,它将提供更多 nameserver 条目,但没有更多的 search 条目。您也可以使用 kubelet --resolv-conf 标志。

其他的 service 类型和解析

https://kubernetes.io/zh-cn/docs/concepts/services-networking/dns-pod-service/

coredns metrics

coredns 提供了一个 :9153/metrics 接口,例如刚部署完集群,没有部署应用的时候,测跨节点通信可以用它这个 http url。

DNS 5s 超时

实际运行中,经常会碰到有容器报错偶现无法解析域名:

dial tcp: lookup xxxx: i/o timeout

5s 超时原因

根本原因是 Linux 内核 conntrack 模块的bug,Weave works的工程师 Martynas Pumputis 对这个问题做了很详细的分析:

DNS client (glibc 或 musl libc) 会并发请求 A 和 AAAA 记录,跟 DNS Server 通信自然会先 connect (建立 fd),后面请求报文使用这个 fd 来发送,由于 UDP 是无状态协议, connect 时并不会发包,也就不会创建 conntrack 表项。 而并发请求的 A 和 AAAA 记录默认使用同一个 fd 发包,send 时各自发的包它们源 Port 相同(因为用的同一个 socket 发送),当并发发包时,两个包都还没有被插入 conntrack 表项,所以 netfilter 会为它们分别创建 conntrack 表项,而集群内请求 kube-dns 或 coredns 都是访问的 CLUSTER-IP,报文最终会被 DNAT 成一个 endpoint 的 POD IP。 当两个包恰好又被 DNAT 成同一个 POD IP 时,它们的五元组就相同了,在最终插入的时候后面那个包就会被丢掉。

IPv6 解析请求: udp srcIP:destIP srcPort destPort
IPv4 解析请求: udp srcIP:destIP srcPort destPort

如果 dns 的 pod 副本只有一个实例的情况就很容易发生(始终被 DNAT 成同一个 POD IP),现象就是 dns 请求超时,client 默认策略是等待 5s 自动重试,如果重试成功,我们看到的现象就是 dns 请求有 5s 的延时。ipvs 也使用了 conntrack, 使用 kube-proxy 的 ipvs 模式,并不能避免这个问题。

规避方法

tcp 请求

因为是 udp 五元组冲突,所以可以 tcp dns 请求,配置 options use-vc since glibc 2.14,但是不一定生效。

避免 A AAAA 记录并发

  • single-request-reopen (since glibc 2.9)
  • single-request (since glibc 2.10)

该选项对于 alpine 的 musl 并不支持,而且也只是缓解。如果先要验证,可以下面这样重定向修改不重启容器:


while read ctr;do
  ResolvConfPath=`docker inspect $ctr --format "{{ .ResolvConfPath }}" `

#  if ! grep -q use-vc $ResolvConfPath;then
#    echo 'options use-vc' >> $ResolvConfPath
#  fi
  if ! grep -q attempts $ResolvConfPath;then
    echo 'options timeout:2 attempts:3 single-request-reopen' >> $ResolvConfPath
  fi  
done < <(docker ps -a --filter label=io.kubernetes.docker.type=container --format '{{.ID}}' )

后续固化 kubectl patch :


function t(){
    local ns deploy
    for ns in $@;do
      for deploy in $(kubectl -n $ns   get deploy --no-headers  | awk '{print $1}');do
        kubectl -n $ns patch deploy $deploy  \
            -p '{"spec":{"template":{"spec":{"dnsConfig":{"options":[{"name":"single-request-reopen"},{"name":"timeout","value":"2"},{"name":"attempts","value":"3"}]}}}}}'
      done
    done
}

t default # 处理default namespace下的 deploy

nodelocaldns

官方也意识到了这个问题比较常见,给出了 coredns 以 cache 模式作为 daemonset 部署的解决方案:

 

红线是默认行为,非红线是部署后的查询逻辑:

  • hostNetwork 的 DaemonSet 部署的 nodelocaldns ,每个节点上都会有 nodelocaldns Pod
  • 创建 dummy 接口 nodelocaldns ,监听两个IP,kube-dns svcIP 和 169.254.20.10,并在 raw 表创建这俩 IP 的 iptables 规则 -j NOTRACK 让 Pod 请求 DNS svcIP 不走 dnat 而走到 nodelocaldns 网卡上:
$ ip -4 a s nodelocaldns
171: nodelocaldns: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default 
    inet 169.254.20.10/32 scope global nodelocaldns
       valid_lft forever preferred_lft forever
    inet 10.0.0.10/32 scope global nodelocaldns
       valid_lft forever preferred_lft forever
  • 客户端 Pod 发起 DNS 查询,发到 nodelocaldns ,nodelocaldns 使用(用户态,非 resolv options use-vc )TCP 向上游 coredns svcIP 查询并缓存,然后返回给客户端 Pod。

部署可以参考官方文档 在 Kubernetes 集群中使用 NodeLocal DNSCache, 整个部署不需要重装 K8S,而如果使用 iptalbes 模式,则不需要更改、配置和重启 kubelet。

而使用 ipvs 模式下,需要修改 nodelocaldns.yaml 取消 -localip 后面的 DNS_SERVER,因为 ipvs 模式下 kube-ipvs0 网卡会绑定 dns serverIP,这里避免 nodelocaldns 再绑定而冲突,然后再需要修改 kubelet 的 --cluster-dns 指定为 nodelocaldns 网卡上的 IP ,例如 169.254.20.10。

部署后,发现也并不是完全解决,只是降低了很多,然后让所有业务都使用基于 glibc 的容器镜像,开 single-request 那俩 options 才降低更多。

另外官方创建了个 svc 选择的集群 DNS,并没有指定 service IP,cmdline 里指定了 这个 svc 名字:

apiVersion: v1
kind: Service
metadata:
  name: kube-dns-upstream
  namespace: kube-system
...
spec:
  ports:
  - name: dns
    port: 53
    protocol: UDP
    targetPort: 53
  - name: dns-tcp
    port: 53
    protocol: TCP
    targetPort: 53
  selector:
    k8s-app: kube-dns
...
 args: [ ..., "-upstreamsvc", "kube-dns-upstream" ]

然后 nodelocaldns 启动的日志里可以看到配置文件被渲染了:

    forward . 10.96.189.136 {
            force_tcp
    }

因为 enableServiceLinks 的默认开启,pod 会有如下环境变量:

$ docker exec dfa env | grep KUBE_DNS_UPSTREAM_SERVICE_HOST
KUBE_DNS_UPSTREAM_SERVICE_HOST=10.96.189.136

代码里 可以看到就是把参数的 - 转换成 _ 取环境变量 KUBE_DNS_UPSTREAM_SERVICE_HOST 值然后渲染配置文件,这样就能取到 SVC 的 IP 了。

func toSvcEnv(svcName string) string {
    envName := strings.Replace(svcName, "-", "_", -1)
    return "$" + strings.ToUpper(envName) + "_SERVICE_HOST"
}

二开 nodelocaldns

需要自己编译 nodelocaldns 的话:

make containers CONTAINER_BINARIES=node-cache DOCKER_BUILD_FLAGS=--load

  • 无标签
写评论...