Service 与 Kubernetes 网络

Service 的概念

Kubernetes Service 定义了这样一种抽象:一个 Pod 的逻辑分组,以及一种可以访问它们的策略 —— 通常称为微服务。这一组 Pod 能够被 Service 访问到,通常是通过 Label Selector 实现的。

Service 是由 kube-proxy 组件加上 iptables 共同实现的。它能够提供负载均衡的能力,但在使用上有以下限制:只提供 4 层负载均衡能力,而没有 7 层功能;有时我们可能需要更多的匹配规则来转发请求,这一点 4 层负载均衡是不支持的。

svc

Service 的类型

Servicek8s 中有以下四种类型:

  1. ClusterIP:默认类型,自动分配一个仅 Cluster 内部可以访问的虚拟 IP
  2. NodePort:在 ClusterIP 基础上为 Service 在每台机器上绑定一个端口,这样就可以通过 NodeIP:NodePort 来访问该服务。
  3. LoadBalancer:在 NodePort 的基础上,借助 cloud provider 创建一个外部负载均衡器,并将请求转发到 NodePort
  4. ExternalName:把集群外部的服务引入集群内部直接使用,没有任何类型的代理被创建。

代理模式分类

当你通过 Service 的域名去访问时,会先通过 CoreDNS 解析出 Service 对应的 Cluster IP(即虚拟 IP)。请求到达宿主机网络后,会被 kube-proxy 所配置的 iptables 规则拦截,按负载均衡策略 DNAT 到其中一个后端 Pod

kube-proxy 支持三种代理模式:

  1. userspace 代理模式:早期实现,由 kube-proxy 在用户态完成转发,数据需在用户态与内核态之间来回拷贝,效率较低。
  2. iptables 代理模式(默认):完全依靠内核 iptables 规则转发,无需用户态介入,效率较高。
  3. ipvs 代理模式:基于内核 IPVS,适合大规模 Service 场景,性能更好,并支持更丰富的负载均衡算法。

ClusterIP

clusterIP

clusterIP2

service-example.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: myapp
namespace: default
spec:
type: ClusterIP
selector:
app: myapp
release: stabel
ports:
- name: http
port: 80
targetPort: 80
1
2
3
4
5
6
7
[root@k8s01 ~]# kubectl get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
myapp-deploy-75bf6f6cd-jnf96 1/1 Running 0 14s 172.18.236.167 k8s02 <none> <none>
myapp-deploy-75bf6f6cd-m8ffb 1/1 Running 0 14s 172.18.236.168 k8s02 <none> <none>
myapp-deploy-75bf6f6cd-njzj2 1/1 Running 0 14s 172.18.235.168 k8s03 <none> <none>
[root@k8s01 ~]# curl 172.18.236.167
## 省略

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

1
2
3
4
5
[root@k8s01 ~]# kubectl get endpoints myapp  # 获取 endpoints
NAME ENDPOINTS AGE
myapp 172.18.236.167:80,172.18.236.168:80,172.18.235.168:80 19m

## Service 的 hostname: myapp.default.svc.cluster.local

Headless Service

Headless Service 用于为 Pod 资源标识符生成可解析的 DNS 资源记录。一个典型、完整可用的 StatefulSet 通常由三个组件构成:Headless ServiceStatefulSetvolumeClaimTemplate。其中 Headless Service 用于为 Pod 生成可解析的 DNS 记录,StatefulSet 用于管控 PodvolumeClaimTemplate 则基于静态或动态的 PV 供给方式为 Pod 提供专有且固定的存储。

有时不需要负载均衡以及单独的 Service IP。这种情况下,可以通过指定 spec.clusterIP 的值为 None 来创建 Headless Service。这类 Service 不会分配 Cluster IPkube-proxy 不会处理它们,平台也不会为它们进行负载均衡和路由。

web-0.nginx 这条记录是谁生成的

一个容易搞错的地方:web-0.nginx.default.svc.cluster.local 这种带 Pod 序号的记录,不是 Headless Service 自己生成的,而是 StatefulSet 和它配合的结果。

机制是这样的:StatefulSet 的 spec.serviceName 指向那个 Headless Service,控制器据此给每个 Pod 设置两个字段——

1
2
3
spec:
hostname: web-0 # 取自 Pod 名
subdomain: nginx # 取自 StatefulSet 的 serviceName

CoreDNS 看到 Pod 带着 hostname + subdomain、并且 subdomain 对应的 Service 存在,才会生成 <hostname>.<subdomain>.<ns>.svc.cluster.local 这条 A 记录。所以三个条件缺一不可:Headless Service 存在、StatefulSet 的 serviceName 填对、Pod 被这个 Service 的 selector 选中。

