看了Kubernetes源码后,我学到的架构设计

重复不需要马上消除

我们一定要小心避免陷入对任何重复都要立即消除的应激反应模式中。一定要确保这些消除动作只针对那些真正意义上的重复。

如果有两段看起来重复的代码,它们走的是不同的演进路径,也就是说它们有着不同的变更速率和变更缘由,那么这两段代码就不是真正的重复。

现在编写的代码不对未来的代码产生阻碍是一项非常重要的技能 ,通常需要花费多年的时间才能掌握。因为真正的麻烦往往并不会在我们运行软件的过程中出现,而是会出现在这个软件系统的开发、部署以及后续的补充开发中。

我最开始的时候也很喜欢做抽象设计,将一些将来可能会通用的内容做了抽象,但是在现在高速迭代产品的过程中,很多内容其实最开始看起来是类似的,但是最终也会走向不同的演进方向,那这两段代码就不是真正意义上重复的代码。

在这个时候如果最开始做了比较固定的设计,这部分的代码就会给未来带来比较大的阻碍 ,如果我们将原来的方法拆的粒度尽可能的小,由用的人自己去进行组装,这样如果是写功能比较固定的中间件是没有必要的,但是对于普通业务来说,可能也许是比较好的方式,复用度也许比一个大而全的方法来的更好。

因为往往大而全的功能里面必然会有一点微小的差异是新需求所不需要的,这个时候有两种方式,一种是在原来的基础上增加 if else 来走不同的逻辑分支,或者最开始将这部分留成了接口,则可以注入不同的实现类来完成。

这种方法都会涉及到改动原来的方法,如果一个系统过于复杂而且老旧,维护的人员已经换了一批(这种情况经常是存在的),这个时候我们并不清楚改动会带来多大的副作用,那另一种更稳妥的做法是重新定义代码入口,然后复用一个一个小颗粒的方法。

但是这样也会引入一个问题,小颗粒度的方法过于零散,组装起来也是会相对来说更复杂,但是它的优点是更加可控。

我在 kubernetes 中也可以看到很多语义明确的方法和接口,这样做的好处也是让后续增加的人能够灵活的去替换实现,而不被原来所束缚,这样做不仅不会需要花费大量精力重新组装所有逻辑,也可以进行局部的修改。

注意不要过度设计,写的时候多问问自己为什么,不稳定之前先不要进行设计 ,而是先等有相同的内容出来之后再进行抽象。让一个对象尽可能简单。重构的时候可以再去对相同内容进行合并减少重复

比如 CRI 的抽象也并非 k8s 一开始就有的,最开始 k8s 是强依赖 DockerManager 的 ,内部通过 Docker Manager 向 Docker Engine以 HTTP 方式发送指令,后面为了脱离docker 的硬编码适应更多环境,k8s 在1.5之后的版本引入 CRI (Container Runtime Interface),从此定义了容器运行时应该如何接入kubelet 的规范标准。

但是由于CRI 是Docker之后才出现的规范,所以k8s 通过DockerShim 来作为Docker 与 CRI的适配层,由它与Docker Engine 以HTTP 进行通信 。 (看一下DokcerShim 的源码,可以用来说明适配器模式)

最后容器以containerd的形式存在,调用链抹去了Docker Engine 的存在,调用链变成了:

markdown 复制代码
**K8s Master → kublet → KubeGenericRuntimeManager → containerd → runC**

所以其实在进行抽象的过程中,我们可以通过增加中间层来进行兼容,例如 k3s 可以不强依赖 ETCD 作为存储,但是要有 watch 等的功能的话就需要给相应的存储包装一层来实现,综合性能和复杂度等多方因素来决定。

当然这个过程可以指定规范,然后通过开源的形式由其他开发者来协助实现,以适配自己的环境。

编程范式告诉我们不能做什么

结构化编程告诉我们不要用 goto ,而是去使用 if else ,方便后续代码拆分成更小的子模块。

