k8s 架构拓扑
这篇文章将从K8S的架构、存储、网络及服务暴露等几个方面介绍,记录K8S的学习过程。
讨论议题:
- k8s 架构
- k8s 存储架构
- k8s 容器网络
- k8s 服务暴露
准备环境
K8S 集群搭建方式参考, ubuntu20.04 部署 Kubernetes (k8s)
搭建教程当时基于的环境 1.18 ,参考时注意更改到最新的对应版本。
- 集群主机(vmware 虚拟机)
- vm-ks0 (master): 172.16.101.135
- vm-ks1 (node1): 172.16.101.136
- 系统版本: ubuntu 20.04 TLS
- kubernetes版本: v1.22.0
k8s 初始化 ClusterConfiguration
yaml
|
|
网络插件:flannel
|
|
去掉master节点的调度taint
去掉NoSchedule使master节点可以调度pod。
|
|
宿主机安装NFS
NFS 提供远程存储服务,并提供PV,NFS的安装工作参考 这里
|
|
K8S 架构
议题:
- K8S 架构是什么样的
- CNI: 容器网络接口
- CRI:容器运行时接口
- OCI:开放容器标准
容器持久化存储
议题:
- 容器的存储是如何实现的
- k8s集群如何使用存储
PV & PVC
PV (PersistentVolume) 描述的是持久化存储数据卷。这个 API 对象主要定义的是一个持久化存储在宿主机上的目录,比如一个 NFS 的挂载目录。
PVC(PersistentVolumeClaim) 描述的是 Pod 所希望使用的持久化存储的属性。比如,Volume 存储的大小、可读写权限等等。
一般PV由运维人员创建并提供,PVC是由开发人员所创建,以 PVC 模板的方式成为 StatefulSet 的一部分,然后由 StatefulSet 控制器负责创建带编号的 PVC。PV 和 PVC 的 storageClassName 字段必须一样,才能使用
如一个 NFS 的 PV
|
|
声明一个 1 GiB 大小的 PVC
|
|
接下来就可以像 hostPath
等常规类型的 Volume 一样,在自己的 YAML 文件里声明使用。
更多例子参考 这里
大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等。
实际生产环境中,一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV。更麻烦的是,随着新的 PVC 不断被提交,运维人员就不得不继续添加新的、能满足条件的 PV。 为了自动化的创建PV,提出了一个 StorageClass 的概念。
而 StorageClass 对象的作用,其实就是创建 PV 的模板。 具体地说,StorageClass 对象会定义如下两个部分内容:
- 第一,PV 的属性。比如,存储类型、Volume 的大小等等。
- 第二,创建这种 PV 需要用到的存储插件。比如,Ceph 等等。
有了这样两个信息之后,Kubernetes 就能够根据用户提交的 PVC,找到一个对应的 StorageClass 了。然后,Kubernetes 就会调用该 StorageClass 声明的存储插件,创建出需要的 PV。
如开源项目 rook。定义的还是一个名叫 block-service 的 StorageClass。(Rook & Ceph 简介)
|
|
作为应用开发者,不必再为难运维人员,上面提到的运维人员创建的PV是运维手工分配的,而 Storage Class 是动态创建的。
|
|
动态 NFS Provision
- 什么是 NFS-Subdir-External-Provisioner
存储组件 NFS subdir external provisioner
是一个存储资源自动调配器,它可用将现有的 NFS 服务器通过持久卷声明来支持 Kubernetes 持久卷的动态分配。自动新建的文件夹将被命名为 ${namespace}-${pvcName}-${pvName}
,由三个资源名称拼合而成。
此组件是对 nfs-client-provisioner 的扩展,nfs-client-provisioner 已经不提供更新,且 nfs-client-provisioner 的 Github 仓库已经迁移到 NFS-Subdir-External-Provisioner 的仓库。
部署需要先将这个项目 clone
下来。
|
|
项目的deployment目录下有我们需要的搭建 yaml 文件。
- 创建 ServiceAccount
现在的 Kubernetes 集群大部分是基于 RBAC 的权限控制,所以我们需要创建一个拥有一定权限的 ServiceAccount 与后面要部署的 NFS Subdir Externa Provisioner 组件绑定。
执行 kubectl 命令将 RBAC 文件部署到 Kubernetes 集群
|
|
- 部署 NFS-Subdir-External-Provisioner
设置 NFS-Subdir-External-Provisioner
部署文件。需要对 deployment.yaml
做一些修改。NFS_SERVER
和 NFS_PATH
都需要改成自己的NFS服务器。另外默认镜像地址为 k8s.gcr.io
, 我这里找了网上的一个地址 registry.cn-beijing.aliyuncs.com/mydlq/nfs-subdir-external-provisioner:v4.0.0
|
|
- 创建 NFS SotageClass
我们在创建 PVC 时经常需要指定 storageClassName 名称,这个参数配置的就是一个 StorageClass 资源名称,PVC 通过指定该参数来选择使用哪个 StorageClass,并与其关联的 Provisioner 组件来动态创建 PV 资源。所以,这里我们需要提前创建一个 Storagelcass 资源。
Provisioner 参数用于声明 NFS 动态卷提供者的名称,该参数值要和上面部署 NFS-Subdir-External-Provisioner 部署文件中指定的 PROVISIONER_NAME 参数保持一致,即设置为 nfs-storage。
- 测试
创建一个用于测试的 pvc。并创建一个 pod 使用pvc写文件
|
|
在宿主机的 nfs 共享目录上发现,已经创建出 SUCCESS
文件。
容器网络
议题:
- 同一台主机的容器既然被隔离,是怎样互相通信的
- 为什么跨主机的容器可以互相通信
- 容器间的网络通信和主机间的网络通信性能相差
- k8s的CNI网络实现
- K8S网络方案L2和L3的区别
- 网络隔离方案
实验环境,起 2个 pod
flannel 网络通信方式
通信方式 | 概念 | 方式 |
---|---|---|
主机内通信 | 1台机器内部的 | veth pair |
L2 主机间通信 | 2台主机连在同一台交换机的场景 | hostgw |
L3 主机间通信 | 2台主机没有连在同一台交换机的场景。 | 内核态:vxlan 用户态:udp (性能差) |
主机内POD通信 (cni0 与 veth pair)
- node:
vm-ks1
:172.16.101.136/16
- pod0:
web-0
:10.100.1.39 eth0@if6 0a:ff:9f:1b:5c:02
- pod1:
web-1
:10.100.1.40 eth0@if7 16:60:24:3d:0f:84
- pod0:
宿主机 vm-ks1 上的网卡, 除了 flannel和cni0 网卡外,还有一堆veth开头的网卡,每创建一个容器或pod都会在宿主机上生成一个 veth pair,这个veth pair可以理解为容器和宿主机之间拉了一条网线。可参考这篇文章
|
|
同一主机内的pod之间的互相通信流量会经过 cni0
,可以看到 pod0 的Mac地址0a:ff:9f:1b:5c:02
可以直接访问 pod1 的Mac地址 16:60:24:3d:0f:84
。
|
|
从宿主机的 arp
命令可知 cni0
上的路由表是既有 pod0
的mac地址+ip地址,也有pod1
的。
|
|
k8s 跨主机ip通信
跨主机间通信分2种,hostgw: eht0 vxlan: flannel0。我们在初始化k8s 集群时使用的是 flannel的网络cni插件。flannel通过给每台宿主机分配一个子网的方式为容器提供虚拟网络,它基于Linux TUN/TAP,使用UDP封装IP包来创建overlay网络,并借助etcd维护网络的分配情况。
udp:使用用户态udp封装,默认使用8285端口。由于是在用户态封装和解包,性能上有较大的损失 vxlan:vxlan封装,需要配置VNI,Port(默认8472)和GBP host-gw:直接路由的方式,将容器网络的路由信息直接更新到主机的路由表中,仅适用于二层直接可达的网络
node:
vm-ks1
:172.16.101.136/16
- pod0:
web-0
:10.100.1.39 eth0@if6 0a:ff:9f:1b:5c:02
- flannel.1:
10.100.0.0 da:e9:f6:2d:73:b5
- pod0:
node:
vm-ks0
:172.16.101.135/16
- pod2:
web-2
:10.100.0.22 eth0@if8 66:14:4c:b9:74:13
- flannel.1:
10.100.1.0 fe:ca:1a:3e:42:41
- pod2:
vm-ks0 的 flannel
设备收到“原始 IP 包”后,就要想办法把“原始 IP 包”加上一个目的 MAC 地址,封装成一个二层数据帧,然后发送给“目的 flannel” 设备。vm-ks0 和 vm-ks1 上的 flannel 设备组成了一个虚拟的2层网络,即:通过二层数据帧进行通信。
|
|
在vm-ks0上执行 ip neigh show dev flannel.1
这条记录的意思非常明确,即:IP 地址 10.100.1.0,对应的 MAC 地址是 fe:ca:1a:3e:42:41。
一个 flannel.1 设备只知道另一端的 flannel.1 设备的 MAC 地址,却不知道对应的宿主机地址是什么。
也就是说,这个 UDP 包该发给哪台宿主机呢?
在这种场景下,flannel.1 设备实际上要扮演一个“网桥”的角色,在二层网络进行 UDP 包的转发。而在 Linux 内核里面,“网桥”设备进行转发的依据,来自于一个叫作 FDB(Forwarding Database)的转发数据库。
这个 flannel.1“网桥”对应的 FDB 信息,也是 flanneld 进程负责维护的。它的内容可以通过 bridge fdb 命令查看到,如下所示
|
|
这下整个转发过程就清楚了,参考下图的封装过程。
从上面的 wireshark 抓包可以看到,跨主机访问的报文是UDP的VXLAN协议,使用 8472 端口。 VXLAN 上的二层Mac地址分别是两台node节点的flannel网卡的Mac地址。 再向上就如同pod0 和 pod2 直接通信的效果了。
3 层主机之间的通信UDP模式已经废弃。 我们在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。这也是为什么,Flannel 后来支持的VXLAN 模式,逐渐成为了主流的容器网络方案的原因。
CNI 插件的部署和实现
我们在部署 Kubernetes 的时候,有一个步骤是安装 kubernetes-cni 包,它的目的就是在宿主机上安装 CNI 插件所需的基础可执行文件。
在安装完成后,你可以在宿主机的 /opt/cni/bin 目录下看到它们,如下所示:
|
|
这些 CNI 的基础可执行文件,按照功能可以分为三类:
- 第一类,叫作 Main 插件,它是用来创建具体网络设备的二进制文件。比如,bridge(网桥设备)、ipvlan、loopback(lo 设备)、macvlan、ptp(Veth Pair 设备),以及 vlan。我在前面提到过的 Flannel、Weave 等项目,都属于“网桥”类型的 CNI 插件。所以在具体的实现中,它们往往会调用 bridge 这个二进制文件。
- 第二类,叫作 IPAM(IP Address Management)插件,它是负责分配 IP 地址的二进制文件。比如,dhcp,这个文件会向 DHCP 服务器发起请求;host-local,则会使用预先配置的 IP 地址段来进行分配。
- 第三类,是由 CNI 社区维护的内置 CNI 插件。比如:flannel,就是专门为 Flannel 项目提供的 CNI 插件;tuning,是一个通过 sysctl 调整网络设备参数的二进制文件;portmap,是一个通过 iptables 配置端口映射的二进制文件;bandwidth,是一个使用 Token Bucket Filter (TBF) 来进行限流的二进制文件。
首先,实现这个网络方案本身。这一部分需要编写的,其实就是 flanneld 进程里的主要逻辑。比如,创建和配置 flannel.1 设备、配置宿主机路由、配置 ARP 和 FDB 表里的信息等等。
然后,实现该网络方案对应的 CNI 插件。这一部分主要需要做的,就是配置 Infra 容器里面的网络栈,并把它连接在 CNI 网桥上。
其启动和配置原理如下,首先,CNI bridge 插件会在宿主机上检查 CNI 网桥是否存在。如果没有的话,那就创建它。这相当于在宿主机上执行:
|
|
接下来,CNI bridge 插件会通过 Infra 容器的 Network Namespace 文件,进入到这个 Network Namespace 里面,然后创建一对 Veth Pair 设备。
|
|
这样,vethb4963f3 就出现在了宿主机上,而且这个 Veth Pair 设备的另一端,就是容器里面的 eth0。(在宿主机上操作也可以,原理都一样)
接下来,CNI bridge 插件就可以把 vethb4963f3 设备连接在 CNI 网桥上
|
|
所有容器都可以直接使用 IP 地址与其他容器通信,而无需使用 NAT。
容器自己“看到”的自己的 IP 地址,和别人(宿主机或者容器)看到的地址是完全一样的。
三层网络方案
除了网桥类型的 Flannel 插件,还有一种纯三层(Pure Layer 3)网络方案,典型例子包括 Flannel 的 host-gw 模式和 Calico 项目。
当你设置 Flannel 使用 host-gw 模式之后,flanneld 会在宿主机上创建这样一条规则,以 vm-ks0 为例:
|
|
这条路由规则的含义是:目的 IP 地址属于 10.100.1.0/24 网段的 IP 包,应该经过本机的 eth0 设备发出去(即:dev eth0);并且,它下一跳地址(next-hop)是 172.16.101.136(即:via 172.16.101.136)。
host-gw 模式的工作原理,其实就是将每个 Flannel 子网(Flannel Subnet,比如:10.100.1.0/24)的“下一跳”,设置成了该子网对应的宿主机的 IP 地址。
也就是说,这台“主机”(Host)会充当这条容器通信路径里的“网关”(Gateway)。这也正是“host-gw”的含义。所以说,Flannel host-gw 模式必须要求集群宿主机之间是二层连通的。
不同于 Flannel 通过 Etcd 和宿主机上的 flanneld 来维护路由信息的做法,Calico 项目使用了一个BGP(Border Gateway Protocol,即:边界网关协议) 来自动地在整个集群中分发路由信息。
比如上述,有2个自治系统(Autonomous System,简称为 AS):AS 1 和 AS 2。在正常情况下,自治系统之间不会有任何“来往”。
比如,AS 1 里面的主机 10.10.0.2,要访问 AS 2 里面的主机 172.17.0.3 的话。它发出的 IP 包,就会先到达自治系统 AS 1 上的路由器 Router 1。
而在此时,Router 1 的路由表里,有这样一条规则,即:目的地址是 172.17.0.2 包,应该经过 Router 1 的 C 接口,发往网关 Router 2(即:自治系统 AS 2 上的路由器)。至此Router 2 就会把数据包交付到真正的目的主机上。
我们就把它形象地称为:边界网关。它跟普通路由器的不同之处在于,它的路由表里拥有其他自治系统里的主机路由信息。
而 BGP 的这个能力,正好可以取代 Flannel 维护主机上路由表的功能。而且,BGP 这种原生就是为大规模网络环境而实现的协议,其可靠性和可扩展性,远非 Flannel 自己的方案可比。
- 三层网络和二层隧道的区别
- 三层和隧道的异同: 相同之处是都实现了跨主机容器的三层互通,而且都是通过对目的 MAC 地址的操作来实现的;不同之处是三层通过配置下一条主机的路由规则来实现互通,隧道则是通过通过在 IP 包外再封装一层 MAC 包头来实现。
- 三层的优点:少了封包和解包的过程,性能肯定是更高的。
- 三层的缺点:需要自己想办法维护路由规则。
- 隧道的优点:简单,原因是大部分工作都是由 Linux 内核的模块实现了,应用层面工作量较少。
- 隧道的缺点:主要的问题就是性能低。
根据实际的测试,host-gw 的性能损失大约在 10% 左右,而其他所有基于 VXLAN“隧道”机制的网络方案,性能损失都在 20%~30% 左右。
网络隔离
在 Kubernetes 里,网络隔离能力的定义,是依靠一种专门的 API 对象来描述的,即:NetworkPolicy。
Kubernetes 里的 Pod 默认都是“允许所有”(Accept All)的,即:Pod 可以接收来自任何发送方的请求;或者,向任何接收方发送请求。而如果你要对这个情况作出限制,就必须通过 NetworkPolicy 对象来指定。
如下例子:
|
|
这个 NetworkPolicy 对象,指定的隔离规则如下所示:
- 该隔离规则只对 default Namespace 下的,携带了 role=db 标签的 Pod 有效。限制的请求类型包括 ingress(流入)和 egress(流出)。
- Kubernetes 会拒绝任何访问被隔离 Pod 的请求,除非这个请求来自于以下“白名单”里的对象,并且访问的是被隔离 Pod 的 6379 端口。这些“白名单”对象包括: a. default Namespace 里的,携带了 role=fronted 标签的 Pod; b. 携带了 project=myproject 标签的 Namespace 里的任何 Pod; c. 任何源地址属于 172.17.0.0/16 网段,且不属于 172.17.1.0/24 网段的请求。
- Kubernetes 会拒绝被隔离 Pod 对外发起任何请求,除非请求的目的地址属于 10.0.0.0/24 网段,并且访问的是该网段地址的 5978 端口。
在 Kubernetes 生态里,目前已经实现了 NetworkPolicy 的网络插件包括 Calico、Weave 和 kube-router 等多个项目,但是并不包括 Flannel 项目。
所以说,如果想要在使用 Flannel 的同时还使用 NetworkPolicy 的话,你就需要再额外安装一个网络插件,比如 Calico 项目,来负责执行 NetworkPolicy。安装方式
服务暴露
我们知道 deployment 是由一堆 pod 组成的。在我们要访问pod 上的服务时有2个问题需要解决。
- pod 的ip是不固定的,随着调度一直在变
- pod之间需要一种负载均衡的访问
Service
一个最典型的 service
定义如下所示:
|
|
可以看到这个名为 nginx
的service,后面挂载了3个pod。
|
|
访问时可以随机负载。
|
|
实际上,Service 是由 kube-proxy 组件,加上 iptables 来共同实现的。
对于我们前面创建的名叫 nginx
的 Service 来说,一旦它被提交给 Kubernetes,那么 kube-proxy 就可以通过 Service 的 Informer 感知到这样一个 Service 对象的添加。而作为对这个事件的响应,它就会在宿主机上创建这样一条 iptables 规则(你可以通过 iptables-save 看到它),如下所示:
|
|
这条 iptables 规则的含义是:凡是目的地址是 10.96.60.101、目的端口是 80 的 IP 包,都应该跳转到另外一条名叫 KUBE-SVC-5JWVWZBQU2R3YJF2 的 iptables 链进行处理。
那如何做到随机访问,实际上是利用了iptable规则里的random组件里的 --probability
实现的,有33%的概率访问到 web-0
, 如果没命中的话,有50%的概率访问到 web-1
, 如果还没有命中的话,则必访问到 web-3
。
|
|
不难想到,当你的宿主机上有大量 Pod 的时候,成百上千条 iptables 规则不断地被刷新,会大量占用该宿主机的 CPU 资源,甚至会让宿主机“卡”在这个过程中。所以说,一直以来,基于 iptables 的 Service 实现,都是制约 Kubernetes 项目承载更多量级的 Pod 的主要障碍。
而 IPVS 模式的 Service,就是解决这个问题的一个行之有效的方法。IPVS 模式的工作原理,其实跟 iptables 模式类似。当我们创建了前面的 Service 之后,kube-proxy 首先会在宿主机上创建一个虚拟网卡(叫作:kube-ipvs0),并为它分配 Service VIP 作为 IP 地址,
所以,在大规模集群里,建议 kube-proxy 设置–proxy-mode=ipvs 来开启这个功能。它为 Kubernetes 集群规模带来的提升,还是非常巨大的。
- DNS 服务发现
在 Kubernetes 中,Service 和 Pod 都会被分配对应的 DNS A 记录(从域名解析 IP 的记录)。
对于 ClusterIP 模式的 Service 来说(比如我们上面的例子),它的 A 记录的格式是:..svc.cluster.local。当你访问这条 A 记录的时候,它解析到的就是该 Service 的 VIP 地址。
|
|
需要注意的是,在 Kubernetes 里,/etc/hosts 文件是单独挂载的,这也是为什么 kubelet 能够对 hostname 进行修改并且 Pod 重建后依然有效的原因。这跟 Docker 的 Init 层是一个原理。