深入剖析Kubernetes笔记

背景

张磊的深入剖析Kubernetes,最近学了下。做下笔记用于备忘。由于这个课程时间是18年的,我们现在得以辩证眼光看待里面的内容。读书笔记内容不一定都来自书上,有些是自己的思考或者搜集的信息,请留意。

课前必读

主要讲了一些历史,主要是k8s和docker的标准之争。google坚持创新,并且通过打容器编排和管理的生态的差异化特点来弯道超车,这点值得我们学习。另外,想要占领标准,足够开放是很重要的。k8s是民主化的架构,从API到运行时都暴露了足够的扩展点。

容器技术基本概念

Croups和Namespace

容器实现的基础是cgroups和namespace技术,这个技术由于是Linux系统本身提供的,控制的粒度很细、控制的内容很薄,所以十分轻量。这也是相比早期虚拟机技术的重要优势。

  • namespace: 主要用于资源隔离。Linux提供多种资源的namespace,包括文件系统、pid、网络、IPC、hostname和用户等。Linux底层的系统调用clone、unshare、setns可以用于namespace相关操作。查看ns信息可以在ls -l /proc/${PID}/ns查看
  • cgroups: 全称,Linux control group。主要用于**资源控制。**这些资源主要指的是硬件资源,例如cpu、memory、网络等。Linux系统重挂载cgroups后可以在/sys/fs/cgroup/下看到相关控制点包括cpu、blkio、memory等等。

TIPS:

  • 容器是单进程模型。一个容器本质是个Linux层面应用namespace的应用进程。容器内其他进程都是容器内1号进程fork出来的子进程。
  • 由于依赖Linux一些关键的系统调用,所以docker是需要root权限来执行的

根文件系统rootfs

容器创建的步骤实际上就三步:

  1. 启用 Linux Namespace 配置;
  2. 设置指定的 Cgroups 参数;
  3. 切换进程的根目录(Change Root),本质采用的是mount namespace,来改变根目录视图。具体的系统调用包括privot_root和chroot。这步执行时,容器进程可以看到宿主机上的整个文件系统

rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

rootfs与容器镜像

docker提出的容器镜像实际上是一种增量制作rootfs的技术。引入layer的概念,配合union file system来达成。Union FS实际上就是将mnt到同一个挂载点的目录进行合并的技术。如下的多个Layer实际上就是五个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上

1
2
3
4
5
6
7
8
9
10
11
12
13

$ docker image inspect ubuntu:latest
...
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:f49017d4d5ce9c0f544c...",
"sha256:8f2b771487e9d6354080...",
"sha256:ccd4d61916aaa2159429...",
"sha256:c01d74f99de40e097c73...",
"sha256:268a067217b5fe78e000..."
]
}

例如Ubuntu容器镜像,可以在/var/lib/docker/aufs/mnt/下查看挂载的union fs。这里是已经合并的fs,如果要查看具体合并了哪些fs,也可以配合/proc/mounts下的信息和/sys/fs/aufs下的信息来查看。一个制作好的Union FS容器镜像,每个layer底下都有个合并前的mnt id:
image.png
这里了解下挂载的权限:

  • ro: readonly只读
  • rw: 可读写
  • wh: whiteout,可以称之为白障,用于支持只读层的删除。实际上不进行物理删除,只是创建一个对应的.wh.${fileName}文件,然后在联合挂载的时候感知到后遮挡掉原文件达到类似delete的效果。

可以看到以上layer分了多个部分:

  • 只读层:主要是操作系统核心的文件
  • init层:docker项目单独生成的内部层,存放/etc/hosts和/etc/resolv.conf等信息
  • 可读写层:容器内新的写入内容会在这一层

Volume

卷主要是为了支持宿主机上的目录和文件映射到容器中,方便读取和修改。使用的是修改inode指针实现的bind mount。

绑定挂载是在容器运行时指定的,一般在docker run的时候可以指定。

Google Borg与K8s

k8s基本的结构如下,请求都是先由api server承接,配合另外2个核心组件controller manager和scheduler完成service、deployment、schedule相关的重要能力。
image.png

