从思维模型到骨架代码
本章内容: 为任务、工作节点、管理器和调度器组件创建骨架代码 确定任务的状态 使用接口支持不同类型的调度器 编写测试程序以验证代码能否编译和运行 一旦我为项目建立了思维模型,我喜欢将这个模型转化为骨架代码。我在日常工作中经常这样做。这类似于木匠建造房屋的方式:他们用两乘四的木板定义外墙和内室,并添加桁架以形成屋顶的形状。这个框架不是最终产品,但它标志着结构的边界,允许其他人在施工过程中添加细节。
同样,骨架代码提供了我想要构建的系统的大致形状和轮廓。最终产品可能不会完全符合这个骨架代码。部分内容可能会改变,新的部分可能会被添加或移除,这都没关系。这通常允许我以一种具体的方式开始思考实现,而不至于过早陷入细节。
如果我们再次查看我们的思维模型(图2-1),应该从哪里开始呢?你会立即发现,最明显的三个组件是管理器、工作节点和调度器。然而,这些组件的基础是任务,所以让我们从任务开始。 图2-1 Cube的思维模型显示了管理器、工作节点和调度器是系统的主要组件。
在本章的其余部分,我们将在项目目录中创建新文件。现在花点时间创建以下目录和文件:
2.1 任务骨架 我们首先要考虑的是任务在其生命周期中会经历的状态。首先,用户将任务提交到系统。此时,任务已被排队,但正在等待调度。让我们称这个初始状态为Pending。一旦系统确定了任务的运行位置,我们可以说它已进入Scheduled状态。Scheduled状态意味着系统已经确定有一台机器可以运行任务,但任务正在发送到选定的机器,或者选定的机器正在启动任务。接下来,如果选定的机器成功启动任务,任务将进入Running状态。当任务成功完成其工作或被用户停止时,任务将进入Completed状态。如果任务在任何时候崩溃或停止按预期工作,任务将进入Failed状态。图2-2显示了这个过程。
图2-2 任务在其生命周期中会经历的状态
现在我们已经确定了任务的状态,让我们创建State类型。
列表2-1 State类型 接下来,我们应该确定任务的其他属性,这些属性对我们的系统有用。显然,ID可以让我们唯一地标识各个任务,我们将使用通用唯一标识符(UUID)来实现这一点。一个可读的Name也很好,因为这意味着我们可以谈论Tim的出色任务,而不是任务74560f1a-b141-40ec-885a-64e4b36b9f9c。有了这些,我们可以勾勒出Task结构的开端。
什么是UUID? UUID代表通用唯一标识符(Universally Unique Identifier)。UUID长度为128位,实际上是唯一的。虽然生成两个相同的UUID并非不可能,但这种概率极低。有关UUID的更多详细信息,请参见RFC 4122(https://tools.ietf.org/html/rfc4122)。
列表2-2 初始的Task结构体 注意,State字段的类型是我们之前定义的State类型。
我们已经说过,我们的编排器将仅处理Docker容器。因此,我们需要知道任务应该使用哪个Docker镜像,为此,我们使用一个名为Image的属性。鉴于我们的任务将是Docker容器,有几个属性对任务的跟踪非常有用。Memory和Disk将帮助系统识别任务所需的资源数量。ExposedPorts和PortBindings由Docker使用,以确保机器为任务分配适当的网络端口,并使其在网络上可用。我们还需要一个RestartPolicy属性,它将告诉系统在任务意外停止或失败时该怎么做。通过这些属性,我们可以更新我们的Task结构体。
列表2-3 使用Docker特定字段更新我们的Task结构体 最后,为了知道任务的开始和结束时间,我们可以在结构体中添加StartTime和FinishTime字段。虽然这些字段不是严格必要的,但它们在命令行界面(CLI)中显示时非常有用。有了这两个属性,我们可以完善Task结构体的其余部分。
列表2-4 向Task结构体添加StartTime和FinishTime字段 我们已经定义了Task结构体,它代表用户希望在我们的集群上运行的任务。如前所述,任务可以处于几种状态之一:Pending、Scheduled、Running、Failed或Completed。Task结构体在用户首次请求运行任务时工作良好,但用户如何告诉系统停止任务呢?为此,我们引入了TaskEvent结构体。
为了标识TaskEvent,它需要一个ID,像我们的Task一样,这将使用UUID来实现。事件需要一个State,指示任务应过渡到的状态(例如,从Running到Completed)。接下来,事件将有一个Timestamp来记录请求事件的时间。最后,事件将包含一个Task结构体。用户不会直接与TaskEvent结构体交互。它将是我们的系统用于触发任务从一个状态到另一个状态的内部对象。
列表2-5 TaskEvent结构体 有了Task和TaskEvent结构体的定义,让我们继续勾勒下一个组件——Worker。
2.2 Worker骨架 如果我们将任务视为这个编排系统的基础,那么Worker就是位于基础之上的下一层。让我们回顾一下Worker的需求:
以Docker容器的形式运行任务 接受来自管理器的任务 向管理器提供相关统计数据以便调度任务 跟踪其任务及其状态 使用与定义Task结构体相同的过程,让我们创建Worker结构体。根据第一个和第四个需求,我们知道Worker需要运行并跟踪任务。为此,Worker将使用一个名为Db的字段,它是UUID到任务的映射。为了满足第二个需求,即接受来自管理器的任务,Worker需要一个Queue字段。使用队列将确保任务以先进先出(FIFO)的顺序处理。然而,我们不会实现自己的队列,而是使用golang-collections中的Queue。我们还将添加一个TaskCount字段,作为跟踪Worker在任何给定时间拥有的任务数量的便捷方式。
在你的项目目录中,创建一个名为worker的子目录,然后切换到该目录。现在,打开一个名为worker.go的文件,并输入以下代码。
列表2-6 Worker结构体的初步定义 注意,通过使用map作为Db字段,我们获得了数据存储的好处,而不必担心外部数据库服务器或嵌入式数据库库的复杂性。
我们已经确定了Worker结构体的字段。现在让我们添加一些实际工作的方法。首先,我们将给结构体一个RunTask方法。顾名思义,它将处理在Worker运行的机器上运行任务。由于任务可以处于几种状态之一,RunTask方法将负责识别任务的当前状态,然后根据状态启动或停止任务。接下来,让我们添加StartTask和StopTask方法,它们将分别启动和停止任务。最后,我们给Worker添加一个CollectStats方法,用于定期收集Worker的统计数据。
列表2-7 Worker组件的骨架 注意,每个方法只是简单地打印出它将要做的事情。稍后在本书中,我们将重新审视这些方法,以实现这些语句所代表的实际行为。
2.3 Manager骨架 与Worker一样,Manager是我们编排系统的另一个主要组件。它将处理大部分工作。
回顾一下,我们在第1章中定义的Manager的需求:
接受用户启动和停止任务的请求 将任务调度到工作节点上 跟踪任务、它们的状态以及它们运行的机器 在manager.go文件中,让我们创建一个名为Manager的结构体。Manager将有一个队列,用pending字段表示,任务在首次提交时将被放置在其中。队列将允许Manager以FIFO(先进先出)的方式处理任务。接下来,Manager将有两个内存数据库:一个用于存储任务,另一个用于存储任务事件。这些数据库分别是字符串到Task和TaskEvent的映射。
我们的Manager需要跟踪集群中的工作节点。为此,我们使用一个名为workers的字段,它将是一个字符串切片。最后,我们添加几个方便的字段,以便在后续工作中更轻松。可以想象,我们会想知道分配给每个工作节点的任务。我们将使用一个名为WorkerTaskMap的字段,它是字符串到任务UUID的映射。同样,给定任务名称时,找到运行该任务的工作节点的简便方法也很有用。这里我们将使用一个名为TaskWorkerMap的字段,它是任务UUID到字符串的映射,其中字符串是工作节点的名称。
列表2-8 Manager骨架的初步定义 根据我们的需求,你可以看到Manager需要将任务调度到工作节点上。因此,让我们在Manager结构体上创建一个名为selectWorker的方法来执行该任务。此方法将负责查看任务中指定的需求,并评估工作节点池中的可用资源,以确定哪个工作节点最适合运行该任务。我们的需求还指出,Manager必须跟踪任务、它们的状态以及它们运行的机器。为了满足这一需求,创建一个名为UpdateTasks的方法。最终,此方法将触发对工作节点的CollectStats方法的调用,但更多细节将在本书后面讨论。
我们的Manager骨架还缺少什么吗?啊,是的。到目前为止,它可以为任务选择一个工作节点并更新现有任务。还有一个需求在需求中隐含:Manager显然需要将任务发送到工作节点。让我们将其添加到我们的需求中,并在Manager结构体上创建一个方法。
列表2-9 向Manager添加方法 与Worker的方法类似,Manager的方法仅打印出它们将要做的事情。这些方法实际行为的实现工作将在后续进行。
2.4 Scheduler骨架 我们思维模型中的最后一个主要组件是调度器。其需求如下:
确定任务可以运行的一组候选工作节点 从最好到最差对候选工作节点进行评分 选择得分最高的工作节点 这个骨架,我们将在scheduler.go文件中创建,将与之前的不同。我们不会定义结构体及其方法,而是要创建一个接口。
Go中的接口 接口是Go支持多态的机制。它们是指定一组行为的契约,任何实现这些行为的类型都可以在指定接口类型的任何地方使用。有关接口的更多详细信息,请参见Effective Go博客文章中的“Interfaces and Other Types”部分:http://mng.bz/j1n9。
为什么使用接口? 正如软件工程中的一切,权衡是常态。在我最初编写编排器的实验中,我想要一个简单的调度器,因为我想专注于其他核心功能,比如运行任务。为此,我的初始调度器使用了一个轮询算法,它维护了一个工作节点列表,并识别哪个工作节点获得了最近的任务。然后,当下一个任务到来时,调度器只需选择列表中的下一个工作节点。
虽然轮询调度器在这种特定情况下工作良好,但显然它有缺陷。如果下一个被分配任务的工作节点没有可用资源怎么办?也许当前的任务正在使用所有的内存和磁盘。此外,我可能希望在任务分配给工作节点时有更多的灵活性。也许我希望调度器将任务填满一个工作节点,而不是将任务分散到多个工作节点上,每个工作节点可能只运行一个任务。相反,我可能希望将任务分散到资源池中,以最小化资源匮乏的可能性。
因此,我们将使用接口来指定一个类型必须实现的方法,以被视为调度器。正如你在下一个列表中看到的,这些方法是SelectCandidateNodes、Score和Pick。每个方法都很好地映射到我们对调度器的需求。
列表2-10 调度器组件的骨架 2.5 其他骨架 到目前为止,我们已经为我们思维模型中的四个主要对象创建了骨架:Task、Worker、Manager和Scheduler。然而,在这个模型中还有另一个对象被暗示,那就是Node。
到目前为止,我们已经讨论了Worker。Worker是处理我们逻辑工作负载(即任务)的组件。然而,Worker有一个物理方面,它本身运行在一台物理机器上,并且它还会在物理机器上运行任务。此外,它需要了解底层机器,以便收集管理器将用于调度决策的机器统计信息。我们将Worker的这个物理方面称为Node。
在Cube的上下文中,节点是表示我们集群中任何机器的对象。例如,管理器是Cube中的一种节点。Worker(可以有多个)是另一种节点。管理器将广泛使用节点对象来表示工作节点。
目前,我们只定义Node结构体的字段,如列表2.11所示。首先,节点将有一个Name,例如,简单的“node-1”。接下来,节点将有一个Ip地址,管理器需要知道它以便发送任务。物理机器还具有一定数量的Memory和Disk空间供任务使用。这些属性表示最大值。在任何时候,机器上的任务将使用一定量的内存和磁盘,我们可以称之为MemoryAllocated和DiskAllocated。最后,节点将有零个或多个任务,我们可以使用TaskCount字段来跟踪。
列表2-11 表示物理机器的Node结构体 2.6 试运行我们的骨架 现在我们已经创建了这些骨架,让我们看看是否可以在一个简单的测试程序中使用它们。我们希望确保我们刚刚编写的代码可以编译和运行。为此,我们将创建每个骨架的实例,打印骨架,并最终调用每个骨架的方法。
以下列表更详细地总结了我们的测试程序将做什么:
创建一个Task对象 创建一个TaskEvent对象 打印Task和TaskEvent对象 创建一个Worker对象 打印Worker对象 调用Worker的方法 创建一个Manager对象 调用Manager的方法 创建一个Node对象 打印Node对象 然而,在编写这个程序之前,让我们处理一个小的管理任务,这是使我们的代码能够编译所必需的。记住,我们说过要使用golang-collections包中的Queue实现,并且我们还使用了Google的UUID包。我们还使用了Docker的nat包。虽然我们已经将它们导入到代码中,但我们还没有在本地安装它们。所以让我们现在来做这件事。
列表2-12 使用 go get 命令安装第三方包 现在我们准备测试我们的骨架了,这将在接下来的两个列表中进行。
列表2-13 使用最小化程序测试骨架:第1部分 列表2-14 使用最小化程序测试骨架:第2部分 现在是见证真相的时刻!使用 go run main.go 命令编译并运行我们的程序,你应该会看到如下所示的输出。
列表2-15 通过运行最小化程序测试骨架 恭喜!你刚刚编写了一个可以编译和运行的编排系统骨架。花点时间庆祝一下。在接下来的章节中,我们将以这些骨架为起点,更详细地讨论每个组件,然后深入到技术实现中。
总结 Cube编排器的代码被组织到我们项目中的单独子目录中:Manager、Node、Scheduler、Task和Worker。 编写骨架有助于将思维模型从抽象概念转化为可运行的代码。因此,我们为编排系统的Task、Worker、Manager和Scheduler组件创建了骨架。这一步还帮助我们识别了最初没有想到的附加概念。TaskEvent和Node组件在我们的模型中没有表示,但在本书后面将会有用。 我们通过编写一个主程序来测试我们的骨架。虽然这个程序没有执行任何操作,但它确实向终端打印了消息,让我们大致了解了事情的工作方式。 任务可以处于五种状态之一:Pending(待处理)、Scheduled(已调度)、Running(运行中)、Completed(已完成)或Failed(失败)。Worker和Manager将使用这些状态对任务执行操作,例如停止和启动它们。 Go通过使用接口实现多态。接口是一种类型,指定了一组行为,任何实现这些行为的其他类型都将被视为与接口类型相同。使用接口将允许我们实现多个调度器,每个调度器具有略微不同的行为。