从头开始使用 Go 构建 Orchestrator(第二部分:从心智模型到骨架代码)

171 阅读18分钟

本章涵盖内容如下:

  • 为任务、工作节点、管理器和调度器组件创建框架代码。

  • 确定任务的状态。

  • 使用接口支持不同类型的调度器。

  • 编写测试程序以验证代码能够编译并运行。

一旦我对一个项目有了概念模型,我就喜欢将这个模型转化为框架代码。在日常工作中我经常这么做。这就好比木匠建造房屋时先搭建框架:他们用 2x4 规格的木材确定外墙和内部房间的位置,并添加桁架来塑造屋顶的形状。这个框架并非最终成品,但它勾勒出了建筑的轮廓,让其他人在后续施工中添加细节。

同样地,框架代码为我想要构建的系统提供了大致的形状和轮廓。最终的产品可能不会与这个框架代码完全一致。可能会有一些细节变动,增加或删除一些部分,这都是正常的。这样做通常能让我以一种具体的方式开始思考实现过程,而又不至于过早陷入细节。

再次查看我们的概念模型(图 2.1),我们应该从哪里入手呢?你会立刻发现最明显的三个组件是管理器、工作节点和调度器。然而,这些组件的基础都是任务,所以我们就从任务开始吧。

image.png

图 2.1 Cube 概念模型展示了管理器、工作节点和调度器是系统的主要组件。

在本章接下来的内容中,我们将在项目目录中创建新文件。现在花些时间创建以下目录和文件:

image.png

2.1 The task skeleton

我们首先要考虑的是任务在其生命周期中会经历的状态。

  1. 首先,用户向系统提交任务。此时,任务已被放入队列,但正在等待调度。我们将这个初始状态称为 Pending(待处理)
  2. 一旦系统确定了在哪里运行该任务,我们就可以说它进入了 Scheduled(已调度) 状态。已调度状态意味着系统已确定有一台机器可以运行该任务,但任务正在被发送到选定的机器上,或者选定的机器正在启动该任务的过程中。
  3. 接下来,如果选定的机器成功启动任务,任务就会进入 Running(运行中) 状态。
  4. 当任务成功完成工作或被用户停止时,任务会进入 Completed(已完成) 状态。
  5. 如果在任何时候任务崩溃或无法按预期工作,那么任务就会进入 Failed(失败) 状态。

图 2.2 展示了这个过程。

image.png

图 2.2 任务在其生命周期中会经历的状态

现在我们已经确定了任务的状态,让我们来创建状态类型。

code 2.1 The State type

package task

type State interface

const (
	Pending State = iotachan
	Scheduled
	Running
	Completed
	Failed
)

接下来,我们应该确定对于我们的系统而言,任务的其他有用属性。显然,一个 ID 能让我们唯一地识别每个任务,我们将使用通用唯一识别码(UUID)来作为任务 ID。一个便于人类阅读的名称也很不错,因为这样我们就可以称任务为 "Tim’s awesome",而不是任务 "74560f1a-b14140ec-885a-64e4b36b9f9c"。基于这些,我们可以勾勒出任务结构体(Task struct)的雏形了。

什么是 UUID?

UUID 是通用唯一识别码(universally unique identifier)的缩写。一个 UUID 长度为 128 位,在实际应用中,它是唯一的。虽然生成两个完全相同的 UUID 并非完全不可能,但这种概率极低。想要了解关于 UUID 的更多详细信息,可以查看 RFC 4122(tools.ietf.org/html/rfc412…)。

code 2.2 The initial Task struct

import (
    "github.com/google/uuid"
)

type Task struct {
    ID uuid.UUID
    Name string
    State State
}

请注意,State字段的类型是我们之前定义的State类型。

我们已经说过,我们将把编排器限制为处理 Docker 容器。因此,我们需要知道一个任务应该使用哪个 Docker 镜像,为此,我们使用一个名为Image的属性。鉴于我们的任务将是 Docker 容器,有几个属性对于任务跟踪是很有用的。Memory(内存)和Disk(磁盘)将帮助系统确定任务所需的资源数量。ExposedPorts(暴露端口)和PortBindings(端口绑定)由 Docker 使用,以确保机器为任务分配正确的网络端口,并使其在网络上可用。我们还需要一个RestartPolicy(重启策略)属性,它将告诉系统在任务意外停止或失败时该怎么做。有了这些属性,我们就可以更新我们的任务结构体(Task struct)了。