borg[2] 是google内部集群管理和任务调度基础设施。K8s中的很多理念都来自于borg并进行了改良。观察k8s的设计,我们可以学到的是:

  • 以任务为中心:区别于很多人以docker为中心设计,k8s参考borg以任务为中心,将docker只是作为一个运行时。这样可以充分利用borg上任务调度、编排的经验。
  • 统一抽象任务的关系并且重点关注任务之间关系的处理:相比docker能够在更加高层的角度抽象任务的关系也是其相比docker swarm的优势。
  • 标准化与丰富的扩展:基于各种标准化的规范设计的API,例如CNI、CRI、CSI。这些为K8S后续的快速发展提供了重要基础
  • 声明式API与资源定义:可以看到k8s基本上是follow google的api design guide设计的。以资源为核心通过声明式API将他们串联起来。这也倒推设计者在设计之初需要做好资源抽象。
  • 设计最小调度单位的抽象pod: 灵感来自于borg,实际上是个任务组。对于pod来说,内部可以包含1个或多个容器,这些容器共享网络和存储资源,是一组密切相关的应用程序实例。[3] POD底层共享namespace。

pod是具体的执行单元,通常为一个docker运行时。围绕编排、调度与管理可以拓展出k8s功能全景图。像configMap保存配置、Deplyment和StatefulSet都是很常用的对象,后面文章会介绍。
image.png

Kubernetes集群搭建与实践

我本地电脑是mac,直接用docker desktop配合IDEA的k8s插件部署本地k8s是比较容易的。可以参考kubernetes IDEA插件的教程视频在本地管理k8s集群。

拓展:serverless k8s

[11] 中提到了几种servless kk8s的流派来解决K8S的遗留问题值得关注。k8s的缺点主要是:

  • 运维复杂性:强大能力和扩展性也使得运维和使用复杂性大幅度提升
  • 资源共享:基于cgroup的隔离有时候不够彻底

因此也衍生了一些serveless k8s的流派来解决以上痛点:

  • Nodeless k8s: 主要是google GKE Autopilot。相当于云厂商进一步托管,然后在资源基础上像用户额外进行收费。没有改变K8S架构,本质是Node的下沉与托管,算是一种商业上的模式。

image.png

  • Serveless container: 代表是AWS EKS on Fargate和阿里云ACK on ECI和Azure AKS with ACI。摒弃Node概念,Pod运行在独立的安全沙箱中,利用虚拟化技术实现资源隔离和安全隔离。

image.png

容器编排与作业管理

POD

  • 为什么需要设计pod的概念:docker本质是进程,k8s把自己当做一个类似OS的系统来设计,需要考虑进程和进程组。这个也是来自于borg的实践知识。没有组的概念,具备密切关系的一组进程将没法很好的被进行管理。
  • **pod保持对等关系和资源共享需要依赖infra容器(pause容器): **一个很轻量的容器(一直处于pending只用来hold住网络等资源),使得pod内其他容器都是对等的容器,可以共享network、volume等资源

image.png

  • pod与sidecar设计模式:有了pod这个概念后,在一个pod内可以通过组合不同功能的容器来达到各种目的,因为一个pod内下的存储、网络都是共享的。举个例子,比如war包容器和tomcat容器配合工作;isto微服务治理 都是基于这种sidecar容器的理念。
  • 熟悉常用pod yaml字段:projected volume可以支持Secret、downloadAPI(获取pod API对象)、探针等
  • 使用PodPreset装饰pod,增加扩展性

控制器模型与负反馈系统

k8s定义了很多控制器来完成编排能力。k8s采用的通用编排模式如下。比较实际和期望状态,可以称为reconcile(调和)。

1
2
3
4
5
6
7
8
9
10

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

控制器模型的完整实现主要就是deployment。deployment的一个非常重要功能就是:

  • 对pod进行水平扩展和收缩:基于应用控制器模式对pod个数做控制,因此要求容器的restartPolicy只能为always
  • 支持金丝雀发布和蓝绿发布:kube-proxy能力不是最强,而且其是基于副本的。现在主流可以配合isto等proxy来做流量控制和发布管理。

控制器模型在往上抽象来理解的话,本质是个负反馈系统。Kubernetes架构的核心就是就是控制循环 (control loops),是一个典型的"负反馈"控制系统。当控制器观察到期望状态与当前状态存在不一致,就会持续调整资源,让当前状态趋近于期望状态。基本都是按照如下模式进行的:
image.png

常用资源对象

StatefulSet

状态抽象

核心是抽象了两种状态:

  • 拓扑状态:有拓扑顺序
  • **存储状态:**pod重建也能关联有状态的存储

headless service

