kubernetes网络:service,插件,策略,dns优化

网络插件

Flannel

前面学过docker的网络模式,大致有bridge,host,containerd这些模式,但这些基本都是同个节点上的容器的通信方式,对于跨主机的容器之间的通信就无能为力了,就需要借助第三方的工具来实现容器的跨主机通信,比如flannel插件。
Flannel 是 CoreOS(Etcd 的公司)推出的一个 Overlay 类型的容器网络插件,目前支持三种后端实现:UDP、VXLAN、host-gw 三种方式。UDP 是最开始支持的最简单的但是却是性能最差的一种方式,因此基本上在正式使用的时候不会使用这种方式,但用来理解容器跨主机通信原理或流程比较方便(主要就是设计来实现良好的容器跨主机通信)

UDP

还是要 UDP 模式我们需要在 Flannel 的配置文件中指定 Backend type 为 UDP

$ kubectl edit cm kube-flannel-cfg -n kube-system
...
  net-conf.json: |
    {
    
    
        {
    
    
      "Network": "10.244.0.0/16",
      "Backend": {
    
    
        "Type": "udp"  # 修改后端类型为 UDP
      }
    }
kind: ConfigMap
......

采用 UDP 模式时后端默认为端口为 8285,即 Flanneld 的监听端口。当采用 UDP 模式时,Flanneld 进程在启动时会通过打开 /dev/net/tun 的方式生成一个 TUN 设备,TUN 设备可以简单理解为 Linux 当中提供的一种内核网络与用户空间通信的一种机制,即应用可以通过直接读写 TUN 设备的方式收发 RAW IP 包。所以我们还需要将宿主机的 /dev/net/tun 文件挂载到容器中去:

$ kubectl edit ds kube-flannel-ds-amd64 -n kube-system
......
  volumeMounts:
    - mountPath: /run/flannel
    name: run
    - mountPath: /etc/kube-flannel/
    name: flannel-cfg
    - mountPath: /dev/net
    name: tun
......
volumes:
- hostPath:
    path: /run/flannel
    type: ""
  name: run
- hostPath:
    path: /etc/cni/net.d
    type: ""
  name: cni
- hostPath:
    path: /dev/net  # 挂载宿主机的 /dev/net/tun 文件,hostPath挂载主机目录
    type: ""
  name: tun
......
kubectl logs -f查看对应的pod的日志
-f:follow,持续输出

看到Found network config - Backend type: udp这个信息证明我们现在已经变成了 UDP 模式了
ip a或ip link show flannel0
会发现节点多了个网络设备(网卡、网络接口)
每个节点都有,安装flannel网络插件是就是在每个节点下都生成个kube-flannel的pod

附加:flannel.1,cni0网桥,pod这三个的ip网段都是10.244.x.x(部署kubernetes时指定的podSubnet)

两个节点上的容器,就是是pod是怎么跨节点通信的:
(你可以进入一个pod向另一个pod发送请求看看,注意pod中的业务容器一般一个,如果有多个,要-c container制定容器)

kubectl exec -it pod_name /bin/bash
route -n 查看下本机的路由表

kubectl exec pod-a -- route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         10.244.1.1      0.0.0.0         UG    0      0        0 eth0
10.244.0.0      10.244.1.1      255.255.0.0     UG    0      0        0 eth0
10.244.1.0      0.0.0.0         255.255.255.0   U     0      0        0 eth0

destination和mask组成个目标网段,gateway表示该到网段的包经由那个网关发出,目标地址0.0.0.0表示默认的地址是最后匹配的,gateway0.0.0.0表示不需要别的网关来发,自身转发发就行,mask0.0.0.0就是表示任意掩码了,也就是任意网段


ifconfig cni0
可以看出10.244.1.1是cni0网络设备的ip地址

上面的路由表规则也可以看出,同个节点的pod通信,通过pod节点自身转发即可

两个节点的pod:
pod-a(10.244.1.236)向 pod-b(10.244.2.123)发送一个请求报文(ping)
1.在 pod-a 当中发出 ICMP 请求报文,其源地址就是 10.244.1.236,目标地址是 10.244.2.123,此时通过 pod-a 内的路由表匹配到应该将该 IP 包发送到 node1 节点上网关 10.244.1.1(cni0网桥,同个节点的pod会连接到这个网桥,跨节点通信的话会先按路由表匹配到这个网桥设备)

2.IP 包的下一个目的地,就取决于宿主机上面的路由规则了,Flanneld 进程已经在宿主机上面创建了一系列的路由规则(就是pod将ICMP请求报文发到节点上,节点进行下一步转发)

route -n
可以查看节点主机的路由表

[root@ydzs-node1 ~]# ip route
default via 10.151.30.11 dev eth0  proto static  metric 100
10.151.30.0/24 dev eth0  proto kernel  scope link  src 10.151.30.22  metric 100
10.244.0.0/16 dev flannel0
10.244.1.0/24 dev cni0  proto kernel  scope link  src 10.244.1.1
172.17.0.0/16 dev docker0  proto kernel  scope link  src 172.17.0.1

3.此时到达 cni0 的 IP 包目标地址 10.244.2.123 匹配不到本机的 cni0 网桥对应的 10.244.1.0/24 网段,只能匹配到第三条 10.244.0.0/16 对应的这条路由规则,这个时候内核将 RAW IP 包发送给 flannel0 设备
flannel0 设备它是一个 TUN 设备(Tunnel 设备)。在 Linux 中,TUN 设备是一种工作在三层(Network Layer网络层)的虚拟网络设备,TUN 设备的功能非常简单,即:在操作系统内核和用户应用程序之间传递 IP 包(flanneld进程,kube-flannel这个pod来提供这个程序服务)。

扫描二维码关注公众号,回复: 14398084 查看本文章

内核网络<—tunnel设备(flannel设备)—>用户应用程序(flanneld)

4.由于 flannel0 是一个 TUN 设备,发送给 flannel0 接口的 RAW IP 包将被 Flanneld 进程接收到,然后在原有的基础上进行 UDP 封包,然后发送到 node2 节点上的 Flanneld 进行解包。

封包
UDP 封包的形式为:10.151.30.22:src port -> 10.151.30.23:8285。
这里其实就是加上了udp的目标端口8285

这里最关键的就是 UDP 封包发送到我们的目标 IP 10.244.2.123 这个容器所在的节点,但是是如何知道这个节点的呢?

这个就需要了解一个非常重要的概念子网(Subnet),Flannel 管理的容器网络,一台宿主机上的所有容器,都属于该宿主机被分配的一个子网(即每个节点主机都会有个kubernetes集群的子网网段,其实就是pod子网),比如我们这里的 node1 节点的子网是 10.244.1.0/24(10.244.1.1-10.244.1.254),pod-a 的 IP 地址是 10.244.1.236;node2 节点的子网是 10.244.2.0/24(10.244.2.1-10.244.2.254),pod-b 的 IP 地址是 10.244.2.123,这些子网信息是当 Flanneld 进程在启动时通过 api-server 保存到 etcd 当中,所以在发送报文时可以通过目的地址 10.244.2.123 匹配到对应的子网是 10.244.2.0/24,这个时候查询 etcd 得到这个子网对应的宿主机的 IP 地址 10.151.30.23,也就是 node2 节点。

5.node2 节点收到 UDP 报文过后经过 Linux 内核通过 UDP 端口 8285 将包交给节点上的 Flanneld 进程。(8285/udp端口是flanneld进程监听的端口)
6.然后 node2 节点上的 Flanneld 进程将接收到的 UDP 包解包后得到 RAW IP 包:10.244.1.236 -> 10.244.2.123
7.解包后的 RAW IP 包匹配到 node2 节点上的路由规则(10.244.2.0/24),内核将 RAW IP 包发送给 cni0 设备

[root@ydzs-node2 ~]# ip route
default via 10.151.30.11 dev eth0  proto static  metric 100
10.151.30.0/24 dev eth0  proto kernel  scope link  src 10.151.30.23  metric 100
10.244.0.0/16 dev flannel0
10.244.2.0/24 dev cni0  proto kernel  scope link  src 10.244.2.1
172.17.0.0/16 dev docker0  proto kernel  scope link  src 172.17.0.1

8.cni0 将 IP 包转发给连接在 cni0 网桥上的 pod-b,这样就完成了这个通信过程

总结:
现在又pod-a(10.244.1.236),pod-b(10.244.2.123)
pod匹配自身的路由表的默认路由配置0.0.0.0,将ICMP请求报文发送到cni0网关设备
10.244.2.123不在cni0的网段,匹配主机的路由表匹配到tunnel设备flannel0,内核网络(flannel这些网卡都属于)将ip包发送给flanneld进程的,flanneld工作于网络层,对ip包在原有的进出上再次进行udp封包,其实就是改端口为8285这个flanneld进程监听的port,改完之后通过flannel0这个tunnel设备将ip包发送到内核网络,也就是flannel0这个网络接口
有flannel0这个网络接口发送给目标主机的8285/udp端口,监听该端口的是flannel进程,内核照样将ip包发送给flanneld进程,flanneld进程解包,也就是得到10.244.1.236–>10.244.2.123,自然也将ip包发送回内核网络
匹配主机路由表,发现cni010.244.2.0/24符合,于是发给cni0
cni0发送给连接在cni0网桥上的pod-b

flannel0:
pod-a—>a的cni0—>a的flanel0网络接口(内核网络)–tun–>flanneld封包–tun–>a的flannel0接口–>b的flannel0接口–tun–>flanneld解包–tun–>b的flannel0接口—>cni0—>pod-b

从上面的整个流程来看,Flanneld 主要有两方面的功能:
1.UDP 封包解包
2.节点上的路由表的动态更新(集群每个节点都会分被配个pod子网网段,得知道这些信息的变化,这些变化由flanneld来通过api-server写入etcd,动态更新)

我们可以明显看出来数据包是通过 tun 设备从内核态复制到用户态的应用中的,然后再通过用户态复制到内核态,仅一次网络传输就进行了两次用户态和内核态的切换,显然这种效率是不会很高的,由于低效率所以这种方式基本上不使用,要提高效率最简单的方式就是把封包解包这些事情都交给内核去干好了,事实上 Linux 内核本身也提供了比较成熟的网络封包解包(隧道传输)实现方案 VXLAN,Flanneld 也实现了基于 VXLAN 的方案,该方案在我们日常使用的时候也是最普遍的。

vxlan

其实就是在udp模式的基础上,将网络封包解包的过程交给linux内核来完成

VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网),是 Linux 内核本身就支持的一种网络虚似化技术。所以说,VXLAN 可以完全在内核态实现上述封装和解封装的工作,从而通过与前面相似的“隧道”机制,构建出覆盖网络(Overlay Network)