code 2.3 Updating our Task struct with Docker-specific fields

import (
    "github.com/google/uuid"
    "github.com/docker/go-connections/nat"
)

type Task struct {
    ID uuid.UUID
    Name string
    State State
    Image string
    Memory int
    Disk   int
    ExposedPort nat.PortSet
    PortBinding map[string]stirng
    RestartPolicy string
}

最后,为了了解任务何时开始和结束,我们可以在结构体中添加StartTime(开始时间)和FinishTime(结束时间)字段。虽然这些字段并非绝对必要,但在命令行界面(CLI)中显示它们会很有帮助。有了这两个属性,我们就可以完善任务结构体的其余部分了。

code 2.4 Adding StartTime and FinishTime fields to the task struct

import (
    ...
    "time"
)

type Task struct {
    ...
    StartTime time.Time
    FinishTime time.Time
}

我们已经定义好了任务结构体(Task struct),它代表了用户想要在我们的集群上运行的一个任务。正如我之前提到的,一个任务可以处于几种状态之一:待处理(Pending)、已调度(Scheduled)、运行中(Running)、失败(Failed)或已完成(Completed)。

当用户首次请求运行一个任务时,任务结构体运行良好,但是用户要如何告知系统停止一个任务呢?为此,我们引入任务事件结构体(TaskEvent struct)。

为了识别一个任务事件,它将需要一个 ID,和我们的任务一样,这将通过使用通用唯一识别码(UUID)来实现。这个事件将需要一个状态(State),该状态将指示任务应该转换到的状态(例如,从运行中转换到已完成)。接下来,该事件将有一个时间戳(Timestamp)来记录请求该事件的时间。最后,该事件将包含一个任务结构体(Task struct)。用户不会直接与任务事件结构体进行交互。它将是我们的系统用于触发任务从一种状态转换到另一种状态的内部对象。

code 2.5 The TaskEvent struct

type TaskEvent struct {
    ID uuid.UUID
    State State
    Timestamp time.Time
    Task Task
}

在定义了任务和任务事件结构体后,让我们继续绘制下一个组件--Worker 的骨架。

2.2 The worker skeleton

如果我们把任务看作是这个编排系统的基础,那么工作节点就可以被视为建立在这个基础之上的下一层。让我们回顾一下工作节点的要求:

  1. 以 Docker 容器的形式运行任务。

  2. 接受来自管理器的任务并运行。

  3. 向管理器提供相关统计信息,以便进行任务调度。

  4. 跟踪自身的任务及其状态。

使用与定义任务结构体(Task struct)相同的方法,我们来创建工作节点结构体(Worker struct)。根据第一个和第四个要求,我们知道工作节点需要运行并跟踪任务。为了实现这一点,工作节点将使用一个名为Db的字段,它将是一个从通用唯一识别码(UUID)到任务的映射。为了满足第二个要求,即接受来自管理器的任务,工作节点将需要一个Queue字段。使用队列可以确保任务按照先进先出(FIFO)的顺序进行处理。不过,我们不会自己实现队列,而是会使用golang-collections中的队列。我们还将添加一个TaskCount字段,作为一种方便的方式来随时跟踪工作节点所拥有的任务数量。

在你的项目目录中,创建一个名为worker的子目录,然后切换到该目录。现在,打开一个名为worker.go的文件,并输入以下清单中的代码。

code 2.6 The beginnings of the Worker struct

package worker

import (
    "fmt"
    "github.com/google/uuid"
    "github.com/golang-collections/collections/queue"
    "cube/task"
)

type Worker struct {
    Name string
    Queue queue.Queue
    Db map[uuid.UUID]*task.Task
    TaskCount int
}

请注意,通过将Db字段设置为映射类型,我们获得了数据存储的功能,同时又无需担心外部数据库服务器或嵌入式数据库库的复杂性。