结构化编程对程序控制权的直接转移进行了限制和规范,不能通过 goto 这种方式来移交控制权。

面向对象编程限制了函数指针的滥用,当我们需要复用一个结构的时候,可以抽象成一个对象,并且使用单例模式,但是当我们不想两个实体间相互影响时,就需要构建出两个独立的实体,虽然它们的属性可能相同,但是值是完全不同的,也代表了现实世界中两个不同的个体。

这样做的好处有利于隔离实体间属性的变化,也能让我们更好的模拟现实生活进行需求实现,比如都是学生,学生A和学生B则是两个独立的人,当我们需要对他们进行数据统计的时候,可以分别进行操作。

函数式编程则限制我们的赋值行为,避免原地修改变量的值,这样更容易出错。

go 复制代码
package main

import (
	"fmt"
)

// Map函数接受一个整数切片和一个函数作为参数,
// 并返回一个新的整数切片,其中包含对原始切片中每个元素应用函数后的结果。
func MapInts(slice []int, f func(int) int) []int {
	result := make([]int, len(slice))
	for i, v := range slice {
		result[i] = f(v)
	}
	return result
}

func main() {
	// 原始整数切片
	nums := []int{1, 2, 3, 4, 5}

	// 使用函数式编程的方式对切片中的每个元素进行平方操作
	squared := MapInts(nums, func(x int) int {
		return x * x
	})

	// 打印结果
	fmt.Println("Original:", nums)
	fmt.Println("Squared:", squared)
}

每个编程范式的目的都是设置限制。这些范式主要是为了告诉我们不能做什么,而不是可以做什么

禁止的编程范式是我们一定不可以去做的,而没有禁止的,只要在我们完成软件的过程中都是可以尽可能去组合尝试使用让整个工程变得敏捷。

不要强依赖框架

比如Golang的Gin相关的数据结构,如gin.Context, 最好只停留在API层,不要传入应用层

通过创建代理类来实现对框架的依赖。

框架的实现更多的是细节,属于固件代码,当一个框架被淘汰之后,如果我们的代码要修改依赖,如果是直接依赖的话修改起来会比较麻烦,每个使用的业务方都需要进行修改。

所谓的服务本身只是一种比函数调用方式成本稍高的,分割应用程序行为的一种形式,与系统架构无关

举个例子,如果方法做了抽象,调用进去是内核代码还是RPC ,其实都是实现的细节,只影响了实现和调试的难度,而在整体做架构设计的时候应该抽离出来。

单一职责原则

一个模块或者一个实体只完成一件事情,属于单一职责。

无论是在实体的设计还是服务的设计上, Kubernetes 都相对比较好的遵守了这个规则。

比如 kube-scheduler 则主要负责 pod 节点的调度,定义了这个职责后,编码再进行逐步拆解,比如先对所有的节点进行预选评分,过滤掉不合适的节点,最终为 Pod 绑定一个运行的 Node,由 kubelet 去维护这个 Node 上所有节点的状态。这个时候 CNICRI 分别负责容器的网络和运行时的沙箱环境,会被 Kubelet 进行调用。

可以看到每个组件都有自己需要做的特定的事情,如果一件事情比较复杂,就会通过组合其他的组件来完成这件事情。

提出需求而不是实现细节

我们在改变资源的状态的时候,应该告诉k8s 期望的状态,而不是告诉k8s应该怎么做,所以这也是kublet rolling-update 被淘汰的原因。告诉期望状态后,kubelet 可以根据期望状态去做出自己相应的动作,外部无需过多干涉。

这时 cAdvisor 对k8s部署的容器进行监控,需要先看能监控的指标内容,然后在自动扩缩容的时候可以拿这些指标进行判断。

这也是我们在设计完成不同任务组件的原则,明确要完成的需求,传递信息时只关注输入和输出,内部如何实现可以进行内聚,不要透露给外部,外部使用尽可能简单。

