为什么要阅读代码?怎么阅读k8s源代码?源代码中包含了所有信息。写开源软件,从文档和其他地方拿到的是二手的信息,代码就是最直接的一手信息。代码就是黑客帝国中neo看到的世界本源。
文本并不是代码本身。文本只是在人类可读的模式和编译器可解析之间做了一个折中。代码的本质是具有复杂拓扑的数据结构,就像树或者电路一样。所以读代码的过程是在脑中构建出这个世界,所谓脑补是也。
阅读好的代码是一种享受。我最喜欢阅读的是redis的代码,用C写的,极端简洁但又威力强大。几句话就把最高效、精妙的数据结构完成出来,就像一篇福尔摩斯的侦探小说。在看的时候我常常想,如果让我实现这个功能,是否能像他这么简单高效?
以阅读k8s其中的一个模块,scheduler为例子,来讲讲我是怎么读代码的
从用户的角度出发,scheduler模块是干什么的?scheduler是k8s的调度模块,做的事情就是拿到pod之后在node中寻找合适的进行适配这么一个单纯的功能。实际上,我已经多次编译和构建这个程序并运行起来。在我的脑中,sheduler在整个系统中是这样的:
scheduler作为一个客户端,从apiserver中读取到需要分配的pod,和拥有的node,然后进行过滤和算分,最后把这个匹配信息通过apiserver写入到etcd里面,供下一步的kubelet去拉起pod使用。这样,立刻有几个问题浮现出来
问1.scheduler读取到的数据结构是怎么样的?(输入)
问2.scheduler写出的的数据结构是怎么样的?(输出)
问3.在前面的测试中,scheduler成为了系统的瓶颈,为什么?
问4.社区有人说增加缓存能有效提高scheduler的效率,他的思路是可行的吗?
读scheduler代码的整个经历
层1:cmd入口kubernetes\plugin\cmd\kube-scheduler\scheduler.go
这段代码比较短就全文贴出来了
package main
import (
"runtime"
"k8s.io/kubernetes/pkg/healthz"
"k8s.io/kubernetes/pkg/util"
"k8s.io/kubernetes/pkg/version/verflag"
"k8s.io/kubernetes/plugin/cmd/kube-scheduler/app"
"github.com/spf13/pflag"
)
func init() {
healthz.DefaultHealthz() //忽略……
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) //忽略……
s := app.NewSchedulerServer() //关注,实际调用的初始化
s.AddFlags(pflag.CommandLine) //忽略,命令行解析
util.InitFlags()
util.InitLogs()
defer util.FlushLogs() //忽略,开日志等
verflag.PrintAndExitIfRequested()
s.Run(pflag.CommandLine.Args()) //关注,实际跑的口子
}
可以看到,对于细枝末节我一概忽略掉,进入下一层,但是,我并不是不提出问题,提出的问题会写在这里,然后从脑子里面“忘掉”,以减轻前进的负担
kubernetes\plugin\cmd\kube-scheduler\app\server.go
进入这个文件后,重点看的就是数据结构和方法:
SchedulerServer这个结构存放了一堆配置信息,裸的,可以看到里面几个成员变量都是基本类型,int, string等
上一层调用的2个方法的主要目的是倒腾配置信息,从命令行参数和配置文件kubeconfig获取信息后
Run方法启动一些性能、健康的信息在http接口,然后实际调用的是下一层。
kubeconfig是为了kubeclient服务的。
还用了一个工厂模式,按照名称AlgorithmProvider来创建具体算法的调度器。
再下一层的入口在:
sched := scheduler.New(config)
sched.Run()
对于这层的问题是:
问5.几个限流是怎么实现的?QPS和Brust有什么区别?
问6.算法提供者AlgorithmProvider是怎么被抽象出来的?需要完成什么事情?
答5.在翻了限流的代码后,发现来自于kubernetes\Godeps\_workspace\src\github.com\juju\ratelimit,实现的是一个令牌桶的算法,burst指的是在n个请求内保持qps平均值的度量。详见这篇文章
层2: pkg外层接口kubernetes\plugin\pkg\scheduler\scheduler.go
答2:在这里我看到了输出的数据结构为:
b := &api.Binding{
ObjectMeta: api.ObjectMeta{Namespace: pod.Namespace, Name: pod.Name},
Target: api.ObjectReference{
Kind: "Node",
Name: dest,
},
}
这个文件最重要的数据结构是:
type Config struct {
// It is expected that changes made via modeler will be observed
// by NodeLister and Algorithm.
Modeler SystemModeler
NodeLister algorithm.NodeLister
Algorithm algorithm.ScheduleAlgorithm
Binder Binder
// Rate at which we can create pods
// If this field is nil, we don't have any rate limit.
BindPodsRateLimiter util.RateLimiter
// NextPod should be a function that blocks until the next pod
// is available. We don't use a channel for this, because scheduling
// a pod may take some amount of time and we don't want pods to get
// stale while they sit in a channel.
NextPod func() *api.Pod
// Error is called if there is an error. It is passed the pod in
// question, and the error
Error func(*api.Pod, error)
// Recorder is the EventRecorder to use
Recorder record.EventRecorder
// Close this to shut down the scheduler.
StopEverything chan struct{}
}
数据结构是什么?数据结构就是舞台上的角色,而函数方法就是这些角色之间演出的一幕幕戏。对象是有生命的,从创建到数据流转,从产生到消亡。而作为开发者来说,首先是搞懂这些人物设定,是关公还是秦琼,是红脸还是黑脸?看懂了人,就看懂了戏。
这段代码里面,结合下面的方法,我可以得出这么几个印象:
Modeler是个所有node节点的模型,但具体怎么做pod互斥还不懂
NodeLister是用来列表节点的
Algorithm是用来做调度的
Binder是用来做实际绑定操作的
其他的,Ratelimiter说了是做限流,其他的都不是很重要,略过
问7.结合观看了modeler.go之后,发现这是在绑定后处理的,所谓的assuemPod,就是把绑定的pod放到一个队列里面去,不是很理解为什么这个互斥操作是放在bind之后做?
问8.Binder是怎么去做绑定操作的?
下一层入口:
dest, err := s.config.Algorithm.Schedule(pod, s.config.NodeLister)
层3: pkg内层实现kubernetes\plugin\pkg\scheduler\generic_scheduler.go
在调到这一层的时候,我发现自己走过头了,上面s.config.Algorithm.Schedule并不会直接调用generic_scheduler.go。对于一门面向对象的语言来说,最后的执行可能是一层接口套一层接口,而接口和实现的分离也造成了当你阅读到某个地方之后就无法深入下去。或者说,纯粹的自顶向下的阅读方式并不适合面向对象的代码。所以,目前我的阅读方法开始变成了碎片式阅读,先把整个代码目录树给看一遍,然后去最有可能解释我心中疑问的地方去寻找答案,然后一片片把真相拼合起来。
问9.generic_scheduler.go是怎么和scehduler.go产生关系的?
这是代码目录树:
从目录树中,可以看出调度算法的目录在algrorithem和algrorithemprovider里面,而把对象组装在一起的关键源代码是在:
文件1:factory.go答8.Binder的操作其实很简单,就是把pod和node的两个字段放到http请求中发送到apiserver去做绑定,这也和系统的整体架构是一致的
factory的最大作用,就是从命令行参数中获取到--algorithm和--policy-config-file来获取到必要算法名称和调度策略,来构建Config,Config其实是调度程序的核心数据结构。schduler这整个程序做的事情可以概括为:获取配置信息——构建Config——运行Config。这个过程类似于java中的sping组装对象,只不过在这里是通过代码显式进行的。从装配工厂中,我们看到了关键的一行
algo := scheduler.NewGenericScheduler(predicateFuncs, priorityConfigs, extenders, f.PodLister, r)
这样就把我上面的问9解答了
答9.scheduler.go是形式,generic_scheduler.go是内容,通过factory组装
也解答了问6
答6.factoryProvider仅仅是一个算法注册的键值对表达地,大部分的实现还是放在generic_scheduler里面的
文件2:generic_scheduler.go这就涉及到调度的核心逻辑,就2行
filteredNodes, failedPredicateMap, err := findNodesThatFit()....
priorityList, err := PrioritizeNodes()...
* 先过滤,寻找不引起冲突的合法节点
* 从合法节点中去打分,寻找分数最高的节点去做绑定
* 为了避免分数最高的节点被几次调度撞车,从分数高的随机找一个出来
层4 调度算法的具体实现这里我就不详细叙述细节了,读者可以按照我的路子去自己寻找答案。
总结
现代的面向对象的代码结构,接口和实现分离,逻辑高度的离散在各个源代码中
人类的大脑适合阅读线性的单线程的故事
先自顶向下读,形成一颗代码的调用树,直到读不下去。分析法
再自底向上读,但不是泛读,而是在掌握这颗树的基础上在某个领域泛读,把事实拼接起来成为真相。归纳法
在单个源码文件中,调用过程依然还是一棵树,可以用树的观念去解析
对象拥有属性和方法,就像游戏人物拥有属性和技能一样。很多时候不需要深究这些属性和技能的细节。
回到戏剧的比喻,现代的代码和运行结构是构建对象——运行对象,就像戏剧中的角色化妆定型——上台演戏。戏台上有大大小小的主角配角,代码里也有主要对象次要对象,但剧本的运作让观众能第一时间锁定主角和主要剧情。看代码,就是看主要剧情和主角。配角的表演可以在后面第二遍第三遍的阅读代码中再去关注细节。