所以我们已经确定了工作节点结构体(Worker struct)的各个字段。现在,让我们添加一些能执行实际工作的方法。首先,我们给这个结构体添加一个RunTask方法。顾名思义,它将负责在运行该工作节点的机器上运行任务。由于任务可能处于几种状态之一,RunTask方法将负责识别任务的当前状态,然后根据状态启动或停止任务。接下来,我们添加一个StartTask方法和一个StopTask方法,它们的功能正如其名称所示 —— 启动和停止任务。最后,我们给工作节点添加一个CollectStats方法,该方法可用于定期收集有关该工作节点的统计信息。

code 2.7 The skeleton of the Worker component

func (w *Worker) CollectStats() {
    fmt.Println("I will collect stats")
}

func (w *Worker) RunTask() {
    fmt.Println("I will start a task")
}

func (w *Worker) StopTask() {
    fmt.Println("I will stop a task")
}

请注意,每个方法都只是简单地打印出一行文字,说明它将要做什么。在本书的后续内容中,我们会再次研究这些方法,以便实现这些语句所代表的实际行为。

2.3 The manager skeleton

除了工作节点(Worker),管理器(Manager)是我们编排系统的另一个主要组件,它将承担大部分的工作。

提醒一下,以下是我们在第 1 章中为管理器定义的需求:

  1. 接受用户启动和停止任务的请求。

  2. 将任务调度到工作节点机器上。

  3. 跟踪任务、它们的状态以及运行它们的机器。

manager.go文件中,让我们创建名为Manager的结构体。管理器将有一个队列,由pending字段表示,任务首次提交时会被放入这个队列。这个队列能让管理器按照先进先出(FIFO)的原则处理任务。接下来,管理器会有两个内存数据库:一个用于存储任务,另一个用于存储任务事件。这两个数据库分别是从字符串到TaskTaskEvent的映射。

我们的管理器需要跟踪集群中的工作节点。为此,我们使用一个名为workers的字段,它是一个字符串切片。最后,让我们添加几个便利字段,这会让我们后续的工作更轻松。很容易想到,我们会想知道分配给每个工作节点的任务。我们将使用一个名为WorkerTaskMap的字段,它是一个从字符串到任务 UUID 的映射。同样,要是能有一种简单的方法,根据任务名称找到运行该任务的工作节点就好了。这里我们使用一个名为TaskWorkerMap的字段,它是一个从任务 UUID 到字符串的映射,其中字符串是工作节点的名称。

code 2.8 The beginings of our Manager skeleton

package manager

import (
    "cube/task"
    "fmt"
    
    "github.com/golang-collections/collections/queue"
    "github.com/google/uuid"
)

type Manager struct {
    Pending queue.Queue
    TaskDb map[string][]*task.Task
    EventDb map[string][]*task.TaskEvent
    Workers []string
    WorkerTaskMap map[string][]uuid.UUID
    TaskWorkerMap map[uuid.UUID]string
}

从我们设定的需求中可以看出,管理器需要将任务调度到工作节点上。所以,我们要在Manager结构体上创建一个名为selectWorker的方法来完成这项任务。该方法负责查看任务中指定的需求,并评估工作节点池中的可用资源,以确定哪个工作节点最适合运行该任务。我们的需求还表明,管理器必须跟踪任务、它们的状态以及运行它们的机器。为了满足这一需求,我们创建一个名为UpdateTasks的方法。最终,这个方法会触发对工作节点的CollectStats方法的调用,关于这一点,本书后续会有更多介绍。

我们的管理器框架代码是否有遗漏呢?啊,确实有。到目前为止,它可以为任务选择合适的工作节点并更新现有任务。需求中还隐含了另一个要求:显然,管理器需要将任务发送给工作节点。让我们把这一点添加到需求中,并在Manager结构体上创建一个相应的方法。

code 2.9 Adding methods to the Manager

func (m *Manager) SelectWorker() {
    fmt.Println("I will select an appropriate worker")
}

func (m *Manager) UpdateTasks() {
    fmt.Println("I will update tasks")
}

