了解了k8s中容器之间的通信,通过CNI插件使得它们之间可以利用IP地址进行通信。但是通过指定IP地址进行通信是不太方便的,一方面是不易于记忆,另一方面是k8s集群中的一个Pod随机可能宕机重启,也就是说IP是变化的,而且为了提高可用性通过每个Pod都是有多个副本的。

这个问题与微服务中的服务发现场景非常相似,实际上也正是利用服务发现来解决通信的问题的。即Service。

Service对外访问中已经介绍过了Service。那么现在就详细说明一下service的工作方式。

Service

一个典型的Service的定义如下:

apiVersion: v1
kind: Service
metadata:
  name: hostnames
spec:
  selector:
    app: hostnames
  ports:
  - name: default
    protocol: TCP
    port: 80
    targetPort: 9376

其中selector字段声明了这个Service只代理携带了app=hostnames标签的Pod,并且这个Serivce的80端口代理的是Pod的targetPort端口。

然后只要创建的Pod携带了指定的标签,那么Serivce就会自动代理这个Pod,例如:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hostnames
spec:
  selector:
    matchLabels:
      app: hostnames
  replicas: 3
  template:
    metadata:
      labels:
        app: hostnames
    spec:
      containers:
      - name: hostnames
        image: k8s.gcr.io/serve_hostname
        ports:
        - containerPort: 9376
          protocol: TCP

这个Pod就被Service代理,而被Service选中的Pod,就称为Service的EndPoints,可以使用kubectl命令来查看

$ kubectl get endpoints hostnames
NAME        ENDPOINTS
hostnames   10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376

Note

只有处于 Running 状态,且 readinessProbe 检查通过的 Pod,才会出现在 Service 的 Endpoints 列表里。当某一个 Pod 出现问题时,Kubernetes 会自动把它从 Service 里摘除掉。

现在通过访问Service 的VIP就可以访问到它代理的Pod了

$ kubectl get svc hostnames
NAME        TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
hostnames   ClusterIP   10.0.1.175   <none>        80/TCP    5s
 
$ curl 10.0.1.175:80
hostnames-0uton
 
$ curl 10.0.1.175:80
hostnames-yp2kp
 
$ curl 10.0.1.175:80
hostnames-bvc05

Service 提供的是 Round Robin 方式的负载均衡。对于这种方式,我们称为:ClusterIP 模式的 Service。

工作方式

Serivce是通过kube-proxy组件和iptables共同实现的。创建的service一旦被提交给k8s,那么kube-proxy的informer就可以感知到这个事件,然后在宿主机上添加这样一条iptables规则:

-A KUBE-SERVICES -d 10.0.1.175/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3

即凡是目的地址是10.0.1.175,目的端口是80的IP包,都应该跳转到另一条KUBE-SVC-NWV5X2332I4OT4T3的iptables链进行处理。这条规则为service设置了固定的入口地址。并且,由于 10.0.1.175 只是一条 iptables 规则上的配置,并没有真正的网络设备,所以你 ping 这个地址,是不会有任何响应的。

而跳转的KUBE-SVC-NWV5X2332I4OT4T3是一组规则的集合

-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-57KPRZ3JQVENLNBR

进行随机转发,而随机转发的目的地,就是这个Serivce代理的Pod。这也是Service负载均衡实现的位置

转到的几条链其实是DNAT规则:

-A KUBE-SEP-57KPRZ3JQVENLNBR -s 10.244.3.6/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-57KPRZ3JQVENLNBR -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.3.6:9376
 
-A KUBE-SEP-WNBA2IHDGP2BOBGZ -s 10.244.1.7/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-WNBA2IHDGP2BOBGZ -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.1.7:9376
 
-A KUBE-SEP-X3P2623AGDH6CDF3 -s 10.244.2.3/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-X3P2623AGDH6CDF3 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.2.3:9376

在路由之前,将流入 IP 包的目的地址和端口,改成–to-destination 所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正是被代理 Pod 的 IP 地址和端口。

也就是说,访问service的IP,会被跳转到这个Service对应的iptables处理链,这个链中包含了这个Service所代理的Pod的规则的集合,然后随机选择一个进行转发,具体到某个Pod的链的规则是一个DNAT,在路由之前将目的地址和端口改为指定的新的目的地址和端口。

这就是Service的工作方式,而这些 Endpoints 对应的 iptables 规则,正是 kube-proxy 通过监听 Pod 的变化事件,在宿主机上生成并维护的。

IPVS模式

通过上面介绍的工作方式可以知道,kube-proxy 通过 iptables 处理 Service 的过程,其实需要在宿主机上设置相当多的 iptables 规则。而且,kube-proxy 还需要在控制循环里不断地刷新这些规则来确保它们始终是正确的

