0. K8s简介

Kubernetes 这个单词是希腊语,它的中文翻译是“舵手”。我们知道,container 这个英文单词也有另外的一个意思就是“集装箱”。Kubernetes 也就借着这个寓意,希望成为运送集装箱的一个轮船,来帮助我们管理这些集装箱,也就是管理这些容器。

Kubernetes是未来云计算系统的操作系统,而Docker 容器其实是未来云计算系统中的进程。

更具体一点地来说:Kubernetes 是一个自动化的容器编排平台,它负责应用的部署、应用的弹性以及应用的管理,这些都是基于容器的。

0.1 核心功能

Kubernetes 有如下几个核心的功能:

  • 服务的发现与负载的均衡;
  • 容器的自动装箱,我们也会把它叫做 scheduling,就是“调度”,把一个容器放到一个集群的某一个机器上,Kubernetes 会帮助我们去做存储的编排,让存储的声明周期与容器的生命周期能有一个连接;
  • Kubernetes 会帮助我们去做自动化的容器的恢复。在一个集群中,经常会出现宿主机的问题或者说是 OS 的问题,导致容器本身的不可用,Kubernetes 会自动地对这些不可用的容器进行恢复;
  • Kubernetes 会帮助我们去做应用的自动发布与应用的回滚,以及与应用相关的配置密文的管理;
  • 对于 job 类型任务,Kubernetes 可以去做批量的执行;

0.2 k8s架构

1panel 容器编排 规则 容器编排 英文_IP

Kubernetes 主要由以下几个核心组件组成:

  • etcd 保存了整个集群的状态;
  • apiserver 提供了资源操作的唯一入口,并提供认证、授权、访问控制、API 注册和发现等机制;
  • controller manager 负责维护集群的状态,比如故障检测、自动扩展、滚动更新等;
  • scheduler 负责资源的调度,按照预定的调度策略将 Pod 调度到相应的机器上;
  • kubelet 负责维护容器的生命周期,同时也负责 Volume(CSI)和网络(CNI)的管理;
  • Container runtime 负责镜像管理以及 Pod 和容器的真正运行(CRI);
  • kube-proxy 负责为 Service 提供 cluster 内部的服务发现和负载均衡;

除了核心组件,还有一些推荐的插件,其中有的已经成为 CNCF 中的托管项目:

  • CoreDNS 负责为整个集群提供 DNS 服务
  • Ingress Controller 为服务提供外网入口
  • Prometheus 提供资源监控
  • Dashboard 提供 GUI
  • Federation 提供跨可用区的集群

1. Pod–调度的最小单位

Pod,实际上是在扮演传统基础设施里“虚拟机”的角色;而容器,则是这个虚拟机里运行的用户程序。

1.1 docker容器 vs 虚拟机 vs pod

很多人把docker容器跟虚拟机相提并论,他们把容器当做性能更好的虚拟机。

但实际上,无论是从具体的实现原理,还是从使用方法、特性、功能等方面,容器与虚拟机几乎没有任何相似的地方;

Docker 容器的本质,其实是未来云计算系统中的进程。而一个运行在虚拟机里的应用,哪怕再简单,也是被管理在 systemd 或者 supervisord 之下的一组进程,而不是一个进程。这跟本地物理机上应用的运行方式其实是一样的。这也是为什么,从物理机到虚拟机之间的应用迁移,往往并不困难。

你可以把整个虚拟机想象成为一个 Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为 Init Container。这才是更加合理的、松耦合的容器编排诀窍,也是从传统应用架构,到“微服务架构”最自然的过渡方式。

1.2 pod概念简介

Pod只是一个逻辑概念。也就是说,Kubernetes 真正处理的是宿主机操作系统上 Linux 容器的 Namespace 和 Cgroups,而并不存在一个所谓的 Pod 的边界或者隔离环境。

Pod,是一组共享了某些资源的容器。具体的说:Pod 里的所有容器,共享的是同一个 Network Namespace,并且可以声明共享同一个 Volume。

1.3 pod 对象设计

把pod 看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”,那么很多关于 Pod 对象的设计就非常容易理解了。

比如,凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的。