当我们使用 VXLAN 模式的时候需要将 Flanneld 的 Backend 类型修改为 vxlan

$ kubectl edit cm kube-flannel-cfg -n kube-system
apiVersion: v1
data:
  cni-conf.json: |
    {
    
    
      "cniVersion": "0.2.0",
      "name": "cbr0",
      "plugins": [
        {
    
    
          "type": "flannel",
          "delegate": {
    
    
            "hairpinMode": true,
            "isDefaultGateway": true
          }
        },
        {
    
    
          "type": "portmap",
          "capabilities": {
    
    
            "portMappings": true
          }
        }
      ]
    }
  net-conf.json: |
    {
    
    
        {
    
    
      "Network": "10.244.0.0/16",
      "Backend": {
    
    
        "Type": "vxlan"  # 修改后端类型为 vxlan
      }
    }
kind: ConfigMap
......

kubectl get ds -o yaml可以看到kube-flannel底下的pod用flannel-cfg作为配置文件

将类型修改为 vxlan 过后,需要重建下 Flanneld 的所有 Pod 才能生效:

$ kubectl delete pod -n kube-system -l app=flannel

kubectl logs -f查看pod日志,出现Found network config - Backend type: vxlan的日志信息就证明已经配置成功了

Flanneld 在启动时会通过 Netlink 机制与 Linux 内核通信,建立一个 VTEP(Virtual Tunnel Access End Point) 设备 flannel.1(命名规则为flannel.[VNI],VNI 默认为1),类似于交换机当中的一个网口。
detail细节

可见flannel.1这个网卡(网络接口)的ip为10.244.0.0,和掩码一块判断网段,配合路由表使用
[root@master net]# ifconfig flannel.1
flannel.1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1450
        inet 10.244.0.0  netmask 255.255.255.255  broadcast 0.0.0.0
        inet6 fe80::4075:9cff:fe7b:aa32  prefixlen 64  scopeid 0x20<link>
        ether 42:75:9c:7b:aa:32  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 41 overruns 0  carrier 0  collisions 0


可见flannel.1这个VTEP设备的local ip为192.168.23.178,目标端口是8472
[root@master net]# ip -d link show flannel.1
6: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UNKNOWN mode DEFAULT group default 
    link/ether 42:75:9c:7b:aa:32 brd ff:ff:ff:ff:ff:ff promiscuity 0 
    vxlan id 1 local 192.168.23.178 dev ens33 srcport 0 0 dstport 8472 nolearning ageing 300 noudpcsum noudp6zerocsumtx noudp6zerocsumrx addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535 

[root@master net]# netstat -utnlp | grep 8472
udp        0      0 0.0.0.0:8472            0.0.0.0:*                           -                   
-表示内核的进程,有名称的一般使用户的程序的进程

[root@master net]# kubectl get pod -n kube-system -o wide
NAME                                 READY   STATUS             RESTARTS   AGE   IP               NODE     NOMINATED NODE   READINESS GATES
coredns-7f89b7bc75-x28zg             1/1     Running            672        17d   10.244.0.6       master   <none>           <none>
coredns-7f89b7bc75-xm7dz             1/1     Running            675        17d   10.244.0.7       master   <none>           <none>
etcd-master                          1/1     Running            2          17d   192.168.23.178   master   <none>           <none>
kube-apiserver-master                1/1     Running            2          17d   192.168.23.178   master   <none>           <none>
kube-controller-manager-master       1/1     Running            12         17d   192.168.23.178   master   <none>           <none>
kube-flannel-ds-8jhgg                1/1     Running            0          17d   192.168.23.177   node1    <none>           <none>
kube-flannel-ds-thkk6                1/1     Running            0          17d   192.168.23.179   node2    <none>           <none>
kube-flannel-ds-vg7qm                1/1     Running            2          17d   192.168.23.178   master   <none>           <none>
kube-proxy-hc7d5                     1/1     Running            2          17d   192.168.23.178   master   <none>           <none>
kube-proxy-m88sk                     1/1     Running            0          17d   192.168.23.177   node1    <none>           <none>
kube-proxy-r8rgl                     1/1     Running            0          17d   192.168.23.179   node2    <none>           <none>
kube-scheduler-master                1/1     Running            10         17d   192.168.23.178   master   <none>           <none>
kube-state-metrics-869959868-9shz5   0/1     CrashLoopBackOff   488        14d   10.244.1.5       node2    <none>           <none>
可见flannel.1的pod的ip实际是节点的连接集群的ip

我们仔细看和 UDP 模式下查看 Flanneld 监听的端口是有区别的,最后一栏显示的不是进程的 ID 和名称,而是一个破折号“-”,这说明 UDP 的8472端口不是由用户态的进程在监听的,也证实了VXLAN模块工作在内核态模式下。(8472端口直接有内核监听的)
在 VXLAN 模式下解封包的事情交由内核处理

vxlan模式下的flanneld的工作流程:
1.同样,当 Flanneld 启动时将创建 VTEP 设备 flannel.1,并将 VTEP 设备的相关信息上报到 etcd 当中(udp是写入子网信息)
2.当在 Flannel 网络中有新的节点发现时,各个节点上的 Flanneld 进程会在各自节点上创建一条该节点所属网段(pod的子网网段,如10.244.1.0,反过来也就是说同一个节点的pod处于同一个pod子网网段,所以它们间的通信直接通过自身发送即可,这点也可以在pod中route -n查看到)的路由表,主要是能让 Pod 当中的流量路由到 对应的节点的flannel.1 接口。

[root@master net]# route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.23.2    0.0.0.0         UG    100    0        0 ens33
10.244.0.0      0.0.0.0         255.255.255.0   U     0      0        0 cni0
10.244.1.0      10.244.1.0      255.255.255.0   UG    0      0        0 flannel.1
10.244.2.0      10.244.2.0      255.255.255.0   UG    0      0        0 flannel.1
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 docker0
192.168.23.0    0.0.0.0         255.255.255.0   U     100    0        0 ens33
192.168.122.0   0.0.0.0         255.255.255.0   U     0      0        0 virbr0

iface表示由哪个接口发出

可见发往10.244.0.0也就是当前节点的ip包发给网关cni0由cni0进行转发,cni0就是个网桥,每个节点都有,节点的pod连接在这个cni0网桥上,类似个交换机
10.244.1.0和10.244.2.0发送给flannel.1由flannel.1进行转发

比如 10.244.2.0 这条路由规则,他的意思就是发往 10.244.2.0/24 网段的 IP 包,都需要经过 flannel.1 设备发出,而且最后被发送到的网关地址是 10.244.2.0。而其实这个网关地址就是 node2 节点上的 VTEP 设备(也就是 flannel.1)的 IP 地址

[root@master net]# kubectl run --image busybox busybox01 -it --rm /bin/sh
If you don't see a command prompt, try pressing enter.
/ # route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         10.244.2.1      0.0.0.0         UG    0      0        0 eth0
10.244.0.0      10.244.2.1      255.255.0.0     UG    0      0        0 eth0
10.244.2.0      0.0.0.0         255.255.255.0   U     0      0        0 eth0
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
3: eth0@if45: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue 
    link/ether 52:33:a1:ed:ff:86 brd ff:ff:ff:ff:ff:ff
    inet 10.244.2.39/24 brd 10.244.2.255 scope global eth0
       valid_lft forever preferred_lft forever
/ # 
可见同个网段的pod(或者说kubernetes的容器)有自身发出,其实就是走该节点cni0网桥(因为同网段的)

记得的docker的网络模式,containerd模式下,一个已经存在的容器是桥接模式,即容器连接到docker0网桥,另一个容器与他共享网络,这个docker下的容器是这样,你可以ifconfig docker0发现docker0网桥的网段不是10.244.x.x

kubernetes创建的容器(pod)不是连接到docker0网桥,而是cni0网桥
所以记住大致的架构是pod-->cni0-->flannel.1

上面知道了目的 VTEP 设备的 IP 地址了,这个时候就需要知道目的 MAC 地址,才能把数据包发送过去,这个时候其实 Flanneld 进程就会在节点当中维护所有节点的 IP 以及 VTEP 设备的静态 ARP 缓存。可通过 arp -n 命令查看到当前节点当中已经缓存了另外四个节点以及 VTEP 的 ARP 信息。

arp定时在该局域网内发送广播,可以获得MAC地址

这里我们可以看到 IP 地址 10.244.2.0 对应的 MAC 地址是 26:67:52:6b:1b:f9,这样我们就知道了目的 VTEP 设备的 MAC 地址。但是现在我们只是知道了目标设备的 MAC 地址,却不知道对应的宿主机的地址是什么?(udp模式是通过etcd中子网的相关信息获取节点ip)

这个时候 Flanneld 进程还会在节点当中添加一条该节点的转发表,通过 bridge 命令查看节点上的 VXLAN 转发表(FDB entry),MAC 为 VTEP 设备即 flannel.1 的 MAC 地址,IP 为 VTEP 对应的对外 IP(可通过 Flanneld 的启动参数 --iface=eth0 指定,若不指定则按默认网关查找网络接口对应的 IP)

[root@master net]# bridge fdb show dev flannel.1
4a:b4:39:87:21:d0 dst 192.168.23.177 self permanent
66:4b:36:6a:b8:6c dst 192.168.23.179 self permanent

这样我们就找到了上面目的 VTEP 设备的 MAC 地址对应的 IP 地址为 10.151.30.23 的主机,这就是我们的 node2 节点,所以我们就找到了要发往的目的地址。

这个时候容器跨节点网络通信实现的完整流程为:
和 UDP 模式一样,pod-a(10.244.1.236)当中的 IP 包通过 pod-a 内的路由表被发送到 cni0
到达 cni0 当中的 IP 包通过匹配节点 node1 当中的路由表发现通往 10.244.2.13 的 IP 包应该交给 flannel.1 接口
flannel.1 作为一个 VTEP 设备,收到报文后将按照 VTEP 的配置进行封包,通过 node1 节点上的 arp 和转发表得知 10.244.2.123 属于节点 node2,并且会将 ydzs-node2 节点对应的 VTEP 设备的 MAC 地址,根据 flannel.1 设备创建时的设置的参数(VNI、local IP、Port)进行 VXLAN 封包
通过节点 node2 跟 node1 之间的网络连接,VXLAN 包到达 node2 的 eth0 接口
通过端口 8472,VXLAN 包被转发给 VTEP 设备 flannel.1 进行解包
解封装后的 IP 包匹配节点 node2 当中的路由表(10.244.2.0),内核将 IP 包转发给cni0
cni0将 IP 包转发给连接在 cni0 上的 pod-b

flannel.1设备是VTEP设备,由内核负责,整个过程是内核态

总结:
vxlan不跨节点通信,就是pod自身或准确地说就是cni0网桥(pod中是containerd模式,容器共享一张网卡,连接到cni0网桥上),如果是同个pod内的话,那就是localhost就行)