开闭原则

对扩展开放,对修改关闭,意味着一个实体在不改变源代码的情况下能够改变它的行为。

Golang 的语法则更推荐我们通过组合的方式去扩展原有的实体,并且通过接口的方式进行隐式转换,能够屏蔽掉使用者不需要了解的细节。

go 复制代码
type Kubelet struct{}

func (kl *Kubelet) HandlePodAdditions(pods []*Pod) {
	for _, pod := range pods {
		fmt.Printf("create pods : %s\n", pod.Status)
	}
}

func (kl *Kubelet) Run(updates <-chan Pod) {
	fmt.Println(" run kubelet")
	go kl.syncLoop(updates, kl)
}

func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) {
	for {
		select {
		case pod := <-updates:
			handler.HandlePodAdditions([]*Pod{&pod})
		}
	}
}

type SyncHandler interface {
	HandlePodAdditions(pods []*Pod)
}

这里我们扩展 kubelet 功能的时候,并不需要修改到原有的逻辑,这个时候 SyncHanlder 则可以将作为类型,使用的时候只持有 SyncHandler ,当需要修改的时候可以直接给 Kubelet 拓展方法,然后通过接口抽象给使用方即可,这样做的好处是即将功能拓展在了 Kubelet 上,也不会导致原来的代码发生变动。

复用性最高的是业务逻辑

业务逻辑应该是系统中最独立、复用性最高的代码。

K8s的核心在于容器的编排的状态的管理,至于用什么样的存储来保存容器的状态,是ETCD 还是关系型数据库,其实都不是最重要的,这部分可以灵活的去替换。设置网络的CNI我们也可以根据实际场景的不同去选择。但是容器的编排和管理,则基本上是最开始确定后就一直稳定的状态。

先有再优

"先让代码工作起来"------如果代码不能工作,就不能产生价值。所以在k8s的早起版本中,其实有很多强依赖,如对 flannel 网络插件的强依赖,对docker 容器运行时的强依赖等。

"然后再试图将它变好" ------通过对代码进行重构,让我们自己和其他人更好地理解代码,并能按照需求不断地修改代码。k8s在后期为了去除这些强依赖,制定了CNI 、CRI的规范,让不同厂商可以根据自己实际的物理环境进行研发,这也是试图让它变的更好

"最后再试着让它运行得更快" ------按照性能提升的"需求"来重构代码。这是软件的成熟阶段,在最开始我们进行功能实现的时候,会存在很多处理不够精细的问题,甚至连监控系统都不足够完善不能帮助我们找出问题,所以在功能完备的情况下,我们可以进行局部调优,替换局部的算法让整体运行的更快。

相关推荐
Tech Synapse10 分钟前
Java根据前端返回的字段名进行查询数据的方法
java·开发语言·后端
.生产的驴11 分钟前
SpringCloud OpenFeign用户转发在请求头中添加用户信息 微服务内部调用
spring boot·后端·spring·spring cloud·微服务·架构
微信-since8119226 分钟前
[ruby on rails] 安装docker
后端·docker·ruby on rails
丁总学Java38 分钟前
ARM 架构(Advanced RISC Machine)精简指令集计算机(Reduced Instruction Set Computer)
arm开发·架构
登云时刻1 小时前
Kubernetes集群外连接redis集群和使用redis-shake工具迁移数据(一)
redis·kubernetes·bootstrap
吴半杯2 小时前
gateway漏洞(CVE-2022-22947)
docker·kubernetes·gateway
代码吐槽菌2 小时前
基于SSM的毕业论文管理系统【附源码】
java·开发语言·数据库·后端·ssm
ZOMI酱2 小时前
【AI系统】GPU 架构与 CUDA 关系
人工智能·架构
豌豆花下猫2 小时前
Python 潮流周刊#78:async/await 是糟糕的设计(摘要)
后端·python·ai