这些属性的共同特征是,它们描述的是“机器”这个整体,而不是里面运行的“程序”。
比如

  • 配置这个“机器”的网卡(即:Pod 的网络定义)
  • 配置这个“机器”的磁盘(即:Pod 的存储定义)
  • 配置这个“机器”的防火墙(即:Pod 的安全定义)
  • 这台“机器”运行在哪个服务器之上(即:Pod 的调度)。

2. 控制器–完成编排逻辑

2.1 编排的概念

“容器”镜像虽然好用,但是容器这样一个“沙盒”的概念,对于描述应用来说,还是太过简单了。

这就好比,集装箱固然好用,但是如果它四面都光秃秃的,吊车还怎么把这个集装箱吊起来并摆放好呢?

所以,Pod 对象,其实就是容器的升级版。它对容器进行了组合,添加了更多的属性和字段。

这就好比给集装箱四面安装了吊环,使得 Kubernetes 这架“吊车”,可以更轻松地操作它。

而 Kubernetes 操作这些“集装箱”的逻辑,都由控制器(Controller)完成。

2.2 k8s中的控制器–控制循环

Kubernetes 项目的 pkg/controller 目录,包含了一系列控制器的集合:

$ cd kubernetes/pkg/controller/
$ ls -d */              
deployment/             job/                    podautoscaler/          
cloud/                  disruption/             namespace/              
replicaset/             serviceaccount/         volume/
cronjob/                garbagecollector/       nodelifecycle/          replication/            statefulset/            daemon/
...

每一个控制器,都以独有的方式负责某种编排功能。这些控制器都遵循 Kubernetes 项目中的一个通用编排模式,即:控制循环(control loop)。

for {
  实际状态 := 获取集群中对象X的实际状态(Actual State)
  期望状态 := 获取集群中对象X的期望状态(Desired State)
  if 实际状态 == 期望状态{
    什么都不做
  } else {
    执行编排动作,将实际状态调整为期望状态
  }
}

2.3 k8s操作方式–配置YAML并应用

在K8S中,要创建和更新两个 Nginx 容器该怎么做呢?

这个流程,相信你已经非常熟悉了:我们需要在本地编写一个 Deployment 的 YAML 文件:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

然后,使用 kubectl create 命令在 Kubernetes 里创建这个 Deployment 对象:

$ kubectl create -f nginx.yaml

两个 Nginx 的 Pod 就会运行起来了。

2.4 Depolyment控制器–编排无状态应用

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80
  • Deployment 控制器从 Etcd 中获取到所有携带了“app: nginx”标签的 Pod,然后统计它们的数量,这就是实际状态;
  • Deployment 对象的 Replicas 字段的值就是期望状态;
  • Deployment 控制器将两个状态做比较,然后根据比较结果,确定是创建 Pod,还是删除已有的 Pod

2.5 StatefulSet控制器–编排有状态应用

Deployment 实际上并不足以覆盖所有的应用编排问题。

造成这个问题的根本原因,在于 Deployment 对应用做了一个简单化假设:它认为,一个应用的所有 Pod,是完全一样的。

所以,它们互相之间没有顺序,也无所谓运行在哪台宿主机上。需要的时候,Deployment 就可以通过 Pod 模板创建新的 Pod;不需要的时候,Deployment 就可以“杀掉”任意一个 Pod。但是,在实际的场景中,并不是所有的应用都可以满足这样的要求。

  • 尤其是分布式应用,它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。
  • 还有就是数据存储类应用,它的多个实例,往往都会在本地磁盘上保存一份数据。而这些实例一旦被杀掉,即便重建出来,实例与数据之间的对应关系也已经丢失,从而导致应用失败。

StatefulSet 其实可以认为是对 Deployment 的改良。

StatefulSet 的独特之处在于,它的每个 Pod 都被编号了。而且,这个编号会体现在 Pod 的名字和 hostname 等标识信息上,这不仅代表了 Pod 的创建顺序,也是 Pod 的重要网络标识(即:在整个集群里唯一的、可被访问的身份)。有了这个编号后,StatefulSet 就使用 Kubernetes 里的两个标准功能:Headless Service 和 PV/PVC,实现了对 Pod 的拓扑状态和存储状态的维护。

2.6 DaemonSet控制器–编排守护进程

