本篇是《深入剖析 Kubernetes》 的读书笔记,作者为张磊老师。

本章讲述了 Kubernetes 的网络模型,会相较于 Pod、存储章更难一些,且需要有一定的计算机网络的知识作为前置。

# 单机容器网络架构

在单机容器网络架构中,容器和宿主机、容器和容器之间的通信,需要借助两样东西,一是网桥Bridge),二是虚拟网卡Veth Pair, Veth = Virtual ethernet)。

Docker Bridge

Docker 会在宿主机上创建一个名为 docker0 的网桥。只要容器连接到这个网桥,就可以和宿主机上的其它容器通信。

容器连接到网桥的方式是 Veth Pair。Veth Pair 是一对虚拟网卡,一端连接到 docker0,一端连接到容器,就可以将二者连通,即使二者不在一个 Namespace 下也可以连通。每创建一个容器,Docker 就会在网桥上创建一对 Veth Pair,将容器和 docker0 连接起来。

容器和宿主机连起来了以后,容器之间的通信是怎么实现的呢?这就要提到网桥的转发机制了。当一个虚拟网卡被插到网桥后,它就会变成网桥的“从设备”,流入的数据包会交给网桥处理。网桥会扮演二层交换机的角色,根据数据包的目的 MAC 地址,将数据包转发给宿主机或者容器对应的虚拟网卡。

# 多机容器网络架构

# Flannel

多机容器间的通信使用的是 Flannel 网络方案。Flannel 是一个简单且高效的网络解决方案,通过子网路由表来实现容器间的通信。

所有 Node 在一个子网下(如 100.96.0.0/16),每个 Node 上的所有容器在 docker0 这个更小的子网里(如100.96.1.0/24)。Flannel 在 Node 上会创建这样路由表:

$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0 proto kernel scope link src 100.96.1.0
100.96.1.0/24 dev docker0 proto kernel scope link src 100.96.1.1
10.168.0.0/24 dev eth0 proto kernel scope link src 10.168.0.2

flannel0 是 UDP 方案所使用的 TUN 设备,其它方案会用别的设备名,但是原理都是将所有 Node 放在一个大的子网下。

由于路由时会用最长匹配原则,所以当容器要访问同一个 Node 上的容器时,会走 100.96.1.0/24 docker0 的路由表;当要访问其它 Node 上的容器时,会走 flannel0 的路由表,发给其它 Node,然后再由这个 Node 发给 docker0

# Flannel 的后端实现

Flannel 的方案里,真正为我们提供通信,把发往 Flannel 的包发送给其它 Node 的,是 Flannel 的后端实现。

Flannel 有多种后端实现,如 VXLAN、Host-GW、UDP 等。

# UDP

UDP 是最简单、最好理解的,也是性能最烂的,所以现在已经被弃用了。

UDP 方案基于 TUN 设备 (opens new window)。这个设备是一个在三层(网络层)工作的虚拟网络设备。其功能是:在操作系统和用户应用程序之间传递 IP 包。TUN 设备可以说是系统内核和用户空间之间的一个隧道

Flannel 会在每个 Node 上启动一个代理进程 flanneld,监听一个 UDP 端口(通常是 8025),用于接收其它 Node 发来的包。Node1 的 flannel0 收到一个 IP 包以后,会通过 TUN 设备转发给 flanneld 进程。flanneld 把这个 IP 包封在一个 UDP 包里,然后发给目标 Node 的 8025 端口。

这个方案很好理解,但是性能瓶颈在于数据在内核空间和用户空间之间进行了反复拷贝。在发出过程中,就进行了 3 次复制。

Flannel UDP

# VXLAN

VXLAN 是目前最常用的 Flannel 后端实现。

VXLAN 方案基于 VXLAN 技术 (opens new window),它负责在两个节点之间建立一个隧道,将二层数据帧封装在 UDP 包里(L2 over L4),然后通过这个隧道传输。

VXLAN 这个隧道两端的接口设备是 VTEP,Flannel 把这个设备命名为 flannel.1(而非 flannel0),它负责对数据帧进行封装和解封装,而且这个工作是在内核里完成的(因为 VLAN 是 Linux 内核里的一个模块),所以性能比 UDP 方案要好。

