11 为任务实现持久化存储
本章涵盖内容:
-
阐述编排系统中数据存储的目的
-
明确我们持久化数据存储的需求
-
定义存储接口
-
介绍 BoltDB
-
使用存储接口实现持久化数据存储
-
探讨管理器数据存储存在的特殊问题
我们编排系统的基本单元是任务。到目前为止,我们一直通过将任务存储在 Go 语言的内置映射类型中来跟踪这个基本单元。我们的工作节点和管理节点都将各自的任务存储在映射中。这种策略一直很有效,但你可能已经注意到了一个主要问题:每次重启工作节点或管理节点时,它们会丢失所有任务。丢失任务的原因是 Go 的内置映射是一种内存中的数据结构,不会持久化到磁盘。
就像我们重新审视之前关于任务调度的决策一样,现在我们要重新考虑如何存储任务。我们将简要讨论编排系统中数据存储的目的,然后开始用持久化存储替换之前的内存映射数据存储。
11.1 存储问题
为什么我们需要在编排系统中存储任务呢?虽然我们之前没怎么讨论这个问题,但任务的存储对于一个正常运行的编排系统至关重要。将任务存储在某种数据存储中能实现系统的高级功能:
-
它能让系统跟踪每个任务的当前状态。
-
它能让系统在调度方面做出明智的决策。
-
它能帮助任务从故障中恢复。
正如我们之前提到的,我们目前的实现使用了 Go 的内置映射类型,这意味着我们将任务存储在内存中。如果我们停止管理节点,然后再启动它(比如因为我们修改了代码),管理节点会丢失所有任务的状态。这样我们就无法让整个系统恢复。如果我们以三个工作节点和一个管理节点启动系统,重启管理节点意味着我们无法通过调用管理节点的 API 来优雅地停止正在运行的任务。例如,我们无法执行 curl -X DELETE http:localhost:5555/tasks/1234567890
。管理节点不再知晓该任务。从根本上说,解决这个问题的办法是用持久化数据存储替换内存映射。这样的解决方案会将任务状态写入磁盘,从而使管理节点和工作节点重启时不会丢失状态。
11.2 存储接口
在直接采用持久化存储解决方案之前,让我们遵循上一章的做法。还记得吗,我们没有直接采用 E - PVM 调度器。相反,我们先创建了调度器接口,然后将现有的轮询调度器适配到该接口。
我们的存储接口的概念模型如图 11.1 所示。在模型的顶部,我们有管理节点和工作节点,它们都使用相同的存储接口。该接口是抽象的,但正如我们所见,它基于两种具体实现:内存存储和持久化存储。
图 11.1 我们商店界面的心智模型
如果我们考虑一下一直以来用于存储和检索任务及任务事件的操作,就可以确定创建一个接口所需的四个方法。这四个方法分别是:
-
Put(key string, value interface{})
-
Get(key)
-
List()
-
Count()
注意:你可能会疑惑为什么这个列表中没有包含 Remove
或 Delete
方法。从理论上讲,数据存储在编排系统中充当任务的历史记录,因此提供一个删除历史记录的方法是没有意义的。然而在实践中,提供这样的方法可能会很有用。例如,随着时间的推移,一个编排系统的数据存储中可能会积累数万个(甚至数十万个或更多)任务。如果数据存储支持 Remove
操作,就可以用它来对数据存储本身进行维护。
Put
方法,顾名思义,是将一个由键标识的条目放入存储中。到目前为止,我们一直是通过直接将任务和任务事件保存到一个映射中来与存储进行交互。例如,在管理器的 SendWork
方法中,我们可以看到几个直接与 TaskDb
和 EventDb
存储进行交互的例子。在第一个例子中,我们从管理器的待处理队列中取出一个任务事件,将其转换为 task.TaskEvent
类型,然后使用任务事件的 ID 作为键,将指向 task.TaskEvent
的指针存储在 EventDb
中。在第二个例子中,我们从任务事件中提取出任务,然后使用任务的 ID 作为键,将指向该任务的指针存储在 TaskDb
中。
code 11.1 Examples of using Go’s built-in map to store tasks and events
e := mPending.Dequeue()
te := e.(task.TaskEvent)
m.EventDb[te.ID] = &te
t := te.Task
// ...
t.State = task.Scheduled
m.TaskDb[t.ID] = &t
到目前为止,我们对任务和任务事件存储的实现方式在技术上并无问题。这种方式快速又简单,更重要的是,它确实能正常工作。然而,这种实现方式有一个缺点,即它依赖于存储所基于的底层数据结构。在这种情况下,管理器必须知道如何将元素存入 Go 语言的内置映射以及如何从中取出元素。换句话说,我们将管理器与内置映射类型紧密耦合在一起了。我们无法轻松地将这种存储实现替换为其他实现。例如,如果我们想使用 SQLite(一种流行的基于 SQL 的嵌入式数据存储),该怎么办呢?
为了让我们更方便地使用不同的数据存储实现,让我们创建下面代码清单中所示的 Store
接口。该接口包含我们之前列出的四个方法:Put
、Get
、List
和 Count
。
code 11.2 The Store interface
type Store interface {
Put(key string, value interface{}) error
Get(key string) (interface{}, error)
List() (interface{}, error)
Count() (int, error)
}
在我们的 Store
接口中需要注意的一点是,我们在方法签名里把几个值声明为 interface{}
类型。这被称作空接口,意味着该值可以是任意类型。例如,Put
方法接收一个字符串类型的键和一个空接口类型(即任意类型)的值。这表明 Put
方法可以接受 task.Task
、task.TaskEvent
或者其他类型的值。
定义好 Store
接口后,接下来我们要实现一个内存存储,用它来替换现有的存储方式。
11.3 为任务实现内存存储
我们将先实现任务存储,然后再实现任务事件存储。管理器和工作节点都会使用任务存储,但只有管理器会使用事件存储。任务存储和事件存储的新实现都将封装 Go 的内置映射类型。通过封装内置映射类型,我们可以消除管理器与底层数据结构的耦合。管理器无需了解映射的工作机制,只需调用 Store
接口的方法,接口的实现会处理与底层数据结构交互的所有底层细节。
出于我们的目的,我们将为任务存储和事件存储分别实现不同的类型。我们本可以创建一个通用存储,使其能够同时处理任务和事件,但这会更复杂,并且会涉及一些超出本书范围的额外概念。
第一个实现是 InMemoryTaskStore
,它为存储任务的目的对 Go 的内置映射类型进行了封装。我们首先定义一个结构体,并为其设置一个名为 Db
的字段。不出所料,这个字段的类型是 map[string]*task.Task
,与当前的实现相同。接下来,我们定义一个辅助函数,它将返回一个 InMemoryTaskStore
的实例。我们将这个辅助函数命名为 NewInMemoryTaskStore
,它不接受任何参数,并返回一个指向 InMemoryTaskStore
的指针,该指针的 Db
字段被初始化为一个空的 map[string]*task.Task
类型的映射。
code 11.3 The InMemoryTaskStore struct
type InMemoryTaskStore struct {
Db map[string]*task.Task
}
func NewInMemoryTaskStore() *InMemoryTaskStore {
return &InMemoryTaskStore{
Db: make(map[string]*task.Task),
}
}
接下来我们继续实现 Put 方法。图 11.2 中的序列图展示了 Put 方法的使用方式。当用户调用管理器的 API 来启动一个任务(POST /tasks)时,管理器会调用 Put 方法将该任务存储在其自身的数据存储中。然后,管理器通过调用工作节点的 API 将任务发送给工作节点。而工作节点则会调用 Put 方法,将该任务存储在自己的数据存储里。
图 11.2 序列图说明管理员和工人如何使用 Put 方法将任务保存到各自的数据存储库中
如代码清单 11.4 所示,Put
方法的实现相当简单直接。该方法接受两个参数:一个 string
类型的键和一个空接口类型的值。在方法体中,我们首先尝试使用类型断言将该值转换为具体类型。我们所做的是断言 value
不为 nil
,并且 value
中的值是一个指向 task.Task
的指针。我们还获取一个名为 ok
的布尔值,它会告诉我们断言是否成功。如果断言失败,我们就返回一个错误;否则,我们将任务 t
存储到映射中。
code 11.4 The Put method
func (i *InMemeoryTaskStore) Put(key string, value interface{}) error {
t, ok := value.(*task.Task)
if !ok {
return fmt.Errorf("value %v is not a task.Task type", value)
}
i.Db[key] = t
return nil
}
接下来,我们将实现 Get
方法。图 11.3 中的序列图展示了 Get
方法的使用方式。当用户调用管理器的 API 来获取一个任务(Get /tasks/{taskID}
)时,管理器会调用 Get
方法从其数据存储中检索该任务并返回。
图 11.3 序列图说明管理器如何使用获取方法从数据存储中检索任务
Get
方法的实现接受一个 string
类型的键,并返回一个空接口,同时可能会返回一个错误。我们首先在存储的 Db
中查找该键。注意,我们在这里也使用了 “逗号 ok” 惯用法。如果该键存在于 Db
中,t
将包含由该键标识的任务,然后我们将其返回。如果该键不存在,t
将为 nil
,ok
将为 false
,这时我们将返回一个错误。
code 11.5 The Get method
func (i *InMemoryTaskStore) Get(key string) (interface{}, error) {
t, ok := i.Db[key]
if !ok {
return nil, fmt.Errorf("task with key %s does not exist", key)
}
return t, nil
}
接下来我们要实现的方法是 List
方法。List
方法通过遍历映射来构建一个任务切片。为了符合 Store
接口所规定的契约,这个方法总是返回 nil
作为错误值。
与返回单个任务的 Get
方法不同,这个方法返回存储中的所有任务。正如你在代码清单 11.6 中看到的,我们首先创建一个名为 tasks
的变量,它是一个指向 task.Task
的指针切片。这个切片将保存存储中的所有任务。然后我们遍历 Db
字段中的映射,并将每个任务追加到 tasks
切片中。一旦我们遍历完所有任务并将它们追加到切片中,我们就返回该切片。
code 11.6 The List method
func (i *InMemoryTaskStore) List() (interface{}, error) {
var tasks []*task.Task
for _, t := range i.Db {
tasks = append(tasks, t)
}
return tasks, nil
}
我们任务存储中的最后一个方法是 Count
。顾名思义,这个方法返回存储的 Db
字段中包含的任务数量。由于 Db
是一个映射,我们可以使用内置的 len
函数来获取其中元素的数量。
code 11.7 The Count function
func (i *InMemoryTaskStore) Count() (int, error) {
return len(i.Db), nil
}
既然我们已经实现了任务存储的内存版本,那么接下来就对任务事件做同样的处理。
11.4 为任务事件实现内存存储
code 11.8 The InMemoryTaskEventStore
type InMemoryEventStore struct {
Db map[string]*task.TaskEvent
}
func NewInMemoryEventStore() *InMemoryEventStore {
return &InMemoryEventStore{
Db: make(map[string]*task.TaskEvent),
}
}
func (i *InMemoryEventStore) Put(key string, value interface{}) error {
e, ok := value.(*task.TaskEvent)
if !ok {
return fmt.Errorf("value %v is not a task.TaskEvent type", value)
}
i.Db[key] = e
return nil
}
func (i *InMemoryEventStore) Get(key string) (interface{}, error) {
e, ok := i.Db[key]
if !ok {
return nil, fmt.Errorf("task event with key %s doest not exist", key)
}
return e, nil
}
func (i *InMemoryEventStore) List() (interface{}, error) {
var events []*task.TaskEvent
for _, e := range i.Db {
events = append(events, e)
}
return events, nil
}
func (i *InMemoryEventStore) Count() (int, error) {
return len(i.Db), nil
}
11.5 重构管理器以使用新的内存存储
至此,我们已经定义了一个可用于存储任务和事件的接口。我们还实现了该存储接口的两种具体类型,它们都封装了 Go 的内置映射类型,从而使管理器和工作节点无需直接与其交互。让我们对管理器和工作节点进行一些更改,以便它们能够使用我们的新代码。从管理器开始,我们需要更新 Manager
结构体中的 TaskDb
和 EventDb
字段。我们不再将这些字段定义为 map[uuid.UUID]*task.Task
和 map[uuid.UUID]*task.TaskEvent
类型,而是将它们都更改为 store.Store
类型。通过这一更改,我们的管理器现在可以使用任何实现了 store.Store
接口的存储类型。
type Manager struct {
// ...
TaskDb store.Store
EventDb store.Store
// ...
}
将 TaskDb
和 EventDb
字段更改为接口类型,这对你来说应该并不陌生。如果你还记得,在第 10 章中,当我们引入 Scheduler
字段时,我们做了类似的事情,该字段的类型为 scheduler.Scheduler
,同样是一个接口。那次更改使我们能够配置管理器以使用不同类型的调度器,现在我们又配置它来使用不同类型的存储。
接下来,让我们修改 manager
包中的 New
函数。在上一章中,我们对其进行了更新,使其除了接受一个工作节点切片之外,还接受一个调度器类型。现在,让我们给 New
函数添加另一个参数,名为 dbType
。新的函数签名如下:
func New(workers []string, schedulerType string, dbType string) *Manager
接下来,我们需要对 New
函数的函数体进行几处修改。首先,要移除使用内置 make()
函数对 taskDb
和 eventDb
变量的初始化操作。目前先把那两行代码删掉。稍后我们会采用稍有不同的做法。
现在,我们要改变函数的返回方式。目前,我们是像下面这样返回一个 Manager
类型的指针:
return &Manager{ ... }
我们不再使用所谓的结构体字面量来直接返回一个指针,而是将其赋值给一个名为 m
的变量。操作完成后,代码会如下所示。
code 11.9 Assigning a struct literal to the m variable instead of returning it
m := Manager{
Pending: *queue.New(),
Workers: workers,
WorkerTaskMap: workerTaskMap,
TaskWorkerMap: taskWorkerMap,
WorkerNodes: nodes,
Scheduler: s,
}
此时,我们已经有了一个 Manager
类型的实例,但它还没有用于存储任务和事件的存储组件。我们将使用添加到 New
函数签名中的 dbType
变量,在 switch
语句里根据 dbType
的值来设置不同类型的数据存储。由于我们目前仅实现了内存存储,所以一开始仅支持 dbType
的值为 memory
的情况。在这种情况下,我们调用 NewInMemoryTaskStore
函数来创建一个内存任务存储的实例,调用 NewInMemoryTaskEventStore
函数来创建一个内存事件存储的实例。
现在剩下要做的就是把 ts
变量的值赋给管理器的 TaskDb
字段,把 es
的值赋给管理器的 EventDb
字段,然后返回一个指向该管理器的指针:
var ts store.Store
var es store.Store
switch dbType {
case "memory":
ts = store.NewInMemoryTaskStore()
es = store.NewInMemoryTaskEventStore()
}
m.TaskDb = ts
m.EventDb = es
return &m
现在,我们可以进行实质性的修改了!我们需要修改管理器的方法,让它通过新 Store
接口的方法与数据存储进行交互,而不是直接操作映射结构。我们要着手修改的第一个方法是 updateTasks
。
在 updateTasks
方法中,所有需要修改的地方都在遍历 task.Task
类型指针切片的 for
循环内部。首先要修改的是检查工作节点报告的单个任务是否存在于管理器任务存储中的代码块。当前代码使用 “逗号 ok” 惯用法进行此检查。我们将用调用存储接口的 Get
方法来替换这个代码块,并检查 err
值以判断该任务是否存在于管理器的存储中。
我们可以在代码清单 11.10 中看到这个修改。现有代码被注释掉了,随后紧跟替换代码。现在,我们更新后的代码调用管理器 TaskDb
存储的 Get
方法,并将任务的 ID 作为字符串传递给它。如果任务存在于管理器的存储中,它将被赋值给 result
变量;如果出现错误,错误将被赋值给 err
变量。接下来,我们进行常规的错误检查,如果有错误,我们记录错误信息并使用 continue
语句处理下一个任务。最后,我们使用类型断言将类型为 interface{}
的 result
转换为具体的 task.Task
类型(实际上是指向 task.Task
的指针)。如果类型断言失败,我们记录一条消息并继续处理下一个任务。
code 11.10 Using the new datastore interface to get a task from TaskDb
for _, t := range tasks {
// ...
// to be replaced
// _, ok := m.TaskDb[t.ID]
// if !ok {
// log.Printf("[manager] Task with ID %s not found\n", t.ID)
// continue
// }
result, err := m.TaskDb.Get(t.ID.String())
if err != nil {
log.Printf("[manager] %s\n", err)
continue
}
taskPersisted, ok := result.(*task.Task)
if !ok {
log.Printf("cannot convert result %v to task.Task type\n", result)
continue
}
}
在 updateTasks
方法中,最后一组需要进行的修改是将对现有映射结构的其余直接操作替换为对 Store
接口相应方法的调用。下面的代码清单展示了现有的代码,这里我们是通过直接修改映射中任务的字段来对任务进行修改。
code 11.11 Existing code directly accessing the TaskDB map
if m.TaskDb[t.ID].State != t.State {
m.TaskDb[t.ID].State = t.State
}
m.TaskDb[t.ID].StartTime = t.StartTime
m.TaskDb[t.ID].FinishTime = t.FinishTime
m.TaskDb[t.ID].ContainerID = t.ContainerID
m.TaskDb[t.ID].HostPorts = t.HostPorts
我们希望用代码清单 11.12 中的代码替换之前的代码。由于我们已经从管理器的任务存储中获取了任务,并将其从空接口类型转换为具体的 task.Task
类型的指针,所以我们可以直接更新 taskPersisted
变量中必要的字段。然后,我们调用存储的 Put
方法来保存更新后的任务,以此完成操作。
code 11.12 Using the new datastore interface to put a task in the TaskDb
if taskPersisted.State != t.State {
taskPersisted.State = t.State
}
m.TaskDb[t.ID].StartTime = t.StartTime
m.TaskDb[t.ID].FinishTime = t.FinishTime
m.TaskDb[t.ID].ContainerID = t.ContainerID
m.TaskDb[t.ID].HostPorts = t.HostPorts
m.TaskDb.Put(taskPersisted.ID.String(), taskPersisted)
接下来我们需要更新的是 GetTasks
方法。该方法使用一个 for
循环来遍历管理器任务存储中的所有任务。到目前为止,这个方法一直是直接遍历任务映射。因此,我们需要对 GetTasks
方法进行修改,让它使用 Store
接口的 List
方法,将结果从空接口切片转换为 task.Task
类型的指针切片,然后返回该切片。
code 11.13 The GetTasks method
func (m *Manager) GetTasks() []*task.Task {
taskList, err := m.TaskDb.List()
if err != nil {
log.Printf("error getting list of tasks: %v\n", err)
return nil
}
return taskList.([]*task.Task)
}
接下来需要更新的方法是 restartTask
方法。这个更新很简单。目前它只有一处与内置的映射进行交互,所以我们只需将其替换为对存储的 Put
方法的调用。也就是说,只需用第二行代码替换第一行代码即可。
// m.TaskDb[t.ID] = t
m.TaskDb.Put(t.ID.String(), t)
最后需要更新的方法是 SendWork
方法。尽管这个方法较长,包含了将任务发送给工作节点的多步流程,但我们只需进行少量更新。第一个更新涉及与新的 EventDb
存储的首次交互。我们要从直接与旧的任务事件映射交互改为使用新的 EventDb
存储。在 SendWork
方法的开头,我们从管理器的待处理队列中取出一个事件并将其转换为 task.TaskEvent
类型。现在我们要调用事件存储的 Put
方法,将事件 ID 作为字符串以及指向任务事件 te
的指针传递给它。如果调用 Put
方法返回错误,我们记录错误信息并返回。
code 11.14 The first change to the SendWork method, using the new Put method
e := m.Pending.Dequeue()
te := e.(task.TaskEvent)
err := m.EventDb.Put(te.ID.String(), &te)
if err != nil {
log.Printf("error attempting to store task event %s: %s\n", te.ID.String(), err)
return
}
第二项更新涉及此方法对任务存储的使用。在代码清单 11.15 中,我们可以看到现有代码仍是直接与 TaskDb
映射进行交互。由于这段代码是从映射中获取任务,所以我们要像之前那样,将代码修改为使用存储接口的 Get
方法。
code 11.15 Existing code that gets a task from the store by operating directly on the map
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
}
}
为了将代码更改为使用我们在store接口上的新Get方法,我们需要稍微调整一下我们的代码:
result, err := m.TaskDb.Get(te.Task.ID.String())
if err != nil {
log.Printf("unable to schedule task: %s", err)
return
}
persistedTask, ok := result.(*task.Task)
if !ok {
log.Printf("unable to convert task to task.Task type")
return
}
对 SendWork
方法的最后一项更新,也是对管理器的最后一处修改,涉及另一处改动,即使用任务存储的 Put
方法,而非直接将任务插入到映射中:
t.State = task.Scheduled
m.TaskDb.Put(t.ID.String(), &t)
code 11.16 Calling the Store interface’s Get method
// taskToStop, ok := a.Manager.TaskDb[tID]
taskToStop, err := a.Manager.TaskDb.Get(tID.String())
11.6 重构工作节点
目前,我们的管理器已经在使用新的 Store
接口。然而,工作节点还没有使用。它仍然在直接操作内置的映射类型。让我们对工作节点进行同样的重构,使其也能使用 Store
接口。
和管理器一样,首要任务是将工作节点的 Db
类型从 map[uuid.UUID]*task.Task
改为 store.Store
接口类型。这样一来,工作节点就可以使用任何实现了 store.Store
接口的存储类型。
type Worker struct {
Db store.Store
}
接下来,我们需要更新 worker
包中的 New
辅助函数。我们要更新其函数签名,使其接受一个新参数。这个新参数名为 taskDbType
,类型为字符串。
然后,我们创建一个类型为新的 store.Store
的变量 s
。接着,针对 taskDbType
参数使用 switch
语句,将调用 NewInMemoryTaskStore
函数的结果赋值给变量 s
。
最后,我们把 s
赋值给工作节点的 Db
字段。之后,我们就可以返回指向工作节点 w
的指针,该工作节点将包含存储接口。
code 11.17 The New helper function creating an instance of the InMemoryTaskStore
func New(name string, taskDbType string) *Worker {
w := Worker{
Name: name,
queue: *queue.New(),
}
var s store.Store
switch taskDbType {
case "memory":
s = store.NewInMemoryTaskStore()
}
w.Db = s
return &w
}
code 11.18 The GetTasks method using the store interface’s List method
func (w *Worker) GetTasks() []*task.Task {
taskList, err := w.Db.List()
if err != nil {
log.Printf("error getting list of tasks: %v\n", err)
return nil
}
return taskList.([]*task.Task)
}
接下来,我们需要修改 runTask
方法。和之前所有代码一样,它一直直接操作 Db
映射。该方法的初始步骤如下:
-
从工作器的队列中取出一个任务。
-
将任务从空接口类型转换为
task.Task
类型。 -
从
Db
映射中获取该任务。 -
如果任务不存在,则创建它。
注意步骤 3 和 4。此过程试图通过任务的 ID 从映射中查找并获取任务。然而,如果任务不在映射中,这个操作不会返回错误。我们的 InMemoryTaskStore
实现了存储接口的 Get
方法,该方法会返回一个错误。这个错误可能由多种因素导致。可能是因为任务在存储中根本不存在,也可能是与底层存储交互时出现了问题。所以,如果我们在切换到使用新的存储接口时采用相同的操作顺序,就会遇到问题。如果调用存储的 Get
方法返回了错误,我们如何区分是因为任务不存在,还是底层存储本身出现了错误呢?在前一种情况下,我们希望创建该任务;在后一种情况下,我们希望返回一个错误。和以往一样,我们的解决方案是进行权衡。我们将改变操作顺序,先调用存储的 Put
方法,如果任务存在,该方法将有效覆盖该任务。如果调用 Put
方法没有返回错误,那么我们再调用存储的 Get
方法从存储中检索任务。runTask
方法改变了操作顺序,以适应我们的存储接口,包括处理返回值中的错误。
code 11.19 The modified runTask method
func (w *Worker) runTask() tsak.DockerResult {
// ...
err := w.Db.Put(taskQueued.ID.String(), &tasskQueued)
if err != nil {
msg := fmt.Errorf("error storing task %s: %v", taskQueued.ID.String(), err)
log.Println(msg)
return task.DockerResult{Error: msg}
}
queuedTask, err := w.Db.Get(taskQueued.ID.String())
if err != nil {
msg := fmt.Errorf("error getting task %s from database: %v", taskQueued.ID.String(), err)
log.Println(msg)
return tas,DockerResult{Error: msg}
}
taskPersisted := *queuedTask.(*task.Task)
// ...
}
接下来需要修改的是 StartTask
方法。该方法会对任务存储执行两项操作,即更新任务的状态,并将更新后的任务存储到 Db
中。在这种情况下,我们可以直接用调用新的 Put
方法来替代对映射的直接操作,具体如以下代码清单所示。
code 11.20 Using the Put method instead of directly operating on a map
func (w *Worker) StartTask(t task.Task) task.DockerResult {
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.Put(t.ID.String(), &t)
return result
}
t.ContainerID = result.ContainerId
t.State = task.Running
w.Db.Put(t.ID.String(), &t)
}
接下来,StopTask
方法仅对任务存储执行一次操作。与 StartTask
方法类似,它会更新任务的状态并将其保存到映射中。同样地,我们只需将与映射的直接交互替换为对存储的 Put
方法的调用即可:
func (w *Worker) StopTask(t task.Task) task.DockerResult {
config := task.NewConfig(&t)
d := task.NewDocker(config)
stopResult := d.Stop(t.ContainerID)
if stopResult.Error != nil {
log.Printf("%v\n", stopResult.Error)
}
removeResult := d.Remove(t.ContainerID)
if removeResult.Error nil {
log.Printf("%v\n", removeResult.Error)
}
t.FinishTime = time.Now().UTC()
t.State = task.Completed
w.Db.Put(t.ID.String(), &t)
log.Printf("Stopped and removed container %v for task %v\n", t.ContainerID, t.ID)
return removeResult
}
最后,updateTasks
方法会对任务存储进行四次操作。第一次操作是一个 for
循环,该循环遍历工作器的 Db
映射。由于 Go 语言支持对映射进行迭代,所以我们能够直接对存储进行循环操作。不过,Go 不支持对函数调用进行迭代,函数调用只会返回一次结果:
func (w *Worker) updateTasks() {
tasks, err := w.Db.List()
if err != nil {
log.Printf("error getting list of tasks: %v\n", err)
return
}
for _, t := range tasks.([]*task.Task) {
if t.State == task.Running {
resp := w.InspectTask(*t)
if resp.Error != nil {
fmt.Printf("Error: %v\n", resp.Error)
}
if resp.Container == nil {
log.Printf("No container for running task %s\n", t.ID)
t.State = task.Failed
w.Db.Put(t.ID.String(), t)
}
if resp.Container.State.Status == "existed" {
log.Printf("Container for task %s in non-running state %s\n", t.ID, resp.Container.State.Status)
t.State = task.Failed
w.Db.Put(t.ID.String(), t)
}
t.HostPorts = resp.Container.NetworkSettings.NetworkSettingsBase.Ports
w.Db.Put(t.ID.String(), t)
}
}
}
与我们对管理器所做的更改类似,我们还需要更新工作器的 API 处理程序,使其使用存储接口。我们需要进行两处更改,一处针对 InspectTaskHandler
,另一处针对 StopTaskHandler
。
// t, ok := a.Worker.Db[tID]
t, err := a.Worker.Db.Get(tID.String())
我们需要在 StopTaskHandler
中进行类似的更改:
// taskToStop, ok := a.Worker.Db[tID]
taskToStop, err := a.Worker.Db.Get(tID.String())
完成了这最后两处更改后,我们就完成了对工作器(worker)和管理器(manager)的重构。现在,它们都使用了我们新存储接口中的方法。
11.7 整合所有内容
到目前为止,我们几乎已经准备好启动管理器和工作器,并让它们使用新的存储接口了。只需对我们的 main.go
程序做一些小调整即可。
首先要做的调整涉及我们创建工作器的方式。如果你还记得的话,我们之前是通过将结构体字面量赋值给一个变量来创建工作器的:
w1 := worker.Worker{
Queue: *queue.New(),
Db: store.NewInMemoryTaskStore(),
}
然而,现在我们可以通过使用工作器(worker)包中的 New
辅助函数来简化代码的这一部分。我们将用对 New
函数的一次调用替换原来的三行代码:
w1 := worker.New("worker-1", "memory")
w2 := worker.New("worker-2", "memory")
23 := worker.New("worker-3", "memory")
第二项调整涉及我们创建管理器的方式。我们原本就使用了一个 New
辅助函数。现在,我们需要在调用 New
时添加一个参数,用以指定管理器应该使用的数据存储类型:
m := manager.New(workers, "epvm", "memory")
随着这些更改,我们现在可以运行我们的主程序并查看我们得到的结果:
CUBE_WORKER_HOST=localhost CUBE_WORKER_PORT=5556 CUBE_MANAGER_HOST=localhost CUBE_MANAGER_PORT=5555 go run main.go
如您所见,变化不大。工人和管理员如预期般启动并各自履行职责。
让我们向管理器发送一项任务:
$ curl -X POST localhost:5555/tasks -d @task1.json
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
df10210e3884 sun4965485/echo-smy:v1 "/app/echo" 17 seconds ago Up 16 seconds 0.0.0.0:7777->7777/tcp test-chapter-9.1
成功了!我们已成功将管理器和工作器重构为使用代表数据存储的接口,而非直接操作数据存储本身。不过仍存在一个问题。如果我们停止并重新启动管理器或工作器,它们会忘记之前处理过的所有任务。我们可以通过实现一个持久化数据存储来解决这个问题。
就像我们为任务和事件数据存储实现内存版本那样,现在我们要为每个数据存储实现持久化版本。
11.8 引入 BoltDB
在从内存数据存储转向持久化数据存储时,我们需要从宏观层面问自己几个问题。首先,我们是否需要一个基于服务器的数据存储?基于服务器的数据存储就像 PostgreSQL、MySQL、Cassandra、Mongo 或其他任何以独立进程形式运行的数据存储。就我们的需求而言,基于服务器的数据存储有些大材小用。这意味着我们得启动并管理另一个进程,而且大多数基于服务器的系统很快就会变得复杂。
相反,我们打算选择嵌入式数据存储。之所以叫嵌入式数据存储,是因为它使用的是可以直接嵌入到应用程序中的库。
第二个问题涉及我们要采用的数据模型。最流行的数据模型是关系模型,像 PostgreSQL 和 MySQL 这类系统就使用这种模型。甚至还有嵌入式关系数据存储,比如 SQLite。虽然这类数据存储很受欢迎且性能稳定,但它们在插入和查询数据时需要使用结构化查询语言(SQL)。SQL 数据存储具有高度结构化的特点,需要严格的模式来定义表和列。
在过去十年中,另一种流行的数据模型是键值数据存储,有时也被称为 NoSQL。知名的开源键值数据存储包括 Cassandra 和 Redis。如果你还记得我们用 Go 内置的映射类型实现的内存数据存储,它有一个简单的接口:我们可以把数据存入数据存储,也能从中取出数据。我们向这个数据存储存入或取出任务的主要机制就是键 —— 在我们的例子中,这个键是一个 UUID。我们的任务就是键所指向的值。
由于我们已经在使用键值数据存储,那么选择一个采用相同范式的持久化数据存储是合理的。为了尽可能简化,我们将使用一个名为 BoltDB(github.com/boltdb/bolt)的嵌入式库。正如 BoltDB 的 README 中所提到的,它是 “一个纯 Go 语言实现的键 / 值存储”,并且 “该项目的目标是为那些不需要像 Postgres 或 MySQL 那样完整数据库服务器的项目提供一个简单、快速且可靠的数据库”。
要使用 BoltDB 库,我们需要先安装它。在项目目录下,使用以下命令来安装该库:
$ go get github.com/boltdb/bolt/...
正如我们实现任务和事件数据存储的内存版本那样,现在我们要为每个数据存储实现持久化版本。
11.9 实现持久任务存储
我们要实现的第一个持久化存储是任务存储(TaskStore)。和内存存储一样,它将实现存储接口(Store interface),唯一的差别在于实现细节。
首先要做的是创建 TaskStore
结构体,如清单 11.21 所示。与内存版本相比,有几处不同值得注意。其一,TaskStore
结构体的 Db
字段采用了不同的类型。内存任务存储(InMemoryTaskStore)使用的是 map[string]*task.Task
类型,而这里该字段是指向 bolt.DB
类型的指针,此类型在 BoltDB 库中定义。其次,TaskStore
结构体定义了 DbFile
和 FileMode
字段。BoltDB 借助磁盘上的文件来持久化数据,DbFile
字段告知 BoltDB 要操作的文件名,FileMode
则确保我们具备读写该文件的必要权限。在 BoltDB 中,键值对存储在名为 “桶” 的集合里,因此结构体的 Bucket
字段定义了我们要为 TaskStore
使用的桶的名称。
code 11.21 The persistent version of our task store, called TaskStore
import (
...
"github.com/boltdb/bolt"
)
type TaskStore struct {
Db *bolt.DB
DbFile string
FileMode os.FileMode
Bucket string
}
code 11.22 The NewTaskStore helper function
func NewTaskStore(file string, mode os.FileMode, bucket string) (*TaskStore, error) {
db, err := bolt.Open(file, mode, nil)
if err != nil {
return nil, fmt.Errorf("unable to open %v", file)
}
t := TaskStore{
DbFile: file,
FileMode: mode,
Db: db,
Bucket: bucket,
}
err := t.CreateBucket()
if err != nil {
log.Printf("bucket already exists, will use it instead of creating new one")
}
return &t, nil
}
在定义好 TaskStore
结构体并创建了辅助函数之后,让我们把注意力转向 TaskStore
的方法。除了 Store
接口定义的方法(Put
、Get
、List
和 Count
)之外,我们还要定义一个 Close
方法。为什么需要这样一个方法呢?要知道,我们的持久化数据存储会将数据写入磁盘上的文件。此外,在 NewTaskStore
辅助函数中,我们调用了 Open
函数来打开文件。Close
方法将在我们使用完文件后把它关闭。
func (t *TaskStore) Close() {
t.Db.Close()
}
要实现的 Store
接口的第一个方法是 Count
。和内存存储一样,持久化版本的 Count
方法会返回数据存储中任务的数量。不过与内存版本不同的是,这个方法的实现要更复杂一些。
和 PostgreSQL、MySQL 等关系型数据存储类似,BoltDB 支持事务。根据 BoltDB 的 README,BoltDB 中的每个事务 “对事务开始时的数据具有一致的视图”。BoltDB 支持三种类型的事务:
-
读写事务
-
只读事务
-
批量读写事务
就我们的需求而言,我们只会使用前两种事务。由于我们的 Count
方法需要获取任务的数量,所以可以使用只读事务。与内存存储不同,在内存存储中我们可以对 Go 的映射类型使用 Go 内置的 len
方法,而在这里我们必须遍历桶中的所有键来统计数量。BoltDB 提供了 ForEach
方法来简化这个过程。
要执行只读事务,我们使用 Bolt 的 View
函数。这个方法接受一个函数作为参数,该函数本身接受一个指向 bolt.Tx
类型的指针作为参数,并返回一个错误。这就是实现事务的机制。在事务内部,我们指定要操作的桶,然后遍历该桶中的每个键,并增加 taskCount
的值。遍历完所有键后,我们检查是否有错误,最后返回 taskCount
。
func (t *TaskStore) Count() (int, error) {
taskCount := 0
err := t.Db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("tasks"))
b.ForEach(func(k, v []byte) error {
taskCount++
return nil
})
return nil
})
if err != nil {
return -1, err
}
return taskCount, nil
}
接下来,我们来实现另一个特定于持久化存储的方法,因此它并非 Store
接口的一部分。如清单 11.22 所示,CreateBucket
方法是对 BoltDB 库中同名函数的封装。它不接受参数,返回一个错误。在这个方法中,我们创建一个用于以键值对形式存储所有任务的桶。由于要创建桶,我们需要使用读写事务,这可以通过 Update
函数实现。Update
函数的使用方式与 View
函数类似,它接受一个函数作为参数,该函数接收一个指向 bolt.Tx
类型的指针并返回一个错误。然后,我们调用 CreateBucket
函数并传入要创建的桶的名称,接着检查是否有错误。CreateBucket
方法的代码将在下一个清单中展示。
code 11.23 The CreateBucket method
func (t *TaskStore) CreateBucket() error {
return t.Db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucket([]byte(t.Bucket))
if err != nil {
return fmt.Errorf("create bucket %s: %s", t.Bucket, err)
}
return nil
})
}
现在让我们回到 Store
接口的方法,来实现其中的第二个方法 Put
。这个 Put
方法的签名和内存版本的一样,所以在这方面没有新内容。新的地方在于我们存储键值对的方式。和在 CreateBucket
方法里一样,我们会使用 Update
函数来开启一个读写事务。我们使用 tx.Bucket
函数指定要存储键值对的桶,给它传入桶名作为字符串参数。为了把值存储到 BoltDB 的桶里,我们得把值转换为字节切片。我们通过调用 json
包的 Marshal
函数来完成这一转换,把转换为 task.Task
类型指针的值作为参数传给它。一旦值被转换为字节切片,我们就对桶调用 Put
函数,把键和值(二者都是字节切片)作为参数传入,具体代码如下个清单所示。
code 11.24 The Put method for the persistent task store
func (t *TaskStore) Put(key string, value itnerface{}) error {
return t.Db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(t.Bucket))
buf, err := json.Marshal(value.(*task.Task))
if err != nil {
return err
}
err = b.Put([]byte(key), buf)
if err != nil {
return err
}
return nil
})
}
要实现的 Store
接口的第三个方法是 Get
方法。它接收一个字符串作为参数,这个字符串是我们想要检索的键。它返回一个 interface{}
类型的值(即我们想要获取的任务)以及一个错误信息。
我们首先定义一个 task.Task
类型的变量 task
。接着,我们使用之前介绍过的 View
函数开启一个只读事务。同样,我们需要指定要操作的桶。然后,我们使用 Get
函数来查找任务,将键作为字节切片传递给它。注意,我们没有对 Get
调用进行错误检查,这是因为 BoltDB 库保证,除非出现系统故障(例如,数据存储文件从磁盘中被删除),否则 Get
操作一定会成功。如果桶中没有与该键对应的任务,Get
会返回 nil
。
一旦获取到任务,我们需要使用 json
包中的 Unmarshal
函数将其从字节切片解码回 task.Task
类型。最后,我们进行一些错误检查,然后返回该任务的指针。
code 11.25 The Get method for the persistent task store
func (t *TaskStore) Get(key string) (interface{}, error) {
var task task.Task
err := t.Db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(t.Bucket))
t := b.Get([]byte(key))
if t == nil {
return fmt.Errorf("task %v not found", key)
}
err := json.Unmarshal(t, &task)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &task, nil
}
要实现的第四个也是最后一个方法是 List
方法。和 Get
方法一样,List
方法也使用只读事务。不过,它不是获取单个任务,而是遍历桶中的所有任务,并创建一个任务切片。在这方面,它和 Count
方法类似。
func (t *TaskStore) List() (interface{}, error) {
var tasks []*task.Task
err := t.Db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(t.Bucket))
b.ForEach(func(k, v []byte) error {
var task task.Task
err := json.Unmarshal(v, &task)
if err != nil {
return err
}
tasks = append(tasks, &task)
return nil
})
return nil
})
if err != nil {
return nil, err
}
return tasks, nil
}
11.10 实现持久任务事件存储
type EventStore struct {
DbFile string
FileMode os.FileMode
Db *bolt.DB
Bucket string
}
func NewEventStore(file string, mode os.FileMode, bucket string) (*EventStore, error) {
db, err := bolt.Open(file, mode, nil)
if err != nil {
return nil, fmt.Errorf("unable to open %v", file)
}
e := EventStore{
DbFile: file,
FileMode: mode,
Db: db,
Bucket: bucket,
}
err = e.CreateBucket()
if err != nil {
log.Printf("bucket already exists, will use it instead of creating new one")
}
return &e, nil
}
同样,EventStore
类型的 Close
和 CreateBucket
方法看起来也会很熟悉。前者用于关闭在 NewEventStore
中打开的数据存储文件,后者则是创建一个用于存储事件的桶。
func (e *EventStore) Close() { e.Db.Close() }
func (e *EventStore) CreateBucket() error {
return e.Db.Update(func(tx *bolt.Tx) error {
-, err := tx.CreateBucket([]byte(e.Bucket))
if err != nil {
return fmt.Erorrf("create bucket %s: %s", e.Bucket, err)
}
return nil
})
}
Count
方法用于统计持久化存储中事件的数量,并返回该数量。
func (e *EventStore) Count() (int, error) {
evetnCount := 0
err := e.Db.View(fumc(tx *bolt.Tx) error {
b :+ tx.Bucket([]byte(e.Bucket))
b.ForEach(func(k, v []byte) error {
eventCount++
return nil
})
return nil
})
if err != nil {
return -1, err
}
return eventCount, nil
}
Put
和 Get
方法也与 TaskStore
类型中的对应方法完全相同。Put
方法接受一个键和一个值,将其写入数据存储,并返回可能出现的错误。Get
方法接受一个键,在数据存储中查找该键,如果找到则返回对应的值。
func (e *EventStore) Put(key string, value interface{}) error {
return e.Db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(e.Bucket))
buf, err := json.Marshal(value.(*task.TaskEvent))
if err != nil {
return err
}
err = b.Put([]byte(key), buf)
if err != nil {
log.Printf("unable to save item %s", key)
return err
}
return nil
})
}
func (e *EventStore) Get(key string) (interface{}, error) {
var event task.TaskEvent
err := e.Db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(e.Bucket))
t := b.Get([]byte(key))
if t == nil {
return fmt.Errorf("event %v not found", key)
}
err := json.Unmarshal(t, &event)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &event, nil
}
最后但同样重要的是,List方法构建数据存储中所有事件的列表并返回它:
func (e *EventStore) List() (inteface{}, error) {
var events []*task.TaskEvent
err := e.Db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(e.Bucket))
b.ForEach(func(k, v []byte) error {
var event task.TaskEvent
err := json.Unmarshal(v, &event)
if err != nil {
return err
}
events = append(events, &event)
return nil
})
return nil
})
if err != nil {
return nil, err
}
return events, nil
}
任务存储和事件存储的持久化版本实现后,我们就可以更改主程序,使用它们来代替内存中的对应存储。
11.11 将内存存储转换为永久存储
我们需要对管理器和工作器的代码做几处小改动,以使用新的持久化存储。这两处改动都涉及在管理器和工作器包的 New
辅助函数中添加创建持久化数据存储的分支情况。
我们先从管理器开始。需要在清单 11.26 所示的 switch
语句中添加持久化存储的分支。这样,当调用 New
函数的代码传入 persistent
作为 dbType
的值时,我们会调用 NewTaskStore
和 NewEventStore
函数,而非它们对应的内存存储版本。注意,每个函数都接受三个参数:用于数据存储的文件名、文件的权限模式以及存储键值对的桶的名称。函数调用中的 0600
代表文件权限模式参数,这意味着只有文件的所有者可以读写该文件。
code 11.26 Adding the persistent case to the manager’s New function
var err error
switch dbType {
case "memory":
ts = store.NewInMemoryTaskStore()
es = store.store.NewInMemoryTaskEventStore()
case "persistent":
ts, err = store.NewTaskStore("tasks.db", 0600, "tasks")
es, err = store.NewEventStore("event.db", 0600, "events")
}
worker.go 文件 switch 增加一个 case:
case "persistent":
dbDir := "db"
if _, err := os.Stat(dbDir); os.IsNotExist(err) {
err = os.MkdirAll(dbDir, 0755)
if err != nil {
log.Printf("failed to create db directory: %s\n", err.Error())
}
}
filename := fmt.Sprintf("db/%s_tasks.db", name)
s, err = store.NewTaskStore(filename, 0600, "tasks")
if err != nil {
log.Printf("new task store failed: %s\n", err.Error())
}
工作器的 New
函数改动与之类似。我们添加一个处理持久化存储的分支,该分支会调用 NewTaskStore
辅助函数。由于我们要启动三个工作器,所以会使用 filename
变量为每个工作器创建一个唯一的文件名。因为工作器仅对任务进行操作,所以无需设置事件存储。
code 11.27 Changing workers to use the persistent store
w1 := worker.New("worker-1", "persistent")
w2 := worker.New("worker-2", "persistent")
w3 := worker.New("worker-3", "persistent")
m := manager.New(workers, "epvm", "persistent")
完成这些更改后,启动主程序并执行本章前面所做的相同操作。你会发现,从外部来看,一切与使用内存存储时并无二致。唯一的区别在于,你现在会在工作目录中看到几个扩展名为 .db
的文件。这些文件就是 BoltDB 用于持久化系统任务和事件的文件。你应该会看到如下文件:
- tasks.db
- worker-1_tasks.db
- worker-2_tasks.db
- worker-3_tasks.db
总结
将编排器的任务和事件存储在持久化数据存储中,使得系统能够跟踪任务和事件的状态,从而在调度方面做出明智的决策,并有助于从故障中恢复。
store.Store
接口让我们能够根据需求更换数据存储的实现方式。例如,在进行开发工作时,我们可以使用内存存储,而在生产环境中使用持久化存储。虽然我们使基于 Go 内置映射类型的旧存储适配了新的 store.Store
接口,但这些内存实现存在同样的问题,即管理器和工作器在重启时仍然会丢失它们的任务。
有了 store.Store
接口和具体的实现,我们对管理器和工作器进行了修改,以避免它们直接操作数据存储。例如,不再操作 map[uuid.UUID]*task.Task
类型的映射,而是将它们改为操作 store.Store
接口。通过这样做,我们将管理器和工作器与底层数据存储的实现解耦。它们不再需要了解特定数据存储的内部工作机制,只需要知道如何调用接口的方法,而所有的技术细节都由具体的实现来处理。
BoltDB 库提供了一个嵌入式键值数据存储,我们在其基础上构建了 TaskStore
和 EventStore
存储。这些数据存储将数据持久化到磁盘上的文件中,因此使得管理器和工作器能够顺利重启而不会丢失任务。
一旦我们创建了 store.Store
接口以及两种实现方式(一种是内存存储,一种是持久化存储),我们就可以通过简单地向 New
辅助函数传入字符串 memory
或 persistent
来在这两种实现之间进行切换。