DaemonSet 的主要作用,是让你在 Kubernetes 集群里,运行一个 Daemon Pod。
这个 Pod 有如下三个特征:

  • 这个 Pod 运行在 Kubernetes 集群里的每一个节点(Node)上;
  • 每个节点上只有一个这样的 Pod 实例;
  • 当有新的节点加入 Kubernetes 集群后,该 Pod 会自动地在新节点上被创建出来;
  • 而当旧节点被删除后,它上面的 Pod 也相应地会被回收掉。

Daemon Pod 的意义确实是非常重要的,比如:

  • 各种网络插件的 Agent 组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络;
  • 各种存储插件的 Agent 组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的 Volume 目录;
  • 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。

示例:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd-elasticsearch
  namespace: kube-system
  labels:
    k8s-app: fluentd-logging
spec:
  selector:
    matchLabels:
      name: fluentd-elasticsearch
  template:
    metadata:
      labels:
        name: fluentd-elasticsearch
    spec:
      tolerations:
      - key: node-role.kubernetes.io/master
        effect: NoSchedule
      containers:
      - name: fluentd-elasticsearch
        image: k8s.gcr.io/fluentd-elasticsearch:1.20
        resources:
          limits:
            memory: 200Mi
          requests:
            cpu: 100m
            memory: 200Mi
        volumeMounts:
        - name: varlog
          mountPath: /var/log
        - name: varlibdockercontainers
          mountPath: /var/lib/docker/containers
          readOnly: true
      terminationGracePeriodSeconds: 30
      volumes:
      - name: varlog
        hostPath:
          path: /var/log
      - name: varlibdockercontainers
        hostPath:
          path: /var/lib/docker/containers

可以看到,DaemonSet 跟 Deployment 其实非常相似,只不过是没有 replicas 字段;

DaemonSet 其实是一个非常简单的控制器。在它的控制循环中,只需要遍历所有节点,然后根据节点上是否有被管理 Pod 的情况,来决定是否要创建或者删除一个 Pod。

2.7 Job & CronJob–编排离线业务

Deployment、StatefulSet,以及 DaemonSet编排的都是在线业务。这些应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在 Running 状态。

“离线业务”在计算完成后就直接退出了。描述离线业务的 API 对象就是:Job。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc 
        command: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

3. K8S容器网络

3.1 单机容器网络–Veth Pair 设备 + 宿主机网桥

一个隔离的容器进程,该如何跟其他 Network Namespace 里的容器进程进行交互呢?

1panel 容器编排 规则 容器编排 英文_Pod_02


被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了跟同其他容器的数据交换。

  • Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信。
  • 可是,我们又该如何把这些容器“连接”到 docker0 网桥上呢?这时候,我们就需要使用一种名叫 Veth Pair 的虚拟设备了。Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现的。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的 Network Namespace 里。这就使得 Veth Pair 常常被用作连接不同 Network Namespace 的“网线”。

当你遇到容器连不通“外网”的时候,你都应该先试试 docker0 网桥能不能 ping 通,然后查看一下跟 docker0 和 Veth Pair 设备相关的 iptables 规则是不是有异常,往往就能够找到问题的答案了。

3.2 集群容器网络–Overlay Network

万变不离其宗。如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?

  • 当 Node 1 上的 Container 1 要访问 Node 2 上的 Container 3 的时候,Node 1 上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机
  • 而 Node 2 上的“特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器,比如 Container 3。

3.3 Flannel UDP

Flannel 项目是 CoreOS 公司主推的容器网络方案。

Flannel 支持三种后端实现,分别是:VXLAN;host-gw;UDP。

接下来我们讲一下最简单的UDP模式。

1panel 容器编排 规则 容器编排 英文_IP_03

3.3.1 网络拓扑

  • 宿主机 Node 1 上有一个容器 container-1,它的 IP 地址是 100.96.1.2,对应的 docker0 网桥的地址是:100.96.1.1/24。
  • 宿主机 Node 2 上有一个容器 container-2,它的 IP 地址是 100.96.2.3,对应的 docker0 网桥的地址是:100.96.2.1/24。

我们现在的任务,就是让 container-1 访问 container-2。