所以其实 VXLAN 方案和 UDP 方案的架构其实差不多,都是在两个宿主机之间建立一个隧道,负责传递跨机容器的 IP 包。VXLAN 主要赢在 VXLAN 是内核模块,不需要在内核空间和用户空间之间进行数据拷贝

# Host-GW

Host-GW 是 Flannel 的另一个后端实现,它是性能最好的方案,但要求所有 Node 二层连通

Host-GW 的意思是通过配置路由表,把宿主机(Host)当做网关(Gateway)。这样,宿主机之间不需要建立隧道,也不需要将 IP 包封装在 UDP 包里,只需要通过路由表,将 IP 包直接发给目标宿主机,免除了封装和解封装的性能损耗

在公有云上,由于网络结构简单,Host-GW 是最好的选择。

# Calico

Calico 是另一个三层网络方案,这个方案复杂很多,实现也更加重量级,但也能应对更复杂的网络环境,是这个领域的“龙头老大”。

和 Flannel 的 Host-GW 类似,Calico 同样是让宿主机作为网关。但不同于 Flannel 通过 etcd 和宿主机上的 flanneld 维护路由信息的做法,Calico 通过 BGP 协议来维护路由信息。

Calico 的工作原理是:在每个 Node 上创建一个 BGP 路由器,然后通过 BGP 协议,将容器的 IP 地址告诉其它 Node。这样,每个 Node 都知道其它 Node 上的容器的 IP 地址,就可以直接通信了。

死去的计算机网络开始攻击我

为了解决不同子网的宿主机之间的通信,Calico 引入了几种方案。

  1. IPIP 模式:类似于 UDP 和 VXLAN,在两个宿主机之间建立隧道。但不同于 UDP 和 VXLAN 建立的 UDP 隧道,IPIP 建立的是 IP 隧道。原始 IP 包进入隧道后,会在头部加上一个新的 IP 报头,封装为一个新的 IP(IP over IP),然后再发出去。这样,就可以实现不同子网的宿主机之间的通信。但这个方案的性能和 VXLAN 差不多,因为也需要封包、解包。
  2. 在所有宿主机和宿主机网关之间建立 BGP Peer 关系。
  3. 使用一个或多个独立组件,负责收集整个集群的路由信息,然后通过 BGP 协议同步给网关。

后两种方案的性能很好,不过需要做额外的配置,会麻烦一些。

# Flannel 的三种方案和 Calico 的对比

方案 UDP VXLAN Host-GW Calico
工作层数 四层(UDP) 四层(UDP) 三层(IP) 三层(IP)
工作环境 三层连通 三层连通 二层连通 二层连通,或在不同子网中
性能
复杂程度 简单 简单 简单 复杂
适用场景 小规模快速部署 公有云 私有云

# Kubernetes 网络模型

Kubernetes 的网络模型其实和上面所讲的“多机容器网络架构”非常相似,只不过 Kubernetes 为了支持多种网络方案,引入了 CNI 插件,Flannel 是 Kubernetes 内置的 CNI 插件。还有一点不同的是,Kubernetes 使用的网桥叫 cni0,而非 docker0。这也合理,因为 Kubernetes 里的容器不一定是 Docker。

# CNI 插件

容器内网络初始化的调用链:CRI 实现(如 dockershim) -> Pod 的 Infra 容器 -> CNI 插件(如 Flannel) -> CNI Bridge 或其它网络设备的可执行文件

CNI 插件的配置是在宿主机的 /etc/cni/net.d 目录下,安装 Flanneld 时会在每台宿主机上创建一个 /etc/cni/net.d/10-flannel.conflist

{
    "name": "cbr0",
    "plugins": [
        {
            "type": "flannel",
            "delegate": {
                "hairpinMode": true,
                "isDefaultGateway": true
            }
        },
        {
            "type": "portmap",
            "capabilities": {
                "portMappings": true
            }
        }
    ]
}

通过这个配置文件,Infra 容器就会依次调用 flannelportmap 这两个插件,完成容器网络的初始化。

