上周我做了一次针对业务开发团队的一次Kubernetes入门分享,目的是让大家了解迁移到Kubernetes后的服务运行环境是什么样子。

懂Pod就懂了Kubernetes的一半

Kubernetes可以理解成一个对计算、网络、存储等云计算资源的抽象后的标准API服务

几乎所有对Kubernetes的操作,不管是用kubectl命令行工具,还是在UI或者CD Pipeline中,都相当于在调用其REST API

很多人说Kubernetes复杂,除了其本身实现架构复杂以外,还有一个原因就是里面有二十多种原生资源的API学起来曲线比较陡。但不用担心,我们只要抓住本质 – 提供容器计算能力的平台,就能纲举目张,很容易快速理解。

在K8S中,最重要也最基础的资源是Pod,翻译一下就是“豆荚”,我们用下面这个最最基础的Nginx容器为例,搞清楚豆荚的一生,K8S就懂了一半。

大家也不需要研究Kubernetes怎么搭建,推荐用 OrbStack 在本地一键安装一套Docker & K8S环境出来,快速开始实验。首先,写一段这样的Yaml文件出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# nginx.yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
app: nginx
spec:
containers:
- name: web
image: nginx
ports:
- name: web
containerPort: 80
protocol: TCP

然后,我们用kubectl把nginx的Pod创建出来,命令后面加-v8是详细日志模式,可以看出来kubectl到底做了什么事情。

1
2
3
kubectl create pod -f nginx.yaml -v8

kubectl get pod -v8

在OpenLens或K9S等可视化工具中,我们可以看到一个叫Nginx的Pod就被”生“出来了,从kubectl的详细日志中也可以看到POST/GET等请求的信息。

1
2
3
4
I1127 14:55:06.886901   83798 round_trippers.go:463] GET http://127.0.0.1:60649/77046cfbc5f80b52d9a1501954ee0672/api/v1/namespaces/default/pods?limit=500
I1127 14:55:06.886916 83798 round_trippers.go:469] Request Headers:
I1127 14:55:06.886921 83798 round_trippers.go:473] User-Agent: kubectl...
I1127 14:55:07.166333 83798 round_trippers.go:580] Cache-Control: no-cache, private

可以看到,在创建完成Pod之后,实际的Pod比我们在Yaml中声明的字段更多,这些多出来的字段就是Pod从出生后的经历证明:由调度器调度到集群可用节点、交给kubelet管理Pod生命周期、分配网络IP、挂载临时存储、容器运行时拉取镜像启动容器、控制器协调校正运行状态等等

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
apiVersion: v1
kind: Pod
metadata:
name: nginx
namespace: default
status:
phase: Running
hostIP: 10.....
podIP: 10.....
conditions:
- type: Initialized
status: 'True'
lastProbeTime: null
lastTransitionTime: '2023-11-27T06:59:13Z'
- type: Ready
status: 'True'
lastProbeTime: null
.....
spec:
volumes:
- name: kube-api-access-72rkq
......
containers:
- name: web
image: nginx
ports:
- name: web
containerPort: 80
protocol: TCP
resources: {}
volumeMounts:
- name: kube-api-access-72rkq
readOnly: true
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
imagePullPolicy: Always
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst
serviceAccountName: default
serviceAccount: default
securityContext: {}
schedulerName: default-scheduler
tolerations:
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 300
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 300
priority: 0
enableServiceLinks: true
preemptionPolicy: PreemptLowerPriority

展开来看,运行一个容器,必要的就是3大件,计算、网络、存储。

计算资源,就是CPU/Mem/GPU,是在spec的container部分声明,这个案例中没有设置到底需要多少resources,requests 和 resources.limits为空, 也就是说可能占满整个宿主机,这种情况一般叫Best-Effort QoS级别,调度优先级是比较低的。实际情况下一般会设置合理的 requests / limits,达到Burstable QoS级别或者设置requests、limits一模一样达到Guarantee级别。这个Pod经过调度器调度到某个节点之后,就会交给一个叫CRI(Container Runtime Interface)的接口,让CRI的实现来把容器真正建出来,通常是containerd, cri-o, podman, docker等等。