func (m *Manager) SendWork() {
    fmt.Println("I will send work to workers")
}

2.4 The scheduler skeleton

在我们概念模型的四大主要组件中,最后一个是调度器(Scheduler)。它的需求如下:

  1. 确定一组可以运行某个任务的候选工作节点。

  2. 对候选工作节点进行评分,从最优到最劣排序。

  3. 选择评分最高的工作节点。

我们将在 scheduler.go 文件中创建的这个框架代码与之前的有所不同。这次我们不会定义结构体及其方法,而是要创建一个接口。

接口是 Go 语言支持多态性的机制。它们是一种契约,规定了一组行为,任何实现了这些行为的类型都可以在指定该接口类型的任何地方使用。想要了解关于接口的更多详细信息,请查看《Effective Go》博客文章中 “接口和其他类型” 部分,网址为 mng.bz/j1n9

code 2.10 The skeleton of the Scheduler component

type Scheduler interface {
    SelectCandidateNodes()
    Score()
    Pick()
}

2.5 Other skeletons

至此,我们已经为概念模型中的四个主要对象创建了框架代码:任务(Task)、工作节点(Worker)、管理器(Manager)和调度器(Scheduler)。然而,在这个模型中还暗示了另一个对象,即节点(Node)。

到目前为止,我们一直在讨论工作节点。工作节点是处理我们逻辑工作负载(即任务)的组件。不过,工作节点有其物理层面的属性,因为它自身运行在一台物理机器上,并且还会使任务在物理机器上运行。此外,它需要了解底层机器的信息,以便收集机器的统计数据,供管理器用于调度决策。我们将把工作节点的这个物理层面的属性称为节点。

在 Cube 这个编排系统的情境中,节点是一个代表我们集群中任何一台机器的对象。例如,在 Cube 中,管理器就是一种类型的节点。工作节点(可以有多个)是另一种类型的节点。管理器将广泛使用节点对象来表示工作节点。

目前,我们只定义构成节点结构体(Node struct)的字段,如清单 2.11 所示。首先,一个节点会有一个名称,例如简单的 “node-1”。其次,一个节点会有一个 IP 地址,管理器需要知道这个地址才能向其发送任务。一台物理机器也有一定数量的内存和磁盘空间供任务使用。这些属性表示的是最大容量。在任何时间点,机器上的任务都会占用一定数量的内存和磁盘空间,我们可以将其称为已分配内存(MemoryAllocated)和已分配磁盘空间(DiskAllocated)。最后,一个节点会有零个或多个任务,我们可以使用一个 TaskCount 字段来跟踪任务数量。

code 2.11 The Node struct, representing a physical machine

package node

type Node struct {
    Name string
    IP   string
    Cores int
    Memory int
    MemoryAllocated int
    Disk int
    DiskAllocated int
    Role string
    TaskCount int
}

2.6 Taking our skeletons for a spin(带着我们的代码骨架去兜风)

既然我们已经创建了这些框架代码,接下来看看能否在一个简单的测试程序中使用它们。我们要确保刚刚编写的代码能够编译并运行。为此,我们将创建每个框架代码所对应的实例,打印这些实例,最后调用每个实例的方法。

下面的内容更详细地总结了我们的测试程序要做的事情:

  1. 创建一个任务(Task)对象。

  2. 创建一个任务事件(TaskEvent)对象。

  3. 打印任务和任务事件对象。

  4. 创建一个工作节点(Worker)对象。

  5. 打印工作节点对象。

  6. 调用工作节点的方法。

  7. 创建一个管理器(Manager)对象。

  8. 调用管理器的方法。

  9. 创建一个节点(Node)对象。

  10. 打印节点对象。

不过,在编写这个程序之前,我们要先处理一个小的管理性任务,这对于让我们的代码能够编译是必要的。记得我们说过要使用 golang-collections 包中的队列实现,还使用了谷歌的 UUID 包,也用到了 Docker 的 nat 包。虽然我们已经在代码中导入了这些包,但还没有在本地安装它们。所以现在就让我们来安装这些包。

code 2.12 Using the go get command to install the third-party packages