serviceName 填错一个字母,Pod 能正常起来、Service 也在,但域名就是解析不出来——这是 StatefulSet 最常见的排查项。

publishNotReadyAddresses 与初始化死锁

默认情况下,未 Ready 的 Pod 不会出现在 DNS 里。对无状态服务这是对的,但对需要互相发现才能完成初始化的集群(etcd、Cassandra、Elasticsearch、各种数据库集群)就形成了死锁:

  • 每个 Pod 的 readinessProbe 要求”已加入集群”才算 Ready;
  • 要加入集群得先解析到其他成员;
  • 但其他成员都还没 Ready,所以谁也解析不到谁。

解法是让 Headless Service 提前把地址暴露出来:

1
2
3
spec:
clusterIP: None
publishNotReadyAddresses: true # 未 Ready 的 Pod 也进 DNS

这就是为什么几乎所有数据库类 Helm chart 的 headless service 都带着这个字段。代价是:这个 Service 的 DNS 会返回还没准备好的地址,所以它只应该用于集群成员之间互相发现,客户端访问要另建一个普通 Service。

不要依赖返回顺序

最后一点:Headless Service 的 DNS 查询返回的是一组 A 记录,kubectl get ep 里 endpoint 的顺序、以及 DNS 返回的顺序都不保证稳定(CoreDNS 默认还会做 shuffle)。所以不能靠”取第一条 A 记录”来定位 Pod-0——要定位特定序号的 Pod,必须显式解析 web-0.nginx 这个名字。

svc-headless-example.yaml

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
name: myapp-headless
namespace: default
spec:
clusterIP: "None"
selector:
app: myapp
ports:
- port: 80
targetPort: 80

Headless Service 的 DNS 记录格式为 $(pod_name).$(service_name).$(namespace).svc.cluster.local;而普通 Pod 的 DNS 记录格式形如 172-20-1-125.default.pod.cluster.local,其中 172.20.1.125 是 Pod 的 IP。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[root@k8s01 ~]# kubectl apply -f svc-headless-example.yaml
service/myapp-headless created

[root@k8s01 ~]# kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 26d
myapp ClusterIP 10.96.221.214 <none> 80/TCP 26m
myapp-headless ClusterIP None <none> 80/TCP 57s

[root@k8s01 ~]# dig -t A myapp-headless.default.svc.cluster.local. @172.18.73.103
## 省略部分
;; ANSWER SECTION:
myapp-headless.default.svc.cluster.local. 30 IN A 172.18.235.168
myapp-headless.default.svc.cluster.local. 30 IN A 172.18.236.167
myapp-headless.default.svc.cluster.local. 30 IN A 172.18.236.168

可见 Headless Service 直接把域名解析到后端各 Pod 的 IP(上面 ANSWER 中的三条 A 记录)。

NodePort

NodePort 的原理在于在 node 上开放一个端口,将访问该端口的流量导入 kube-proxy,再由 kube-proxy 转发给对应的 Pod

nodePort

myapp-noode-service.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: myapp
namespace: default
spec:
type: NodePort
selector:
app: myapp
release: stabel
ports:
- name: http
port: 80
targetPort: 80
1
2
3
4
5
6
7
8
9
10
11
[root@k8s01 ~]# kubectl apply -f myapp-noode-service.yaml
service/myapp configured
[root@k8s01 ~]# kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 26d
myapp NodePort 10.96.221.214 <none> 80:30260/TCP 32m
myapp-headless ClusterIP None <none> 80/TCP 6m31s
[root@k8s01 ~]# curl 172.18.236.167:80 # Pod IP + port
[root@k8s01 ~]# curl 10.96.221.214:80 # Service IP + port
[root@k8s01 ~]# curl 192.168.43.101:30260 # NodePort:节点 IP + port
[root@k8s01 ~]# curl 192.168.43.102:30260 # 另一节点同样可访问

同一网段下

LoadBalancer

LoadBalancerNodePort 本质是同一种方式,区别在于 LoadBalancerNodePort 多了一步:可以调用 cloud provider 去创建 LB 来向节点导流。

myapp-loadbalance.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name: myapp-loadbalance
namespace: default
spec:
selector:
app: myapp
release: stabel
ports:
- port: 8765
targetPort: 80
type: LoadBalancer

LoadBalance

LoadBalance2

ExternalName

这种类型的 Service 通过返回 CNAME 及其值,将服务映射到 externalName 字段的内容(例如 www.baidu.com)。ExternalName ServiceService 的特例,它没有 selector,也没有定义任何端口和 Endpoint;对于运行在集群外部的服务,它通过返回该外部服务的别名来提供服务。