service的访问有两种方式:

  • normal service: 通过LB的VIP地址访问下挂的pod
  • headless service: 通过DNS直接访问POD。在yaml文件中,clusterIp设置为None就表示是headless service了。绑定的DNS记录格式如下:
1
<pod-name>.<svc-name>.<namespace>.svc.cluster.local

statefulset和deployment

statefulset可以理解是deployment的改良,写法上相差很小,仅仅是把引用的service改成引用headless service。

Persistent Volume Claim(PVC)和Persistent Volume(PV)

直接使用Volume的话耦合太高,如下例子中volume包含很多存储本身自带的领域信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

apiVersion: v1
kind: Pod
metadata:
name: rbd
spec:
containers:
- image: kubernetes/pause
name: rbd-rw
volumeMounts:
- name: rbdpd
mountPath: /mnt/rbd
volumes:
- name: rbdpd
rbd:
monitors:
- '10.16.154.78:6789'
- '10.16.154.82:6789'
- '10.16.154.83:6789'
pool: kube
image: foo
fsType: ext4
readOnly: true
user: admin
keyring: /etc/ceph/keyring
imageformat: "2"
imagefeatures: "layering"

k8s为了简化使用和降低耦合,提供了PVC对象,本质是在存储和POD之间加了一层额外的抽象层,用来统一描述下挂的存储。PVC是开发者对所需要的PV的一个声明,k8s会从预定义好的pv中获取符合要求的pv。以下是pvc和pv的yaml文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

apiVersion: v1
kind: Pod
metadata:
name: pv-pod
spec:
containers:
- name: pv-container
image: nginx
ports:
- containerPort: 80
name: "http-server"
volumeMounts:
- mountPath: "/usr/share/nginx/html"
name: pv-storage
volumes:
- name: pv-storage
persistentVolumeClaim:
claimName: pv-claim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

kind: PersistentVolume
apiVersion: v1
metadata:
name: pv-volume
labels:
type: local
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
rbd:
monitors:
# 使用 kubectl get pods -n rook-ceph 查看 rook-ceph-mon- 开头的 POD IP 即可得下面的列表
- '10.16.154.78:6789'
- '10.16.154.82:6789'
- '10.16.154.83:6789'
pool: kube
image: foo
fsType: ext4
readOnly: true
user: admin
keyring: /etc/ceph/keyring

PVC与pod绑定

支持有状态应用除了拓扑以外另外个关键点就是状态存储。pvc可以绑定pod,这样pod重建的时候pvc和pv不会被删除,等pod重启后直接关联即可。如果使用普通volume的话,k8s会重新创建新的volume,虽然可以用远程存储保存持久化数据,但是每次重新初始化持久化数据效率就比较低了。

绑定通过volumeClaimTemplates来完成,效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: "nginx"
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.9.1
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

核心设计

StatefulSet 这个控制器来支持有状态pod的核心设计是:

  • 稳定的网络标识符:每个pod的网络标识符固定(hostanme,一般后面接序号)
  • 单个pod按序执行:避免并发产生脑裂问题,1次启动一个,成功后再执行下一个

限制

statefulset要求管理的多个有状态应用实例是通过同一份pod模板创建出来的,用的同一个docker镜像。如果不同节点镜像不一样需要用Operator

DaemonSet

k8s中damonpod会运行在集群里的每个节点上,常见的应用比如监控。daaemonset的设计中有如下关键点:

  • 通过tolerations来容忍一些原本不允许被调度的情况,例如下面网络插件未安装的情况也允许部署daemonset pod:
1
2
3
4
5
6
7
8
9
10
11

...
template:
metadata:
labels:
name: network-plugin-agent
spec:
tolerations:
- key: node.kubernetes.io/network-unavailable
operator: Exists
effect: NoSchedule
  • 通过nodeAffinity确保daemonSet pod绑定node

一些实践注意点:

  • daemonset需要设置resources字段避免宿主机资源过多占用

Job

deploy、ds和statefulset都是面向Long running task这种长作业的场景。job与crontab则面向离线业务、一次性任务。这个也是来自于borg的理念,将任务分成了LRS(long running service)和Batch jobs。一个基本的job可以按照如下定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

job是一次性执行,pod最后会进入completed状态。restartPolicy只允许设置为Always何OnFailure。

使用job常用的三种方式:

  • 外部管理器+JOB模板:通过设置变量的方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