网络方面,可以看到在status里面,多出了PodIP字段,这个是调用底层一个叫CNI(Container Network Interface)的接口,让CNI的实现层给出的IP,这个过程比较复杂,涉及到一个叫pause容器的东西,入门的时候可以忽略这些细节。

存储方面,可以看到自动挂载了一个volume / volumeMounts, 这是对Pod挂载的额外存储,可能是配置文件或密钥,也可能是挂载一些云厂商提供的持久化存储,比如EBS、EFS盘,则会涉及到K8S第三类底层接口,CSI(Container Storage Interface,我们用的云厂商的Kubernetes发行版本一般都已经内置了CSI的实现。

有了计算网络存储,Pod就运行起来了,如果我们要更新Pod,可以用Update, Patch接口,但是,Pod是一个Kubernetes的原子资源,只能更新极少数字段,比如image和readinessGate

如果想结束掉这个Pod,可以用Delete接口,来让Pod进入Terminating状态,最终被控制器删除,回收掉计算资源,容器镜像文件最终也会被GC掉。

这里只是讲了最浅显的流程,详细的Pod生命周期可以参考这里:https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/, 尤其是一些和业务强相关的生命周期活动,比如postStart并行的启动hook,preStop串行的停止hook,发送SIGTERM尝试结束进程,过了Graceful Period后发送SIGKILL信号等等,对业务很有用。

Kubernetes集群视角的计算、网络、存储

至此,我们明白了计算网络存储资源,如何赋予到Pod这个载体上。那么,计算、网络、存储的资源池本身,在Kubernetes里面叫什么呢?

Kubernetes集群的计算节点叫Node,和传统云平台对硬件机器的定义不同,Node也是抽象的资源,可以长出来,也可以死掉,不和运行哪个容器直接绑定,而是通过label/selector, affinity等调度相关的机制关联到Pod。

Kubernetes集群的块存储资源叫PersistentVolume,实际使用场景下,一般是用分布式文件系统来实现,根据业务的磁盘请求,自动创建PersistentVolume的东西叫StorageClass。

Kubernetes的网络是分好几层的:让整个集群变成一个大内网的Pod网络;让集群内服务互相访问自带L4负载均衡的Service网络,以及做精细流量治理的L7 Ingress/GatewayAPI/ServiceMesh网络。还有控制网络访问策略的NetworkPolicy资源。

首先我们看Pod网络,虽然不同CNI实现的网络原理差别巨大,但目的都是一样的,给每个Pod分配IP,并打通和其他Pod之间的路由。比如AWS就用了一个很讨巧的方式实现,直接给Pod分配当前VPC-Subnet的二级IP,DHCP和路由表都是复用的,Pod之间和现有EC2节点之间的通信方式完全一样。

再来看作为内部L4负载均衡器的Service网络,给每个K8S services资源分配一个虚拟IP(ClusterIP)。ClusterIP分配后,kube-proxy组件负责来实现这个虚拟IP的路由的创建和Pod Endpoint变化的实时校正。
还是以AWS EKS为例,EKS默认使用的iptables模式,kube-proxy会在每个节点上把每个ClusterIP Service的IP写入iptables,用iptables命令可以看到实现细节。

  • 由于每次变更导致的iptables修改,大规模集群用K8S内置的Service负载均衡是存在性能问题的,切换到ipvs模式可以解决;
  • 还有一种没有ClusterIP的Headless Service,借助DNS实现了端点自动发现,不是常规的L4负载均衡
  • 如果需要直接把某个Service暴露到公网去,还有NodePort/LoadBalancer类型的Service,kube-proxy会在集群每个节点listen NodePort端口,iptables写入NodePort对应的DNAT规则
  • LoadBalancer / NodePort类型的Service还有一个关键字段’externalTrafficPolicy’,简单理解是跨节点负载均衡模式还是本地节点直连模式,跨节点负载均衡还会引起外部LB的健康检查失效问题以及内部服务无法获取Client IP问题,这些都是平台方需要处理好的,不需要业务方关心,业务团队记住一个原则,永远不要用NodePort Service就行。
1
2
3
4
5
6
7
8
9
10
11
12
13
# https://zhuanlan.zhihu.com/p/196393839
iptables -L -n
# Chain OUTPUT (policy ACCEPT)
# target prot opt source destination
# KUBE-PROXY-FIREWALL all -- anywhere anywhere ctstate NEW /* kubernetes load balancer firewall */
# KUBE-SERVICES all -- anywhere anywhere ctstate NEW /* kubernetes service portals */
# KUBE-FIREWALL all -- anywhere anywhere

iptables -L -t nat
#Chain KUBE-SVC-TCOU7JCQXEZGVUNU (1 references)
#target prot opt source destination
#KUBE-SEP-HI2KQBDGYW5OVKWN all -- anywhere anywhere /* kube-system/kube-dns:dns -> 10.52.xx.xx:53 */ statistic mode random probability 0.50000000000
#KUBE-SEP-XQT5TF2PMBOMEGDC all -- anywhere anywhere /* kube-system/kube-dns:dns -> 10.52.xx.xx:53 */

最后了解一下L7服务网络,一般由类似Nginx Ingress / Envoy 之类的应用流量负载均衡器提供,对于业务来说,就当把nginx conf拆成一个一个yaml片段就好。Ingress直管南北向流量,而Service Mesh把东西南边向流量全托管了,也能实现一些比Nginx conf里面更复杂的行为,比如流量加密、鉴权、故障注入、熔断降级、重试等等。

其他原生资源要么是对Pod套娃、要么是打辅助的

Kubernetes的API设计非常符合单一职责原则(SRP),Pod就是一个包容器的单纯的豆荚,delete了就没了。

但是,你要跑服务怎么办?没关系,单一职责原则下,实现新功能就是一个套娃,Kubernetes抽象了一个叫Deployment套住一个叫ReplicaSet的东西,ReplicaSet再来套住Pod。
这样,Deployment只管变更处理轮换RS,RS只管保证有n个pod在跑,Pod没了再生,死了重启,这里也体现了Erlang典型的let it crash的思维。

哪天你说要训练一个AI模型干掉OpenAI,Kubernetes给你提供了一个叫CronJob和Job的抽象,CronJob套住了Job,Job又套住了Pod。Job只管一次性run的东西,要重试几次,跑完多久删Pod这些事,Cronjob则是一个天然的分布式cron,只管定时把job这个东西生出来。CronJob和Job广泛用在大数据处理管线、CICD管线,AI训练这些领域。OpenAI也是一个包含8000多个节点的巨大Kubernetes集群训练出来的。

哪天你又想用Kubernetes运行一个数据库,恭喜头铁的你,学到了最复杂的一种原生资源,StatefulSet,deployment是把豆荚当牲畜,想杀就杀,StatefulSet是把Pod当宠物,宠物不好养的,每个Pod都不能随便动,更新的时候只能按序一个一个更新。

除了这些对Pod套娃的资源,剩下的可以理解成打辅助的,比如把流量引入集群的Ingress,内部流量负载均衡的Service,给每个Pod提供的分布式配置ConfigMap、分布式密钥存储Secret。还有一些策略控制和资源限额的辅助类,这里不一一展开了。

Custom Resource - Kubernetes变成超纲题的源头

说完了Kubernetes原生资源类型的分类、用法,剩下的就是非原生资源了,或者说叫Custom Resource。Custom Resource提供了一个无限想象的扩展点,社区有个Operator Framework专门用来开发Kubernetes的扩展资源。

搞云原生的没有几个没开发过几个Custom Resource,这种人人都能写扩展的机制,直接把Kubernetes这个本来就有难度的东西整成了超纲题。

其中有一些典型的成功案例,比如应用非常广泛的Prometheus Operator,在集群里扩展了ServiceMonitor,PrometheusRules这些资源,可以用Yaml非常方便动态地定义监控告警,也可以通过写Prometheus,AlertManager Yaml资源一键部署Prometheus集群实例。

社区还有一个项目叫CrossPlane, 把Kubernetes原教旨主义发挥到了极致,这个项目把所有AWS/GCP/Azure的资源,全部给Kubernetes Yaml化了,部署一个RDS、ElastiCache集群,就是声明一个RDS、ElastiCache的Custom Resource Yaml,Kubernetes既是云资源状态数据库也是云资源的部署脚本,再也不用写Terraform或者到AWS Console上吭哧点击了。

我们也做过的一个非常简单的自定义资源,CronDaemonJob,用来在每台机器上像DaemonSet一样跑Cron Job, 用来自动更新OS patch、自动清理应用归档的日志防止磁盘刷爆,简单来说,这个资源可以用来当Kubernetes里面的ansible。

重新思考Kubernetes是什么

到这里,我们大概搞清楚了Kubernetes对于使用者来说意味着什么。从Kubernetes自身的组件视图来看包括这些东西:

  • 每个机器装一个叫kubelet的Agent,控制这台机器运行什么
  • 每个机器装一个kube proxy的东西用来托管网络防火墙规则,并装一个CNI的实现,控制集群内部的Pod IP分配和网络路由
  • 可选的,装一个CSI的实现,接管持久化存储盘的创建和挂载
  • 这些东西都听API Server + Controller Manager + Scheduler 组成的控制中心,这套控制中心暴露一套标准的可扩展的REST API,数据全部存到了ETCD元数据集群里。

,让我们操作分布式集群,再也不用撸shell命令,一切命令都API化,一切资源都变成了ETCD的数据记录。

了解了这些,也就明白了Kubernetes本质上是对现有技术的封装,形成了一套云资源操作系统,真正干活的还是服务器上的进程而已,真正对资源做隔离的还是cgroup和namespace这些linux内核原有的东西。

了解了这些,也就明白了,为什么Kubernetes挂了不影响正在跑的服务?为什么在Kubernetes集群做应用性能调优,还是去看EC2用什么instance type,PV存储是哪一代的EBS、EFS,还是去看Subnet内核VPC之间怎么优化RTT延迟和提升带宽?

Kubernetes包含了分布式集群的一切,Kubernetes又一无所有

Kubernetes的A/B面

Kubernetes带来的最大的几个好处,分别是标准化、弹性、可扩展。

REST API带来的管理界面完全标准化,

存个ETCD记录就创建或校正资源状态带来了极致弹性,扩缩容就在弹指之间,

开发一个自定义资源就实现任意功能带来了丰富的可扩展性,演化出庞大的CloudNative生态系统

既要又要还要,标准,弹性,可扩展,都有了,看起来很完美,但任何事物都有两面,Kubernetes的这些好处,也是坏处的根源。

标准化的暗面是复杂化。标准要考虑到所有情况,所以这个标准不可能简单,仅仅是一个Pod的spec,就有几十个字段,可能一小半字段大部分人都没有见过。Kubernetes学透的难度和自建运维难度,从一堆培训考证机构可见一斑。即使是大公司,也最好不要有自建Kubernetes的念头,Kubernetes自己的几个组件每个都有上百个启动参数,要么是不懂坑有多大的年轻人,要么是假装整明白的人,要么是身在云厂商里面真正懂的人。

弹性的暗面是易失性,连集群的Node能随时长出、随时消逝,带来了对应用架构的侵入,在Kubernetes中运行的有状态服务必须具备动态发现的能力,想在代码中配置静态IP的时代结束了。我还发现一个Kubernetes带来的效应,”日志丢失焦虑“,在VM上大概没有人会担心日志没采集到,Kubernetes上Pod飘来飘去,总有人问,服务挂掉前的最后一行日志怎么采的,采不到怎么办?

可扩展的暗面是良莠不齐,在CNCF Landscape和开源社区的并不是都是优秀的产品,甚至有些问题很大的东西也流行起来。比如之前团队有位同事仔细读过Click house operator的代码,这个1.5K star的项目,代码质量可能在及格线以下。4年前和另一位同事尝试用ES Operator和Kafka Helm Chart来运维ES/Kafka,当时成熟度还远没有达到生产可用的水平。另外,Helm这个Kubernetes最流行的包管理工具,也是”worse is better“的典型代表,Helm作者哲学家Matt Butcher提出OAM的思想后,自己去搞”下一代云计算”WASM生态去了。

**复杂,是成长的代价**。

最后以一张经典动图结束本文。