ExternalName

externalname-example.yaml

1
2
3
4
5
6
7
8
kind: Service
apiVersion: v1
metadata:
name: my-service-1
namespace: default
spec:
type: ExternalName
externalName: www.baidu.com

当查询主机 my-service-1.default.svc.cluster.local(即 SVC_NAME.NAMESPACE.svc.cluster.local)时,集群的 DNS 服务将返回一个值为 www.baidu.comCNAME 记录。访问方式与其他 Service 相同,唯一不同的是重定向发生在 DNS 层,而且不会进行代理或转发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[root@k8s01 ~]# kubectl apply -f externalname-example.yaml
service/my-service-1 unchanged
[root@k8s01 ~]# kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 26d
my-service-1 ExternalName <none> www.baidu.com <none> 3m27s
myapp NodePort 10.96.221.214 <none> 80:30260/TCP 47m
myapp-headless ClusterIP None <none> 80/TCP 21m
[root@k8s01 ~]# dig -t A my-service-1.default.svc.cluster.local. @172.18.73.103
## 省略部分
;; ANSWER SECTION:
my-service-1.default.svc.cluster.local. 1 IN CNAME www.baidu.com. ## 相当于做域名解析
www.baidu.com. 1 IN CNAME www.a.shifen.com.
www.a.shifen.com. 1 IN A 163.177.151.110
www.a.shifen.com. 1 IN A 163.177.151.109

除了 ExternalName,也可以通过手动创建 Endpoints 的方式把外部服务(如数据库)引入集群。此时先定义一个没有 selectorService

1
2
3
4
5
6
7
8
9
# mysql_service.yaml
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
type: ClusterIP
ports:
- port: 3306

再手动定义同名的 Endpoints,指向外部数据库地址:

1
2
3
4
5
6
7
8
9
10
# mysql_endpoint.yaml
apiVersion: v1
kind: Endpoints
metadata:
name: mysql
subsets:
- addresses:
- ip: 192.168.43.101
ports:
- port: 3306
1
2
# kubectl apply -f mysql_service.yaml -f mysql_endpoint.yaml
# mysql -u root -h mysql.default.svc.cluster.local ## 即可访问外部 MySQL 数据库

后端 Deployment 示例(myapp-deploy)

上述各类 Service 示例所选中的后端 Pod(app: myapp)由如下 Deployment 提供:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-deploy
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: myapp
release: stabel
template:
metadata:
labels:
app: myapp
release: stabel
env: test
spec:
containers:
- name: myapp
image: nginx:1.7.9
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80

Kubernetes 网络

理解了 Service 的用法后,再来看它底层依赖的容器网络是如何工作的。

容器网络基础

先从单机上的容器网络说起,看容器是如何获得 IP 并与外界通信的。

直接使用宿主机网络

启动一个 nginx,直接使用宿主机网络:

1
[root@k8s01 service]# docker run -d --net=host --name nginx-host nginx

这个容器启动后,直接监听的就是宿主机的 80 端口。像这样直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也不可避免地引入了共享网络资源的问题,比如端口冲突。所以在大多数情况下,我们都希望容器进程能使用自己 Network Namespace 里的网络栈,即拥有属于自己的 IP 地址和端口。

nginx-host

使用 docker0 网桥

查看宿主机上的 docker0 网桥(地址为 172.17.0.1):

1
2
3
4
5
[root@k8s01 service]# ifconfig
## 省略,docker0 为 172.17.0.1
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
ether 02:42:87:56:a7:bb txqueuelen 0 (Ethernet)

启动两个 busybox 容器:

1
2
docker run -d -it --name busybox1 busybox
docker run -d -it --name busybox2 busybox

查看它们的 IP(分别为 172.17.0.2172.17.0.3)并测试连通性:

1
2
3
4
5
6
7
[root@k8s01 service]# docker exec -it busybox2 /bin/sh
/ # ifconfig # 172.17.0.3
eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:04
inet addr:172.17.0.3 Bcast:172.17.255.255 Mask:255.255.0.0
/ # ping 172.17.0.2 # 可以 ping 通:busybox2 eth0 → vethdf30199 → docker0 → veth1f4b2a8 → busybox1 eth0
/ # ping 192.168.43.101 # 宿主机可以 ping 通:busybox2 eth0 → vethdf30199 → docker0 → 宿主机 eth0
/ # ping 192.168.43.102 # 另一台主机可以 ping 通:… → 宿主机 eth0 → 另一台宿主机 eth0

veth

