读书:深入刨析Kubernetes(二)
声明式API
kubectl apply命令
可以简单地理解为,kubectl replace 的执行过程,是使用新的 YAML 文件中的 API对象,替换原有的 API 对象;而 kubectl apply,则是执行了一个对原有 API 对象的 PATCH 操作
例子,Istio 项目,实际上就是一个基于 Kubernetes 项目的微服务治理框架
.assets/image-20210817141529538.png)
Istio 项目架构的核心所在。Istio 最根本的组件,是运行在每一个应用 Pod 里的 Envoy 容器
这个 Envoy 项目是 Lyft 公司推出的一个高性能 C++ 网络代理,也是 Lyft 公司对 Istio 项目的唯一贡献
而 Istio 项目,则把这个代理服务以 sidecar 容器的方式,运行在了每一个被治理的应用 Pod中。我们知道,Pod 里的所有容器都共享同一个 Network Namespace。所以,Envoy 容器就能够通过配置 Pod 里的 iptables 规则,把整个 Pod 的进出流量接管下来。
这时候,Istio 的控制层(Control Plane)里的 Pilot 组件,就能够通过调用每个 Envoy 容器的API,对这个 Envoy 代理进行配置,从而实现微服务治理。
这个Envoy 是怎么被嵌入的?
在 Kubernetes 项目中,当一个 Pod 或者任何一个 API 对象被提交给 APIServer 之后,总有一些“初始化”性质的工作需要在它们被 Kubernetes 项目正式处理之前进行。
而这个“初始化”操作的实现,借助的是一个叫作 Admission 的功能。它其实是 Kubernetes项目里一组被称为 Admission Controller 的代码,可以选择性地被编译进 APIServer 中,在API 对象创建之后会被立刻调用到。
但这就意味着,如果你现在想要添加一些自己的规则到 Admission Controller,就会比较困难。因为,这要求重新编译并重启 APIServer。显然,这种使用方法对 Istio 来说,影响太大了
所以,Kubernetes 项目为我们额外提供了一种“热插拔”式的 Admission 机制,它就是Dynamic Admission Control,也叫作:Initializer
Istio 要做的,就是编写一个用来为 Pod“自动注入”Envoy 容器的 Initializer。
首先,Istio 会将这个 Envoy 容器本身的定义,以 ConfigMap 的方式保存在 Kubernetes 当中。
编写一个自定义控制器
声明式API详解
在 Kubernetes 项目中,一个 API 对象在 Etcd 里的完整资源路径,是由:Group(API 组)、Version(API 版本)和 Resource(API 资源类型)三个部分组成的。
.assets/image-20210817143550433.png)
.assets/image-20210817150554001.png)
CRD
Custom Resource Definition。自定义 API 资源。允许用户在Kubernetes 中添加一个跟 Pod、Node 类似的、新的 API 资源类型
需要写go代码
还需要编写自定义控制器
RBAC
一般我们直接使用k8s的内置用户,ServiceAccount
Operator
Operator 的工作原理,实际上是利用了 Kubernetes 的自定义 API 资源(CRD),来描述我们想要部署的“有状态应用”;然后在自定义控制器里,根据自定义 API 对象的变化,来完成具体的部署和运维工作。
约等于用代码来生成每个pod的启动命令,并把他们启动起来。
PV && PVC && StorageClass
PV 描述的,是持久化存储数据卷。这个 API 对象主要定义的是一个持久化存储在宿主机上的目录,比如一个 NFS 的挂载目录。通常情况下,PV 对象是由运维人员事先创建在 Kubernetes 集群里待用的。
而PVC 描述的,则是 Pod 所希望使用的持久化存储的属性。比如,Volume 存储的大小、可读写权限等等。PVC 对象通常由开发人员创建;或者以 PVC 模板的方式成为 StatefulSet 的一部分,然后由StatefulSet 控制器负责创建带编号的 PVC。
用户创建的 PVC 要真正被容器使用起来,就必须先和某个符合条件的 PV 进行绑定。而绑定要满足两个条件:
- PV 和 PVC 的 spec 字段。比如,PV 的存储(storage)大小,就必须满足 PVC 的要求
- PV 和 PVC 的 storageClassName 字段必须一样
Pod 需要做的,就是在 volumes 字段里声明自己要使用的 PVC 名字
不难看出,PVC 和 PV 的设计,其实跟“面向对象”的思想完全一致。
PVC 可以理解为持久化存储的“接口”,它提供了对某种持久化存储的描述,但不提供具体的实现;而这个持久化存储的实现部分则由 PV 负责完成。
这样做的好处是,作为应用开发者,我们只需要跟 PVC 这个“接口”打交道,而不必关心具体的实现是 NFS 还是 Ceph。
有一个问题,如果创建pod时,没有合适的pv给pvc去绑定,怎么办?
Kubernetes 中,实际上存在着一个专门处理持久化存储的控制器,PersistentVolumeController。
PersistentVolumeController 会不断地查看当前每一个 PVC,是不是已经处于 Bound(已绑定)状态。如果不是,那它就会遍历所有的、可用的 PV,并尝试将其与这个“单身”的 PVC进行绑定。这样,Kubernetes 就可以保证用户提交的每一个 PVC,只要有合适的 PV 出现,它就能够很快进入绑定状态,从而结束“单身”之旅。
进一步,pv由运维人员提前创建,在大规模的生产环境里,这其实是一个非常麻烦的工作。在实际操作中,这几乎没办法靠人工做到。所以,Kubernetes 为我们提供了一套可以自动创建 PV 的机制,即:Dynamic Provisioning。
Dynamic Provisioning 机制工作的核心,在于一个名叫 StorageClass 的 API 对象。
而 StorageClass 对象的作用,其实就是创建 PV 的模板。
具体地说,StorageClass 对象会定义如下两个部分内容:
第一,PV 的属性。比如,存储类型、Volume 的大小等等。
第二,创建这种 PV 需要用到的存储插件。比如,Ceph 等等
有了这样两个信息之后,Kubernetes 就能够根据用户提交的 PVC,找到一个对应的StorageClass 了。然后,Kubernetes 就会调用该 StorageClass 声明的存储插件,创建出需要的 PV。
这时候,作为应用开发者,我们只需要在 PVC 里指定要使用的 StorageClass 名字即可。
.assets/image-20210819142850853.png)
有了 Dynamic Provisioning 机制,运维人员只需要在 Kubernetes 集群里创建出数量有限的StorageClass 对象就可以了。这就好比,运维人员在 Kubernetes 集群里创建出了各种各样的PV 模板。这时候,当开发人员提交了包含 StorageClass 字段的 PVC 之后,Kubernetes 就会根据这个 StorageClass 创建出对应的 PV。
Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来。实际上,如果你的集群已经开启了名叫 DefaultStorageClass 的 Admission Plugin,它就会为PVC 和 PV 自动添加一个默认的 StorageClass;否则,PVC 的 storageClassName 的值就是“”,这也意味着它只能够跟 storageClassName 也是“”的 PV 进行绑定。
网络基础
docker0
在 Linux 中,能够起到虚拟交换机作用的网络设备,是网桥(Bridge)。它是一个工作在数据 链路层(Data Link)的设备,主要功能是根据 MAC 地址学习来将数据包转发到网桥的不同端 口(Port)上。
而为了实现上述目的,Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连 接在 docker0 网桥上的容器,就可以通过它来进行通信。
那么如何把这些容器“连接”到 docker0 网桥上呢?
需要使用一种名叫Veth Pair的虚拟设备了。
Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出 现的。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网 卡”上,哪怕这两个“网卡”在不同的 Network Namespace 里。 这就使得 Veth Pair 常常被用作连接不同 Network Namespace 的“网线”。
.assets/image-20210823102732579.png)
跨主机容器通信
在 Docker 的默认配置下,一台宿主机上的 docker0 网桥,和其他宿主机上的 docker0 网桥, 没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进 行通信了。
我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接 到这个网桥上
.assets/image-20210823103054993.png)
构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一 个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被 称为:Overlay Network(覆盖网络)。
Flannel
Flannel 项目是 CoreOS 公司主推的容器网络方案。事实上,Flannel 项目本身只是一个框架,真 正为我们提供容器网络功能的,是 Flannel 的后端实现。目前,Flannel 支持三种后端实现,分别 是: 1. VXLAN; 2. host-gw; 3. UDP。
UDP模式
UDP 模式,是最直接、也是 最容易理解的容器跨主网络实现。却也是性能最差的一种方式。已废弃。
在 Linux 中,TUN 设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN 设备的功能 非常简单,即:在操作系统内核和用户应用程序之间传递 IP 包。
Flannel 项目里一个非常重要的概念:子网(Subnet)。事实上,在由 Flannel 管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一 个“子网”。而这些子网与宿主机的对应关系,正是保存在 Etcd 当中。
flanneld 在收到 container-1 发给 container-2 的 IP 包之后,就会把这个 IP 包直接封装 在一个 UDP 包里,然后发送给 Node 2。
.assets/image-20210823105043545.png)
Flannel UDP 模式提供的其实是一个三层的 Overlay 网络,即:它首先对发出端的 IP 包进行 UDP 封装,然后在接收端进行解封装拿到原始的 IP 包,进而把这个 IP 包转发给目标容 器。
性能问题
仅在发出 IP 包的过程中,就需要经过三次用户态与内核态之间的数据拷贝
.assets/image-20210823105233969.png)
第一次:用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;
第二次:IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;
第三次:flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出去。
此外,我们还可以看到,Flannel 进行 UDP 封装(Encapsulation)和解封装(Decapsulation) 的过程,也都是在用户态完成的。在 Linux 操作系统中,上述这些上下文切换和用户态操作的代价 其实是比较高的,这也正是造成 Flannel UDP 模式性能不好的主要原因。
VXLAN模式
主流的容器网络方案。
VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网),是 Linux 内核本身就支持的一种网络 虚似化技术。所以说,VXLAN 可以完全在内核态实现上述封装和解封装的工作,从而通过与前面 相似的“隧道”机制,构建出覆盖网络(Overlay Network)。
VXLAN 的覆盖网络的设计思想是:在现有的三层网络之上,“覆盖”一层虚拟的、由内核 VXLAN 模块负责维护的二层网络,使得连接在这个 VXLAN 二层网络上的“主机”(虚拟机或者容器都可 以)之间,可以像在同一个局域网(LAN)里那样自由通信。当然,实际上,这些“主机”可能分 布在不同的宿主机上,甚至是分布在不同的物理机房里。
为了能够在二层网络上打通“隧道”,VXLAN 会在宿主机上设置一个特殊的网络设备作为“隧 道”的两端。这个设备就叫作 VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。
.assets/image-20210823105654600.png)
每台主机上都有一个VTEP。这些 VTEP 设备之间,就需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。
“源 VTEP 设备”收到“原始 IP 包”后,就要想办法把“原始 IP 包”加上 一个目的 MAC 地址,封装成一个二层数据帧,然后发送给“目的 VTEP 设备”
K8S的CNI
Kubernetes 是通过一个叫作 CNI 的接口,维护了一个单独的网桥来代替 docker0。这个网桥 的名字就叫作:CNI 网桥,它在宿主机上的设备名称默认是:cni0
以 Flannel 的 VXLAN 模式为例,在 Kubernetes 环境里,它的工作方式没有任何不同。只不过,docker0 网桥被替换成了 CNI 网桥而已。
.assets/image-20210823111221761.png)
需要注意的是,CNI 网桥只是接管所有 CNI 插件负责的、即 Kubernetes 创建的容器 (Pod)。而此时,如果你用 docker run 单独启动一个容器,那么 Docker 项目还是会把这个 容器连接到 docker0 网桥上。所以这个容器的 IP 地址,一定是属于 docker0 网桥的 172.17.0.0/16 网段。
纯三层方案
其中的典型例子,是Flannel 的 host-gw 模式和 Calico 项目
Flannel 的 host-gw 模式
host-gw 示意图
.assets/image-20210823141353700.png)
可以看到,host-gw 模式的工作原理,其实就是将每个 Flannel 子网(Flannel Subnet,比 如:1x.xx.1.0/24)的“下一跳”,设置成了该子网对应的宿主机的 IP 地址。
host-gw 模式能够正常工作的核心,就在于 IP 包在封 装成帧发送出去的时候,会使用路由表里的“下一跳”来设置目的 MAC 地址。这样,它就会经 过二层网络到达目的宿主机。 所以说,Flannel host-gw 模式必须要求集群宿主机之间是二层连通的。
宿主机之间二层不连通的情况也是广泛存在的。比如,宿主机分布在了不同的子 网(VLAN)里。
Calico
Calico 项目提供的网络解决方案,与 Flannel 的 host-gw 模式,几乎是完全一样的。
1 | < 目的容器 IP 地址段 > via < 网关的 IP 地址 > dev eth0 |
网关的 IP 地址,正是目的容器所在宿主机的 IP 地址。这个三层网络方案得以正常工作的核心,是为每个容器的 IP 地址,找到它所对 应的、“下一跳”的网关。
不同于 Flannel 通过 Etcd 和宿主机上的 flanneld 来维护路由信息的做法,Calico 项目 使用了一个“重型武器”来自动地在整个集群中分发路由信息。这个“重型武器”,就是 BGP。
BGP 的全称是 Border Gateway Protocol,即:边界网关协议。它是一个 Linux 内核原生就支 持的、专门用在大规模数据中心里维护不同的“自治系统”之间路由信息的、无中心的路由协 议。
.assets/image-20210823144829233.png)
边界网关。它跟普通 路由器的不同之处在于,它的路由表里拥有其他自治系统里的主机路由信息。
对于一个超大超复杂的网络系统,手动去维护这个路由信息是不现实的。这种情况下,BGP 大显身手的时刻就到了
在使用了 BGP 之后,你可以认为,在每个边界网关上都会运行着一个小程序,它们会将各自的 路由表信息,通过 TCP 传输给其他的边界网关。而其他边界网关上的这个小程序,则会对收到 的这些数据进行分析,然后将需要的信息添加到自己的路由表里。
所以说,所谓 BGP,就是在大规模网络中实现节点路由信息共享的一种协议。
.assets/image-20210823145450671.png)
Calico 项目与 Flannel 的 host-gw 模式的另一个不同之处, 就是它不会在宿主机上创建任何网桥设备
Calico 项目实际上将集群里的所有节点,都当作是边界路由器来处理,它们一起组 成了一个全连通的网络,互相之间通过 BGP 协议交换路由规则。这些节点,我们称为 BGP Peer。
但是如果二层不通,需要开启IPIP模式
IPIP模式
.assets/image-20210823150010535.png)
在 Calico 的 IPIP 模式下,Felix 进程在 Node 1 上添加的路由规则,会稍微不同,如下所示:
1 | 10.233.2.0/24 via 192.168.2.2 tunl0 |
可以看到,尽管这条规则的下一跳地址仍然是 Node 2 的 IP 地址,但这一次,要负责将 IP 包 发出去的设备,变成了 tunl0。注意,是 T-U-N-L-0,而不是 Flannel UDP 模式使用的 T-U-N-0(tun0),这两种设备的功能是完全不一样的。
Calico 使用的这个 tunl0 设备,是一个 IP 隧道(IP tunnel)设备
IP 包进入 IP 隧道设备之后,就会被 Linux 内核的 IPIP 驱动接管。IPIP 驱动 会将这个 IP 包直接封装在一个宿主机网络的 IP 包
这样就通过三层路由,发到node2。然后node2进行解包。