3.3.2 报文发送流程

  • container-1 容器里的进程发起的 IP 包,其源地址就是 100.96.1.2,目的地址就是 100.96.2.3。由于目的地址 100.96.2.3 并不在 Node 1 的 docker0 网桥的网段里,所以这个 IP 包会被交给默认路由规则
  • Flannel 已经在宿主机上创建出了一系列的路由规则
# 在Node 1上
$ 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
  • 可以看到,由于我们的 IP 包的目的地址是 100.96.2.3,它匹配不到本机 docker0 网桥对应的 100.96.1.0/24 网段,只能匹配到第二条、也就是 100.96.0.0/16 对应的这条路由规则,从而进入到一个叫作 flannel0 的设备中。
  • 而这个 flannel0 设备的类型就比较有意思了:它是一个 TUN 设备(Tunnel 设备)。在 Linux 中,TUN 设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN 设备的功能非常简单,即:在操作系统内核和用户应用程序之间传递 IP 包。
  • 以 flannel0 设备为例:像上面提到的情况,当操作系统将一个 IP 包发送给 flannel0 设备之后,flannel0 就会把这个 IP 包,交给创建这个设备的应用程序,也就是 Flannel 进程。这是一个从内核态(Linux 操作系统)向用户态(Flannel 进程–flanneld)的流动方向。
  • flanneld 进程在处理由 flannel0 传入的 IP 包时,就可以根据目的 IP 的地址(比如 100.96.2.3),匹配到对应的子网(比如 100.96.2.0/24),从 Etcd 中找到这个子网对应的宿主机的 IP 地址是 10.168.0.3
  • flanneld 在收到 container-1 发给 container-2 的 IP 包之后,就会把这个 IP 包直接封装在一个 UDP 包里,然后发送给 Node 2。不难理解,这个 UDP 包的源地址,就是 flanneld 所在的 Node 1 的地址,而目的地址,则是 container-2 所在的宿主机 Node 2 的地址。
  • 这个请求得以完成的原因是,每台宿主机上的 flanneld,都监听着一个 8285 端口,所以 flanneld 只要把 UDP 包发往 Node 2 的 8285 端口即可。

3.3.3 报文接收流程

  • Node 2 上监听 8285 端口的进程也是 flanneld,所以这时候,flanneld 就可以从这个 UDP 包里解析出封装在里面的、container-1 发来的原 IP 包。而接下来 flanneld 的工作就非常简单了:flanneld 会直接把这个 IP 包发送给它所管理的 TUN 设备,即 flannel0 设备。
  • TUN 设备的原理是一个从用户态向内核态的流动方向(Flannel 进程向 TUN 设备发送数据包),所以 Linux 内核网络栈就会负责处理这个 IP 包,具体的处理方法,就是通过本机的路由表来寻找这个 IP 包的下一步流向。
# 在Node 2上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.2.0
100.96.2.0/24 dev docker0  proto kernel  scope link  src 100.96.2.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.3
  • 由于这个 IP 包的目的地址是 100.96.2.3,它跟第三条、也就是 100.96.2.0/24 网段对应的路由规则匹配更加精确。所以,Linux 内核就会按照这条路由规则,把这个 IP 包转发给 docker0 网桥。
  • docker0 网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过 Veth Pair 设备进入到 container-2 的 Network Namespace 里。

3.4 Flannel Vxlan

3.3.1 Flannel UDP的性能问题

Flannel UDP 模式有严重的性能问题,我们看一下Flannel UDP报文的流程:

  • 第一次,用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;
  • 第二次,IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;
  • 第三次,flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出去。

在 Linux 操作系统中,上述这些用户态和内核态的切换,性能是非常低的。

3.3.2 Flannel VXLAN的改进方案

VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网),是 Linux 内核本身就支持的一种网络虚似化技术。

VXLAN 可以完全在内核态实现上述封装和解封装。省去了一次上下文切换,提升了性能。

1panel 容器编排 规则 容器编排 英文_Pod_04

VXLAN 会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作 VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。

而 VTEP 设备的作用,其实跟前面的 flanneld 进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核里完成的(VXLAN 本身就是 Linux 内核中的一个模块)。