apiVersion: batch/v1
kind: Job
metadata:
name: process-item-$ITEM
labels:
jobgroup: jobexample
spec:
template:
metadata:
name: jobexample
labels:
jobgroup: jobexample
spec:
containers:
- name: c
image: busybox
command: ["sh", "-c", "echo Processing item $ITEM && sleep 5"]
restartPolicy: Never
  • 固定任务数目并行:设置parallelism和completion来并行
  • 只设置并行度:把k8s当做一个工作队列,我们自己往里面填充任务

CronJob

写法如下。cronjob本质是个controller来控制job。通过配置job template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: hello
spec:
schedule: "*/1 * * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: hello
image: busybox
args:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

定时任务需要处理的问题就是上个任务还没执行完毕,下个任务将要开始,k8s提供了几种策略:

  • concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在
  • concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过
  • concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job

声明式API与k8s编程范式

基本理念

理解k8s的声明式API可以和过去使用docker swarm的命令式的命令行风格进行比较。k8s本身也提供命令行,但是它本质是创建资源对象然后对资源对象进行处理的过程。资源对象是k8s的核心。这个可以参考google api design guide来了解如何设计声明式API。

课程中提到的是一个isito使用K8S声明式API PATCH的能力来将envoy的配置字段自动添加到用户提交的Pod的API对象里。采用的是k8s的dynamic admission controller能力,底层是依靠了API PATCH的能力,对API对象进行更新。可见k8s依托于面向资源的API提供了非常强大的资源管理以及拓展能力。envoy能够击败nginx、haproxy等竞品和他基于声明式API的配置能力是分不开的,换句话说也就是说envoy更加的云原生

API对象实现原理

k8s中所有API对象可以采用如下树形结构表示:
image.png
例如声明一个cronjob对象,在这个 YAML 文件中,“CronJob”就是这个 API 对象的资源类型(Resource),“batch”就是它的组(Group),v2alpha1 就是它的版本(Version)。

1
2
3
4

apiVersion: batch/v2alpha1
kind: CronJob
...

创建一个cronjob的流程可以参考下图,图上可以看到group、version的层层递进查找。
image.png

tips: 对于 Kubernetes 里的核心 API 对象,比如:Pod、Node 等,是不需要 Group 的(即:它们的 Group 是“”)。所以,对于这些 API 对象来说,Kubernetes 会直接在 /api 这个层级进行下一步的匹配过程。

过去K8S ApiServer大量基于go代码生成,复杂度较高,不太容易添加一个新的K8S风格的API资源类型。1.7以后提供了新的API插件机制CRD(custom resource definition)后才变得比较容易。通过一个资源定义yaml和go代码可以自己实现一个API资源类型。

自定义资源类型步骤(利用CRD)

  • 利用CRD(custom resource definition)声明新资源类型
  • 为API对象编写自定义控制器,支持API对象的增、删、改操作

自定义控制器的工作原理参考下图:

  • informer:其实就是一个带有本地缓存和索引机制的、可以注册 EventHandler 的 API server的client。通过Reflector包中的ListAndWatch监听APIServer端资源对象上的动作。
  • **control loop: **informer监听的事件在workqueue被control loop轮询处理。control loop实现的核心就是我们说的控制器模式,拿实际状态和期望状态进行比较,然后进行处理,也就是reconcile(协调)。

image.png

基于角色的权限控制:RBAC

基本概念

api server接受kubectl请求,访问控制会经历如下步骤:认证、权限、合规。
image.png
权限控制主要是authorization,这块主要是基于RBAC的理念来做的。在k8s中:

  • user:k8s提供了一个轻量化的抽象subjects字段来描述用户主体。subjects可以指定外部认证对象,也可以指定ServiceAccount。配合accountService(主要用于内部认证)以及外部用户认证系统和凭证可以达成用户认证的目的。采用这种轻量化的设计,将用户认证能力委托出去的方式非常好,可以使得k8s非常好的整合业界已经成熟的认证系统。
  • role: k8s提供了role和clusterRole两个资源对象来定义角色。role主要用在namespace,clusterRole作用于集群。
  • 权限:k8s中的权限就是vers,可以理解为可以针对资源对象具体做什么操作,例如get、watch、list
1
2
3
4
5
6
7
8
9
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: default
name: pod-reader
rules:
- apiGroups: [""] # "" 标明 core API 组
resources: ["pods"]
verbs: ["get", "watch", "list"]
1
2
3
4
5
6
7
8
9
10
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
# "namespace" 被忽略,因为 ClusterRoles 不受名字空间限制
name: secret-reader
rules:
- apiGroups: [""]
# 在 HTTP 层面,用来访问 Secret 资源的名称为 "secrets"
resources: ["secrets"]
verbs: ["get", "watch", "list"]