当你的宿主机上有大量 Pod 的时候,成百上千条 iptables 规则不断地被刷新,会大量占用该宿主机的 CPU 资源,甚至会让宿主机“卡”在这个过程中。所以说,一直以来,基于 iptables 的 Service 实现,都是制约 Kubernetes 项目承载更多量级的 Pod 的主要障碍

而k8s的IPVS模式就是解决这个限制的一个方法。相比于 iptables,IPVS 在内核中的实现其实也是基于 Netfilter 的 NAT 模式,所以在转发这一层上,理论上 IPVS 并没有显著的性能提升。但是,IPVS 并不需要在宿主机上为每个 Pod 设置 iptables 规则,而是把对这些“规则”的处理放到了内核态,从而极大地降低了维护这些规则的代价。这也正印证了我在前面提到过的,“将重要操作放入内核态”是提高性能的重要手段。

IPVS模式,创建了Service之后,kube-proxy会在宿主机上创建一个虚拟网卡,并为他分配Service VIP作为IP地址,如下所示:

# ip addr
  ...
  73:kube-ipvs0:<BROADCAST,NOARP>  mtu 1500 qdisc noop state DOWN qlen 1000
  link/ether  1a:ce:f5:5f:c1:4d brd ff:ff:ff:ff:ff:ff
  inet 10.0.1.175/32  scope global kube-ipvs0
  valid_lft forever  preferred_lft forever

kube-proxy就会通过Linux的IPVS模块,为这个IP地址设置三个IPVS虚拟主机,并设置这三个虚拟主机使用轮询模式来作为负载均衡策略,这三个主机正是被代理的三个Pod

# ipvsadm -ln
 IP Virtual Server version 1.2.1 (size=4096)
  Prot LocalAddress:Port Scheduler Flags
    ->  RemoteAddress:Port           Forward  Weight ActiveConn InActConn     
  TCP  10.102.128.4:80 rr
    ->  10.244.3.6:9376    Masq    1       0          0         
    ->  10.244.1.7:9376    Masq    1       0          0
    ->  10.244.2.3:9376    Masq    1       0          0

这样发往Service的请求都会被IPVS模块转发到某一个后端Pod上。

IPVS 模块只负责上述的负载均衡和代理功能。而一个完整的 Service 流程正常工作所需要的包过滤、SNAT 等操作,还是要靠 iptables 来实现。只不过,这些辅助性的 iptables 规则数量有限,也不会随着 Pod 数量的增加而增加。

==在大规模集群里,我非常建议你为 kube-proxy 设置–proxy-mode=ipvs 来开启这个功能。它为 Kubernetes 集群规模带来的提升,还是非常巨大的==。

DNS

k8s中,Service和Pod都会被分配对应的DNS A记录。对于Cluster IP模式的Service来说,它的A记录的格式为..svc.cluster.local

对于ClusterIP=None的Headless Service来说,它的A记录也是这个,但是访问这个DNS返回的是它所代理的Pod的IP集合而不是Serivce的IP,当然,如果你的客户端没办法解析这个集合的话,它可能会只会拿到第一个 Pod 的 IP 地址。

ClusterIP 模式的 Service 来说,它代理的 Pod 被自动分配的 A 记录的格式是:..pod.cluster.local。这条记录指向 Pod 的 IP 地址

Headless Service 来说,它代理的 Pod 被自动分配的 A 记录的格式是:…svc.cluster.local。这条记录也指向 Pod 的 IP 地址。但如果你为 Pod 指定了 Headless Service,并且 Pod 本身声明了 hostname 和 subdomain 字段,那么这时候 Pod 的 A 记录就会变成:<pod 的 hostname>…svc.cluster.local,比如:

apiVersion: v1
kind: Service
metadata:
  name: default-subdomain
spec:
  selector:
    name: busybox
  clusterIP: None
  ports:
  - name: foo
    port: 1234
    targetPort: 1234
---
apiVersion: v1
kind: Pod
metadata:
  name: busybox1
  labels:
    name: busybox
spec:
  hostname: busybox-1
  subdomain: default-subdomain
  containers:
  - image: busybox
    command:
      - sleep
      - "3600"
    name: busybox

上面的Pod的A记录就为:busybox-1.default-subdomain.default.svc.cluster.local

需要注意的是,在 Kubernetes 里,/etc/hosts 文件是单独挂载的,这也是为什么 kubelet 能够对 hostname 进行修改并且 Pod 重建后依然有效的原因。这跟 Docker 的 Init 层是一个原理