跨包通信就是:
1.a发出ICMP请求报文,自身路由表匹配发送到cni0网桥
2.节点的路由表匹配发现该网段要有a的flannel.1网络接口发送,于是发送到flannel.1接口
3.VTEP设备对报文根据VETP的配置进行封包,其实主要就是加上8472端口(主机的对外ip用来连接主机,端口就是用来区分服务,这里对应的是内核态的进程flanneld)
4.通过pod的arp发现b的ip(10.244.2.0)对应的MAC地址,通过转发表发现该MAC地址所处的节点的对外ip,比如ens33(主机的对外端口,连接集群的端口)
5.发送数据包到b的节点的ens33,通过端口8472,到flanneld服务,flanneld服务进行解包
6.匹配自身路由表,发送到cni0设备,在发送到确切的pod-b

跟udp模式的变化,主要就是flanneld是内核态的,有内核进行udp数据包封包解包

pod-a—>cni0—>flannel.1(flanneld封包)—>匹配路由表,该目标节点由这个flannel.1接口发送—>pod-b的ens33—>8472端口—>flannel.1(flanneld解包)—>cni0—>pod-b
(用的都是对外接口)

host-gw

host-gw 即 Host Gateway,从名字中就可以想到这种方式是通过把主机当作网关来实现跨节点网络通信的。那么具体如何实现跨节点通信呢?

同理 UDP 模式和 VXLAN 模式,首先将 Backend 中的 type 改为host-gw,这里就不再赘述,更新完成后,随便查看一个 flannel 的 Pod 日志,如果出现如下所示的 Found network config - Backend type: host-gw 日志就证明已经是 host-gw 模式了

采用 host-gw 模式后 Flanneld 的唯一作用就是负责主机上路由表的动态更新(udp,vxlan,host-gw,flanneld都有负责主机路由表的动态更新),其实就是将每个 Flannel 子网(Flannel Subnet,比如:10.244.1.0/24)的“下一跳”,设置成了该子网对应的宿主机的 IP 地址,当然,Flannel 子网和主机的信息,都是保存在 etcd 当中的。Fanneld 只需要 WACTH 这些数据的变化,然后实时更新路由表即可。主要流程如下所示:

1、同 UDP、VXLAN 模式一致,通过容器A 的路由表 IP 包到达cni0
2、到达 cni0 的 IP 包匹配到 ydzs-node1 当中的路由规则(10.244.2.0),并且网关为 10.151.30.23,即节点 ydzs-node2,所以内核将 IP 包发送给节点 ydzs-node2(10.151.30.23)
就是将节点的对外网卡,比如ens33作为网关
3、IP 包通过物理网络到达节点的 ydzs-node2 的 eth0 设备(节点的对外网卡)
4、到达 ydzs-node2 节点 eth0 的 IP 包匹配到节点当中的路由表(10.244.2.0/24),IP 包被转发给 cni0 设备 5、cni0 将 IP 包转发给连接在 cni0 上的 pod-b

这样就完成了整个跨主机通信流程,这个流程可能是最简单最容器理解的模式了,而且容器通信的过程还免除了额外的封包和解包带来的性能损耗,所以理论上性能肯定要更好,但是该模式是通过节点上的路由表来实现各个节点之间的跨节点网络通信,那么就得保证两个节点是可以直接路由过去的。按照内核当中的路由规则,网关必须在跟主机当中至少一个 IP 处于同一网段,故造成的结果就是采用host-gw 这种模式的时候,集群中所有的节点必须处于同一个网络当中,这对于集群规模比较大时需要对节点进行网段划分的话会存在一定的局限性,另外一个则是随着集群当中节点规模的增大,Flanneld 需要维护主机上成千上万条路由表的动态更新也是一个不小的压力。

calico提前简介

除了 Flannel 之外,Calico 这种网络插件和 Flannel 的 host-gw 模式基本上是一样的,都是在每台书主机上面添加子网和对应的书主机的 IP 地址为网关这样的路由信息,不过,不同于 Flannel 通过 Etcd 和宿主机上的 flanneld 来维护路由信息的做法,Calico 使用 bgp 来自动地在整个集群中分发路由信息。

总体上,flannel还是用vxlan模式较多

网络策略

在 Kubernetes 中要实现容器之间网络的隔离,是通过一个专门的 API 对象 NetworkPolicy(网络策略)来实现的,要让网络策略生效,就需要特定的网络插件支持,目前已经实现了 NetworkPolicy 的网络插件包括 Calico、Weave 和 kube-router 等项目,但是并不包括 Flannel 项目。所以说,如果想要在使用 Flannel 的同时还使用 NetworkPolicy 的话,你就需要再额外安装一个网络插件,比如 Calico 项目,来负责执行 NetworkPolicy。由于我们这里使用的是 Flannel 网络插件,所以首先需要安装 Calico 来负责网络策略。

flannel+calico:容器跨节点通信+网络策略实现容器间网络隔离

安装 Calico

首先确定 kube-controller-manager 配置了如下的两个参数:
(/etc/kubernetes/manifests)

......
spec:
  containers:
  - command:
    - kube-controller-manager
    - --allocate-node-cidrs=true
    - --cluster-cidr=10.244.0.0/16
......
如果是按照本专栏的部署kubernetes一文部署的kubernetes,这里就已经设置好了不用更改,直接下载canal.yaml下来apply即可

在这里插入图片描述
canal运河

下载清单文件
curl https://projectcalico.docs.tigera.io/manifests/canal.yaml -O
注意这里用的是calico(策略)+flannel(网络)的模式
-0 指定output file,即保存的文件名,这里不加文件名就是使用它的文件名(wget也是),tar -C标识改变到某个目录

如果之前配置的 pod CIDR 就是 10.244.0.0/16 网段,则可以跳过下面的配置,如果不同则可以使用如下方式进行替换:


$ POD_CIDR="<your-pod-cidr>" 
$ sed -i -e "s?10.244.0.0/16?$POD_CIDR?g" canal.yaml

kubernetes的pod的网段最好安装时指定好10.244.0.0/16

直接apply即可(会定义很多crd资源)

NetworkPolicy

默认情况下 Pod 是可以接收来自任何发送方的请求,也可以向任何接收方发送请求。而如果我们要对这个情况作出限制,就必须通过 NetworkPolicy 对象来指定。

这里定义了一个网络策略资源清单文件:(test-networkpolicy.yaml)

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default   #注意只在当前的namespace生效,即只控制当前的namespace的pod
spec:
  podSelector:   #podSelector选择生效的pod,如果是podSelector: {}表示所有的pod
    matchLabels:
      role: db
  policyTypes:   #进出都控制
  - Ingress
  - Egress
  ingress:
  - from:   #form支持3中并列的情况,也就是满足任一即可
    - ipBlock:
        cidr: 172.17.0.0/16   #网络不是很熟,按照划分子网的些许经历,一般掩码小的网段含有的ip数多,所以这里16包含了24
        except:
        - 172.17.1.0/24
    - namespaceSelector:
        matchLabels:
          project: myproject
    - podSelector:   #当前的名称空间下
        matchLabels:
          role: frontend
#default 命名空间下面带有 role=frontend 标签的 Pod
#带有 project=myproject 标签的 Namespace 下的任何 Pod
#任何源地址属于 172.17.0.0/16 网段,且不属于 172.17.1.0/24 网段的请求。
    ports:
    - protocol: TCP
      port: 80
#(-是列表声明,上面可见from和ports是同属于一个列表元素,也就是说得同时满足)
  egress:
  - to:
    - ipBlock:
        cidr: 10.0.0.0/24
    ports:
    - protocol: TCP
      port: 5978
#表示 Kubernetes 会拒绝被隔离 Pod 对外发起任何请求,除非请求的目的地址属于 10.0.0.0/24 网段,并且访问的是该网段地址的 5978 端口

每个 NetworkPolicy 包含一个 policyTypes 列表,可以是一个 Ingress、Egress 或者都包含,该字段表示给当前策略是否应用于所匹配的 Pod 的入口流量、出口流量或者二者都包含,如果没有指定 policyTypes,则默认情况下表示 Ingress 入口流量,如果配置了任何出口流量规则,则将指定为 Egress。
上面示例表示的是该隔离规则只对 default 命名空间下的,携带了 role=db 标签的 Pod 有效。限制的请求类型包括 ingress(流入)和 egress(流出)。

一旦 Pod 被 NetworkPolicy 选中,那么这个 Pod 就会进入“拒绝所有”(Deny All)的状态,即这个 Pod既不允许被外界访问,也不允许对外界发起访问,所以 NetworkPolicy 定义的规则,其实就是“白名单”了

ingress: 每个 NetworkPolicy 包含一个 ingress 规则的白名单列表。其中的规则允许同时匹配 from 和 ports 部分的流量

Kubernetes 会拒绝任何访问被隔离 Pod 的请求(一旦被NetworkPolicy选中的pod就进入了拒绝所有的状态),除非这个请求来自于以下“白名单”里的对象,并且访问的是被隔离 Pod 的 80 端口

egress: 每个 NetworkPolicy 包含一个 egress 规则的白名单列表。每个规则都允许匹配 to 和 port 部分的流量(同时满足to和port)

测试

1.创建一个pod-1带有role=db标签的(使用的镜像是nginx)
2.用pod-2(busybox)执行,kubectl exec pod-2 wget pod-1_ip(wget会访问该ip指向的www服务器,该服务器通过请求的ip,port和请求路径等信息判断出它访问的位置,这里没有也就是服务器默认的网页,一般是index.html,所以这里是下载index.html下来)

可见请求成功