binding授权

定义好了角色和对应的权限,还有非常重要的步骤就是授权。

  • RoleBinding: 将role授权给主体。授权的role可以是普通的role或者是ClusterRole。只能绑定到一个命名空间内的用户或者组
  • ClusterRoleBinding: 可以绑定到整个集群范围内的用户或组,也就是说绑定到集群的所有命名空间。创建了绑定后,不能修改绑定对象所引用的role或者clusterrole。如果要修改只能删除再重新绑定roleRef。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: rbac.authorization.k8s.io/v1
# 此角色绑定允许 "jane" 读取 "default" 名字空间中的 Pod
# 你需要在该命名空间中有一个名为 “pod-reader” 的 Role
kind: RoleBinding
metadata:
name: read-pods
namespace: default
subjects:
# 你可以指定不止一个“subject(主体)”
- kind: User
name: jane # "name" 是区分大小写的
apiGroup: rbac.authorization.k8s.io
roleRef:
# "roleRef" 指定与某 Role 或 ClusterRole 的绑定关系
kind: Role # 此字段必须是 Role 或 ClusterRole
name: pod-reader # 此字段必须与你要绑定的 Role 或 ClusterRole 的名称匹配
apiGroup: rbac.authorization.k8s.io

Operator

Operator是现在有状态应用接入k8s的主要手段。operator是基于CRD能力的拓展(CRD相当于提供了机制),提供更强的自定义资源对象的管理。

K8S容器持久化存储

PV与PVC的更多内容

  • 大规模环境可以用自动创建PV的机制:dynamic provisioning。利用StorageClass API对象来定义PV,避免管理大量PV yaml。在整体体系中位置如下图所示:

image.png

容器网络

容器通信基础

不同容器网络之间通过两端容器内的Veth网卡(容器内名字是eth0,宿主机上显示未vethxxxx)和网桥docker0相连来实现通信。大体上就是通过宿主机docker0当网桥配合veth虚拟网卡协同工作的过程。当你遇到容器连不通“外网”的时候,你都应该先试试 docker0 网桥能不能 ping 通,然后查看一下跟 docker0 和 Veth Pair 设备相关的 iptables 规则是不是有异常,往往就能够找到问题的答案了。

Flannel容器跨主网络实现

UDP

数据放在UDP包发送,配合虚拟网络设备TUN和管理的子网meta信息将UDP包发送到指定对端。Flannel UDP实现本质是一个三层的overlay网络。
image.png
UDP实现的主要问题是,用户态的UDP包处理,性能不太好,后续被弃用。

VXLAN

利用Linux内核的vxlan能力,利用VTEP虚拟设备直接在内核态构建overlay网络。通信的两端节点可以看成是一个VTEP设备。宿主机不感知inner vxlan的信息,只是作为UDP包发送,包内部包含完整overlay网络的信息,例如inner ip、vtep mac地址等。流程如下,可以看到包头有额外的vxlan信息和inner信息。这里注意路由的元信息是在node初始化的时候就确定好了(放在一个转发数据库FDB中),不需要ARP协议来确定IP地址对应的MAC地址了。
image.png

host-gw

即host gateway,直接将本机作为网关,直接设置下一跳地址为目标宿主机的IP地址。使用flannel的host-gw模式,flanneld进程会直接在宿主机上创建规则。同样的,这些网络路由拓扑信息都是在etcd元数据中的。采用这种点对点的方式性能相对来说也会比vxlan好。当然直接指定的话要求宿主机在一个vlan内,否则这样通信时不可达的。

1
10.244.1.0/24 via 10.168.0.3 dev eth0

image.png

Calico与host-gw

类似采用host-gw模式实现的还有Calico,只不过路由信息是依靠Linux内核原声的BGP(border gateway protocl边际网关协议)来实现的,利用BGP共享网络节点的路由信息。calico也提供IPIP模式来支持跨VLAN通信,不过性能就要变差了,也要依赖etcd元数据引入额外的隧道。
image.png

K8S网络

CNI网络