3.3.3 报文发送流程

  • 我们的 container-1 的 IP 地址是 10.1.15.2,要访问的 container-2 的 IP 地址是 10.1.16.3。
  • 当 container-1 发出请求之后,这个目的地址是 10.1.16.3 的 IP 包,会先出现在 docker0 网桥。
  • 然后被路由到本机 flannel.1 设备进行处理。也就是说,来到了“隧道”的入口
  • 为了能够将“原始 IP 包”封装并且发送到正确的宿主机,VXLAN 就需要找到这条“隧道”的出口,即:目的宿主机的 VTEP 设备。而这个设备的信息,正是每台宿主机上的 flanneld 进程负责维护的
  • flanneld 就会添加一条如下所示的路由规则:
$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.1.16.0       10.1.16.0       255.255.255.0   UG    0      0        0 flannel.1
  • 凡是发往 10.1.16.0/24 网段的 IP 包,都需要经过 flannel.1 设备发出,并且,它最后被发往的网关地址是:10.1.16.0。10.1.16.0 正是 Node 2 上的 VTEP 设备(也就是 flannel.1 设备)的 IP 地址。
  • 这些 VTEP 设备之间,就需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。在我们的例子中,“源 VTEP 设备”收到“原始 IP 包”后,就要想办法把“原始 IP 包”加上一个目的 MAC 地址,封装成一个二层数据帧,然后发送给“目的 VTEP 设备”。
  • 根据前面的路由记录,我们已经知道了“目的 VTEP 设备”的 IP 地址。而要根据三层 IP 地址查询对应的二层 MAC 地址,这正是 ARP(Address Resolution Protocol )表的功能。
  • ARP 记录,也是 flanneld 进程在 Node 2 节点启动时,自动添加在 Node 1 上的。
# 在Node 1上
$ ip neigh show dev flannel.1
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT
  • 然后,Linux 内核会把这个数据帧封装进一个 UDP 包里发出去。

3.3.4 报文接收流程

  • Node 1 上的 flannel.1 设备就可以把这个数据帧从 Node 1 的 eth0 网卡发出去。这个帧会经过宿主机网络来到 Node 2 的 eth0 网卡。
  • Node 2 的内核网络栈会发现这个数据帧里有 VXLAN Header,并且 VNI=1。所以 Linux 内核会对它进行拆包,拿到里面的内部数据帧,然后根据 VNI 的值,把它交给 Node 2 上的 flannel.1 设备。
  • flannel.1 设备则会进一步拆包,取出“原始 IP 包”。
  • 接下来就回到单机容器网络的处理流程。最终,IP 包就进入到了 container-2 容器的 Network Namespace 里。

3.5 Kubernetes网络模型与CNI网络插件

3.5.1 网络插件

对于K8S来说,Flannel其实就是一个网络插件。

我们看到,用户的容器都连接在 docker0 网桥上。而网络插件则在宿主机上创建了一个特殊的设备(UDP 模式创建的是 TUN 设备,VXLAN 模式创建的则是 VTEP 设备),docker0 与这个设备之间,通过 IP 转发(路由表)进行协作。

网络插件要做的事情,就是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的。

3.5.2 CNI接口

Kubernetes 是通过一个叫作 CNI 的接口,维护了一个单独的网桥来代替 docker0。这个网桥的名字就叫作:CNI 网桥,它在宿主机上的设备名称默认是:cni0。

1panel 容器编排 规则 容器编排 英文_1panel 容器编排 规则_05


需要注意的是,CNI 网桥只是接管所有 CNI 插件负责的、即 Kubernetes 创建的容器(Pod)。而此时,如果你用 docker run 单独启动一个容器,那么 Docker 项目还是会把这个容器连接到 docker0 网桥上。

3.5.3 k8s网络模型

了解了 Kubernetes 中 CNI 网络的实现原理后,你其实就很容易理解所谓的“Kubernetes 网络模型”了:

  • 所有容器都可以直接使用 IP 地址与其他容器通信,而无需使用 NAT。
  • 所有宿主机都可以直接使用 IP 地址与所有容器通信,而无需使用 NAT。反之亦然。
  • 容器自己“看到”的自己的 IP 地址,和别人(宿主机或者容器)看到的地址是完全一样的。