简谈 Kubernetes 网络模型
Jul 13, 2021 22:00 · 4348 words · 9 minute read
网络是一个很大的课题,有很多成熟的技术,包括但不限于网络命名空间(network namespace)、虚机网卡(virtual interface)、IP 转发(IP forwarding)和地址转换技术(NAT)。本文旨在讨论这些技术用于如何实现 Kubernetes 网络模型,解开 Kubernetes 网络神秘的面纱。
我将默认读者对 Kubernetes 有一定的了解,直接讨论 Kubernetes 网络模型还有在设计和实现上的决策;然后是本文最有趣的部分:通过不同的用例深入研究流量在 Kubernetes 是如何路由的。
- Kubernetes 网络模型
- 容器之间的网络
- Pod 之间的网络
- Pod 到 Service 的网络
1. Kubernetes 网络模型
Kubernetes 对网络实现有非常明确的规定 https://kubernetes.io/docs/concepts/cluster-administration/networking/#the-kubernetes-network-model:
- 同一个节点上所有 Pod 之间无需 NAT 就能直接通讯
- 节点上的进程能与同一节点上所有 Pod 通讯
- 走宿主机网络(host network)的 Pod 无需 NAT 就能与所有节点上的所有 Pod 通讯
可以将以上规则的限制归纳成四种不同的要解决的网络问题:
- 容器之间
- Pod 之间
- Pod 到 Service
- 外网到 Service
2. 容器之间的网络
实际上要比这个复杂得多。在 Linux 中,每个正在运行的进程都在一个网络命名空间(network namespace)中通讯,这个命名空间提供了有着自己的路由、防火墙规则和网络设备的逻辑网络栈。本质上,网络命名空间为其中的所有进程提供了一个全新的网络栈。
在 Linux 操作系统中,可以使用 ip
命令来创建网络命名空间:
$ ip netns add ns1
当创建命名空间时,会在 /var/run/netns 路径下为其创建一个挂载点,即使没有进程连接到它也会持续存在。
当然就可以通过 /var/run/netns 路径下所有的挂载点来列出可用的命名空间:
$ ls /var/run/netns
ns1
$ ip netns
ns1
Linux 默认将所有进程分配至 root 网络命名空间来访问外网,如图 2:
就 Docker 架构而言,一个 Pod 就是一组共享网络命名空间的 Docker 容器。Pod 中的容器都有相同的 IP 地址,通过 localhost 就能找到对方,毕竟它们在同一个世界。通过 Docker 来实现的话,就要用一个 “Pod 容器”来维持网络命名空间,“app 容器” 通过 Docker 的 net=container:
功能来加入命名空间中。
3. Pod 之间的网络
Kubernetes 中,每个 Pod 都拥有真实的 IP 地址并通过它与其他 Pod 通讯。我们要了解如何通过真实的 IP 实现 Pod 之间的通讯,无论 Pod 是否在同一个节点上。首先考虑同一节点上的 Pod,跨节点通讯的比较复杂后面再说。
从 Pod 视角来看,它要与同一节点上另一个网络命名空间通讯。通过 Linux Virtual Ethernet Device(也就是常说的 veth pair)可以做到跨网络命名空间通讯。要连接 Pod 所在的网络命名空间,我们将 veth pair 的一端分配到 root 网络命名空间,而另一端连接 Pod 网络命名空间。每对 veth pair 就像一条网线连接两端,流量从中通过。节点上有多少个 Pod,就会设置多少对 veth pair。
Pod 只能看到自己的网卡和 IP 地址,而且都与节点 root 命名空间连接。为了使 Pod 通过 root 命名空间通讯,我们要使用网桥(network bridge)。
Linux 以太网桥是一种虚拟的二层网络设备,用来连接两个以上的网段。网桥会维护一张源主机与目标主机之间的转发表,通过检查流过它的数据包的目标并确定是否将数据包转发到与之连接的其他网段,通过查看 MAC 地址决定是否转发还是丢弃。
网桥实现了 ARP 协议来问询某个 IP 地址关联的链路层 MAC 地址。当网桥收到数据帧,网桥将该数据帧广播至所有与之连接的设备(发送方除外),应答的设备会记录至一个查找表中;后面相同的 IP 地址就直接通过查找表来找到对应的 MAC 地址后转发。
3.1 同节点 Pod 之间
网络命名空间将每个 Pod 隔离在它们自己的网络栈中,veth 将每个命名空间连接至 root 命名空间,网桥又将命名空间连在一起,此时 Pod 就可以向同节点的另一个 Pod 发送网络数据包了:
图 6 中,Pod1 将数据包发送至它自己的以太网设备 eth0。对于 Pod1 来说 eth0 通过 veth 与 root 命名空间连接。veth0 被插在了 cbr0 网桥上。当数据包到达网桥,网桥使用 ARP 协议解析出正确的网段来转发数据包,将包发送至 veth1。当数据包到达虚拟设备 veth1,会被直接传递到 Pod2 的网络命名空间中,出现在该命名空间中的 eth0 网卡上。在整条数据通路中,每个 Pod 只和本地的 eth0 通讯,数据包会被路由到正确的 Pod 那去。
Kubernetes 网络模型规定了 Pod 必须在节点直接通过其 IP 可达。也就是说 Pod IP 地址必须对网络中的其他 Pod 始终可见;从 Pod 内外还是其他 Pod 所看到的 IP 都是一样的。下面会说在不同节点之间路由流量的问题。
3.2 不同节点 Pod 之间
Kubernetes 网络模型要求 Pod IP 在整个网络中都是可达的,但是没有指定如何实现。实际上,这是由网络决定的,但也有一些模式可以套用。
通常,集群中的节点都会被分配一段 CIDR 地址块来指定节点上的 Pod 可用 IP 地址。当流量到达节点,由节点负责转发至正确的 Pod。图 7 演示了两个节点之间的流量路径:
- 数据包首先通过 Pod1 的以太网卡发送,和 root 网络命名空间中的 veth 配对。
- 最终,数据包出现在 root 网络命名空间中的网桥上。
- 因为没有任何连接到网桥的设备有数据包中的 MAC 地址,所以 ARP 会失败。一旦失败,网桥就根据默认路由发送数据包,也就是 root 命名空间的 eth0 网卡。此时,数据包即将离开节点进入网络。
- 假设网络可以根据分配给节点的 CIDR 地址块,将数据包路由至正确的节点
- 包进入目的节点的 root 网络命名空间(VM2 上的 eth0 网卡),然后会通过网桥路由至正确的 veth 设备
- 最终经过 veth pair 流入 Pod4 的命名空间内
总的来说,每个节点知道如何将数据包传递到该节点上运行的 Pod 中。一旦数据包到达目的节点,其流径和同一节点上 Pod 之间的情况相同。
我们是避开了如何配置网络,才将流量路由至负责这些 IP 的正确节点。举个栗子,在 AWS,亚马逊维护了一个 Kubernetes 容器网络插件用于使得节点之间的网络通讯在 VPC 网络执行。
Container Networking Interface(CNI) 提供了一组通用 API 来连接容器与外部网络。作为开发者,我们想要一个 Pod 可以通过 IP 地址进行网络通讯,并且我们希望隐藏下面的细节。AWS 开发的 CNI 插件通过已有的 VPC、IAM 和安全组等功能来满足这些要求,提供安全可管理的环境。
4. Pod 到 Service
我们已经了解了如何在 Pod 之间路由流量。但是 Pod IP 并非一成不变,水平伸缩、应用程序崩溃、节点重启都会导致 Pod IP 改变。这些事件都会悄然改变 Pod IP 而客户端又无从得知。Kubernetes Service 就是用来解决这个问题。
一个 Kubernetes Service 管理了一组 Pod 的状态,使你能够追踪 Pod IP 随着时间的动态变化。Service 作为 Pod 的抽象,为一组 Pod IP 地址分配一个虚拟 IP。任何访问虚拟 IP 的流量都会被路由到与其关联的 Pod 集合。这样 Pod IP 就可以随时变化了,客户端只需要知道 Service 的虚拟 IP,这个是不会改变的。
当创建一个新的 Kubernetes Service 对象,同时会给你一个新的虚拟 IP。在集群中任何地方访问虚拟 IP 流量都会被负载均衡到与 Service 关联的 Pod 那去。本质上,Kubernetes 自动维护集群内的分布式负载均衡。
4.1 netfilter & iptables
为了实现集群内的负载均衡,Kubernetes 依赖于 Linux 内置的网络框架 netfilter,允许以自定义回调函数的方式来实现网络操作。netfilter 提供了数据包过滤、NAT、端口转换等功能。
iptables 是一个用户空间的应用程序,提供了一个基于规则表的系统,定义规则来使用 netfilter 框架操作或转换数据包。在 Kubernetes 中,由 kube-proxy 监听 Kubernetes API 变化并配置 iptables 规则。当 Service 或 Pod 变更了 Service 的虚拟 IP 或 Pod IP 时,kube-proxy 将更新 iptables 规则以正确地将流量路由至后端 Pod。iptables 规则会监听所有以 Service 的虚拟 IP 为目的的流量,一旦匹配,会从 Pod IP 集合中随机选择一个 Pod,iptables 规则会将数据包的目的 IP 地址改成所选 Pod IP。随着 Pod 的启停,iptables 规则集也会被更新以反映集群的状态变化。换句话说,iptables 在机器上做了负载平衡,把指向 Service IP 的流量导向实际的 Pod IP。
从返回路径来看,数据包的源地址为 Pod IP。iptables 会再次重写 IP 头将 Pod IP 替换成 Service IP,这样客户端就会认为它一直都在与 Service IP 进行通讯。
4.2 IPVS
Kubernetes 从 1.11 版本开始提供了另一个实现集群内负载均衡的选择:IPVS。IPVS 作为 Linux 内核的一部分,同样也是基于 netfilter 构建并实现了传输层负载均衡。IPVS 被合并到了 LVS(Linux Virtual Server) 中,在宿主机上运行作为负载均衡器挡在集群中的真实服务器之前。IPVS 能够基于 TCP 和 UDP 的请求转发到真实服务器,使得一组真实服务器看起来像是只通过一个单一 IP 地址访问的服务一样。所以 IPVS 非常适合用于 Kubernetes Service。
每当声明一个 Kubernetes Service,你可以指定使用 iptables 或者 IPVS 来实现集群内负载均衡。IPVS 是专门为负载均衡设计的,使用更高效的数据机构(哈希表),相比 iptables 支持几乎无限的网络规模。当创建一个选用 IPVS 的 Service 时:
- 在节点上创建一个 dummy IPVS 网卡
- Service IP 被绑定到 dummy IPVS 网卡上
- 为每个 Service IP 创建 IPVS 服务器
将来 IPVS 会成为 Kubernetes 默认的集群内负载均衡方式。
4.3 Pod 到 Service 数据包生命周期
当在 Pod 和 Service 之间路由数据包,就踏上了与之前相同的旅途。
- 数据包首先通过 Pod 网络命名空间的 eth0 网卡发出
- 然后数据包通过 veth 设备到达网桥
- 网桥无法通过 ARP 协议找到 Service,所以数据包根据默认路由发送至宿主机(默认命名空间)的 eth0 网卡
- 这里有些不同了。在到达 eth0 之前,数据包会被 iptables 过滤。iptables 根据 kube-proxy 在节点上创建的规则重写数据包的目的 IP 地址(将 Service IP 改成具体 Pod IP)。
- 数据包现在被安排到 Pod4 而非 Service 的虚拟 IP。iptables 利用 Linux 内核的 conntrack(连接追踪)工具来记住所选 Pod,未来的流量会被路由到同一个 Pod(除非有扩展事件发生)。本质上,iptables 直接在节点上完成了集群内负载均衡。流量如何到达 Pod 之前已经解释过了。
4.4 Service 到 Pod 数据包生命周期
- Pod 收到数据包后会返回,回包的源 IP 就是它自己的 IP,目的 IP 是发送这个数据包的 Pod IP。
- 一旦包到达节点上,将通过 iptables,记录在 conntrack 中的信息被用于将数据包的源地址改成 Service IP。
- 数据包通过网桥到达 Pod 命名空间内的 veth pair,最终到达 Pod 的网卡。
4.5 使用 DNS
在你的应用程序中可以选择性地使用 DNS 来避免写死服务的 Cluster IP。Kubernetes DNS 就像普通的 Kubernetes 服务一样在集群上运行。**每个节点上运行的 kubelets 会将容器网络栈中的 DNS 服务器修改为 Kubernetes DNS 的 Service IP,这样容器应用程序将使用 Kubernetes DNS 来解析域名。**集群中每个服务(包括 DNS 自己)都会被分配一个 DNS 域名。DNS 将域名解析成服务的 Cluster IP 或 Pod 的 IP。SRV 记录被用于指定 Service 的已命名端口。
DNS Pod 自己作为一个 Kubernetes Service 暴露,它的 Cluster IP 在每个容器启动时被写入 /etc/resolv.conf。Kubernetes 1.11 之后,CoreDNS 就成为了默认的 DNS 实现。
原文:https://sookocheff.com/post/kubernetes/understanding-kubernetes-networking-model