k8s的网络也是基于这样的方式构建一个overlay网络,只不过采用的CNI接口,宿主机上设备从docker0变成了cni0。基于CNI标准实现的网络插件在K8S当中都可以使用,包括flannel、calico等。关于网络插件选型可以参考[8]。像flannel在K8S中使用的效果就变成如下的方式,k8s给flannel.1 vtep设备分配了个子网。k8s不使用docker0而使用CNI是为了提供一个标准化的网络实现扩展点,可以快速和已有的网络实现生态融合,而不是自己陷入网络实现细节。

image.png

TIPS: CNI网络插件由kubelet管理

NetworkPolicy与k8s网络隔离

k8s提供NetworkPolicy资源来管理网络,如果是支持NetworkPolicy的网络插件则可以配合使用。networkpolicy主要用于做网络隔离,本质是基于iptables实现的。

内部服务发现与负载均衡

k8s内部的服务发现和负载均衡,主要指的是serviice controller之间和pod之间的关系,发现pod,对一个service下的pod代理和负载均衡。

基本步骤

service是由kube-proxy(宿主机上的进程,不是容器)和iptables一起共同实现的。步骤是:

  • 创建service对象提交给k8s
  • kube-proxy通过service controller的Informer感知到service对象添加,然后针对该事件创建一个iptables规则链(iptable规则的集合),最终通过iptable指向代理的pod

IPVS负载均衡

基于大量iptable规则,在control loop中刷新并且判断iptable规则的效率会比较低,因此kube-proxy引入了IPVS模式(ip virtual server)来解决这个问题。IPVS是Linux内核态提供的高性能负载均衡机制,通过一个hash表维护ipset,相比单纯的iptable规则性能会好很多。IPVS模式通过维护一个虚拟 IP 地址和端口的映射表,以及一个后端 Pod 列表,来实现请求的分发。请求首先到达虚拟 IP 地址和端口,然后根据负载均衡算法将请求分配给后端 Pod 进行处理。

外部服务发现与负载均衡

外部需要感知k8s中的service也需要设计服务发现以及负载均衡,一般有如下方式

通过NodePort做服务发现

NodePort实际上就是内部serivce的代理,用于对外暴露端口和服务。下图我们可以看到service中spec中可以指定NodePort

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

apiVersion: v1
kind: Service
metadata:
name: my-nginx
labels:
run: my-nginx
spec:
type: NodePort
ports:
- nodePort: 8080
targetPort: 80
protocol: TCP
name: http
- nodePort: 443
protocol: TCP
name: https
selector:
run: my-nginx

NodePort模式下,宿主机IP包发往POD的时候会做一次SNAT操作将IP包中的源端IP地址换成宿主机CNI网桥地址。对包做SNAT转换,主要是为了避免POD直接根据IP包中源端IP地址回包给client,引发client报错。因为client实际期望的回包是来自于自己期望的目标端。下面拓扑可以参考:

1
2
3
4
5
6
7
8
9
10

client
\ ^
\ \
v \
node 1 <--- node 2
| ^ SNAT
| | --->
v |
endpoint

上图中如果需要pod直接感知client的地址,可以设置spec.externalTracfficPolicy为local,不过这样拓扑就变成如下,node2不能访问到pod和service了,相当于必须直连。

1
2
3
4
5
6
7
8
9
10

client
^ / \
/ / \
/ v X
node 1 node 2
^ |
| |
| v
endpoint

通过LoadBalancer做服务发现和负载均衡

可以指定loadbalancer类型依赖外部的负载均衡器。

1
2
3
4
5
6
7
8
9
10
11
12
13

---
kind: Service
apiVersion: v1
metadata:
name: example-service
spec:
ports:
- port: 8765
targetPort: 9376
selector:
app: example
type: LoadBalancer

ExternalName指定DNS做服务发现

externalname可以直接通过service的dns访问service,实际上就是将service的dns映射成一个外部DNS,就是在kube-dns中添加一条CNAME记录。

1
2
3
4
5
6
7
8

kind: Service
apiVersion: v1
metadata:
name: my-service
spec:
type: ExternalName
externalName: my.database.example.com

ExternalIp指定可访问service的外部ip

指定后ipvs网桥只允许特定外部ip访问service。注意这种方式没法做负载均衡。

ingress负载均衡