3.创建上述的NetworkPolicy文件,pod-1被选中,进入决绝所有(拒绝进入发出)的情况,而pod-2这个请求流量信息不符合NetworkPolicy的ingress的定义,所以整个流程访问失败

这个时候我们可以用 NetworkPolicy 白名单里面匹配的 Pod-2 来对上面的 Pod-1 发起网络请求,比如在带有标签project=myproject 的 Namespace 下面的 Pod 来发起网络请求,或者给 pod-2 加上一个role=frontend 的标签

namespace是命名空间级别的,也有label,还有以上访问的端口默认就是80,互联网上基本都是web服务器,通过ip访问,默认访问的就是端口80

Kubernetes 的 NetworkPolicy 实现了访问控制,依赖的底层是依靠网络插件添加 iptables 规则来进行控制的,但是在每个节点上都需要配置大量 iptables 规则,加上不同维度控制的增加,导致运维、排障难度较大,所以如果不是特别需要的场景,最好不要使用了。

Service

pod是有生命周期的,ReplicaSet 和Deployment 来动态的创建和销毁 Pod,每个 Pod 都有自己的 IP 地址,但是如果 Pod 重建了的话那么他的 IP 很有可能也就变化了

这就会带来一个问题:比如我们有一些后端的 Pod 集合为集群中的其他应用提供 API 服务,如果我们在前端应用中把所有的这些后端的 Pod的地址都写死,然后以某种方式去访问其中一个 Pod 的服务,这样看上去是可以工作的,对吧?但是如果这个 Pod挂掉了,然后重新启动起来了,是不是 IP 地址非常有可能就变了,这个时候前端就极大可能访问不到后端的服务了。

关键词服务发现,上面的问题可以通过服务发现解决,使用一个服务发现工具,对于新建的pod,我们将它注册到各服务发现中心区,pod删除就从该中心删除掉它的相关信息,但是这样的话我们的前端应用就不能直接去连接后台的 Pod 集合了,应该连接到一个能够做服务发现的中间件上面,为解决这个问题 Kubernetes 就为我们提供了这样的一个对象 - Service,Service 是一种抽象的对象,它定义了一组 Pod 的逻辑集合和一个用于访问它们的策略,其实这个概念和微服务非常类似。一个 Serivce 下面包含的 Pod 集合是由 Label Selector 来决定的

其实service也是个逻辑概念,service会对应一个endpoint,endpoint列表里边就是实际的工作负载实例pod(容器),会动态更新。pod的ip容易变,service的ip不常变化,所以上面的问题就可以解决了,前端应用只需要连接到service即可

三种IP和四种端口

Node IP:Node 节点的 IP 地址
Pod IP: Pod 的 IP 地址,网络插件进行分配的,10.244.0.0/16
Cluster IP: Service 的 IP 地址(service都会有这个cluster ip),是一个虚拟的 IP,仅仅作用于 Kubernetes Service 这个对象,由 Kubernetes 自己来进行管理和分配地址。10.96.0.0

nodeport:nodeport类型下的开启的节点主机端口,一般不手动指定,自动分配(30000~32767)
port:service的port(clusterIP类型)
targetPort:pod的端口(跟下面的containerPort,不定义的时候都是默认和对方相同)
containerPort:容器的端口
(前三种port在svcspec.port[]中定义,containerport一般在pod.spec.port[]中定义或者不定义)

port—>targetPort—>containerPort

NodeIP一般是Kubernetes 集群中节点的物理网卡 IP 地址(一般为内网),所有属于这个网络的服务器之间都可以直接通信,所以Kubernetes 集群外要想访问 Kubernetes 集群内部的某个节点或者服务,肯定得通过 Node IP进行通信(这个时候一般是通过外网 IP 了)(内外网看你实际的分配,有时候内外网卡一样也有可能)

定义 Service

apiVersion: v1
kind: Service
metadata:
  name: myservice
spec:
  selector:
    app: myapp   #通过selector来获取pod,有这些个标签的pod会被服务发现,也可以是多个标签的,多个标签需要同时满足
  ports:
  - protocol: TCP   #udp、tcp。默认tcp
    port: 80
    targetPort: 8080
    name: myapp-http

