本书的第二部分聚焦于 Cube 工作节点组件。顾名思义,工作节点负责在编排系统中执行工作。工作的对象就是任务。如果你使用过 Docker,那你应该熟悉通过 Docker 命令行界面来启动容器。在这种模式下,你就相当于工作节点。在 Cube 中,我们用一个程序来替代你,从概念上来说,这个程序执行的操作类似于你启动和停止 Docker 容器时所做的操作。
在第 4 章中,我们将详细阐述工作节点(Worker)对象的实现。实现的重点在于启动和停止任务。
在第 5 章中,我们将为工作节点构建一个 API。管理节点(Manager)对象将是第三部分的主题,它会成为这个 API 的使用者。
在第 6 章中,我们将创建一个框架,使工作节点能够在其 API 上公开指标。在后续章节中,管理节点和调度器组件将使用这些指标。
立方体的工人们,团结起来!
本章涵盖以下内容:
-
回顾编排系统中工作节点组件的作用
-
回顾任务(Task)和 Docker 结构体
-
定义并实现一种处理传入任务的算法
-
构建一个简单的状态机,以便在不同状态之间转换任务
-
实现工作节点用于启动和停止任务的方法
设想运行一个提供静态页面服务的 Web 服务器。在很多情况下,在单个物理机或虚拟机上运行一个 Web 服务器实例就足够了。然而,随着网站越来越受欢迎,这种设置会带来几个问题:
-
资源可用性 —— 考虑到机器上运行的其他进程,是否有足够的内存、中央处理器(CPU)资源和磁盘空间来满足我们 Web 服务器的需求?
-
弹性 —— 如果运行 Web 服务器的机器出现故障,我们的网站也会随之无法访问。
运行多个 Web 服务器实例有助于我们解决这些问题。在本章中,我们将专注于完善第 2 章中勾勒出的工作节点框架。它将使用我们在第 3 章中介绍的任务实现方式。在本章结尾,我们将使用所实现的工作节点来运行多个简单 Web 服务器实例,就如同我们前面描述的场景那样。
4.1 Cube 工作节点
借助编排系统,工作节点组件使我们能够轻松地对应用程序进行扩展,比如扩展前面场景中提到的 Web 服务器。图 4.1 展示了我们如何运行网站的三个实例,分别由方框 W1、W2 和 W3 表示,每个实例都在一个独立的工作节点上运行。在这张图中,需要明白 “工作节点(Worker)” 一词具有双重含义:它既代表一台物理机或虚拟机,也代表在该机器上运行的编排系统的工作节点组件。
图 4.1 在这张图中,工作节点方框具有双重意义。它们代表着运行编排系统工作节点组件的物理机或虚拟机。
现在,我们不太可能遇到资源可用性方面的问题了。由于我们在三个不同的工作节点上运行网站的三个实例,用户请求可以分散到这三个实例上,而不是都发往运行在单个机器上的单个实例。
同样,我们的网站现在对故障的抵御能力更强了。例如,如果图 4.2 中的工作节点 1(Worker1)崩溃了,那么我们网站的 W3 实例也会随之不可用。虽然这可能会让我们感到沮丧,并且需要我们做些工作让工作节点 1 重新上线,但我们网站的用户应该不会察觉到有什么不同。他们将能够继续向我们的网站发送请求,并获取预期的静态内容。
图 4.2 在某个工作节点出现故障的场景中,运行在其他节点上的 Web 服务器仍然能够响应请求。
工作节点由多个执行特定功能的子组件构成。如图 4.3 所示,这些子组件包括一个 API、一个运行时环境、一个任务队列、一个任务数据库(DB)以及指标模块。在本章中,我们将仅聚焦于其中三个组件:运行时环境、任务队列和任务数据库。在后续章节中,我们再处理另外两个组件。
图 4.3 我们的 Worker 将由这五个组件组成,但本章将只关注运行时、任务队列和任务 DB。
4.2 Tasks and Docker
这一定义,在清单 4.1 中我们可以再次看到这个结构体。这个结构体是工作节点的主要处理对象。工作节点从管理节点接收任务,然后运行它。在本章中我们会一直使用这个结构体。
作为最小的工作单元,任务通过作为 Docker 容器来运行以完成其工作。所以任务和容器之间存在一一对应的关系。工作节点使用这个结构体来启动和停止任务。
code 4.1 Task struct defined in chapter2
type Task struct {
ID uuid.UUID
ContainerID string
Name string
State State
Image string
Memory int64
Disk int64
ExposedPorts nat.PortSet
PortBinding map[string]string
RestsrtPolicy string
StartTime time.Time
FinishTime time.Time
}
在第 3 章中,我们还定义了如下所示的 Docker 结构体。工作节点将使用这个结构体,把任务作为 Docker 容器来启动和停止。
code 4.2 Docker struct defined in chapter 3
type Docker struct {
Cient *client.Client
Config Config
}
这两个对象将成为流程的核心,使我们的 Worker 能够启动和停止任务。
4.3 The role of the queue
让我们看一下清单 4.3,回顾一下 Worker 结构体的样子。这个结构体还是我们在第 2 章结束时的状态。
工作节点会把 Worker 结构体中的 Queue 字段作为一个临时存储区域,用于存放那些需要处理的传入任务。当管理节点向工作节点发送一个任务时,该任务会进入这个队列,工作节点将按照先进先出(FIFO)的原则处理队列中的任务。
code 4.3 Worker skeleton from cahpter 2
package worker
type Worker struct {
Name string
Queue queue.Queue
Db map[uuid.UUID]*task.Task
TaskCount int
}
需要重点注意的是,Queue 字段本身就是一个结构体,它定义了几个方法,我们可以使用这些方法将元素入队(Enqueue)、出队(Dequeue)以及获取队列的长度(Len)。Queue 字段是 Go 语言中组合(composition)的一个示例。因此,我们可以利用其他结构体来组合成新的、更高级别的对象。
另外还要注意,Queue 是从 github.com/golangcollections/collections/queue 包中导入的。所以我们是在复用别人为我们编写好的队列实现。如果你还没有这么做的话,就需要将这个包指定为一个依赖项(具体可参考附录内容)。
4.4 The role of the DB
工作节点将使用 Db 字段来存储有关其任务的状态信息。这个字段是一个映射(map),其中键的类型是来自 github.com/google/uuid 包的 uuid.UUID,值的类型是我们自定义的 task 包中的 task 类型。
对于将映射用作 Db 字段这一点,有一件事需要注意。我们从使用映射开始是为了方便起见。这能让我们快速编写出可运行的代码。但这也存在一个权衡:每当我们重启工作节点时,都会丢失数据。对于项目起步阶段来说,这种权衡是可以接受的,但在以后,我们会用一个持久化数据存储来替代这个映射,这样当我们重启工作节点时,就不会丢失数据了。
4.5 统计任务数量
最后,TaskCount 字段简单记录了分配给工作节点的任务数量。直到下一章我们才会用到这个字段。
4.6 实现工作节点的方法
既然我们已经回顾了 Worker 结构体中的各个字段,接下来就让我们继续探讨在第 2 章中预留(未完全实现)的那些方法。在下一个清单中看到的 RunTask、StartTask 和 StopTask 方法,目前除了打印一条语句之外并没有做太多实际的事情,但到本章结束时,我们将完整地实现这些方法中的每一个。
4.4 code The stubbed-out versions of RunTask, StartTask, and StopTask
func (w *Worker) RunTask() {
fmt.Println("I will start or stop a task")
}
func (w *Worker) StartTask() {
fmt.Println("I will start a task")
}
func (w *Worker) StopTask() {
fmt.Println("I will stop a task")
}
我们将按照与清单 4.4 中顺序相反的方式来实现这些方法。之所以按此顺序实现,是因为 RunTask 方法将使用另外两个方法来启动和停止任务。
4.6.1 实现 StopTask 方法
StopTask 方法并不复杂。它只有一个目的:停止正在运行的任务,要记住一个任务对应一个正在运行的容器。清单 4.5 中展示的实现步骤可以总结如下:
- 创建一个
Docker结构体的实例,借助 Docker SDK 与 Docker 守护进程进行通信。 - 调用
Docker结构体的Stop()方法。 - 检查停止任务的过程中是否出现了任何错误。
- 更新任务
t的FinishTime字段。 - 将更新后的任务
t保存到工作节点的Db字段中。 - 打印一条信息性的消息,并返回操作结果。
code 4.5 Our implementation of the StopTask method
func (w *Worker) StopTask(t task.Task) task.DockerResult {
config := task.NewConfig(&t)
d := task.NewDocker(config)
result := d.Stop(t.ContainerID)
if result.Error != nil {
log.Printf("Error stopping container %v: %v\n", t.ContainerID, result.Error)
}
t.FinishTime = time.Now().UTC()
t.State = task.Completed
w.Db[t.ID] = &t
log.Printf("Stopped and removed container %v for task %v\n", t.ContainerID, t.ID)
return result
}
请注意,StopTask 方法返回的是 task.DockerResult 类型。该类型的定义可以在清单 4.6 中看到。如果你还记得的话,Go 语言支持多种返回类型。我们本可以将 DockerResult 结构体中的每个字段都列举为 StopTask 方法的返回类型。虽然从技术角度来看,那种方法并没有什么错误,但使用 DockerResult 这种方式可以让我们将与操作结果相关的所有信息都封装到一个结构体中。当我们想了解有关操作结果的任何信息时,只需查看 DockerResult 结构体即可。
code 4.6 A reminder of that the DockerResult type loooks like
type DockerResult struct {
Error error
Action string
ContainerId string
Result string
}
4.6.2 实现 StartTask 方法
接下来,我们来实现 StartTask 方法。和 StopTask 方法类似,StartTask 方法也相当简单,但启动一个任务的过程要多几个步骤。具体步骤如下:
-
更新任务
t的StartTime字段。 -
创建一个
Docker结构体的实例,以便与 Docker 守护进程进行通信。 -
调用
Docker结构体的Run()方法。 -
检查启动任务的过程中是否出现了任何错误。
-
将正在运行的容器的 ID 添加到任务
t的Runtime.ContainerId字段中。 -
将更新后的任务
t保存到工作节点的Db字段中。 -
返回操作结果。
这些步骤的具体实现可以在下面的清单中看到。
code 4.7 Our implementtation of the StartTask method
func (w *Worker) StartTask(t task.Task) task.DockerResult {
t.StartTime = time.Now().UTC()
config := task.NewConfig(&t)
d := task.NewDocker(config)
result := d.Run()
if result.Error != nil {
log.Printf("Err running task %v: %v\n", t.ID, result.Error)
t.State = task.Failed
w.Db[t.ID] = &t
return result
}
t.ContainerID = result.ContainerId
t.State = task.Running
w.Db[t.ID] = &t
return result
}
通过在 StartTask 方法中记录开始时间(StartTime),并结合在 StopTask 方法中记录结束时间(FinishTime),我们后续就能在其他输出中使用这些时间戳。例如,在本书后面的内容里,我们会编写一个命令行界面,让我们可以与编排器进行交互,而开始时间和结束时间的值就可以作为任务状态的一部分输出。
在结束对这两个方法的介绍之前,我想指出的是,这两个方法都没有直接与 Docker SDK 进行交互。相反,它们只是调用了我们创建的 Docker 对象的 Run 和 Stop 方法。正是这个 Docker 对象负责与 Docker 客户端进行直接交互。通过将与 Docker 的交互封装在 Docker 对象中,我们的工作节点无需了解底层的实现细节。
StartTask 和 StopTask 方法是我们工作节点的基础。但回顾我们在第 2 章创建的框架,会发现还缺少另一个基础方法。我们该如何向工作节点添加任务呢?还记得吗,我们说过工作节点会用其 Queue 字段作为传入任务的临时存储区,等准备好后,会从队列中取出一个任务并执行必要的操作。让我们通过添加下面清单中展示的 AddTask 方法来解决这个问题。这个方法只完成一项任务:将任务 t 添加到队列中。
code 4.8 The worker's AddTask method
func (w *Worker) AddTask(t task.Task) {
w.Queue.Enqueue(t)
}
4.6.3 关于任务状态的插曲
现在剩下要做的就是实现 RunTask 方法了。不过,在动手之前,我们先暂停一下,回顾一下 RunTask 方法的用途。在第 2 章中,我们提到 RunTask 方法将负责识别任务的当前状态,然后根据该状态启动或停止任务。但我们为什么需要 RunTask 方法呢?
处理任务有两种可能的场景:
-
任务是首次提交,因此工作节点对其并不了解。
-
任务是第 n 次提交,此次提交的任务代表当前任务应转换到的期望状态。
在处理从管理节点接收到的任务时,工作节点需要判断自己面对的是哪种场景。我们将采用一种简单的启发式方法来帮助工作节点解决这个问题。还记得我们的工作节点有 Queue 和 Db 字段吧。在这个简单的实现中,工作节点将使用 Queue 字段来表示任务的期望状态。当工作节点从队列中取出一个任务时,它会将其解读为 “将任务 t 设置为状态 s”。工作节点会把 Db 字段中已有的任务视为已存在的任务,也就是至少已经处理过一次的任务。如果一个任务在 Queue 中但不在 Db 中,那么这是工作节点首次处理该任务,默认会启动它。
除了判断是哪种场景之外,工作节点还需要验证从当前状态到期望状态的转换是否有效。
让我们回顾一下在第 2 章中定义的状态。接下来的清单显示,我们定义了 “待处理(Pending)”、“已调度(Scheduled)”、“运行中(Running)”、“已完成(Completed)” 和 “失败(Failed)” 这些状态。
code 4.9 The State type, which defines the valid states for a task
const (
Pending State = iota
Sceduled
Running
Completed
Failed
)
我们已经明确了这些状态与任务的关联以及各自的含义,但还没有定义任务如何从一个状态过渡到下一个状态,也没有讨论哪些过渡是有效的。例如,如果一个工作节点已经在运行某个任务,即该任务处于 “运行中(Running)” 状态,它能否过渡到 “已调度(Scheduled)” 状态呢?如果一个任务失败了,它是否可以从 “失败(Failed)” 状态过渡到 “已调度(Scheduled)” 状态呢?
所以,在继续实现 RunTask 方法之前,看来我们得先解决如何处理状态过渡的问题。为此,我们可以借助表 4.1 中的状态表来对状态和过渡进行建模。
这张表有三列,分别代表任务的 “当前状态(CurrentState)”、触发状态过渡的 “事件(Event)”,以及任务应过渡到的 “下一状态(NextState)”。表中的每一行都代表一种特定的有效过渡。注意,这里不存在从 “运行中” 到 “已调度”,或者从 “失败” 到 “已调度” 的过渡。
表 4.1 状态过渡表,展示了从一个状态到另一个状态的有效过渡情况
既然我们对任务状态以及它们之间的转换有了更清晰的理解,就可以将这些理解转化为代码。像 Borg、Kubernetes 和 Nomad 这类编排器会使用状态机来处理状态转换问题。不过,为了尽量减少我们需要处理的概念和技术数量,我们会把状态转换逻辑硬编码到清单 4.10 里的 stateTransitionMap 类型中。这个映射(map)对我们在表 4.1 中确定的转换规则进行了编码。
stateTransitionMap 构建了一个从 State 到状态切片 []State 的映射。也就是说,这个映射的键是当前状态,值是有效的转换目标状态。例如,“待处理(Pending)” 状态只能转换到 “已调度(Scheduled)” 状态。而 “已调度” 状态则可以转换到 “运行中(Running)”、“已完成(Completed)” 或者 “失败(Failed)” 状态。
code 4.10 The stateTransitionMap map
var stateTransitionMap = map[State][]State{
Pending: []State{Scheduled},
Scheduled: []State{Scheduled, Running, Failed},
Running: []State{Running, Completed, Failed},
Completed: []State{},
Failed: []State{},
}
code 4.11 Helper methods
func Contains(states []State, state State) bool {
for _, s := range states {
if s == state {
return true
}
}
return false
}
func ValidStateTransition(src State, dst State) bool {
return Contains(stateTransitionMap[src], dst)
}
4.6.4 实现 RunTask 方法
现在我们终于可以更具体地讨论 RunTask 方法了。我们花了一些时间才到这一步,但在讨论这个方法之前,我们需要先把其他细节处理好。由于我们已经做了那些前期工作,实现 RunTask 方法会更顺利一些。
正如我们在本章前面所说,RunTask 方法会识别任务的当前状态,然后根据该状态启动或停止任务。我们可以使用一个相当简单的算法来确定工作节点是应该启动还是停止任务。具体步骤如下:
-
从队列中取出一个任务。
-
将其从接口类型转换为
task.Task类型。 -
从工作节点的
Db中获取该任务。 -
检查状态转换是否有效。
-
如果队列中的任务处于 “已调度(Scheduled)” 状态,调用
StartTask方法。 -
如果队列中的任务处于 “已完成(Completed)” 状态,调用
StopTask方法。 -
否则,说明是无效的状态转换,返回一个错误。
现在剩下要做的就是在代码中实现这些步骤,具体代码可在下面的清单中看到。
code 4.12 Our implementation of the RunTask method
func (w *Worker) RunTask() task.DockerResult {
t := w.Queue.Dequeue()
if t == nil {
log.Println("No tasks in the queue")
return task.DockerResult{Error: nil}
}
// 任务队列中的最新任务对象
taskQueued := t.(task.Task)
// 持久化的任务对象
taskPersisted := w.Db[taskQueued.ID]
// 如果该任务对象从来没有持久化存储过,那么说明是首次处理,将其持久化存储
if taskPersisted == nil {
taskPersisted = &taskQueued
w.Db[taskQueued.ID] = &taskQueued
}
var result task.DockerResult
// 对于首次处理的任务对象,taskPersisted == taskQueued(Scheduled), ValidStateTransition 为 true,命中 switch case,调用 w.StartTask 启动该任务
// 对于非首次处理的任务对象,taskPersisted = Scheduled
// - taskQueued = Running,调用 StartTask 启动任务
// - taskQueued = Failed,StartTask 失败,返回
// 对于非首次处理的任务对象,taskPersisted = Running
// - taskQueued = Completed,调用 StopTask 停止任务(清理)
// - taskQueued = Failed,Running 过程中失败,返回
// 因此可以理解 taskPersisted.State 为当前状态,taskQueued.State 为 Expect 状态
if task.ValidStateTransition(taskPersisted.State, taskQueued.State) {
switch taskQueued.State {
case task.Scheduled:
result = w.StartTask(taskQueued)
case task.Completed:
result = w.StopTask(taskQueued)
default:
result.Error = errors.New("we should not get here")
}
} else {
err := fmt.Erorrf("Invalid transition from %v to %v", taskPersisted.State, taskQueued.State)
result.Error = err
}
return result
}
我们首先调用 Dequeue() 方法从工作节点的队列中取出一个任务。注意,我们会检查是否从队列中获取到了任务。如果没有获取到,这意味着队列是空的,那么我们会记录一条日志信息,并返回一个 Error 字段为 nil 的结果。
接下来,我们必须将从队列中取出的任务转换为合适的类型,即 task.Task 类型。这一步是必要的,因为队列的 Dequeue 方法返回的是一个接口类型。
现在我们从队列中拿到了一个任务,所以需要尝试从 Db 中获取同一个任务。如果在 Db 中没有找到该任务,这意味着这是我们首次处理这个任务,我们会将其添加进去。
然后我们就可以使用本章前面创建的 ValidStateTransition 函数了。注意,我们传入的是 Db 中任务的状态 taskPersisted.State 以及队列中任务的状态 taskQueued.State。
如果存在有效的状态转换,并且队列中的任务状态为 “已调度(Scheduled)”,那么我们调用 StartTask 方法。或者,如果存在有效的状态转换,但队列中的任务状态为 “已完成(Completed)”,我们调用 StopTask 方法。
如果不存在有效的转换,换句话说,从 taskPersisted.State 到 taskQueued.State 的转换是无效的,那么我们会设置结果变量的 Error 字段。
4.7 整合所有功能
呼,我们终于完成了。在为工作节点实现这些方法的过程中,我们涉及了大量内容。如果你还记得第 3 章,我们最后编写了一个程序,利用了该章前面所做的工作。在本章,我们将延续这一做法。
不过,在开始之前,请回想一下,在第 3 章我们构建了 Task 和 Docker 结构体,借助这些工作我们能够启动和停止容器。本章所做的工作是建立在上一章的基础之上的。所以,我们将再次编写一个程序,用于启动和停止任务。工作节点是在 Task 层面进行操作,而 Docker 结构体则在更低的容器层面进行操作。现在,让我们编写一个程序,将所有内容整合起来,打造一个能正常工作的工作节点。你可以注释掉上一章使用的 main.go 文件中的代码,也可以创建一个新的 main.go 文件供本章使用。
这个程序很简单。我们创建一个工作节点 w,它包含本章开头提到的 Queue 和 Db 字段。接着,我们创建一个任务 t。这个任务初始状态为 “已调度(Scheduled)”,并且使用名为 strm/helloworld-http 的 Docker 镜像。稍后会详细介绍这个镜像。创建好工作节点和任务后,我们调用工作节点的 AddTask 方法,并将任务 t 作为参数传入。然后调用工作节点的 RunTask 方法。该方法会从队列中取出任务 t 并执行相应操作。它会捕获 RunTask 方法的返回值,并将其存储在变量 result 中。(如果你还记得 RunTask 方法返回的是什么类型,那就太棒了。)
此时,我们有一个正在运行的容器。在休眠 30 秒后(你可以随意更改休眠时间),我们开始停止任务的流程。我们将任务的状态更改为 “已完成(Completed)”,再次调用 AddTask 方法并传入相同的任务,最后再次调用 RunTask 方法。这一次,当 RunTask 从队列中取出任务时,任务会有一个容器 ID 并且状态不同。结果就是,任务会被停止。下面的清单展示了我们创建工作节点、添加任务、启动任务,最后停止任务的程序。
code 4.13 Pulling everything together into a functioning worker
func main() {
db := make(map[uuid.UUID]*task.Task)
w := worker.Worker{
Queue: *queue.New(),
Db: db,
}
t := task.Task{
ID: uuid.New(),
Name: "test-container-1",
State: task.Scheduled,
Image: "strm/helloworld-http",
}
// first time the worker will see the task
fmt.Println("starting task")
w.AddTask(t)
result := w.RunTask()
if result.Error != nil {
panic(result.Error)
}
t.ContainerID = result.ContainerId
fmt.Printf("task %s is running in container %s\n", t.ID, t.ContainerID)
fmt.Println("Sleepy time")
time.Sleep(time.Second * 30)
fmt.Printf("stopping task %s\n", t.ID)
t.State = task.Completed
w.AddTask(t)
result = w.RunTask()
if result.Error != nil {
panic(result.Erorr)
}
}
让我们稍作停顿,来谈谈前面代码清单中所使用的镜像。在本章开头,我们讨论了使用编排器(特别是工作节点组件)来扩展静态网站的场景。这个名为 strm/helloworld-http 的镜像提供了一个静态网站的具体示例:它运行一个 Web 服务器,该服务器提供单个文件的服务。
为了验证这一行为,当你运行该程序时,在另一个终端中输入 docker ps 命令。你应该会看到类似于清单 4.14 的输出。在该输出中,你可以通过查看 PORTS 列来找到 Web 服务器正在运行的端口。然后打开你的浏览器,并输入 localhost:<port>。就以下面清单中的输出为例,我将在浏览器中输入 localhost:55001 。为了使其更易于阅读,输出内容已被截断。
code 4.14 Truncated output from the docker ps command
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
49d6aea04599 strm/helloworld-http "/main.sh" 2 seconds ago Up 1 second 0.0.0.0:55001->80/tcp test-container-1
在我的机器上浏览服务器时,我看到 "# Hello from 49d6aea04599"。继续运行程序。你应该会看到与下面列表类似的输出。
code 4.15
$ go run main.go
starting task
{"status":"Pulling from strm/helloworld-http","id":"latest"}
{"status":"Digest: sha256:bd44b0ca80c26b5eba984bf498a9c3bab0eb1c59d30d8df3cb2c073937ee4e45"}
{"status":"Status: Image is up to date for strm/helloworld-http:latest"}
task 482047d2-6fff-41b6-bf0e-879c651f7daa is running in container 49d6aea04599a9bb9ab9837fdce9b62497afeb9500abe96e058c10b31e03aa03
Sleepy time
stopping task 482047d2-6fff-41b6-bf0e-879c651f7daa
2025/03/11 17:39:28 Attempting to stop container 49d6aea04599a9bb9ab9837fdce9b62497afeb9500abe96e058c10b31e03aa03
2025/03/11 17:39:33 Stopped and removed container 49d6aea04599a9bb9ab9837fdce9b62497afeb9500abe96e058c10b31e03aa03 for task 482047d2-6fff-41b6-bf0e-879c651f7daa
task 482047d2-6fff-41b6-bf0e-879c651f7daa has been stopped
恭喜你!现在你已经拥有了一个能正常工作的工作节点。在进入下一章之前,好好研究一下你所构建的内容。特别是,修改清单 4.13 中的 main 函数,创建多个工作节点,然后向每个工作节点添加任务。
总结
- 任务是作为容器来执行的,这意味着任务和容器之间存在一一对应的关系。
- 工作节点对任务执行两项基本操作,即启动或停止任务。这些操作会导致任务从一种状态转换到下一个有效的状态。
- 工作节点展示了 Go 语言是如何支持对象组合的。工作节点本身就是其他对象的组合;具体来说,工作节点的
Queue字段是一个结构体,它是在github.com/golangcollections/collections/queue包中定义的。 - 就如我们所设计和实现的那样,工作节点的逻辑很简单。我们采用了清晰简洁的流程,这些流程在代码中很容易实现。
- 工作节点并不直接与 Docker SDK 进行交互。相反,它使用我们的
Docker结构体,该结构体是对 SDK 的封装。通过将与 SDK 的交互封装在Docker结构体中,我们可以使StartTask和StopTask方法的代码量保持较少,并且易于阅读。