每个service去配置对应的负载均衡器效率很低,因此产生了全局的为代理不同后端service而设置的负载均衡服务Ingress服务。 Ingress可以理解成service的 service,相当于对k8s service做反向代理。配置起来效果如下,下面通过一个统一的ingress代理了tea和coffee两个service。ingress有很多实现,但是他们都符合k8s ingress对象的标准,例如nginx ingress controller。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: cafe-ingress
spec:
tls:
- hosts:
- cafe.example.com
secretName: cafe-secret
rules:
- host: cafe.example.com
http:
paths:
- path: /tea
backend:
serviceName: tea-svc
servicePort: 80
- path: /coffee
backend:
serviceName: coffee-svc
servicePort: 80

TIPS: 配置TLS等HTTP相关配置的时候要在更高层的Ingress层面配置,而不是service层面

k8s资源与调度

资源模型与资源管理

资源定义

资源是在pod层面定义的,因为pod是调度的基本单位。资源主要是指:

  • 可压缩资源CPU: 只会有资源饥饿,影响服务性能,不会导致服务不可用
  • 不可压缩资源MEM:资源不足会直接退出进程

TIPS: cgroup可以管理很多资源,k8s/kubelet中主要用到了cpu/memory/pid/hugetlb等几种类型

资源配额——动态资源边界

K8S的资源限额采用了borg论文中动态资源边界的定义,这个很值得我们学习。资源配额如果只设定一个值 ,那么只能进行静态的资源分配,资源就没有弹性的空间。k8s设置request和limit,实际上就是给了pod资源弹性的空间,同时limit也保障了调度时整体阈值的控制,避免pod资源scale up导致的系统性资源短缺问题。

我们自己在设计调度系统的时候也可以多借鉴这种思路,资源配额采用动态资源边界。

资源分配与QoS: POD的QoS与Eviction

基于动态资源边界的设置,k8s针对不同的request、limit组合定义了POD的QoS

  • Guranteed: request==limit,不会被抢占,优先级最高,k8s会优先evict其他QoS类型pod
  • Burstable: 可突发的,意味着pod资源可以有弹性,guranteed的pod剩余未使用的资源,burstable的pod有需要的时候可以进行弹性。
  • BestEffort: 优先级最低,集群有资源的时候可以运行,否则则会被优先evict出集群

k8s根据pod的QoS类型在资源不足时针对性的进行evict操作。eviction阈值可以设置,例如:

1
2
3
4
5

memory.available<100Mi
nodefs.available<10%
nodefs.inodesFree<5%
imagefs.available<15%

cpuset和cpushare

可以设置cpuset让pod绑定cpu,避免不同CPU之间的上下文切换。

调度器

基本工作原理

调度器实现是两个独立的控制循环:

  • informer path:监听调度相关的API对象的变化
  • scheduling path:负责POD调度的主循环,利用predicates过滤Node,然后利用priorities对Node打分,然后通过Bind将Pod对象的nodeName字段修改为选出来的Node的名字。bind只更新scheduler cache,后续是异步和API server发起真正的POD更新。k8s核心组件都是通过异步和cache来配合ApiServer工作的,这样异步化后可以方便做并行,避免进程阻塞等问题,提高整个系统的效率。

image.png

调度算法

调度主循环提供的两组调度算法是:

  • Predicate: 本质是个filter,可以过滤符合条件的node,可以针对pod、volume等进行过滤。
  • Priority: 本质是排序,针对节点打分。打分规则可以按照空闲资源、资源均衡性等来分配。

调度时的POD抢占

这个注意和QoS eviction的区别。QoS eviction是运行的pod如果资源占用变大时如何进行eviction,强调运行时。调度时的POD抢占主要针对的是调度时,如果发现集群资源不足,如何抢占资源,并且驱逐pod。驱逐pod也是优先考虑QoS,然后再看priorityClass的。是的,当集群资源不足时,即使一个 Pod 的 PriorityClass 设置为低优先级,但其 QoS 为 guaranteed,该 Pod 也可能会被优先保留而驱逐 PriorityClass 为高优先级、但 QoS 不为 guaranteed 的 Pod。

民主化的scheduler

scheduler也支持扩展,例如可以在节点过滤、排序等维度上都进行自定义。下图参考k8s调度框架的官方文档,可以看到k8s调度框架提供的扩展点。
image.png

k8s调度器扩展

k8s的调度器的特色是足够开放与民主,同时良好的抽象与设计也保证了其强大的能力。业内我们可以看到很多的调度系统设计。一般而言设计一个调度系统从能力上需要考虑:

  • 工作负载类型:这里有多种划分方式,例如按照long-running、batch或者按照应用特点,微服务、AI、数据库等等。这里我觉得可以考虑通用抽象+label的方式可能比较合理。
  • 物理资源类型:cpu、内存、存储、GPU等等
  • 调度效率与性能
  • 弹性效率
  • 资源利用率
  • 安全性