flannel 配置了 delegate,表示它会将网络的初始化委托给其它插件完成。在委托之前,flannel 会对配置进行补全。补全后的 delegate 配置如下:

{
    "hairpinMode": true,
    "isDefaultGateway": true,

    "name": "cbr0",
    "type": "bridge",
    "isGateway": true,
    "ipMasq": false,
    "ipam": {
        "type": "host-local",
        "subnet": "10.244.1.0/24",
        "routes": [
            {
                "dst": "10.244.0.0/16"
            }
        ]
    },
    "mtu": 1410
}

从补全的配置可以看到,flannel 将委托 bridge 插件完成网络初始化。它会创建一个 cni0 的网桥(如果宿主机上已经有了就跳过),然后将容器连接到这个网桥。

# Kubernetes 中的网络隔离:NetworkPolicy

上面都在说如何让容器之间连通,这里来说一下如何让容器之间不连通。

从直觉上来说,相较于“让容器连通”,“让容器不连通”会更简单,因为只需要让用户声明一些规则,然后用这些规则来过滤数据包就行了。Kubernetes 也是这么做的,让用户声明 NetworkPolicy 这样的规则,然后让网络插件(Calico、kube-router)等实现这些规则

NetworkPolicy 是一个 Kubernetes 的资源对象,它定义了一组规则,这些规则决定了哪些 Pod 之间可以通信,哪些 Pod 之间不能通信。

一条 NetworkPolicy 的内容分为三部分:

  1. podSelector:选择器,选择哪些 Pod 会受到这个 NetworkPolicy 的影响。在 Pod 没有被选择的情况下,Pod 默认可以进行任何通信;Pod 被选择后,Pod 默认不能通信,只有符合 NetworkPolicy 的规则才能通信
  2. ingress:入口规则,定义了允许哪些 Pod 访问这些 Pod。
  3. egress:出口规则,定义了这些 Pod 可以访问哪些 Pod。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  # 控制 role=db 的 Pod
  podSelector:
    matchLabels:
      role: db
  policyTypes:
  - Ingress
  - Egress
  # ingress:流入 Pod 的流量
  ingress:
  - from:
    # role=db 的 Pod 可以从符合这三种条件之一的 Pod 接收入口流量
    - ipBlock:
        cidr: 172.17.0.0/16
        except:
        - 172.17.1.0/24
    - namespaceSelector:
        matchLabels:
          project: myproject
    - podSelector:
        matchLabels:
          role: frontend
    ports:
    - protocol: TCP
      port: 6379
  egress:
  - to:
    # role=db 的 Pod 可以向这个 IP 段的 Pod 发送出口流量
    - ipBlock:
        cidr: 10.0.0.0/24
    ports:
    - protocol: TCP
      port: 5978

三层插件(如 Calico 和 kue-router)对 NetworkPolicy 的实现原理是:在每个 Node 上,为每个 Pod 都创建一个 iptables 规则,根据 NetworkPolicy 的规则,对数据包进行过滤

# 将所有 Pod 的流量从正常流量都“拦截”到 FORWARD 链中,跳转到 KUBE-POD-SPECIFIC-FW-CHAIN 规则
for pod := range 该 Node 上的所有 Pod {
  if pod 是 networkPolicy.spec.podSelector 选中的 {
    # 拦截同宿主机容器通过网桥发的包
    iptables -A FORWARD -s $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
    # 拦截跨主机容器发的包
    iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
  }
}

# 将 KUBE-POD-SPECIFIC-FW-CHAIN 规则中的包跳转到 KUBE-NWPLCY-CHAIN 规则。如果跳转后没有匹配到结果,则拒绝包
iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j KUBE-NWPLCY-CHAIN
iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j REJECT --reject-with icmp-port-unreachable