容器里的 eth0 网卡是一个 Veth Pair:它的一端在 busybox 容器的 Network Namespace 里,另一端位于宿主机上(Host Namespace,如 veth1f4b2a8),并被“插”在宿主机的 docker0 网桥上;另一个 busybox2 也同样被“插”在 docker0 网桥上。

1
2
3
4
[root@hdp03 ~]# brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242a434516d no veth1f4b2a8
vethdf30199

veth2

宿主机 ping 容器也能通:

1
2
3
4
[root@k8s01 service]# ping 172.17.0.3
PING 172.17.0.3 (172.17.0.3) 56(84) bytes of data.
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.099 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.079 ms

veth3

在默认情况下,被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备加宿主机网桥的方式,实现了与其他容器的数据交换。在宿主机上访问该宿主机上容器的 IP 时,请求数据包也是先根据路由规则到达 docker0 网桥,再被转发到对应的 Veth Pair 设备,最后出现在容器里。

但是,如果在另一台宿主机(192.168.43.102)上启动 busybox3,它同样会拿到 172.17.0.x 的地址:

1
2
3
4
5
[root@k8s02 ~]# docker run -d -it --name busybox3 busybox
[root@k8s02 ~]# docker exec -it busybox3 /bin/sh
/ # ifconfig # 172.17.0.2
eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:02
inet addr:172.17.0.2 Bcast:172.17.255.255 Mask:255.255.0.0

在 Docker 的默认配置下,不同宿主机上的容器通过 IP 相互访问是根本做不到的,即 192.168.43.101 上的容器无法 ping 通 192.168.43.102 上的容器。这正是需要引入跨主机容器网络方案的原因。

跨主机容器网络方案

要打通跨主机的容器网络,业界主要有以下三种思路。

Overlay Network(覆盖网络)

通过软件的方式创建一个整个集群“公用”的网桥,把集群里所有容器都连接到这个网桥上;也就是在已有的宿主机网络之上,再通过软件构建一个覆盖其上、能把所有容器连通的虚拟网络,这种技术被称为 Overlay Network(覆盖网络)。

其本质是将 Pod 的地址信息封装在宿主机地址信息之内,实现跨主机、跨 node 子网的通信报文。因为多了封装与解封装,性能较差。典型实现有 flannel VXLAN、flannel UDP、Calico VXLAN、Calico IPIP 等。(Calico BGP 不封装,属于下面的”直接路由”一类。)

OverlayNetwork

直接路由

基于主机路由,实现报文从源主机到目的主机的直接转发,不需要进行报文的叠加封装,性能好。典型实现有 flannel host-gw、flannel VXLAN directrouting、Calico Directrouting 等。

underlay

为 Pod 启用单独的虚拟网络,直接使用宿主机物理网络,Pod 甚至可以在 k8s 环境之外的节点直接访问。相当于把 Pod 当作桥接模式的虚拟机使用(Pod 和宿主机在同一子网)。这种方式比较方便从 k8s 环境之外访问 k8s 环境之内 Pod 中的服务,性能最好。

flannel 工作模式

flannel 支持多种后端(backend):

UDP

最早支持的模式,也是性能最差的一种。它在用户态通过 flanneld 进程对 IP 包进行封装与解封装,数据需要在用户态与内核态之间多次拷贝,开销大,现已基本弃用。

VXLAN

借助 Linux 内核的 VXLAN 能力,在宿主机上创建名为 flannel.1 的 VTEP(VXLAN Tunnel End Point)设备,将容器的二层数据帧封装进 UDP 报文,通过宿主机网络转发到目的节点后再解封装。它属于 Overlay 方案,对底层网络要求低,可跨三层网络工作,性能优于 UDP。

host-gw

host-gw 模式的工作原理,是将每个 Flannel 子网(Flannel Subnet,比如 10.244.1.0/24)的“下一跳”,设置成该子网对应宿主机的 IP 地址,不做封装,直接依靠宿主机路由转发,性能好。

Flannel host-gw 模式要求集群宿主机之间是二层连通的。

calico 工作模式

Calico 是基于 BGP 的三层网络方案:每个节点上的 Felix 负责编程路由和 ACL,BIRD 负责在节点间通过 BGP 协议分发路由信息,使每台宿主机都像一台路由器一样直接转发 Pod 流量,不做封装,性能好。

  • BGP 模式(默认):纯三层路由转发,要求节点之间网络可达并能建立 BGP 邻居。
  • IPIP / VXLAN 模式:当节点跨子网、无法直接路由时,通过 IPIP 或 VXLAN 隧道封装实现 Overlay 转发。
  • Calico 同时提供 NetworkPolicy 能力,可实现 Pod 级别的网络访问控制。

参考