GPU和Device Plugin

调度系统除了经典的CPU、内存,也需要考虑异构资源的管理,例如GPU和其他设备。像AI的应用,就涉及GPU比较多。k8s通过device plugin来引入新设备的管理。像这种设备管理,在容器运行前就需要初始化好的,都基本是通过kubelet来管理(类似CNI网络插件)。
image.png

容器运行时

Kubelet与容器运行时

kubelet像ApiServer一样,是K8S中最核心的组件之一。主要负责的能力包括.

  • POD生命周期管理:作为独立容器之外运行的进程,用于管理POD
  • 资源管理:管理节点资源使用,避免超限使用
  • 设备管理:网络、存储、其他设备(例如GPU)
  • 监控与日志
  • 安全

实现上也是一个负反馈系统,采用一个syncLoop和多个子循环来不断监听事件、比较期望值和调整。
image.png

kubelet与容器之间通过CRI接口提供抽象,屏蔽底层容器运行时的差异。底下的容器运行时可以是docker,也可以是其他的(像2023年比较时髦的大概就是K8S中的WASM运行时了)。
image.png

CRI与容器运行时

CRI是K8S提供的容器运行时接入标准。具体运行时的实现,一般可以按照高层运行时和底层运行时来理解。

  • 高层容器运行时(CNCF项目):像container-d和CRI-O,主要负责容器生命周期管理(包括镜像的上传下载等),为容器生命周期管理提供统一接口
  • 低层容器运行时(或者称之为运行时引擎,OCI项目):像mysql存储可以下挂各种存储引擎一样,但是他们都遵循mysql存储接口标准。底层容器运行时主要是具体的运行时引擎实现,比如runC、Kata-container还有gVisor。

image.png
容器引擎现在可以分为两大类

  • runC: runC是基于Linux的cgroup、namespace和seccomp等特性实现的,可以在裸机或者虚拟机上运行。相比runV更加轻量化,缺点是共享内核态,安全和隔离不彻底。
  • runV: 一般是基于硬件上虚拟化的KVM技术或者OS上虚拟化的运行时,容器有独立的内核态,相比runC重一些,但是有更好的安全和隔离性。这块近些年发展比较快,各大云厂商和开源生态在runV运行时引擎上项目比较多。追求更加轻量、高性能的runV引擎是现在主流的方向。现在主流的是kata-container和gVisor

gVisor

gVisor的runV比较特别点。采用的是一种轻量虚拟化技术叫做用户态OS内核。提供的sentry进程需要实现一个用户态的OS内核,会借用KVM进行syscall拦截。利用syscall拦截和用户态OS也达到了runV运行时的安全和隔离性。

TIPS:

  • CRI是直接和容器交互的,不耦合pod,避免因为耦合带来额外的副作用。
  • OCI主要提供了容器镜像标准和容器运行时标准

监控与日志

k8s与prometheus

prometheus是对k8s进行监控的主要部件。
image.png
搜集的监控指标包括:

  • 宿主机监控数据:通过Node Exporter
  • 核心组件的metric: 通过K8S核心组件的metric api来获取metric信息
  • k8s core metric: 主要涉及pod、node、容器、service等核心metric。通过metric server暴露

监控指标规划USE原则和RED原则

其中,USE 原则指的是,按照如下三个维度来规划资源监控指标,主要关注资源

  • 利用率(Utilization),资源被有效利用起来提供服务的平均时间占比;
  • 饱和度(Saturation),资源拥挤的程度,比如工作队列的长度;
  • 错误率(Errors),错误的数量。

而 RED 原则指的是,按照如下三个维度来规划服务监控指标,主要关注运行时的服务质量

  • 每秒请求数量(Rate)
  • 每秒错误数量(Errors)
  • 服务响应时间(Duration)

custom metrics

利用custom metrics配合k8s的HPA(horizontal pod autoscaler)可以来制定扩展策略。利用Aggregator APIServer扩展一个custom metrics apiServer的实现可以使用KubeBuilder的工具库

容器日志搜集与管理

k8s采用cluster-level-logging确保可以搜集到所有的日志。日志搜集一般可以部署个sidecar容器来负责日志搜集:
image.png

参考资料