直接apply即可,会创建相应的svc,会将请求代理到使用 TCP 端口为 8080,具有标签 app=myapp 的 Pod 上,这个 Service 会被系统分配一个我们上面说的 Cluster IP,该 Service 还会持续的监听 selector 下面的 Pod,会把这些 Pod 信息更新到一个名为 myservice 的Endpoints 对象上去,这个对象就类似于我们上面说的 Pod 集合了(服务发现,就是就是发现服务然后注册到服务中心,动态更新。

kube-proxy

前面我们讲到过,在 Kubernetes 集群中,每个 Node 会运行一个 kube-proxy 进程, 负责为 Service 实现一种 VIP(虚拟 IP,就是我们上面说的 clusterIP)的代理形式,现在的 Kubernetes 中默认是使用的 iptables 这种模式来代理。
每生成一个service,kube-proxy都会为它分配个cluster ip,并持续监控,service通过服务发现机制动态更新endpoint列表中的pod,当请求到service(cluster ip,nodeport模式其实也是会到这个cluster ip),会分配到实际的工作负载要就是pod,分配过程由kube-proxy来负责,也就是kube-proxy将流量转发到实际的pod

iptables

这种模式,kube-proxy 会 watch apiserver 对 Service 对象和 Endpoints 对象的添加和移除。对每个 Service,它会添加上 iptables 规则,从而捕获到达该 Service 的 clusterIP(虚拟 IP)和端口的请求,进而将请求重定向到 Service 的一组 backend 中的某一个 Pod 上面。我们还可以使用 Pod readiness 探针 验证后端 Pod 可以正常工作,以便 iptables 模式下的 kube-proxy 仅看到测试正常的后端,这样做意味着可以避免将流量通过 kube-proxy 发送到已知失败的 Pod 中,所以对于线上的应用来说一定要做 readiness 探针

iptables 模式的 kube-proxy 默认的策略是,随机选择一个后端 Pod。

比如当创建 backend Service 时,Kubernetes 会给它指派一个虚拟 IP 地址,比如 10.0.0.1。假设 Service 的端口是 1234,该 Service 会被集群中所有的 kube-proxy 实例观察到。当 kube-proxy 看到一个新的 Service,它会安装一系列的 iptables 规则,从 VIP 重定向到 per-Service 规则。 该 per-Service 规则连接到 per-Endpoint 规则,该 per-Endpoint 规则会重定向(目标 NAT)到后端的 Pod。

ipvs

除了 iptables 模式之外,kubernetes 也支持 ipvs 模式,在 ipvs 模式下,kube-proxy watch Kubernetes 服务(service)和端点(endpoint),调用 netlink 接口相应地创建 IPVS 规则, 并定期将 IPVS 规则与 Kubernetes 服务和端点同步。该控制循环可确保 IPVS 状态与所需状态匹配。访问服务时,IPVS 将流量定向到后端 Pod 之一。

IPVS 代理模式基于类似于 iptables 模式的 netfilter 钩子函数,但是使用哈希表作为基础数据结构,并且在内核空间中工作。 所以与 iptables 模式下的 kube-proxy 相比,IPVS 模式下的 kube-proxy 重定向通信的延迟要短,并且在同步代理规则时具有更好的性能。与其他代理模式相比,IPVS 模式还支持更高的网络流量吞吐量。所以对于较大规模的集群会使用 ipvs 模式的 kube-proxy,只需要满足节点上运行 ipvs 的条件,然后我们就可以直接将 kube-proxy 的模式修改为 ipvs,如果不满足运行条件会自动降级为 iptables 模式,现在都推荐使用 ipvs 模式,可以大幅度提高 Service 性能。

比iptables模式好:
1.数据包的重定向通信更快
2.同步代理规则性能更好
3.支持更高的网络流量吞吐量

IPVS 提供了更多选项来平衡后端 Pod 的流量,默认是 rr,有如下一些策略:

rr: round-robin
lc: least connection (smallest number of open connections)
dh: destination hashing
sh: source hashing
sed: shortest expected
delay nq: never queue

不过现在只能整体修改策略,可以通过 kube-proxy 中配置 –ipvs-scheduler 参数来实现,暂时不支持特定的 Service 进行配置。

所以service也有类似nginx的负载均衡的效果,只是远远不够满足生产环境,负载均衡也就是流量管理的一环,现在比较火热的方案是istio,这里不用花时间去学习service的负载均衡。

service的会话亲和性

其实也是一系列的iptabled或ipvs规则

就是说有个客户端访问service,kube-proxy将流量转发到service对应的某个负载实例pod,一定时间内,该客户端在访问,可以用基于客户端的ip建立的亲和性(iptables或ipvs规则)将流量转发到上一次访问的pod

我们也可以实现基于客户端 IP 的会话亲和性,可以将 service.spec.sessionAffinity 的值设置为 “ClientIP” (默认值为 “None”)即可,此外还可以通过适当设置 service.spec.sessionAffinityConfig.clientIP.timeoutSeconds 来设置最大会话停留时间(默认值为 10800 秒,即 3 小时):

apiVersion: v1
kind: Service
spec:
  sessionAffinity: ClientIP
  ...

亲和性

Service 只支持两种形式的会话亲和性服务:None 和 ClientIP,不支持基于 cookie 的会话亲和性,这是因为Service 不是在 HTTP 层面上工作的,处理的是 TCP 和 UDP 包,并不关心其中的载荷内容,因为 cookie 是 HTTP 协议的一部分,Service 并不知道它们,所有会话亲和性不能基于 Cookie。

service

我们可以使用的服务类型如下:

1.ClusterIP:通过集群的内部 IP 暴露服务,选择该值,服务只能够在集群内部可以访问,这也是默认的服务类型。

2.NodePort:通过每个 Node 节点上的 IP 和静态端口(NodePort)暴露服务。NodePort 服务会路由到 ClusterIP 服务,这个 ClusterIP 服务会自动创建。通过请求 NodeIp:NodePort,可以从集群的外部访问一个 NodePort 服务。

3.LoadBalancer:使用云提供商的负载局衡器(常常用来放在这个云微服务应用架构的最外层),可以向外部暴露服务。外部的负载均衡器可以路由到 NodePort 服务和 ClusterIP 服务,这个需要结合具体的云厂商进行操作。(这个是将外部的流量接收)

4.ExternalName:通过返回 CNAME 和它的值,可以将服务映射到 externalName 字段的内容(例如, foo.bar.example.com)。(这个是将访问到service自身的流量路由出去)

类型在service.spec.type设置

ClusterIP类型

这是默认的类型
nodeport类型其实也是会转发到clusterIP,clusterIP主要是给集群内访问用

NodePort类型

如果设置 type 的值为 “NodePort”,Kubernetes master 将从给定的配置范围内(默认:30000-32767)分配端口,每个 Node 将从该端口(每个 Node 上的同一端口)代理到 Service(每个节点都会监听该端口,监听进程是kube-proxy)。该端口将通过 Service 的 spec.ports[*].nodePort 字段被指定,如果不指定的话会自动生成一个端口。

[root@master ~]# netstat -tunlp | grep 31970
tcp        0      0 0.0.0.0:31970           0.0.0.0:*               LISTEN      5891/kube-proxy     
两个地址一个是localaddress,一个是foreignaddress,0.0.0.0表示任意

这里表示该服务监听的是任意本地地址ip加31970端口(ip用来访问主机,port用来区分服务),所以任何的客户端的请求流量请求到该服务期的任一ip:31970(总之就是流量请求到这台服务器即可,服务由你访问的端口来定)都会交给kube-proxy这个服务
同样你也可以将服务器自身当成客户端访问127.0.0.1:31970,如果kube-proxy设置监听的地址端口为127.0.0.1:31970,意思也就是只能本机访问该服务

foreignaddress是与本机端口通信的外部socket,一般不去管它现实规则和localaddress类似

可以发现service.spec.port[],是一个列表,也就是说可以定义多组端口规则

NodePort类型下,该service的访问方式两种clusterip:port和任意节点ip:nodeport

apiVersion: v1
kind: Service
metadata:
  name: myservice
spec:
  selector:
    app: myapp
  type: NodePort
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
    name: myapp-http
[root@master ~]# kubectl get svc -A
NAMESPACE     NAME                   TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                  AGE
default       kubernetes             ClusterIP   10.96.0.1        <none>        443/TCP                  18d
kube-mon      alertmanager           NodePort    10.109.246.63    <none>        9093:31970/TCP           15d
kube-mon      dingtalk-hook          ClusterIP   10.106.167.196   <none>        5000/TCP                 14d
kube-mon      grafana                NodePort    10.101.113.195   <none>        3000:31165/TCP           15d
kube-mon      redis                  ClusterIP   10.105.131.143   <none>        6379/TCP,9121/TCP        15d
kube-mon      thanos-querier         NodePort    10.97.79.53      <none>        9090:31383/TCP           11d
kube-mon      thanos-store-gateway   ClusterIP   None             <none>        10901/TCP                11d
kube-system   kube-dns               ClusterIP   10.96.0.10       <none>        53/UDP,53/TCP,9153/TCP   18d
kube-system   kube-state-metrics     ClusterIP   None             <none>        8080/TCP,8081/TCP        15d

可以看到有些service的type是NodePort类型,并且多了一个port,前面的port是用于clusterip,后面的port是用于任一节点ip:port来访问

ExternalName类型

ExternalName 是 Service 的特例,它没有 selector,也没有定义任何的端口和 Endpoint。对于运行在集群外部的服务,它通过返回该外部服务的别名这种方式来提供服务。

kind: Service
apiVersion: v1
metadata:
  name: my-service
  namespace: prod
spec:
  type: ExternalName
  externalName: my.database.example.com

当访问地址 my-service.prod.svc.cluster.local(集群内的完整域名格式service_name.namespace.svc.cluster.local,对于常规类型的service,访问域名会解析到clusterIP先,这里是通过kubernetes的dns比如coredns这个pod,然后再进一步将流量转发到实际的pod,请求的转发和service的ip和port的监听是kube-proxy负责)时,集群的 DNS 服务将返回一个值为 my.database.example.com 的 CNAME 记录。
访问这个服务的工作方式与其它的相同,唯一不同的是重定向发生在 DNS 层,而且不会进行代理或转发。如果后续决定要将数据库迁移到 Kubernetes 集群中,可以启动对应的 Pod,增加合适的 Selector 或 Endpoint,修改 Service 的 type,完全不需要修改调用的代码,这样就完全解耦了。

其实就是将自身service作为一个中间的跳板,接收请求,将请求转发出去给实际服务端,可以是集群的某个服务,也可以是外网的服务比如百度

LoadBlancer类型

主要是云提供商提供的负载均衡器,先知道即可,后续用到在学,差不了多少

endpoint

除了可以直接通过 externalName 指定外部服务的域名之外,我们还可以通过自定义 Endpoints 来创建 Service,前提是 clusterIP=None,名称要和 Service 保持一致,如下所示
(一般创建service,会有个相应的endpoint产生)

apiVersion: v1
kind: Service
metadata:
  name: etcd-k8s
  namespace: kube-system
  labels:
    k8s-app: etcd
spec:
  type: ClusterIP   #缺省选项,ClusterIP类型,但是不用kube-proxy分配clusterIP
  clusterIP: None
  ports:
  - name: port
    port: 2379

---

apiVersion: v1
kind: Endpoints
metadata:
  name: etcd-k8s  # 名称必须和 Service 一致
  namespace: kube-system
  labels:
    k8s-app: etcd
subsets:
- addresses:
  - ip: 10.151.30.57  # Service 将连接重定向到 endpoint   服务端的ip,比如pod的ip,宿主机的ip,一般都是集群外的ip
  ports:
  - name: port
    port: 2379   # endpoint 的目标端口,服务端的端口

#都是核心组

上面这个服务就是将外部的 etcd 服务引入到 Kubernetes 集群中来,集群内部通过域名访问svc即可到集群的外部服务

所以HeadlessService+endpoint=ExternalName

了解即可,对于加入外部服务,复杂的环境还是用istio较好

获取客户端 IP

通常,当集群内的客户端连接到服务的时候,是支持服务的 Pod 可以获取到客户端的 IP 地址的,但是,当通过节点端口接收到连接时,由于对数据包执行了源网络地址转换(SNAT),因此数据包的源 IP 地址会发生变化,后端的 Pod 无法看到实际的客户端 IP,对于某些应用来说是个问题,比如,nginx 的请求日志就无法获取准确的客户端访问 IP 了

不仅节点发送SNAT这种情况,还客户端如果设置了正向代理,也很可能让服务端获取不了正确的客户端的ip,但nginx的日志有获取实际的客户端的ip,有一个方法比如nginx的日志的配置设置x_forward这个选项,这需要修改nginx这个pod的配置或者修改他的镜像

这里我们用设置service的方式来让nginx的pod获取实际的客户端ip

测试:部署个nginx的deployment,并绑定到个svc上,nodeport类型,我们可以访问任意节点(比如这里是master节点)加nodeport类型的svc的port可以访问该svc,即访问到nginx的pod(比如node1节点上),nginx此时会有日志,但此时日志现实的访问的客户端的ip是集群某一节点的ip,也就是我们访问svc时写的某个节点ip,并不是实际客户端ip

这个是因为我们 master 节点上并没有对应的 Pod,所以通过 master 节点去访问应用的时候必然需要额外的网络跳转才能到达其他节点上 Pod,在跳转过程中由于对数据包进行了 SNAT,所以看到的是 master 节点的 IP

这个时候我们可以在 Service 设置 externalTrafficPolicy 来减少网络跳数:

spec:
  externalTrafficPolicy: Local

如果 Service 中配置了 externalTrafficPolicy=Local,并且通过服务的节点端口来打开外部连接,则 Service 会代理到本地运行的 Pod,如果本地没有本地 Pod 存在,则连接将挂起,比如我们这里设置上该字段更新,这个时候我们去通过 master 节点的 NodePort 访问应用是访问不到的,因为 master 节点上并没有对应的 Pod 运行,所以需要确保负载均衡器将连接转发给至少具有一个 Pod 的节点。

增加了externalTrafficPolicy: Local这个配置后,接收请求的节点和目标 Pod 都在一个节点上,所以没有额外的网络跳转(不执行 SNAT),所以就可以拿到正确的客户端 IP,如下所示我们把 Pod 都固定到 master 节点上:

apiVersion: apps/v1
kind: Deployment
metadata:
  name:  nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      tolerations:
      - operator: "Exists"
      nodeSelector:
        kubernetes.io/hostname: ydzs-master
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  externalTrafficPolicy: Local
  selector:
    app: nginx
  type: NodePort
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

这时候通过master节点的ip区访问svc,实际的工作负载实例就是master节点得pod(svc没有属于哪个节点这种说法)

服务发现(pod是如何知道service的域名信息以及得到该域名对应的ip:port)

上面我们讲解了 Service 的用法,我们可以通过 Service 生成的 ClusterIP(VIP) 来访问 Pod 提供的服务,但是在使用的时候还有一个问题:我们怎么知道某个应用的 VIP 呢?比如我们有两个应用,一个是 api 应用,一个是 db 应用,两个应用都是通过 Deployment 进行管理的(db类服务还是用stateful或operator来部署比较好),并且都通过 Service 暴露出了端口提供服务。api 需要连接到 db 这个应用,我们只知道 db 应用的名称和 db 对应的 Service 的名称,但是并不知道它的 VIP 地址,那怎么获取VIP

环境变量

为了解决上面的问题,在之前的版本中,Kubernetes 采用了环境变量的方法,每个 Pod 启动的时候,会通过环境变量设置所有服务(service)的 IP 和 port 信息,这样 Pod 中的应用可以通过读取环境变量来获取依赖服务的地址信息,这种方法使用起来相对简单,但是有一个很大的问题就是依赖的服务必须在 Pod 启动之前就存在,不然是不会被注入到环境变量中的。

可以先创建个svc创建一个镜像busybox的测试,进入执行env,可以看到很多变量,其中就有kubernetes自动注入pod的serivce相关的环境变量,有 HOST、PORT、PROTO、ADDR 等

$ kubectl logs test-pod
...
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_SERVICE_PORT=443
HOSTNAME=test-pod
HOME=/root
NGINX_SERVICE_PORT_5000_TCP_ADDR=10.107.225.42
NGINX_SERVICE_PORT_5000_TCP_PORT=5000
NGINX_SERVICE_PORT_5000_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NGINX_SERVICE_SERVICE_HOST=10.107.225.42
NGINX_SERVICE_PORT_5000_TCP=tcp://10.107.225.42:5000
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_PROTO=tcp
NGINX_SERVICE_SERVICE_PORT=5000
NGINX_SERVICE_PORT=tcp://10.107.225.42:5000
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
KUBERNETES_SERVICE_HOST=10.96.0.1
PWD=/
...

如果我们需要在这个 Pod 里面访问 nginx-service(在测试得pod创建之前就创建了) 的服务,我们是不是可以直接通过 NGINX_SERVICE_SERVICE_HOST 和 NGINX_SERVICE_SERVICE_PORT 就可以了,但是如果这个 Pod 启动起来的时候 nginx-service 服务还没启动起来,在环境变量中我们是无法获取到这些信息的,当然我们可以通过 initContainer 之类的方法来确保 nginx-service 启动后再启动 Pod,但是这种方法毕竟增加了 Pod 启动的复杂性,所以这不是最优的方法,局限性太多了。

DNS

由于上面环境变量这种方式的局限性,我们需要一种更加智能的方案,其实我们可以自己思考一种比较理想的方案:那就是可以直接使用 Service 的名称,因为 Service 的名称不会变化,我们不需要去关心分配的 ClusterIP 的地址,因为这个地址并不是固定不变的,所以如果我们直接使用 Service 的名字,然后对应的 ClusterIP 地址的转换能够自动完成就很好了。我们知道名字和 IP 直接的转换是不是和我们平时访问的网站非常类似啊?他们之间的转换功能通过 DNS 就可以解决了,同样的,Kubernetes 也提供了 DNS 的方案来解决上面的服务发现的问题。

DNS 服务不是一个独立的系统服务,而是作为一种 addon 插件而存在,现在比较推荐的两个插件:kube-dns 和 CoreDNS,实际上在比较新点的版本中已经默认是 CoreDNS 了,因为 kube-dns 默认一个 Pod 中需要3个容器配合使用,CoreDNS 只需要一个容器即可,我们在前面使用 kubeadm 搭建集群的时候直接安装的就是 CoreDNS 插件:

[root@master ~]# kubectl get pod -n kube-system -l k8s-app=kube-dns -o wide
NAME                       READY   STATUS    RESTARTS   AGE   IP           NODE     NOMINATED NODE   READINESS GATES
coredns-7f89b7bc75-x28zg   1/1     Running   672        18d   10.244.0.6   master   <none>           <none>
coredns-7f89b7bc75-xm7dz   1/1     Running   675        18d   10.244.0.7   master   <none>           <none>
[root@master ~]# 

CoreDns 是用 GO 写的高性能,高扩展性的 DNS 服务,基于 HTTP/2 Web 服务 Caddy 进行编写的。CoreDns 内部采用插件机制,所有功能都是插件形式编写,用户也可以扩展自己的插件,coredns是kubernetes的一个dns服务的插件,自身的功能也是用插件形式编写

Kubernetes 部署 CoreDns 时的默认配置:

[root@master ~]# kubectl get cm coredns -n kube-system -o yaml
apiVersion: v1
data:
  Corefile: |
    .:53 {
    
    
        errors
        health {
    
    
           lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
    
    
           pods insecure
           fallthrough in-addr.arpa ip6.arpa
           ttl 30
        }
        prometheus :9153
        forward . /etc/resolv.conf {
    
    
           max_concurrent 1000
        }
        cache 30
        loop
        reload
        loadbalance
    }
kind: ConfigMap
metadata:
  creationTimestamp: "2022-04-13T15:04:47Z"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:data:
        .: {
    
    }
        f:Corefile: {
    
    }
    manager: kubeadm
    operation: Update
    time: "2022-04-13T15:04:47Z"
  name: coredns
  namespace: kube-system
  resourceVersion: "233"
  uid: 56021a1a-54fc-4517-a6d8-ee0d44987890

主要看data部分,这是kubernetes部署coredns默认的功能

每个 {} 代表一个 zone,格式是 “Zone:port{}”, 其中"."代表默认zone

{} 内的每个名称代表插件的名称,只有配置的插件才会启用,当解析域名时,会先匹配 zone(都未匹配会执行默认 zone),然后 zone 内的插件从上到下依次执行(这个顺序并不是配置文件内谁在前面的顺序,而是core/dnsserver/zdirectives.go内的顺序),匹配后返回处理(执行过的插件从下到上依次处理返回逻辑),不再执行下一个插件

CoreDNS 的 Service 地址一般情况下是固定的,类似于 kubernetes 这个 Service 地址一般就是第一个 IP 地址 10.96.0.1,CoreDNS 的 Service 地址就是 10.96.0.10(service的网段部署时就定下来了,10.0.0.0),该 IP 被分配后,kubelet 会将使用 --cluster-dns= 参数配置的 DNS 传递给每个容器。DNS 名称也需要域名,本地域可以使用参数–cluster-domain = 在 kubelet 中配置:

$ cat /var/lib/kubelet/config.yaml
......
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
......

完整域名:
主机名…二级域名.一级域名.
最后的点表示根域服务器

普通的 Service:会生成 servicename.namespace.svc.cluster.local 的域名,会解析到 Service 对应的 ClusterIP 上,在 Pod 之间的调用可以简写成 servicename.namespace,如果处于同一个命名空间下面,甚至可以只写成 servicename 即可访问
Headless Service:无头服务,就是把 clusterIP 设置为 None 的,会被解析为指定 Pod 的 IP 列表,同样还可以通过 podname.servicename.namespace.svc.cluster.local 访问到具体的某一个 Pod。

集群内可以直接用域名访问服务

$ kubectl run -it --image busybox:1.28.3 test-dns --restart=Never --rm /bin/sh
If you don't see a command prompt, try pressing enter.
/ # cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
/ #

/etc/resolv.conf下面是域名服务器(dns)的信息(domain name)

对于你输入的域名,进行解析的流程一般是浏览器的dns缓存-->本地的dns缓存-->/etc/hosts-->本地dns服务器-->外部dns服务器(会一层一层域名服务器去找解析,最多找到根域名服务器,根服务器会告诉他对应的域名要去那台域名服务器找,知道解析出域名对应的ip)
域名服务器的架构是分布式架构
这里只是kubernetes内不会这么复杂
访问下前面我们创建的 nginx-service 服务:
/ # wget -q -O- nginx-service.default.svc.cluster.local

上面我们使用 wget 命令去访问 nginx-service 服务的域名的时候被 hang 住了,没有得到期望的结果,这是因为上面我们建立 Service 的时候暴露的端口是 5000(默认80端口):
/ # wget -q -O- nginx-service.default.svc.cluster.local:5000
(q:quiet,不显示命令执行过程 O:output,下载内容并保存到某个文件下,不指定文件名则和下载的文件同名,-O-:下载的文件内容标准输出

再试一试访问:nginx-service.default.svc、nginx-service.default、nginx-service(默认名称空间defalut),这些域名也可以访问到,注意短域名形式得要求你的pod跟service同个namespace

给 Pod 添加 DNS 记录

Pod 自己本身也是可以有自己的 DNS 记录的

然后定义如下的 Headless Service:


# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  clusterIP: None
  ports:
  - name: http
    port: 80
    protocol: TCP
  selector:
    app: nginx
  type: ClusterIP

对这个headless service用nslookup或dig进行域名解析,会发现返回dns的网这是kube-dns这个svc的地址10.96.0.10,实际工作的是两个coredns的pod

[root@master ~]# kubectl get svc -n kube-system -o wide
NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE   SELECTOR
kube-dns             ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   18d   k8s-app=kube-dns
kube-state-metrics   ClusterIP   None         <none>        8080/TCP,8081/TCP        15d   app.kubernetes.io/name=kube-state-metrics

附:对pod的selector其实也受namespace的限制

Pod 规范中包含一个可选的 hostname 字段,可以用来指定 Pod 的主机名。当这个字段被设置时,它将优先于 Pod 的名字成为该 Pod 的主机名。举个例子,给定一个 hostname 设置为 “my-host” 的 Pod, 该 Pod 的主机名将被设置为 “my-host”。Pod 规约还有一个可选的 subdomain 字段,可以用来指定 Pod 的子域名

使用有pod_name组成的域名来访问pod,svc一般是headless service

创建一个pod,直接指定spec.hostname和spec.subdomain(比如上面的nginx这个service_name),然后就可以用fqdn:pod_name.svc_name.namespace.svc.cluster.local来访问该pod

用deployment来创建pod,同时在template下指定hostname和subdomain,也可以用同样的方式访问pod,但是要注意,这里是deploy的多个pod主机名都相同,所以kube-dns解析该域名出的ip有多个,也就是用该域名访问每次会访问到不同的pod上,这就不太合理了。不过知道了这种方式过后我们就可以自己去写一个 Operator 去直接管理 Pod 了,给每个 Pod 设置不同的 hostname 和一个 Headless SVC 名称的 subdomain,这样就相当于实现了 StatefulSet 中的 Pod 解析。
还记得在statefulset控制器说过,对于有状态的服务的应用(数据本地持久化)(或者比如代码中写定了要访问某个固定的应用的pod),采用statefulset+headless service不如使用相应的operator来的好,其实这里的operator也是使用了上面这个kubernetes的域名的特性

Pod 的 DNS 策略

DNS 策略可以单独对 Pod 进行设定,目前 Kubernetes 支持以下特定 Pod 的 DNS 策略。这些策略可以在 Pod 规范中的 dnsPolicy 字段设置:

Default: 有人说 Default 的方式,是使用宿主机的方式,这种说法并不准确。这种方式其实是让 kubelet 来决定使用何种 DNS 策略。而 kubelet 默认的方式,就是使用宿主机的 /etc/resolv.conf(可能这就是有人说使用宿主机的 DNS 策略的方式吧),但是,kubelet 是可以灵活来配置使用什么文件来进行DNS策略的,我们完全可以使用 kubelet 的参数 –resolv-conf=/etc/resolv.conf 来决定你的 DNS 解析文件地址(那我们就可以手动写dns解析文件来满足特殊的dns解析需求)

ClusterFirst: 这种方式,表示 Pod 内的 DNS 使用集群中配置的 DNS 服务,简单来说,就是使用 Kubernetes 中 kubedns 或 coredns 服务进行域名解析。如果解析不成功,才会使用宿主机的 DNS 配置进行解析。

ClusterFirstWithHostNet:在某些场景下,我们的 Pod 是用 HostNetwork 模式启动的,一旦用 HostNetwork 模式,表示这个 Pod 中的所有容器,都要使用宿主机的 /etc/resolv.conf 配置进行 DNS 查询,但如果你还想继续使用 Kubernetes 的DNS服务,那就将 dnsPolicy 设置为 ClusterFirstWithHostNet。

None: 表示空的 DNS 设置,这种方式一般用于想要自定义 DNS 配置的场景,往往需要和 dnsConfig 配合一起使用达到自定义 DNS 的目的。

需要注意的是 Default 并不是默认的 DNS 策略,如果未明确指定 dnsPolicy,则使用 ClusterFirst。

下面的示例显示了一个 Pod,其 DNS 策略设置为 ClusterFirstWithHostNet,因为它已将 hostNetwork 设置为 true。

apiVersion: v1
kind: Pod
metadata:
  name: busybox
  namespace: default
spec:
  containers:
  - image: busybox:1.28
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
    name: busybox
  restartPolicy: Always
  hostNetwork: true
  dnsPolicy: ClusterFirstWithHostNet

对于kubernetes的开启了hostNetwork的pod还是指定下ClusterFirstWithHostNet这个dnsPolicy

Pod 的 DNS 配置

Pod 的 DNS 配置可让用户对 Pod 的 DNS 设置进行更多控制。dnsConfig 字段是可选的,它可以与任何 dnsPolicy 设置一起使用。 但是,当 Pod 的 dnsPolicy 设置为 “None” 时,必须指定 dnsConfig 字段。

用户可以在 dnsConfig 字段中指定以下属性:

nameservers:将用作于 Pod 的 DNS 服务器的 IP 地址列表。 最多可以指定 3 个 IP 地址。当 Pod 的 dnsPolicy 设置为 “None” 时,列表必须至少包含一个 IP 地址,否则此属性是可选的。所列出的服务器将合并到从指定的 DNS 策略生成的基本名称服务器,并删除重复的地址。

searches:用于在 Pod 中查找主机名的 DNS 搜索域的列表。此属性是可选的。 指定此属性时,所提供的列表将合并到根据所选 DNS 策略生成的基本搜索域名中。重复的域名将被删除,Kubernetes 最多允许 6 个搜索域。

options:可选的对象列表,其中每个对象可能具有 name 属性(必需)和 value 属性(可选)。此属性中的内容将合并到从指定的 DNS 策略生成的选项。重复的条目将被删除。
以下是具有自定义 DNS 设置的 Pod 示例:

apiVersion: v1
kind: Pod
metadata:
  namespace: default
  name: dns-example
spec:
  containers:
    - name: test
      image: nginx
  dnsPolicy: "None"
  dnsConfig:
    nameservers:
      - 1.2.3.4
    searches:
      - ns1.svc.cluster-domain.example
      - my.dns.search.suffix
    options:
      - name: ndots
        value: "2"
      - name: edns0

创建上面的 Pod 后,容器 test 会在其 /etc/resolv.conf 文件中获取以下内容:

nameserver 1.2.3.4   #dns服务器地址
search ns1.svc.cluster-domain.example my.dns.search.suffix   #搜索的域名,当主机名未声明域名,从这些域名中查找先
options ndots:2 edns0

主要的是nameserver,其他的可选

DNS 优化

前面我们讲解了在 Kubernetes 中我们可以使用 CoreDNS 来进行集群的域名解析,但是如果在集群规模较大并发较高的情况下我们仍然需要对 DNS 进行优化,典型的就是大家比较熟悉的 CoreDNS 会出现超时5s的情况。

超时原因

在 iptables 模式下(默认情况下),每个服务的 kube-proxy 在主机网络名称空间的 nat 表中创建一些 iptables 规则。
比如在集群中具有两个 DNS 服务器实例的 kube-dns 服务,其相关规则大致如下所示:

(1) -A PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES
<...>
(2) -A KUBE-SERVICES -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-SVC-TCOU7JCQXEZGVUNU
<...>
(3) -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-LLLB6FGXBLX6PZF7
(4) -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns" -j KUBE-SEP-LRVEW52VMYCOUSMZ
<...>
(5) -A KUBE-SEP-LLLB6FGXBLX6PZF7 -p udp -m comment --comment "kube-system/kube-dns:dns" -m udp -j DNAT --to-destination 10.32.0.6:53
<...>
(6) -A KUBE-SEP-LRVEW52VMYCOUSMZ -p udp -m comment --comment "kube-system/kube-dns:dns" -m udp -j DNAT --to-destination 10.32.0.7:53

我们知道每个 Pod 的 /etc/resolv.conf 文件中都有填充的 nameserver 10.96.0.10 这个条目。所以来自 Pod 的 DNS 查找请求将发送到 10.96.0.10,这是 kube-dns 服务的 ClusterIP 地址。 由于 (1) 请求进入 KUBE-SERVICE 链,然后匹配规则 (2),最后根据 (3) 的 random 随机模式(iptables默认的规则),跳转到 (5) 或 (6) 条目,将请求 UDP 数据包的目标 IP 地址修改为 DNS 服务器的实际 IP 地址,这是通过 DNAT(目标网络地址转换) 完成的。其中 10.32.0.6 和 10.32.0.7 是我们集群中 CoreDNS 的两个 Pod 副本的 IP 地址。

pod发出的域名访问请求,不考虑缓存的情况下,去使用dns服务器解析,经过多个步骤的DNAT,才将udp数据包(向域名服务器查找域名时用udp包,不用强调数据包的安全性和完整性等等,所以这并不是发往想要请求的服务器的请求数据包)发送到dns服务器,即实际的coredns的pod

内核中的 DNAT

DNAT 的主要职责是同时更改传出数据包的目的地,响应数据包的源,并确保对所有后续数据包进行相同的修改。后者严重依赖于连接跟踪机制,也称为 conntrack,它被实现为内核模块。conntrack 会跟踪系统中正在进行的网络连接。

conntrack 中的每个连接都由两个元组表示,一个元组用于原始请求(IP_CT_DIR_ORIGINAL),另一个元组用于答复(IP_CT_DIR_REPLY)。对于 UDP,每个元组都由源 IP 地址,源端口以及目标 IP 地址和目标端口组成,答复元组包含存储在src 字段中的目标的真实地址。(主要就是修改目标地址)(容器共享同一个内核)

例如,如果 IP 地址为 10.40.0.17 的 Pod 向 kube-dns 的 ClusterIP 发送一个请求,该请求被转换为 10.32.0.6(kube-proxy),则将创建以下元组:

原始:src = 10.40.0.17 dst = 10.96.0.10 sport = 53378 dport = 53
回复:src = 10.32.0.6 dst = 10.40.0.17 sport = 53 dport = 53378

通过这些条目内核可以相应地修改任何相关数据包的目的地和源地址,而无需再次遍历 DNAT 规则,此外,它将知道如何修改回复以及应将回复发送给谁。创建 conntrack 条目后,将首先对其进行确认,然后如果没有已确认的 conntrack 条目具有相同的原始元组或回复元组,则内核将尝试确认该条目。conntrack 创建和 DNAT 的简化流程如下所示:

在这里插入图片描述

问题

DNS 客户端 (glibc 或 musl libc) 会并发请求 A 和 AAAA 记录,跟 DNS Server 通信自然会先 connect (建立fd),后面请求报文使用这个 fd 来发送,由于 UDP 是无状态协议,connect 时并不会创建 conntrack 表项, 而并发请求的 A 和 AAAA 记录默认使用同一个 fd 发包,这时它们源 Port 相同,当并发发包时,两个包都还没有被插入 conntrack 表项,所以 netfilter 会为它们分别创建 conntrack 表项,而集群内请求 CoreDNS 都是访问的 CLUSTER-IP,报文最终会被 DNAT 成一个具体的 Pod IP,当两个包被 DNAT 成同一个 IP,最终它们的五元组就相同了,在最终插入的时候后面那个包就会被丢掉,如果 DNS 的 Pod 副本只有一个实例的情况就很容易发生(设置两个dns的pod就是为了缓解dns请求超时这个问题),现象就是 DNS 请求超时,客户端默认策略是等待 5s 自动重试,如果重试成功,我们看到的现象就是 DNS 请求有 5s 的延时。

只有多个线程或进程,并发从同一个 socket 发送相同五元组的 UDP 报文时,才有一定概率会发生 glibc、musl(alpine
linux 的 libc 库)都使用 parallel query, 就是并发发出多个查询请求,因此很容易碰到这样的冲突,造成查询请求被丢弃
由于 ipvs 也使用了 conntrack, 使用 kube-proxy 的 ipvs 模式,并不能避免这个问题

解决方法

要彻底解决这个问题最好当然是内核上去 FIX 掉这个 BUG,除了这种方法之外我们还可以使用其他方法来进行规避,我们可以避免相同五元组 DNS请求的并发。

在 resolv.conf 中就有两个相关的参数可以进行配置:

single-request-reopen:发送 A 类型请求和 AAAA 类型请求使用不同的源端口,这样两个请求在 conntrack表中不占用同一个表项,从而避免冲突。
single-request:避免并发,改为串行发送 A 类型和 AAAA类型请求。没有了并发,从而也避免了冲突。
当然并发的效率会更高

要给容器的 resolv.conf 加上 options 参数,有几个办法(主机遇到这种dns请求超时也可以这样解决):

1.在容器的 ENTRYPOINT 或者 CMD 脚本中,执行 /bin/echo 'options single-request-reopen' >> /etc/resolv.conf
2.在 Pod 的 postStart hook 中添加:

lifecycle:
  postStart:
    exec:
      command:
      - /bin/sh
      - -c 
      - "/bin/echo 'options single-request-reopen' >> /etc/resolv.conf"

使用 template.spec.dnsConfig 配置:

template:
  spec:
    dnsConfig:
      options:
        - name: single-request-reopen

使用 ConfigMap 覆盖 Pod 里面的 /etc/resolv.conf:

# configmap
apiVersion: v1
data:
  resolv.conf: |
    nameserver 1.2.3.4
    search default.svc.cluster.local svc.cluster.local cluster.local
    options ndots:5 single-request-reopen timeout:1
kind: ConfigMap
metadata:
  name: resolvconf
---
# Pod Spec
spec:
    volumeMounts:
    - name: resolv-conf
      mountPath: /etc/resolv.conf   
      subPath: resolv.conf  # 在某个目录下面挂载一个文件(保证不覆盖当前目录)需要使用subPath -> 不支持热更新
...
  volumes:
  - name: resolv-conf
    configMap:
      name: resolvconf
      items:
      - key: resolv.conf
        path: resolv.conf

其实就是想办法将single-request-reopen或single-request加入容器的/etc/resolv.conf

上面的方法在一定程度上可以解决 DNS 超时的问题,但更好的方式是使用本地 DNS 缓存,容器的 DNS 请求都发往本地的 DNS 缓存服务,也就不需要走 DNAT,当然也不会发生 conntrack 冲突了,而且还可以有效提升 CoreDNS 的性能瓶颈。

性能测试

这里我们使用一个简单的 golang 程序来测试下使用本地 DNS 缓存的前后性能。代码如下所示:

// main.go
package main

import (
    "context"
    "flag"
    "fmt"
    "net"
    "sync/atomic"
    "time"
)

var host string
var connections int
var duration int64
var limit int64
var timeoutCount int64

func main() {
    
    
    flag.StringVar(&host, "host", "", "Resolve host")
    flag.IntVar(&connections, "c", 100, "Connections")
    flag.Int64Var(&duration, "d", 0, "Duration(s)")
    flag.Int64Var(&limit, "l", 0, "Limit(ms)")
    flag.Parse()

    var count int64 = 0
    var errCount int64 = 0
    pool := make(chan interface{
    
    }, connections)
    exit := make(chan bool)
    var (
        min int64 = 0
        max int64 = 0
        sum int64 = 0
    )

    go func() {
    
    
        time.Sleep(time.Second * time.Duration(duration))
        exit <- true
    }()

endD:
    for {
    
    
        select {
    
    
        case pool <- nil:
            go func() {
    
    
                defer func() {
    
    
                    <-pool
                }()
                resolver := &net.Resolver{
    
    }
                now := time.Now()
                _, err := resolver.LookupIPAddr(context.Background(), host)
                use := time.Since(now).Nanoseconds() / int64(time.Millisecond)
                if min == 0 || use < min {
    
    
                    min = use
                }
                if use > max {
    
    
                    max = use
                }
                sum += use
                if limit > 0 && use >= limit {
    
    
                    timeoutCount++
                }
                atomic.AddInt64(&count, 1)
                if err != nil {
    
    
                    fmt.Println(err.Error())
                    atomic.AddInt64(&errCount, 1)
                }
            }()
        case <-exit:
            break endD
        }
    }
    fmt.Printf("request count:%d\nerror count:%d\n", count, errCount)
    fmt.Printf("request time:min(%dms) max(%dms) avg(%dms) timeout(%dn)\n", min, max, sum/count, timeoutCount)
}

首先配置好 golang 环境,然后直接构建上面的测试应用:

$ go build -o testdns .

构建完成后生成一个 testdns 的二进制文件,然后我们将这个二进制文件拷贝到任意一个 Pod 中去进行测试:

$ kubectl cp testdns svc-demo-546b7bcdcf-6xsnr:/root -n default

拷贝完成后进入这个测试的 Pod 中去:

$ kubectl exec -it svc-demo-546b7bcdcf-6xsnr -- /bin/bash
root@svc-demo-546b7bcdcf-6xsnr:/# cd /root

然后我们执行 testdns 程序来进行压力测试,比如执行 200 个并发,持续 30 秒:

# 对 nginx-service.default 这个地址进行解析
root@svc-demo-546b7bcdcf-6xsnr:~# ./testdns -host nginx-service.default -c 200 -d 30 -l 5000
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
request count:12533
error count:5
request time:min(5ms) max(16871ms) avg(425ms) timeout(475n)
root@svc-demo-546b7bcdcf-6xsnr:~# ./testdns -host nginx-service.default -c 200 -d 30 -l 5000
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
request count:10058
error count:3
request time:min(4ms) max(12347ms) avg(540ms) timeout(487n)
root@svc-demo-546b7bcdcf-6xsnr:~# ./testdns -host nginx-service.default -c 200 -d 30 -l 5000
lookup nginx-service.default on 10.96.0.10:53: no such host
lookup nginx-service.default on 10.96.0.10:53: no such host
request count:12242
error count:2
request time:min(3ms) max(12206ms) avg(478ms) timeout(644n)
root@svc-demo-546b7bcdcf-6xsnr:~# ./testdns -host nginx-service.default -c 200 -d 30 -l 5000
request count:11008
error count:0
request time:min(3ms) max(11110ms) avg(496ms) timeout(478n)
root@svc-demo-546b7bcdcf-6xsnr:~# ./testdns -host nginx-service.default -c 200 -d 30 -l 5000
request count:9141
error count:0
request time:min(4ms) max(11198ms) avg(607ms) timeout(332n)
root@svc-demo-546b7bcdcf-6xsnr:~# ./testdns -host nginx-service.default -c 200 -d 30 -l 5000
request count:9126
error count:0
request time:min(4ms) max(11554ms) avg(613ms) timeout(197n)

我们可以看到大部分平均耗时都是在 500ms 左右,这个性能是非常差的,而且还有部分解析失败的条目。接下来我们就来尝试使用 NodeLocal DNSCache 来提升 DNS 的性能和可靠性。

NodeLocal DNSCache

NodeLocal DNSCache 通过在集群节点上运行一个 DaemonSet 来提高集群 DNS 性能和可靠性。处于 ClusterFirst 的 DNS 模式下的 Pod 可以连接到 kube-dns 的 serviceIP 进行 DNS 查询,通过 kube-proxy 组件添加的 iptables 规则将其转换为 CoreDNS 端点。通过在每个集群节点上运行 DNS 缓存,NodeLocal DNSCache 可以缩短 DNS 查找的延迟时间、使 DNS 查找时间更加一致,以及减少发送到 kube-dns 的 DNS 查询次数。

在集群中运行 NodeLocal DNSCache 有如下几个好处:

如果本地没有 CoreDNS 实例,则具有最高 DNS QPS 的 Pod 可能必须到另一个节点进行解析,使用 NodeLocal DNSCache 后,拥有本地缓存将有助于改善延迟
跳过 iptables DNAT 和连接跟踪将有助于减少 conntrack 竞争并避免UDP DNS 条目填满 conntrack 表(上面提到的5s超时问题就是这个原因造成的)
从本地缓存代理到 kube-dns 服务的连接可以升级到 TCP,TCP conntrack 条目将在连接关闭时被删除,而 UDP 条目必须超时(默认nfconntrackudp_timeout 是 30 秒)
将 DNS 查询从 UDP 升级到 TCP 将减少归因于丢弃的 UDP 数据包和 DNS 超时的尾部等待时间,通常长达 30 秒(3 次重试+ 10 秒超时)

部署NodeLocal DNSCache:

$ wget https://github.com/kubernetes/kubernetes/raw/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml
该资源清单文件中包含几个变量值得注意,其中:

__PILLAR__DNS__SERVER__ :表示 kube-dns 这个 Service 的 ClusterIP,可以通过命令 kubectl get svc -n kube-system | grep kube-dns | awk '{ print $3 }' 获取(我们这里就是 10.96.0.10)
__PILLAR__LOCAL__DNS__:表示 DNSCache 本地的 IP,默认为 169.254.20.10
__PILLAR__DNS__DOMAIN__:表示集群域,默认就是 cluster.local,集群定义的域名,可以在/var/lib/kubelet/config.yaml中修改传到pod中的集群域名,当然你也要先修改集群的域名
另外还有两个参数 __PILLAR__CLUSTER__DNS__ 和 __PILLAR__UPSTREAM__SERVERS__,这两个参数会通过镜像 1.15.16 版本去进行自动配置,对应的值来源于 kube-dns 的 ConfigMap 和定制的 Upstream Server 配置。

直接执行如下所示的命令即可安装:

$ sed 's/k8s.gcr.io\/dns/cnych/g
s/__PILLAR__DNS__SERVER__/10.96.0.10/g
s/__PILLAR__LOCAL__DNS__/169.254.20.10/g
s/__PILLAR__DNS__DOMAIN__/cluster.local/g' nodelocaldns.yaml |
kubectl apply -f -

需要注意的是这里使用 DaemonSet 部署 node-local-dns 使用了 hostNetwork=true,会占用宿主机的 8080 端口,所以需要保证该端口未被占用。

但是到这里还没有完,如果 kube-proxy 组件使用的是 ipvs 模式的话我们还需要修改 kubelet 的 --cluster-dns 参数(/var/lib/kubelet/config.yaml),将其指向 169.254.20.10,Daemonset 会在每个节点创建一个网卡来绑这个 IP,Pod 向本节点这个 IP 发 DNS 请求,缓存没有命中的时候才会再代理到上游集群 DNS 进行查询。

iptables 模式下 Pod 还是向原来的集群 DNS 请求,节点上有这个 IP 监听,会被本机拦截,再请求集群上游 DNS,所以不需要更改 --cluster-dns 参数。

如果担心线上环境修改 --cluster-dns 参数会产生影响,我们也可以直接在新部署的 Pod 中通过 dnsConfig 配置使用新的 localdns 的地址来进行解析。(pod.spec.dnsConfig.nameservers[]),ip就是cluster-dns的值

由于我这里使用的是 kubeadm 安装的 1.19 版本的集群,所以我们需要替换节点上 /var/lib/kubelet/config.yaml 文件中的 clusterDNS 这个参数值,然后重启即可:

$ sed -i 's/10.96.0.10/169.254.20.10/g' /var/lib/kubelet/config.yaml
$ systemctl daemon-reload && systemctl restart kubelet

新部署个pod,我们可以看到 nameserver 已经变成 169.254.20.10 了,当然对于之前的历史 Pod 要想使用 node-local-dns 则需要重建。

无论是最大解析时间还是平均解析时间都比之前默认的 CoreDNS 提示了不少的效率,所以我们还是非常推荐在线上环境部署 NodeLocal DNSCache 来提升 DNS 的性能和可靠性的,唯一的缺点就是由于 LocalDNS 使用的是 DaemonSet 模式部署(尽管默认的更新策略是滚动更新),所以如果需要更新镜像则可能会中断服务(不过可以使用一些第三方的增强组件来实现原地升级解决这个问题,比如 openkruise)。

对于镜像更新,除了deployment,其他类型的控制器更新竟像是建议使用第三方的组件来更新

猜你喜欢

转载自blog.csdn.net/weixin_45843419/article/details/124515170