本书的第四部分将引导你完成两个重构练习。
在第 10 章中,我们会设计一个调度器接口,借助这个接口可以实现多个具体的调度器。之后,我们会实现两个调度器:一个是轮询调度器,用于替换我们在第 7 章实现的版本;另一个是更为复杂的调度器,名为 E-PVM。然后,我们会对 Manager
对象进行重构,使其使用这个接口。
在第 11 章中,我们会设计一个存储接口,该接口能让我们实现多个具体的存储组件。利用这个接口,我们会实现内存存储组件和持久化存储组件,接着对工作节点和管理器进行重构,让它们使用这些存储组件。
10 实现更复杂的调度器
本章涵盖以下内容:
-
描述调度问题
-
定义调度的阶段
-
重新实现轮询调度器
-
讨论增强型并行虚拟机(E-PVM)的概念和算法
-
实现 E-PVM 调度器
我们在第 7 章实现了一个简单的轮询调度器。现在,让我们回过头来更深入地探讨调度这一通用问题,看看如何实现一个更复杂的调度器。
10.1 调度问题
无论我们是否意识到,调度问题在日常生活中无处不在。在家里,我们有各种家务要做,比如扫地、做饭、洗衣服、修剪草坪等等。根据家庭成员数量,会有一个或多个 "劳动力" 来完成这些必要的工作。如果你独自生活,那么只有你这一个 "劳动力";如果你和伴侣一起生活,就有两个 "劳动力";要是和伴侣及孩子一起生活,就有三个或更多 "劳动力"。
那么,我们该如何把家务分配给这些 "劳动力" 呢?是因为伴侣要带孩子去参加足球训练,所以你独自承担所有家务吗?还是你负责修剪草坪和洗衣服,而伴侣在带孩子参加完足球训练回来后负责扫地和做晚餐?亦或是你带孩子去参加足球训练,伴侣做晚餐,你回来后,大孩子去修剪草坪,小孩子洗衣服,伴侣扫地?
在第 6 章,我们用周五晚上一家繁忙餐厅的接待员安排顾客就座的场景来描述调度问题。接待员有六名服务员为分散在餐厅各处餐桌的顾客服务。每张桌子上的顾客都有不同的需求。有的顾客是和许久未见的朋友一起来喝饮料、吃开胃菜;有的顾客是来享用包括开胃菜和甜点的全套晚餐;还有的顾客有严格的饮食要求,只吃素食。
这时,新来了一家四口,两个成年人和两个青少年。接待员该把他们安排在哪里就座呢?是安排在约翰负责区域的一张桌子,而约翰已经在服务三张各坐四人的桌子了吗?还是安排在吉尔负责区域的桌子,她那里有六张各坐一位顾客的桌子?又或者安排在威利负责区域的桌子,他那里只有一张坐了三位顾客的桌子?同样的调度问题在我们的工作中也存在。我们大多在团队中工作,有很多工作要完成,比如为新功能或新基础设施编写文档、修复客户在周末反馈的关键漏洞、起草下一季度的团队目标等。我们该如何在团队成员之间分配这些工作呢?从上述例子可以看出,调度问题无处不在。
10.2 调度考虑因素
在第 7 章实现轮询调度器时,我们没有考虑太多因素。我们只是想快速实现一个简单的调度器,以便将精力集中在编排系统的其他方面。
然而,如果我们花时间思考,有很多方面需要考虑。我们试图实现的目标是什么呢?
-
尽快安排顾客就座,避免顾客大量排队等待。
-
让顾客在服务员之间均匀分布,以提供最佳服务。
-
让顾客尽快用餐完毕离开,以提高客流量。
在编排系统中也存在同样的考虑因素。我们不是安排顾客就座,而是将任务分配到机器上:
- 我们是希望任务能尽快被分配并运行起来吗?
- 我们是想把任务分配到最能满足其独特需求的机器上吗?
- 我们是想把任务分配到能使所有工作节点负载均匀的机器上吗?
10.3 调度器接口
遗憾的是,调度并没有一种通用的解决方案。我们如何调度任务取决于我们想要实现的目标。因此,大多数编排器都支持多种调度器。Kubernetes 通过调度配置文件(见 kubernetes.io/docs/refere...)实现这一点,而 Nomad 通过四种调度器类型(见 developer.hashicorp.com/nomad/docs/...)来实现。
和 Kubernetes 与 Nomad 一样,我们也希望支持多种类型的调度器。我们可以通过使用接口来实现这一点。实际上,我们在第 2 章已经定义了这样一个接口:
go
scss
type Scheduler interface {
SelectCandidateNodes()
Score()
Pick()
}
我们的接口很简单,有三个方法:
-
SelectCandidateNodes
-
Score
-
Pick
我们可以把这些方法看作是调度问题的不同阶段,如图 10.1 所示。

图 10.1 调度问题可分为三个阶段:选择候选节点、给候选节点打分以及最后选择其中一个节点。
code 10.1 The updated Scheduler interface
go
type Scheduler interface {
SelectCandidateNodes(t task.Task, nodes []*node.Node) []*node.Node
Score(t task.Task, nodes []*node.Node) map[string]float64
Pick(scores map[string]float64, candidates []*node.Node) *node.Node
}
10.4 让轮询调度器适配调度器接口
由于我们已经实现了一个轮询调度器,现在让我们对该代码进行调整,使其适配我们的调度器接口。图 10.2 中的序列图展示了我们的管理器将如何与 Scheduler
接口进行交互,以选择一个用于运行任务的节点。