for dstIP := range 所有被 networkPolicy.spec.podSelector 选中的 Pod 的 IP 地址 {
  for srcIP := range 所有被 ingress.from.podSelector 选中的 Pod 的 IP 地址 {
    for port, protocol := range ingress.ports {
      # 允许 KUBE-NWPLCY-CHAIN 规则中,符合条件的包通过
      iptables -A KUBE-NWPLCY-CHAIN -s $srcIP -d $dstIP -p $protocol -m $protocol --dport $port -j ACCEPT
    }
}

从上面的伪代码可以看到,Kubernetes 的 NetworkPolicy 是通过 FORWARDKUBE-POD-SPECIFIC-FW-CHAINKUBE-NWPLCY-CHAIN 这条规则链实现的。

真是简单粗暴的实现。这也说明 iptables 是一个非常强大的工具。

# Service 的实现

Kubernetes 的 Service 是用于负载均衡的,它可以将一组 Pod 封装成一个服务,然后通过 Service 的 ClusterIP、NodePort、LoadBalancer 等类型,将这个服务暴露出去。

Service 的实现原理是:Worker 结点上的 kube-proxy 监听 Service 对象,然后生成对应的 iptables 规则,将流量转发到对应的 Pod 上。

iptables -A KUBE-SERVICES -d 10.0.1.175/32 -p tcp -m comment --comment "default/my-service:clusterIP" -m tcp --dport 80 -j KUBE-SVC-ABCDEF1234

iptables -A KUBE-SVC-ABCDEF1234 -m comment --comment "default/my-service:clusterIP" -m statistic --mode random --probability 0.3333333333 -j KUBE-SEP-ABCDEF1234
iptables -A KUBE-SVC-ABCDEF1234 -m comment --comment "default/my-service:clusterIP" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-ABCDEF5678
iptables -A KUBE-SVC-ABCDEF1234 -m comment --comment "default/my-service:clusterIP" -j KUBE-SEP-ABCDEF9012

iptables -A KUBE-SEP-ABCDEF1234 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.3.6:9376
iptables -A KUBE-SEP-ABCDEF5678 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.1.7:9376
iptables -A KUBE-SEP-ABCDEF9012 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.2.3:9376

这个规则的意思是:当有流量发往 10.0.1.175/32 的这个 Service IP 时,会根据 KUBE-SVC-ABCDEF1234 这个规则链,随机转发到 KUBE-SEP-ABCDEF1234KUBE-SEP-ABCDEF5678KUBE-SEP-ABCDEF9012 这三个 Pod 上。需要注意的是,iptables 规则的匹配是从上到下逐条执行的,所以为了保证 3 条规则每条规则的流量占比都是 1/3,我们应该将 probability 设置为 1/3、1/2、1,即以 1/3 的概率命中第一条,剩下的情况里再以 1/2 的概率命中第二条,如果都没命中就命中第三条。

基于 iptables 的实现在大的 Worker 节点上可能会有性能问题。每个 Service 的 Pod 都有对应的 iptables 规则,流量到达 Pod 之前会经过很多规则,影响整体性能(NetworkPolicy 虽然也是基于 iptables,但它的规则比较简单,只是允许或拒绝某些流量)。所以诞生了 IPVS 这个更高效的实现。

IPVS 模式的工作原理和 iptables 类似。

  1. 创建 Service 以后,kube-proxy 会在 Worker 节点上创建一个 IPVS 的虚拟网卡,并将 Service VIP 分配给它作为 IP 地址。
  2. 然后 kube-proxy 会为每个 Pod 创建一个 IPVS 的 EndPoint,将 Pod 的 IP 地址和端口映射到这个 EndPoint 上。

虽然 IPVS 在内核中的实现和 iptables 一样基于 Netfilter 的 NAT 模式,但它不需要维护 iptables 规则链,而是把这些放在内核态里处理,所以性能更好。

# Ingress 对象

这里说的不是 NetworkPolicy 对象里的 ingress 字段,而是 Ingress 对象,两者是不同的。

Kubernetes 的 Ingress 对象是一个全局的负载均衡器,负责将外部流量转发到集群内部的 Service 上。其实 Ingress 起到的作用和平时单机部署时使用的 Nginx 很相似,这么类比一下就很好理解 Ingress 了。实际上,Kubernetes 的 Ingress Controller 的其中一种实现,就是基于 Nginx 的。

所以 Ingress 能配置的东西也和 Nginx 类似,比如:反向代理、负载均衡、SSL、基于 URL 的路由等。