go get github.com/golang-collections/collections/queue 
go get github.com/google/uuid 
go get github.com/docker/go-connections/nat

现在我们可以测试我们的骨架了,我们将在下面两个代码中进行测试。

code 2.13 Testing the skeletons with a minimal program: Part1

package main

import (
    "cube/node"
    "cube/task"
    "fmt"
    "time"
    
    "github.com/golang-collections/collections/queue" 
    "github.com/google/uuid"
    "cube/manager"
    "cube/worker"
)

func main() {
    t := task.Task{
        ID: uuid.New(),
        Name: "Task-1",
        State: task.Pending,
        Image: "Image-1",
        Memory: 1024,
        Disk: 1,
    }
}

code 2.14 Testing the skeletons with a minimal program: Part2

    te := task.TaskEvent{
        ID: uuid.New(),
        State: task.Pending,
        Timestamp: time.Now(),
        Task: t,
    }

    fmt.Printf("task: %v\n", t)
    fmt.Printf("task event: %v\n", te)

    w := worker.Worker{
        Name: "worker-1",
        Queue: *queue.New(),
        Db: make(map[uuid.UUID]*task.Task),
    }
    
    fmt.Printf("worker: %v\n", w)
    w.CollectStats()
    w.RunTask()
    w.StartTask()
    w.StopTask()

    m := manager.Manager{
        Pending: *queue.New(),
        TaskDb: make(map[string][]task.Task),
        EventDb: make(map[string][]task.TaskEvent),
        Workers: []string(w.Name),
    }

    fmt.Printf("manager: %v\n", m)
    m.SelectWorker()
    m.UpdateTasks()
    m.SendWork()

    n := node.Node{
        Name: "Nde-1",
        IP: "192.168.1.1",
        Cores: 4,
        Memory: 1024,
        Disk: 25,
        Role: "worker",
    }

    fmt.Printf("node: %v\n", n)

现在到了关键时刻!是时候编译并运行我们的程序了。使用 go run main.go 命令来执行程序,你应该会看到类似下面清单中的输出内容。

code 2.15 Testing the skeletons by running our minimal program

$ go run main.go
task: {eaa4067f-1ff9-4e13-8260-d61dabf1fcb6 Task-1 0 1024 1 ubuntu:24.04  0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC}
task event: {3b343a30-9d61-44f1-af04-afc9419058a7 0 {eaa4067f-1ff9-4e13-8260-d61dabf1fcb6 Task-1 0 1024 1 ubuntu:24.04  0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC} 2025-03-10 17:00:36.384206 +0800 CST m=+0.000181085}
worker: {worker-1 {<nil> <nil> 0} map[] 0}
I will collect stats
I will run a task
I will start a task
I will stop a task
manager: {{<nil> <nil> 0} map[] map[] [worker-1] map[] map[]}
I will select an appropriate worker
I will update the task
I will send work to worker
node: {Node-1 192.168.1.1 4 1024 0 1 0 worker 0}

总结

Cube 编排器的代码在我们的项目中被组织在不同的子目录里,分别是:Manager(管理器)、Node(节点)、Scheduler(调度器)、Task(任务)和 Worker(工作节点)。

编写框架代码有助于将概念模型从抽象概念转化为可运行的代码。因此,我们为编排系统的任务、工作节点、管理器和调度器组件创建了框架代码。这一步还帮助我们识别出了一些最初没有想到的额外概念。任务事件(TaskEvent)和节点组件在我们最初的模型中没有体现,但在本书后面的内容中会很有用。

我们通过编写一个主程序来测试这些框架代码。虽然这个程序没有执行任何实际操作,但它确实向终端打印了消息,让我们对系统的工作方式有了一个大致的了解。

一个任务可以处于五种状态之一:待处理(Pending)、已调度(Scheduled)、运行中(Running)、已完成(Completed)或失败(Failed)。工作节点和管理器将利用这些状态对任务执行操作,比如停止和启动任务。

Go 语言通过接口实现多态性。接口是一种指定了一组行为的类型,任何实现了这些行为的其他类型都将被视为与该接口属于同一类型。使用接口可以让我们实现多个调度器,每个调度器的行为略有不同。