图 10.2 显示管理器、调度器和工人之间交互的序列图
我们先打开 scheduler.go
文件,创建如清单 10.2 所示的 RoundRobin
结构体。这个结构体有两个字段:Name
,用于为其赋予描述性名称;LastWorker
,它将取代 Manager
结构体中同名字段的作用。
code 10.2 The RoundRobin struct
go
type RoundRobin struct {
Name string
LastWorker int
}
code 10.3 The SelectCandidateNodes method for the round-robin scheduler
go
func (r *RoundRobin) SelectCandidateNodes(t task.Task, nodes []*node.Node) []*node.Node {
return nodes
}
现在,让我们为轮询调度器的实现来编写 Score
方法。在这里,我们实际上是把现有管理器的 SelectWorker
方法中的代码提取出来,粘贴到 Score
方法中。不过,我们确实需要做一些修改。
首先,我们定义了 nodeScores
变量,其类型为 map[string]float64
。这个变量将用来存储我们给每个节点分配的分数。根据我们所使用的节点数量,最终得到的映射会类似于下面这样:
json
{
"node1": 1.0,
"node2": 0.1,
"node3": 1.0,
}
code 10.4 The Score method
go
func (r *RoundRobin) Score(t task.Task, nodes []*node.Node) map[string]float64 {
nodeScore := make(map[string]float64)
var newWorker int
if r.LastWorker + 1 < len(nodes) {
newWorker = r.LastWorker + 1
r.LastWorker++
} else {
newWorker = 0
r.LastWorker = 0
}
for idx, node := range nodes {
if idx == newWorker {
nodeScores[node.Name] = 0.1
} else {
nodeScores[node.Name] = 1.0
}
}
return nodeScores
}
在实现了 SelectCandidateNodes
和 Score
方法之后,让我们把注意力转向 Scheduler
接口的最后一个方法 ------ Pick
方法。顾名思义,这个方法会挑选出运行任务的最佳节点。它接受一个 map[string]float64
类型的参数,这个参数就是 Score
方法返回的分数。它还接受一个候选节点列表。该方法返回一个指向 node.Node
类型的指针。
对于轮询调度器的实现而言,最佳分数就是最低的分数。所以,如果我们有一个包含三个节点的列表,分数分别是 0.1、1.0 和 1.0,那么分数为 0.1 的节点将会被选中。
code 10.6 The round-robin scheduler's Pick method
go
// 返回 bestNode,在本例中就是得分最低的节点
func (r *RoundRobin) Pick(scores map[string]float64, candidates []*node.Node) *node.Node {
var bestNode *node.Node
var lowestScore float64
for idx, node := range candidates {
if idx == 0 {
bestNode = node
lowestScore = scores[node.Name]
continue
}
if scores[node.Name] < lowestScore {
bestNode = node
lowestScore = scores[node.Name]
}
}
return bestNode
}
通过实现Pick方法,我们已经完成了将循环调度器适配到调度器接口。现在让我们看看我们如何使用它。
code 10.6 Adding the WorkerNodes and Scheduler fields to the Manager struct
go
type Manager struct {
...
WorkerNodes []*node.Node
Scheduler scheduler.Scheduler
}
第二类更改涉及修改管理器包中的 New
函数。我们需要在函数签名中添加 schedulerType
参数。这个参数将使我们能够创建一个使用某种具体调度器类型的管理器,首先从轮询(RoundRobin
)类型开始。
code 10.7 The New helper function, modified to take a new argument, schedulerType
go
func New(workers []string, schedulerType string) *Manager
函数的下一处改动在函数体内部。我们定义变量 nodes
来存储一个 node.Node
类型的指针切片。我们会在现有的遍历 workers
切片的循环里完成这项工作。在这个循环中,我们通过调用 node.NewNode
函数来创建一个节点,给该函数传入工作节点的名称、工作节点 API 的地址(例如 http://192.168.33.17:5556
)以及节点的角色。传入 NewNode
函数的这三个值均为字符串。一旦创建好节点,我们就调用内置的 append
函数将其添加到 nodes
切片中。
创建好节点列表后,我们可以进行函数体中倒数第二处改动。根据传入的 schedulerType
,我们需要创建一个合适类型的调度器。为此,我们创建变量 s
来存储调度器。然后使用 switch
语句来初始化合适的调度器。一开始我们只有一个 case
来支持 "roundrobin" 调度器。如果 schedulerType
不是 "roundrobin" 或者为空字符串 ""
,那么就会进入 default
分支并设置一个合理的默认值。由于目前我们只有一种调度器,所以就直接使用它。
函数的最后改动很简单。我们需要将节点列表和调度器 s
添加到函数末尾要返回的 Manager
实例中。具体做法是将节点切片添加到 WorkerNodes
字段,将调度器 s
添加到 Scheduler
字段。
code 10.8 Changing the New helper function to use the new Scheduler interface
css
func New(workers []string, schedulerType string) *Manager {
// ...
var nodes []*node.Node
for worker := range workers {
workerTaskMap[workers[worker]] = []uuid.UUID{}
nAPI := fmt.Sprintf("http://%v", workers[worker])
n := node.NewNode(workers[worker], nAPI, "worker")
nodes = append(nodes, n)
}
var s scheduler.Scheduler
switch schedulerType {
case "roundrobin":
s = &scheduler.RoundRobin{Name: "roundrobin"}
default:
s = &scheduler.RoundRobin{Name: "roundrobin"}
}
return &Manager{
Pending: *queue.New(),
Workers: workers,
TaskDb: taskDb,
EventDb: eventDb,
WorkerTaskMap: workerTaskMap,
TaskWorkerMap: taskWorkerMap,
WorkerNodes: nodes,
Scheduler: s,
}
}
通过对 New
函数做出这些更改,我们现在可以创建使用不同类型调度器的管理器了。但在管理器真正能够使用调度器之前,我们还有更多工作要做。
我们需要修改的 Manager
类型的下一个部分是 SelectWorker
方法。我们要摒弃该方法之前的实现并进行替换。为什么呢?因为之前的实现是专门为轮询调度算法设计的。随着 Scheduler
接口的创建以及该接口的轮询实现的完成,我们需要重构 SelectWorker
方法,使其基于调度器接口进行操作。
正如清单 10.9 所示,SelectWorker
方法变得更加简洁。它会执行以下操作:
调用管理器的 Scheduler.SelectCandidateNodes
方法,将任务 t
和管理器 WorkerNodes
字段中的节点切片传递给它。如果调用 SelectCandidateNodes
后,candidates
变量为 nil
,则返回一个错误。
调用管理器的 Scheduler.Score
方法,将任务 t
和候选节点切片传递给它。
调用管理器的 Scheduler.Pick
方法,将上一步得到的分数和候选节点切片传递给它。
返回选中的节点 selectedNode
。
code 10.9 The SelectWorker method using the Scheduler interface
go
func (m *Manager) SelectWorker(t task.Task) (*node.Node, error) {
candidates := m.Scheduler.SelectCandidateNodes(t, m.WorkerNodes)
if candidates == nil {
msg := fmt.Sprintf("No available candidates match resource request for task %v", t.ID)
err := errors.New(msg)
return nil, err
}
scores := m.Scheduler.Score(t, candidates)
selectedNode := m.Scheduler.Pick(socres, candidates)
return selectedNode, nil
}
code 10.10 The original SendWork method
go
func (m *Manager) SendWork() {
if m.Pending.Len() > 0 {
w := m.SelectWorker()
e := nmPending.Dequeue()
te := e.(task.TaskEvent)
t := te.Task
log.Printf("Pulled %v off pending queue\n", t)
m.EventDb[te.ID] = &te
m.WorkerTaskMap[w] = append(m.WorkerTaskMap[w], te.Task.ID)
m.TaskWorkerMap[t.ID] = w
}
}
现在,我们不再首先调用 SelectWorker
方法,而是将从管理器的待处理任务队列中取出一个任务作为第一步。然后,我们要做一些记录工作,特别是将 task.TaskEvent
类型的 te
事件添加到管理器的 EventDb
映射中。只有在从队列中取出任务并完成必要的记录工作之后,我们才会调用新版本的 SelectWorker
方法。此外,我们会将任务 t
传递给新的 SelectWorker
方法。因此,新的 SendWork
方法重新调整了向工作节点发送任务这一流程中的步骤顺序。
code 10.11 The new SendWork method
go
fun (m *Manager) SendWork() {
if m.Pending.Len() > 0 {
e := m.Pending.Dequeue()
te := e.(task.TaskEvent)
m.EventDb[te.ID] = &te
log.Printf("Pulled %v off pending queue", te)
t := te.Task
w, err := m.SelectWorker(t)
if err != nil {
log.Printf("error selecting worker for task %s: %v\n", t.ID, err)
}
}
}
关于前面这些更改,有一点非常重要需要注意:新实现的 SelectWorker
方法返回的类型不再是字符串。现在,SelectWorker
返回的是 node.Node
类型。说得更具体些,旧版本的 SelectWorker
返回的字符串可能类似 192.168.13.13:1234
。所以在 SendWork
方法的其余部分,我们需要做一些小的调整,把任何使用变量 w
保存的旧字符串值的地方,替换为节点的 Name
字段的值。下面的清单显示,w
变量的类型已从字符串变为 node.Node
,因此我们需要使用 w.Name
字段。
code 10.12 Using the w.Name field
go
m.WorkerTaskMap[w.Name] = append(m.WorkerTaskMap[w.Name], te.Task.ID)
m.TaskWorkerMap[t.ID] = w.Name
url := fmt.Sprintf("http://%s/tasks", w.Name)
至此,我们已经完成了 manager 的所有必要变动,算是吧。
10.6 你注意到那个 bug 了吗?
直到本章之前,我们一直只使用一个工作节点实例。这样的选择让我们能更轻松地关注整体情况。然而,在下一节中,我们将修改 main.go
程序,启动三个工作节点。
管理器的 SendWork
方法中潜藏着一个问题。注意,它会从队列中取出一个任务,然后选择一个工作节点来发送该任务。但是,当从队列中取出的任务是针对一个已经存在的任务时,会发生什么呢?这种情况最明显的例子就是停止一个正在运行的任务。在这种情况下,管理器已经知晓这个正在运行的任务以及相关的任务事件,所以我们不应该创建新的任务和事件。相反,我们需要检查是否存在现有任务,并在必要时对其进行更新。
我们前几章的代码能正常运行只是巧合。因为我们当时只运行了一个工作节点,所以当 SelectWorker
方法遇到一个用于停止正在运行任务的任务时,它只有一个选择。由于现在我们要运行三个工作节点,现有代码有 67% 的概率会选择一个没有运行要停止的现有任务的工作节点。让我们来修复这个问题吧!
首先,我们为管理器引入一个名为 stopTask
的新方法。这个方法接受两个参数:一个类型为字符串的 worker
和一个同样类型为字符串的 taskID
。从方法名和参数名就可以明显看出这个方法要做什么。它使用 worker
和 taskID
参数构建一个指向工作节点 /tasks/{taskID}
端点的 URL。然后,它通过调用 http
包中的 NewRequest
函数创建一个请求。接下来,它执行这个请求。
code 10.13 The new stopTask method
go
func (m *Manager) stopTask(worker string, taskID string) {
client := &http.Client{}
url := fmt.Sprintf("http://%s/tasks/%s", worker, taskID)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
log.Printf("error creating request to delete task %s: %v\n", taskID, err)
return
}
resp, err := client.Do(req)
if err != nil {
log.Printf("error connecting to worker at %s: %v\n", url, err)
return
}
if resp.StatusCode != 204 {
log.Printf("Error sending request: %v\n", err)
return
}
log.Printf("task %s has been scheduled to be stopped", taskID)
}
现在,让我们通过在 SendWork
方法中调用 stopTask
方法来使用它。我们将在第一个 if
语句的开头附近添加新代码,这个 if
语句用于检查管理器的 Pending
队列的长度。就在打印 "从待处理队列中取出了 % v" 的日志语句之后,添加一行新代码,并在 // new code
注释之后输入以下代码,如下面的清单所示。
code 10.14 Checking for existing tasks and calling the new stopTask method
go
// existing code
if m.Pending.Len() > 0 {
e := m.Pending().Dequeue()
te := e.(task.TaskEvent)
m.EventDb[te.ID] = &te
log.Printf("Pulled %v off pending queue\n", te)
// new code
taskWorker, ok := m.TaskWorkerMap[te.Task.ID]
if ok {
persistedTask := m.TaskDb[te.Task.ID]
if te.State == task.Completed && task.ValidStateTransition(persistedTask.State, te.State) {
m.stopTask(taskWorker, te.Task.ID.String())
return
}
log.Printf("invalid request: existing task %s is in state %v and ➥ cannot transition to the completed state\n", ➥ persistedTask.ID.String(), persistedTask.State)
return
}
}
code 10.15 Creating three workers and their APIs
go
w1 := worker.Worker{
Queue: *queue.New(),
Db: make(map[uuid.UUID]*task.Task),
}
wapi1 := worker.Api{Address: whost, Port: wport, Worker: &w1}
w2 := worker.Worker{
Queue: *queue.New(),
Db: make(map[uuid.UUID]*task.Task),
}
wapi2 := worker.Api{Address: whost, Port: wport + 1, Worker: &w2}
w3 := worker.Worker{
Queue: *queue.New(),
Db: make(map[uuid.UUID]*task.Task),
}
wapi3 := worker.Api{Address: whost, Port: wport + 2, Worker: &w3}
code 10.16 Starting up each worker in the same way as we did for a single worker
go
go w1.RunTasks()
go w1.UpdateTasks()
go wapi1.Start()
go w2.RunTasks()
go w2.UpdateTasks()
go wapi2.Start()
go w3.RunTasks()
go w3.UpdateTasks()
go wapi3.Start()
下一个变化是建立一个包含所有三个 worker 的切片:
go
workers := []string{
fmt.Sprintf("%s:%d", whost, wport),
fmt.Sprintf("%s:%d", whost, wport+1),
fmt.Sprintf("%s:%d", whost, wport+2),
}
现在,我们需要更新对管理器包中 New
函数的现有调用,以指定我们希望管理器使用的调度器类型。如你所见,我们将首先使用 "roundrobin"(轮询)调度器:
go
func main() {
m := manager.New(workers, "roundrobin")
}
进行了这些更改之后,我们现在可以启动主程序了。请注意,我们以相同的方式启动程序,即传入工作节点和管理器所需的环境变量,然后使用命令 go run main.go
。还要注意的是,我们看到的输出看起来和以前一样。我们可以看到程序先启动工作节点,然后启动管理器,接着程序开始循环运行,检查是否有新任务、收集统计信息、检查任务状态,并尝试更新任何现有的任务。
yaml
CUBE_WORKER_HOST=localhost CUBE_WORKER_PORT=5556 CUBE_MANAGER_HOST=localhost CUBE_MANAGER_PORT=5555 go run main.go
Starting Cube Worker
Starting Cube Manager
2025/03/17 17:18:47 No tasks in the queue
2025/03/17 17:18:47 Sleeping for 10 second
2025/03/17 17:18:47 No tasks in the queue
2025/03/17 17:18:47 Sleeping for 10 second
2025/03/17 17:18:47 Collecting stats
2025/03/17 17:18:47 Performing task health check
2025/03/17 17:18:47 Task health check completed
2025/03/17 17:18:47 Collecting stats
2025/03/17 17:18:47 Sleeping for 60 seconds
2025/03/17 17:18:47 Collecting stats
2025/03/17 17:18:47 No tasks in the queue
2025/03/17 17:18:47 Sleeping for 10 second
2025/03/17 17:18:47 Checking for task updates from workers
2025/03/17 17:18:47 Checking worker 127.0.0.1:5555 for task updates
2025/03/17 17:18:47 Processing any tasks in the queue
2025/03/17 17:18:47 No work in the queue
2025/03/17 17:18:47 Sleeping for 10 seconds
2025/03/17 17:18:47 No tasks in the database
2025/03/17 17:18:47 Checking worker 127.0.0.1:5556 for task updates
2025/03/17 17:18:47 Checking worker 127.0.0.1:5557 for task updates
2025/03/17 17:18:47 Performing task health check
2025/03/17 17:18:47 Task health check completed
2025/03/17 17:18:47 Sleeping for 60 seconds
接下来,让我们给管理器发送一个任务。我们将使用和前面章节相同的命令来启动一个回声服务器实例。curl
命令的输出看起来和我们在前面章节中常见的输出一样。
java
$ curl -X POST localhost:5556/tasks -d @task1.json
{
"ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",
"ContainerID": "",
"Name": "test-chapter-9.1",
"State": 1,
"CPU": 0,
"Memory": 0,
"Disk": 0,
"Image": "docker.io/sun4965485/echo-smy:v1",
"RestartPolicy": "",
"ExposedPorts": {
"7777/tcp": {}
},
"HostPorts": null,
"PortBindings": {
"7777/tcp": "7777"
},
"StartTime": "0001-01-01T00:00:00Z",
"FinishTime": "0001-01-01T00:00:00Z",
"HealthCheck": "/health",
"RestartCount": 0
}
在将任务发送给管理器之后,我们应该会在主程序的输出中看到类似以下的内容。这应该看起来很熟悉。管理器正在检查其待处理任务队列中的任务,并且它找到了我们发送给它的任务:
yaml
2025/03/17 17:25:33 Processing any tasks in the queue
2025/03/17 17:25:33 Pulled {a7aa1d44-08f6-443e-9378-f5884311019e 2 {bb1d59ef-9fc1-4e4b-a44d-db571eeed203 test-chapter-9.1 1 0 0 0 docker.io/sun4965485/echo-smy:v1 map[7777/tcp:{}] map[] map[7777/tcp:7777] 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC /health 0} 0001-01-01 00:00:00 +0000 UTC} off pending queue
2025/03/17 17:26:00 Updated task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 with port mappings: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/17 17:26:00 Successfully processed task bb1d59ef-9fc1-4e4b-a44d-db571eeed203, new state: 2
当管理器调用其 SelectWorker
方法时选择了该工作节点,而 SelectWorker
方法又会调用调度器上的 SelectCandidateNodes
、Score
和 Pick
方法。
任务运行后,我们可以看到管理器会检查任务是否有更新:
yaml
2025/03/17 17:33:58 Checking for task updates from workers
2025/03/17 17:33:58 Checking worker 127.0.0.1:5555 for task updates
2025/03/17 17:33:58 No tasks in the database
2025/03/17 17:33:58 Checking worker 127.0.0.1:5556 for task updates
2025/03/17 17:33:58 Attempting to update task bb1d59ef-9fc1-4e4b-a44d-db571eeed203
2025/03/17 17:33:58 Checking worker 127.0.0.1:5557 for task updates
2025/03/17 17:33:58 Attempting to update task bb1d59ef-9fc1-4e4b-a44d-db571eeed203
2025/03/17 17:33:58 Task update completed
此时,我们可以启动之前章节中一直在使用的另外两个任务。使用轮询(RoundRobin)调度器时,你应该会注意到管理器会依次选择另外两个工作节点中的每一个。
我们可以看到轮询调度器是起作用的。现在,让我们实现第二个调度器。
10.8 The E-PVM scheduler
我们接下来要实现的调度器类型比轮询调度器更为复杂。对于这个新调度器,我们的目标是将任务分散到工作节点集群中,从而使每个节点的 CPU 负载最小化。换句话说,我们希望集群中的每个节点都承担一些工作,以应对工作负载的突发情况。
10.8.1 理论依据
为了实现将负载分散到集群的目标,我们将采用机会成本的方法来为任务评分。这是谷歌早期在其 Borg 编排器中使用的方法之一,其理论基础来源于论文《An Opportunity Cost Approach for Job Assignment in a Scalable Computing Cluster》(mosix.cs.huji.ac.il/pub/ocja.pd...)。根据该论文,"核心思想...... 是将多种异构资源的总使用情况...... 转化为单一的同构'成本'。然后将作业分配到成本最低的机器上。" 这里的异构资源指的是 CPU 和内存。论文作者将这种方法称为增强型 PVM(PVM 代表并行虚拟机)。
这里的主要思路是,当一个新任务进入系统并需要被调度时,该算法会为集群中的每台机器计算边际成本。边际成本是什么意思呢?如果每台机器都有一个代表其所有资源总使用情况的同构成本,那么边际成本就是在其工作负载中添加一个新任务后,该同构成本增加的量。
论文为我们提供了该算法的伪代码,如清单 10.17 所示。如果将作业分配给某台机器的边际成本小于 MAX_COST
,我们就将该机器选为 machine_pick
。当我们遍历完机器列表后,machine_pick
中将包含边际成本最低的机器。我们会对这个伪代码的实现进行一些小修改,以满足我们自身的需求。
10.17 Pseudocode describing the algorithm used in the E-PVM scheduling method
go
max_jobs = 1;
while () {
machine_pick = 1; cost = MAX_COST
repeat {} until (new job j arrives)
for (each machine m) {
marginal_cost = power(n, percentage memory utilization on
m if j was added) + power(n, (jobs on m + 1/max_jobs) - power(n, memory use on m) - power(n, jobs on m/max_jobs));
if (marginal_cost < cost) { machine_pick = m; }
}
assign job to machine_pick;
if (jobs on machine_pick > max_jobs) max_jobs = max_jobs * 2;
}
实现我们的新调度器,也就是我们将称之为 E - PVM 调度器的过程,与我们将轮询算法适配到调度器接口的过程类似。我们首先定义 Epvm
结构体。注意,我们只定义了一个字段 Name
,因为和轮询调度器不同,我们不需要记录上一次选择的节点:
go
type Epvm struct {
Name string
}
code 10.18 The Epvm scheduler's SelectCandidateNodes method
go
func (e *Epvm) SelectCandidateNodes(t task.Task, nodes []*node.Node) []*node.Node {
var candidates []*node.Node
for node := range nodes {
if checkDisk(t, nodes[node].Disk-nodes[node].DiskAllocated) {
candidates = append(candidates, nodes[node])
}
}
return candidates
}
func checkDisk(t task.Task, diskAvaiable int64) bool {
return t.Disk <= diskAvaiable
}
现在,让我们深入探讨 E - PVM 调度器的核心部分。是时候依据 E - PVM 伪代码来实现 Score
方法了。
我们首先定义几个后续会在该方法中用到的变量。第一个定义的变量是 nodeScores
,它是一个 map[string]float64
类型的变量,用于存储每个节点的得分。接下来,我们定义 maxJobs
变量,将其随机设为 4.0,这意味着每个节点最多能处理四个任务。我选择这个值是因为我最初为本书开发代码时用的是由几个树莓派组成的集群,在我看来,这是对每个树莓派能处理任务数量的一个合理预估。在生产系统中,我们会根据对正在运行的生产系统所观测到的指标进行分析,来调整这个值。
下一步是遍历传入该方法的每个节点,并计算将任务分配给该节点的边际成本。这个过程包含八个步骤。
为了计算节点当前的 CPU 使用率,我们会使用稍后定义的 calculateCpuUsage
辅助函数。然后,我们调用 calculateLoad
辅助函数,该函数接受两个参数:使用率(usage)和容量(capacity)。使用率的值取自上一步调用 calculateCpuUsage
的结果,而容量则取我们预估的最大负载的一部分。这里对使用率的定义源自 E - PVM 论文,该论文假定最大可能负载是 "大于我们在任何给定时刻所观测到的最大负载的最小 2 的整数次幂"。同样,由于我最初是用树莓派开发的代码,而且当时只有三个树莓派,所以我猜测任何节点所出现的最高负载为 80%。
E - PVM 调度器的 Score
方法与轮询调度器中的 Score
方法签名相同,但它计算得分的方式更为复杂。
code 10.19 Signature of the E-PVM scheduler's Score
go
func (e *Epvm) Score(t task.Task, nodes []*node.Node) map[string]float64 {
nodeScores := make(map[string]float64)
maxJobs := 4.0
for _, node := range nodes {
cpuUsage, err := calculateCpuUsage(node)
if err != nil {
log.Printf("error calculating CPU usage for node %s, skipping: %v", node.Name, err)
continue
}
cpuLoad := calculateLoad(*cpuUsage, math.Pow(2, 0.8))
memoryAllocated := float64(node.Stats.MemUsedKb()) + float64(node.MemoryAllocated)
memoryPercentAllocated := memoryAllocated / float64(node.Memory)
newMemPercent := (calculateLoad(memoryAllocated+float64(t.Memory/1000), float64(node.Memory)))
memCost := math.Pow(LIEB, newMemPercent) + math.Pow(LIEB, (float64(node.TaskCount+1))/maxJobs) - math.Pow(LIEB, memoryPercentAllocated) - math.Pow(LIEB, float64(node.TaskCount)/float64(maxJobs))
cpuCost := math.Pow(LIEB, cpuLoad) + math.Pow(LIEB, (float64(node.TaskCount+1))/maxJobs) - math.Pow(LIEB, cpuLoad) - math.Pow(LIEB, float64(node.TaskCount)/float64(maxJobs))
nodeScores[node.Name] = memCost + cpuCost
}
return nodeScores
}
我们的 Score
方法使用了两个辅助函数来计算 CPU 使用率和负载。其中第一个辅助函数 calculateCpuUsage
本身就是一个多步骤的过程。这个函数的代码是基于 Stack Overflow 上的一篇帖子(网址为 stackoverflow.com/a/23376195 )中所给出的算法。我不会对这个算法展开更多细节讲解,因为那篇帖子已经很好地阐述了相关主题。如果你感兴趣的话,我建议你去阅读一下那篇帖子。
code 10.20 The calculateCpuUsage helper function
go
func calculateCpuUsage(node *node.Node) *float64 {
stat1 := getNodeStats(node)
time.Sleep(3 * time.Second)
stats := getNodeStats(node)
stat1Idle := stat1.CpuStats.Idle + stat1.CpuStats.IOWait
stat2Idle := stat2.CpuStats.Idle + stat2.CpuStats.IOWait
stat1NonIdle := stat1.CpuStats.User + stat1.CpuStats.Nice + stat1.CpuStats.System + stat1.CpuStats.IRQ + stat1.CpuStats.SoftIRQ + stat1.CpuStats.Steal
stat2NonIdle := stat2.CpuStats.User + stat2.CpuStats.Nice + stat2.CpuStats.System + stat2.CpuStats.IRQ + stat2.CpuStats.SoftIRQ + stat2.CpuStats.Steal
stat1Total := stat1Idle + stat1NonIdle
stat2Total := stat2Idle + stat2NonIdle
total := stat2Total - stat1Total
idle := stat2Idle - stat1Idle
var cpuPercentUsage float64
if total == 0 && idle == 0 {
cpuPercentUsage = 0.00
} else {
cpuPercentUsage = (float64(total) - float64(idle)) / float64(total)
}
return &cpuPercentUsage
}
请注意,这个函数使用了第二个辅助函数 getNodeStats
。如下清单所示的这个函数,会调用工作节点上的 /stats
端点,并获取该时刻工作节点的统计信息。
code 10.21 The getNodeStats helper function returning the stats for a given node
go
func getNodeStats(node *node.Node) *stats.Stats {
url := fmt.Sprintf("%s/stats", node.Api)
resp, err := http.Get(url)
if err != nil {
log.Printf("Error connecting to %v: %v", node.Api, err)
}
if resp.StatusCode != 200 {
log.Printf("Error retrieving stats from %v: %v", node.Api, err)
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
var stats stats.Stats
json.Unmarshal(body, &stats)
return &stats
}
我们的 Score
方法所使用的第三个辅助函数是 calculateLoad
函数。这个函数比 calculateCpuUsage
函数简单得多。它接受两个参数:一个是 float64
类型的 usage
(使用率),另一个同样是 float64
类型的 capacity
(容量)。然后,它只是将 usage
除以 capacity
并返回结果:
go
func calculateLoad(usage float64, capacity float64) float64 {
return usage / capacity
}
我们的 E - PVM 调度器最后需要实现的方法是 Pick
方法。这个方法与轮询调度器中的同名方法类似。唯一的区别在于,将 lowestScore
变量改名为 minCost
,以体现 E - PVM 调度器对边际成本的关注。除此之外,该方法的基本功能是相同的:即选择成本最小(最低)的节点。如下清单所示,E - PVM 调度器的 Pick
方法与轮询调度器中的 Pick
方法几乎一模一样。
code 10.22 The E-PVM scheduler's Pick method
go
func (e *Epvm) Pick(scores map[string]float64, candidates []*node.Node) *node.Node {
minCost := 0.00
var bestNode *node.Node
for idx, node := range candidates {
if idx == 0 {
minCost = scores[node.Name]
bestNode = node
continue
}
if scores[node.Name] < minCost {
minCost = scores[node.Name]
bestNode = node
}
}
return bestNode
}
随着 Pick
方法的实现,我们已经完成了第二个调度器的开发。这个调度器和轮询调度器一样,实现了 Scheduler
接口。因此,我们可以在管理器中使用这两种调度器中的任意一种。不过,在修改 main.go
程序以使用这个新调度器之前,我们先稍微绕个小弯,处理一些遗留问题。
10.9 完善节点(Node)实现
早些时候,我们实现了一个名为 getNodeStats
的辅助函数。这个函数接收一个 node
变量,它是一个指向 node.Node
类型的指针。从函数名就能看出,它通过向节点的 /stats
端点发起 GET 请求与节点进行通信。然后,它将从节点获取到的统计信息作为一个指向 stats.Stats
类型的指针返回。由于这个函数属于调度器的一部分,让它去处理调用节点 /stats
端点、检查响应以及对 JSON 响应进行解码等底层细节就显得不太合适。
我们把这段代码从调度器中提取出来,放到它真正该在的地方 ------Node
类型里。我们在第 2 章就实现了 Node
类型,由于距离上次看到它已经有一段时间了,所以我们来回顾一下它的样子。
正如我们在下面的代码清单中看到的,Node
类型非常简单。它的字段保存着代表充当工作节点角色的物理机或虚拟机各种属性的值。
code 10.23 The Node type defined in chapter 2
go
type Node struct {
Name string
Ip string
Cores int64
Memory int64
MemoryAllocated int64
Disk int64
DiskAllocated int64
Stats stats.Stats
Role string
TaskCount int
}
在实际移动 getNodesStats
函数之前,我们先实现另一个辅助函数。我们将这个辅助函数命名为 HTTPWithRetry
,并会在 getNodesStats
函数中使用它。HTTPWithRetry
能够确保:
即使工作节点遇到临时问题,比如节点重启或者出现临时网络问题,我们(最终)也能从工作节点获取到统计信息。因此,在调用 /stats
端点时,我们不会在单次尝试失败后就直接放弃,而是会进行多次尝试。
HTTPWithRetry
会接受一个 f(string)
类型的函数和一个字符串类型的 url
作为参数。f(string)
参数将是 net/http
包中的一个 HTTP 方法(例如 http.Get
)。该函数会返回一个指向 HTTP 响应的指针,还可能返回一个错误。当我们调用 HTTPWithRetry
时,代码会是这样的:
go
result, err := utils.HTTPWithRetry(http.Get, "http://localhost:5556/stats")
在该函数的主体部分,我们将计数器 count
初始化为 10。然后在 for
循环里使用这个计数器,在循环中调用 f(string)
函数并检查响应。如果该函数返回错误,我们会暂停 5 秒后再次尝试。如果函数调用成功,我们就跳出循环。最后,我们返回 HTTP 响应和可能出现的错误。
我们要把 HTTPWithRetry
函数放到一个单独的包中。在你的项目里创建一个名为 utils
的新目录,在这个目录下创建一个名为 retry.go
的文件。在这个文件中,让我们添加以下代码清单里的内容。
code 10.24 The HTTPWithRetry helper function in the new utils package
go
package utils
func HTTPWithRetry(f func(string) (*http.Response, error), url string) (*http.Response, error) {
count := 10
var resp *http.Response
var err error
for i := 0; i < count; i++ {
resp, err = f(url)
if err = nil {
fmt.Printf("Error calling url %v\n", url)
time.Sleep(5 * time.Second)
} else {
break
}
}
return resp, err
}
在定义好 HTTPWithRetry
辅助函数后,让我们回到 getNodesStats
函数。把它从 scheduler.go
文件中移除,然后添加到 node/
包目录下的 node.go
文件里。在移动这个函数的过程中,我们还把它的名称改为 GetStats
,并将其作为 node.Node
类型的一个方法。
code 10.25 Renaming the getNodeStats helper function
go
func (n *Node) GetStats() (*stats.Stats, error) {
var resp *http.Response
var err error
url := fmt.Sprintf("%s/stats", n.Api)
resp, err := utils.HTTPWithRetry(http.Get, url)
if err != nil {
msg := fmt.Sprintf("Unable to connect to %v. Permanent failure.\n", n.Api)
log.Println(msg)
return nil, errors.New(msg)
}
if resp.StatusCode != 200 {
msg := fmt.Sprintf("Error retrieving stats from %v: %v", n.Api, err)
log.Println(msg)
return nil, errors.New(msg)
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
var stats stats.Stats
err = json.Unmarshal(body, &stats)
if err != nil {
msg := fmt.Sprintf("error decoding message while getting stats for node %s", n.Name)
log.Println(msg)
return nil, errors.New(msg)
}
n.Memory = int64(stats.MemTotalKb())
n.Disk = int64(stats.DiskTotal())
n.Stats = stats
return &n.Stats, nil
}
现在 Node
类型上已经实现了 GetStats
方法,我们可以从 scheduler.go
中移除旧的 getNodeStats
辅助函数。最后,我们可以更新 calculateCpuUsage
辅助函数,让它使用 node.GetStats
方法。除了使用 node.GetStats
方法之外,我们还把函数签名修改为返回一个指向 float64
的指针和一个错误。修改后的函数如下所示:
go
func calculateCpuUsage(node *node.Node) (*float64, error) {
stat1, err := node.GetStats()
if err != nil {
return nil, err
}
time.Sleep(3 * time.Second)
stat2, err := node.GetStats()
if err != nil {
return nil, err
}
// ...
return &cpuPercentUsage, nil
}
GetStats
辅助函数会调用节点的工作节点 API,因此我们需要在工作节点的 Api
类型上暴露 /stats
端点。这个更改很简单:
go
func (a *Api) initRouter() {
....
a.Router.Route("/stats", func(r chi.Router) {
r.Get("/", a.GetStatsHandler)
})
}
完成这些修改后,我们已经处理好了之前所说的 "小插曲"。现在,让我们回到炫酷的新型 E - PVM 调度器,来实际测试一下它吧!
10.10 Using the E-PVM scheduler
code 10.26 Adding a new "epvm" case to the switch statement
go
switch schedulerType {
case "roundrobin":
s = &scheduler.RoundRobin{Name: "roundrobin"}
case "epvm":
s = &scheduler.Epvm{Name: "epvm"}
default:
s = &scheduler.RoundRobin{Name: "roundrobin"}
}
第二项也是最后一项修改发生在我们的 main.go
程序中。如果你还记得的话,我们之前使用 New
函数创建了一个带有 "轮询(roundrobin)" 调度器的管理器新实例:
go
func main() {
m := manager.New(workers, "roundrobin")
}
然而,现在我们希望创建一个使用 E - PVM 调度器的管理器实例。为此,我们只需在调用 New
函数时,将传入的字符串从 roundrobin
改为 epvm
即可:
go
m := manager.New(workers, "epvm")
就是这样!现在我们可以运行主程序了,它将使用 E - PVM 调度器而不是轮询调度器。试试看吧!
go
CUBE_WORKER_HOST=127.0.0.1 CUBE_WORKER_PORT=5000 CUBE_MANAGER_HOST=127.0.0.1 CUBE_MANAGER_PORT=5556 go run main.go
总结
调度问题在我们生活中随处可见,从家庭琐事安排到餐厅给顾客安排座位。
调度没有一种通用的解决方案。有多种解决办法,而且每种方案都基于我们想要达成的目标进行权衡取舍。调度可以很简单,比如使用轮询算法依次选择每个节点;也可以很复杂,例如设计一种方法,根据一组数据(如每个节点当前的 CPU 负载和内存使用情况)为每个节点计算得分。就编排系统中的任务调度而言,我们可以将这个过程概括为三个函数:选择候选节点,即根据某些选择标准减少可能的节点数量(例如,节点是否有足够的磁盘空间来拉取任务的容器镜像?);对候选节点集进行评分;最后,挑选出最佳候选节点 。
我们可以利用这三个函数创建一个通用框架,以便实现多q种调度器。在 Go 语言中,接口让我们能够创建这样的框架。
在本章中,我们启动了三个工作节点,与前面章节中只使用单个工作节点不同。使用三个工作节点能让我们看到一个更贴近实际的调度过程示例。不过,这与使用多个物理机或虚拟机的真实场景仍有差异。