从头开始使用 Go 构建 Orchestrator(第三部分:往骨架中塞一些肉)

191 阅读23分钟

本章涵盖内容如下:

  • 回顾如何通过命令行启动和停止 Docker 容器
  • 介绍用于启动和停止容器的 Docker API 调用
  • 实现用于启动和停止容器的任务概念

想象一下烹饪你最喜欢的美食。比如说你喜欢自制披萨。要最终从烤箱里取出美味、热气腾腾的披萨,你必须执行一系列任务。如果你喜欢在披萨上加洋葱、青椒或其他蔬菜,你就得把它们切碎。你得揉面团,然后把面团铺在烤盘上。接着,在面团上抹上番茄酱,再撒上奶酪。最后,在奶酪上面铺上你的蔬菜和其他配料。

编排系统中的一项任务,类似于制作披萨过程中的某一个单独步骤。和如今的大多数公司一样,你的公司很可能也有一个网站。那家公司的网站运行在一个 Web 服务器上,也许是无处不在的 Apache Web 服务器。这就是一项任务。该网站可能会使用像 MySQL 或 PostgreSQL 这样的数据库来存储动态内容。这也是一项任务。

在我们制作披萨的类比中,披萨可不是凭空做出来的。它是在一个特定的环境中制作的,也就是厨房。厨房为制作披萨提供了必要的资源:用来存放奶酪的冰箱、用来存放披萨酱的橱柜、用来烤制披萨的烤箱,以及用来把披萨切成片的刀。

同样地,一项任务也是在特定的环境中运行的。在我们的例子里,这个环境就是 Docker 容器。就像厨房一样,容器将为任务的运行提供必要的资源:它会根据任务的需求提供 CPU 计算周期、内存和网络支持。

提醒一下,任务是编排系统的基础。图 1.1 展示了我们在第 1 章中概念模型的一个改进版本。

image.png

图 3.1 编排系统的主要目的是接受用户提交的任务,并在系统的工作节点上运行这些任务。在此图中,我们可以看到一个用户向管理器节点提交了一项任务,管理器节点随后选择了工作节点 2 来运行该任务。指向工作节点 1 和工作节点 3 的虚线表示这些节点曾被纳入考虑范围,但最终未被选中来运行该任务。

在本章的剩余部分,我们将完善上一章中编写的任务框架代码。但首先,让我们快速回顾一些 Docker 的基础知识。

3.1 Docker:从命令行启动、停止和检查容器

如果你是一名开发人员,在编写代码时,你很可能使用过 Docker 容器在自己的笔记本电脑上运行应用程序及其后端数据库。如果你是一名 DevOps 工程师,你也许已经将 Docker 容器部署到了公司的生产环境中。容器使开发人员能够将他们的代码及其所有依赖项打包在一起,然后将该容器部署到生产环境中。如果一个 DevOps 团队负责向生产环境进行部署,那么他们只需操心部署容器这件事。他们不必担心运行容器的机器上是否安装了应用程序用于连接数据库的 PostgreSQL 库的正确版本。

提示 如果你需要更详细地回顾 Docker 容器以及如何对其进行控制,请查看《Docker 实战》(mng.bz/PRq8)的第 2 章。

要运行一个 Docker 容器,我们可以使用 docker run 命令,下例展示了该命令的一种用法。在此示例中,docker run 命令正在一个容器中启动一个 PostgreSQL 数据库,在开发新应用程序时,该数据库可能会被用作后端数据存储。

code 3.1 Running the Postgres database server as a Docker container

$ docker run -it \
    -p 5432:5432 \
    --name cube-book \
    -e POSTGRES_USER=cube \
    -e POSTGRES_PASSWORD=secret \ postgres

此命令在前台运行容器,这意味着我们可以看到它的日志输出(-it选项),为容器命名为postgres,并且设置了POSTGRES_USERPOSTGRES_PASSWORD环境变量。

一旦容器开始运行,它所执行的功能,就如同你在笔记本电脑或台式机上把它作为常规进程运行时一样。就清单 3.1 中的 Postgres 数据库而言,现在我可以使用psql命令行客户端登录到数据库服务器,并像下面清单中那样创建一个表。

3.2 Loggin in to the Postgres server and creating a table

$ psql -h localhost -p 5432 -U cube 
Password for user cube:
psql (9.6.22, server 13.2 (Debian 13.2-1.pgdg100+1)) 
WARNING: psql major version 9.6, server major version 13. 
    Some psql features might not work.

Type "help" for help.

cube=# \d 
No relations found.
cube=# CREATE TABLE book (
isbn char(13) PRIMARY KEY, 
title varchar(240) NOT NULL, 
author varchar(140) 
); 
CREATE TABLE

cube=# \d 
    List of relations 
Schema | Name | Type | Owner 
--------+------+-------+------
public | book | table | cube (1 row)

code 3.3 Using the docker inspect command

image.png

最后,我们可以使用 docker stop cube-book 命令来停止一个 Docker 容器。该命令不会有任何输出,但是如果我们现在运行 docker inspect cubebook 命令,我们会看到容器的状态已经从 “运行中” 变为 “已退出”。

code 3.4 Running docker inspect cube-book after docker stop cube-book

image.png

3.2 Docker:从 API 启动、停止和检查容器

在我们的编排系统中,工作节点将负责启动、停止其所运行的任务,并提供这些任务的相关信息。为了执行这些功能,工作节点将使用 Docker 的 API。可以通过 HTTP 协议,使用像 curl 这样的客户端工具,或者编程语言的 HTTP 库来访问该 API。下面的示例展示了如何使用 curl 来获取与之前使用 docker inspect 命令所得到的相同信息。

code 3.5 Querying the Docker API with the curl HTTP client

image.png

Docker守护进程通过位于/var/run/docker.sock的 Unix 套接字暴露 REST API。

注意,我们在 curl 命令中使用了 --unix-socket 标志。默认情况下,Docker 监听 Unix 套接字,但也可以将其配置为监听 TCP 套接字。URL http://docker/containers/6970e8469684/json 包含了要查看详情的容器的 ID,这个 ID 是我通过在本地机器上运行 docker ps 命令获取的。最后,curl 的输出被管道输送给 jq 命令,jq 会以比 curl 更易读的格式打印输出内容。

返回的响应显示了容器的详细信息,包括:

  • 完整的容器ID
  • 创建时间
  • 在容器中运行的命令(docker-entrypoint.sh postgres
  • 当前状态信息(运行状态、进程ID、启动时间等)

在我们的编排系统中,我们本可以使用 Go 的 HTTP 库,但这会让我们不得不处理许多底层细节,比如 HTTP 方法、状态码以及请求的序列化和响应的反序列化。相反,我们将使用 Docker 的 SDK,它会为我们处理所有底层的 HTTP 细节,让我们能够专注于主要任务:创建、运行和停止容器。该 SDK 提供了以下六个能满足我们需求的方法:

  • NewClientWithOpts —— 一个辅助方法,用于实例化客户端并将其返回给调用者。

  • ImagePull —— 将镜像拉取到要运行它的本地机器上。

  • ContainerCreate —— 根据给定的配置创建一个新容器。

  • ContainerStart —— 向 Docker 引擎发送请求以启动新创建的容器。

  • ContainerStop —— 向 Docker 引擎发送请求以停止正在运行的容器。

  • ContainerRemove —— 从主机上移除容器。

注意:Docker 的 Go 语言 SDK 有详尽的文档(pkg.go.dev/github.com/…),值得一读。特别是关于 Go 客户端的文档(pkg.go.dev/github.com/…)与我们在本书后续内容中的工作相关。

我们在上一节回顾的 docker 命令行示例,其底层使用的就是这个 Go SDK。在本章后续部分,我们将实现一个 Run() 方法,该方法会使用 ImagePullContainerCreate 和 ContainerStart 方法来创建并启动容器。图 3.2 以图形化的方式展示了我们的自定义代码以及使用该 SDK 的 docker 命令。

image.png

图 3.2 无论起点如何,创建和运行容器的所有路径都要通过 Docker SDK。

Task configuration

通过在我们的编排系统中使用 Go 语言编写的 Docker SDK 来控制 Docker 容器,我们无需重复造轮子。我们可以直接复用日常 docker 命令所使用的代码。

为了将我们的任务作为容器运行,它们需要一个配置。什么是配置呢?回想一下本章开头我们用做披萨所做的类比。制作披萨的任务之一是切洋葱(如果你不喜欢洋葱,可以换成你喜欢的蔬菜)。为了完成这个任务,我们会使用一把刀和一块砧板,并且会以特定的方式切洋葱。也许我们会把它们切成均匀的薄片,或者把它们切成小方块。这都是切洋葱任务 “配置” 的一部分。(好吧,我可能把做披萨的类比扯得有点远了,但我想你明白我的意思。)

对于我们编排系统中的任务,我们将使用清单 3.6 中的 Config 结构体来描述其配置。这个结构体封装了关于任务配置的所有必要信息。注释应该能让每个字段的用途一目了然,但有几个字段值得特别强调。

Name 字段将用于在我们的编排系统中标识一个任务,并且它还会兼任正在运行的容器的名称。在本书的其余部分,我们将使用这个字段为我们的容器命名,比如 testcontainer-1

你可能已经猜到了,Image 字段保存着容器要运行的镜像的名称。请记住,镜像可以被看作是一个软件包:它包含了运行一个程序所需的文件和指令集合。这个字段可以设置为像 postgres 这样简单的值,也可以设置为包含版本号的更具体的值,比如 postgres:13

Memory 和 Disk 字段有两个用途。调度器会使用它们来在集群中找到能够运行任务的节点。它们还会被用来告诉 Docker 守护进程一个任务所需的资源数量。

Env 字段允许用户指定要传递到容器中的环境变量。在我们运行 Postgres 容器的命令中,我们设置了两个环境变量:

  • -e POSTGRES_USER=cube 来指定数据库用户

  • -e POSTGRES_PASSWORD=secret 来指定该用户的密码

最后,RestartPolicy 字段告诉 Docker 守护进程如果容器意外终止该怎么做。这个字段是我们编排系统中提供弹性的机制之一。从注释中可以看到,可接受的值为空字符串、alwaysunless-stopped 或 on-failure。将这个字段设置为 always,顾名思义,如果容器停止,就会重新启动它。将其设置为 unless-stopped,除非容器已被停止(例如,通过 docker stop 命令),否则会重新启动它。将其设置为 on-failure,如果容器因错误退出(即退出代码非零),则会重新启动容器。文档(mng.bz/1JdQ)中详细说明了一些细节。

我们将把下面清单中的 Config 结构体添加到第 2 章的 task.go 文件中。

code 3.6 The Config struct that will hold the configuration for orchestration tasks

type Config struct {
    Name string
    AttachStdin bool
    AttachStdout bool
    AttachStderr bool
    ExportedPorts nat.PortSet
    Cmd []string
    Image string
    Cpu float64
    Memory int64
    Disk   int64
    Env    []string
    RestartPolicy string
}

3.4 启动和停止任务

既然我们已经讨论了任务的配置,接下来让我们着手处理任务的启动和停止。要知道,在我们的编排系统里,工作节点会负责为我们运行任务。这项职责主要涉及启动和停止任务。

我们先把清单 3.7 里的 Docker 结构体代码添加到 task.go 文件中。这个结构体将封装把我们的任务作为 Docker 容器运行所需的一切。Client 字段会存储一个 Docker 客户端对象,我们将用它与 Docker API 进行交互。Config 字段会存储任务的配置信息。而且,一旦任务开始运行,它还会包含容器 ID。凭借这个 ID,我们就能与正在运行的任务进行交互。

code 3.7 The Docker struct

type Docker struct {
    Client *client.Client
    Config Config
}

为了方便起见,我们来创建一个名为 DockerResult 的结构体。我们可以在启动和停止容器的方法中使用这个结构体作为返回值,将对调用者有用的常见信息封装起来。该结构体包含一个 Error 字段,用于存储任何错误消息;一个 Action 字段,可用于标识正在执行的操作,例如启动或停止;一个 ContainerId 字段,用于标识结果所对应的容器;最后,还有一个 Result 字段,它可以存储任意文本,以提供关于操作结果的更多信息。

code 3.8 The DockerResult struct

type DockerResult struct {
    Error error
    Action string
    ContainerId string
    Result string
}

现在,激动人心的时刻到了:我们要真正编写代码,将任务作为容器来创建和运行。为此,我们先给之前创建的 Docker 结构体添加一个方法,就叫它 Run 方法。

Run 方法的第一步,是从 Docker Hub 这类容器镜像仓库中拉取任务要使用的 Docker 镜像。容器镜像仓库就是存放镜像的地方,便于托管的镜像进行分发。为了拉取镜像,Run 方法首先会创建一个上下文(context)。上下文是一种可以跨 API、进程等边界传递值的类型,在向 API 发起请求时,常用它来传递截止时间或取消信号。在我们的例子中,我们会使用 Background 函数返回的空上下文。

接着,Run 方法会调用 Docker 客户端对象的 ImagePull 方法,传入上下文对象、镜像名称以及拉取镜像所需的任何选项。ImagePull 方法会返回两个值:一个实现了 io.ReadCloser 接口的对象,以及一个错误对象。这两个值会分别存储在 reader 和 err 变量中。

方法的下一步,会检查 ImagePull 方法返回的错误值。如果该值不为 nil,方法会打印错误信息,并以 DockerResult 的形式返回。

最后,方法会通过 io.Copy 函数,将 reader 变量的值复制到 os.Stdoutio.Copy 是 Go 语言标准库 io 包中的一个函数,它的作用就是把数据从源(这里是 reader)复制到目标(这里是 os.Stdout)。由于我们在运行编排系统的各个组件时,通常会在命令行环境下操作,所以把 reader 变量的内容输出到标准输出(Stdout),能让我们了解 ImagePull 方法执行过程中发生的情况。

code 3.9 The start of our Run() method

func (d *Docker) Run() DockerResult {
    ctx := context.Background()
    reader, err := d.Client.ImagePull(ctx, d.Config.Image, types.ImagePullOptions{})
    if err != nil {
        log.Printf("Error pulling image %s: %v\n", d.Config.Image, err)
        return DockerResult{Error: err}
    }
    io.Copy(os.Stdout, reader)
}

和通过命令行运行容器类似,这个方法也是从拉取容器镜像开始的。

一旦 Run 方法成功拉取了镜像并检查没有错误(希望如此),接下来的任务就是准备要发送给 Docker 的配置信息。不过在此之前,我们先看看 Docker 客户端的 ContainerCreate 方法的签名。我们将使用这个方法来创建容器。

正如清单 3.10 所示,ContainerCreate 方法接受多个参数。和之前使用的 ImagePull 方法类似,它的第一个参数是 context.Context 类型。第二个参数是实际的容器配置,是一个指向 container.Config 类型的指针。我们会把自己定义的 Config 类型中的值复制到这个类型中。第三个参数是一个指向 container.HostConfig 类型的指针。这个类型将保存任务对容器运行所在主机(例如 Linux 机器)的配置要求。第四个参数同样是一个指针,指向 network.NetworkingConfig 类型。这个类型可用于指定网络细节,比如容器的网络 ID、所需的与其他容器的链接以及 IP 地址等。就我们的需求而言,我们不会使用网络配置,而是让 Docker 为我们处理这些细节。第五个参数也是一个指针,指向 specs.Platform 类型。这个类型可用于指定镜像运行的平台细节,比如可以指定 CPU 架构和操作系统等。我们也不会使用这个参数。ContainerCreate 方法的第六个也是最后一个参数是容器名称,以字符串形式传递。

code 3.10 The Docker client's ContainerCreate method

func (cli *Client) ContainerCreate(
    ctx context.Context,
    config *container.Config,
    hostConfig *container.HostConfig,
    networkingConfig *network.NetworkingConfig,
    platform *specs.Platform,
    containerName string) (container.ContainerCreateCreatedBody, error) 
)

现在我们已经清楚在 ContainerCreate 方法中需要传递哪些信息了,接下来就从我们自定义的 Config 类型中收集这些信息,并将其转换为 ContainerCreate 方法能够接受的合适类型。最终得到的结果如清单 3.11 所示。

首先,我们创建一个名为 rp 的变量。这个变量将存储 container.RestartPolicy 类型的数据,其中包含我们在清单 3.6 里自定义的 Config 结构体中定义的 RestartPolicy

在定义完 rp 变量后,我们声明一个名为 r 的变量。这个变量将以 container.Resources 类型存储容器所需的资源。在我们的编排系统中,最常用的资源是内存。

接着,我们创建一个名为 cc 的变量来存储容器配置。该变量的类型为 container.Config,我们会将自定义 Config 类型中的两个值复制到这个变量中。第一个值是容器要使用的 Image,第二个值是所有环境变量,这些环境变量会被放入 Env 字段。

最后,我们把之前定义的 rp 和 r 变量添加到第三个名为 hc 的变量中。这个变量的类型是 container.HostConfig。除了在 hc 变量中指定 RestartPolicy 和 Resources 外,我们还会将 PublishAllPorts 字段设置为 true。这个字段有什么作用呢?回顾一下清单 3.2 中启动 PostgreSQL 容器的 docker run 命令示例,在那个命令里,我们使用 -p 5432:5432 来告知 Docker,我们希望将运行容器的主机上的 5432 端口映射到容器内部的 5432 端口。但这并不是在主机上暴露容器端口的最佳方式,其实有更简单的方法。我们可以将 PublishAllPorts 设置为 true,这样 Docker 会自动通过在主机上随机选择可用端口来暴露容器的端口。

下面的清单创建了四个变量来存储配置信息,这些信息将被传递给 ContainerCreate 方法。

code 3.11 The next phase of running a container

func (d *Docker) Run() DockerResult {
    ctx := context.Background()
    reader, err := d.Client.ImagePull(ctx, d.Config.Image, types.ImagePullOptions{})
    if err != nil {
        log.Printf("Error pulling image %s: %v\n", d.Config.Image, err)
        return DockerResult{Error: err}
    }
    io.Copy(os.Stdout, reader)
    
    rp := container.RestartPolicy{
        Name: d.Config.RestartPolicy,
    }
    
    r := containter.Resources{
        Memory: d.Config.Memory,
        NanoCPUs: int64(d.Config.Cpu * math.Pow(10, 9)),
    }
    
    cc := container.Config{
        Image: d.Config.Image,
        Tty: false,
        Env: d.Config.Env,
        ExposedPorts: d.Config.ExposedPorts,
    }
    
    hc := container.HostConfig{
        RestartPolicy: rp,
        Resources: r,
        PublishAllPorts: true,
    }
}

我们已经完成了所有必要的准备工作,现在可以创建并启动容器了。我们在清单 3.10 中已经提到过 ContainerCreate 方法,所以现在剩下要做的就是像清单 3.12 中那样调用它。不过,有一点需要注意,我们将第四个和第五个参数设为 nil 值,正如你从清单 3.10 中所记得的,这两个参数分别是网络参数和平台参数。在我们的编排系统中不会使用这些功能,所以目前可以忽略它们。

和之前的 ImagePull 方法一样,ContainerCreate 方法会返回两个值:一个响应,它是一个指向 container.ContainerCreateCreatedBody 类型的指针,以及一个错误类型。ContainerCreateCreatedBody 类型的值会存储在 resp 变量中,而错误则存储在 err 变量中。接下来,我们检查 err 变量中是否有错误,如果发现错误,就打印出来,并以 DockerResult 类型返回。

太棒了!我们已经把所有的要素都准备齐全,并将它们组合成了一个容器。剩下要做的就是启动它。为了完成这最后一步,我们调用 ContainerStart 方法。

除了一个上下文参数外,ContainerStart 方法还需要传入一个现有容器的 ID(我们从 ContainerCreate 方法返回的 resp 变量中获取这个 ID),以及启动容器所需的任何选项。在我们的例子中,我们不需要任何选项,所以只需传入一个空的 types.ContainerStartOptionsContainerStart 方法只返回一个类型,即错误类型,所以我们用和之前调用其他方法相同的方式来检查它。如果有错误,我们就打印出来,然后以 DockerResult 类型返回。

code 3.12 The penultimate phase

func (d *Docker) Run() DockerResult {
    ctx := context.Background()
    reader, err := d.Client.ImagePull(ctx, d.Config.Image, types.ImagePullOptions{})
    if err != nil {
        log.Printf("Error pulling image %s: %v\n", d.Config.Image, err)
        return DockerResult{Error: err}
    }
    io.Copy(os.Stdout, reader)
    
    rp := container.RestartPolicy{
        Name: d.Config.RestartPolicy,
    }
    
    r := containter.Resources{
        Memory: d.Config.Memory,
        NanoCPUs: int64(d.Config.Cpu * math.Pow(10, 9)),
    }
    
    cc := container.Config{
        Image: d.Config.Image,
        Tty: false,
        Env: d.Config.Env,
        ExposedPorts: d.Config.ExposedPorts,
    }
    
    hc := container.HostConfig{
        RestartPolicy: rp,
        Resources: r,
        PublishAllPorts: true,
    }
    
    resp, err := d.Client.ContainerCreate(ctx, &cc, &hc, nil, nil, d.Config.Name)
    if err != nil {
        log.Printf("Error creating container using image %s: %v\n", d.Config.Image, err)
        return DockerResult{Error: err}
    }
    
    err = d.Client.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
    if err != nil {
        log.Printf("Error starting container %s: %v\n", resp.ID, err)
        return DockerResult{Error: err}
    }
}

至此,如果一切顺利,我们就有一个运行着任务的容器了。现在剩下要做的就是处理一些记录工作,你可以在清单 3.13 中看到相关内容。我们首先将容器 ID 添加到配置对象中(这个配置对象最终会被存储起来,但我们先不着急说这个!)。和之前将 ImagePull 操作的结果输出到标准输出一样,我们也对容器启动的结果做同样的操作。这可以通过调用 ContainerLogs 方法,然后使用 stdcopy.StdCopy(os.Stdout, os.Stderr, out) 调用将返回值写入标准输出来实现。

code 3.13 The final phase of creating and running a container

func (d *Docker) Run() DockerResult {
    // ...
    
    d.Config.Runtime.ContainerID = resp.ID
    out, err := cli.ContainerLogs{
        ctx,
        resp.ID,
        types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}
    }
    if err != nil {
        log.Printf("Error getting logs for container %s: %v\n", resp.ID, err)
        return DockerResult{Error: err}
    }
    
    stdcopy.StdCopy(os.Stdout, os.Stderr, out)
    return DockerResult{ContainerId: resp.ID, Action: "start", Result: "sucess"}
}

提醒一下,我们在清单 3.9、3.11、3.12 和 3.13 中编写的 Run 方法,执行的操作与 docker run 命令相同。当你在命令行中输入 docker run 时,在底层,docker 二进制文件使用的 SDK 方法与我们在代码中用于创建和运行容器的方法是一样的。

既然我们已经能够创建并启动一个容器,那么现在来编写停止容器的代码。与我们的 Run 方法相比,Stop 方法会简单得多,正如你在清单 3.14 中看到的那样。因为停止容器不需要进行必要的准备工作,这个过程仅仅包括使用容器 ID 调用 ContainerStop 方法,然后使用容器 ID 和必要的选项调用 ContainerRemove 方法。同样,在这两个操作中,代码都会检查从方法中返回的 err 的值。和 Run 方法一样,我们的 Stop 方法执行的操作与 docker stop 和 docker rm 命令所执行的操作相同。

code 3.14 Stopping a container

func (d *Docker) Stop(id stirng) DockerResult {
    log.Printf("Attempting to stop container %v", id)
    ctx := context.Background()
    err := d.Client.ContainerStop(ctx, id, nil)
    if err != nil {
        log.Printf("Error stopping container %s: %v\n", id, err)
        return DockerResult{Error: err}
    }
    
    err = d.Client.ContainerRemove(ctx, id, types.ContainerRemoveOptions{
        RemoveVolumes: true,
        RemoveLinks: false,
        Force:       false,
    })
    if err != nil {
        log.Printf("Error removing container %s: %v\n", id, err)
        return DockerResult{Error: err}
    }
    
    return DockerResult{Action: "stop", Result: "success", Error: nil}
}

现在,让我们更新第 2 章创建的 main.go 程序,使其能够创建和停止容器。

首先,将清单 3.15 中的 createContainer 函数添加到 main.go 文件的末尾。在这个函数内部,我们会为任务设置配置,并将其存储在名为 c 的变量中,然后创建一个新的 Docker 客户端并将其存储在 dc 中。接下来,我们创建一个 task.Docker 类型的 d 对象。从这个对象调用 Run() 方法来创建容器。

code 3.15 The createContainer function

func createContainer() (*task.Docker, *task.DockerResult) {
    c := task.Config{
        Name: "test-container-1",
        Image: "postgres:13",
        Env: []string{
            "POSTGRES_USER=cube",
            "POSTGRES_PASSWORD=secret",
        },
    }
    
    dc, _ := client.NewClientWithOpts(client.FromEnv)
    d := task.Docker{
        Client: dc,
        Config: c,
    }
    
    result := d.Run()
    if result.Error != nil {
        fmt.Printf("%v\n", result.Error)
        return nil, nil
    }
    
    fmt.Printf("Container %s is running with config %v\n", result.ContainerId, c)
    return &d, &result
}

其次,在 createContainer 函数下方添加 stopContainer 函数。这个函数接受一个参数 d,它与清单 3.15 中 createContainer 函数里创建的 d 对象是同一个。剩下要做的就是调用 d.Stop() 方法。

code 3.16 The stopContainer function

func stopContainer(d *task.Docker, id string) *task.DockerResult {
    result := d.Docker.Stop(id)
    if result.Error != nil {
        fmt.Printf("%v\n", result.Error)
        return nil
    }
    fmt.Printf("Container %s has been stopped and removed\n", result.ContainerId)
    return &result
}

code 3.17 Calling the createContainer and stopContainer functions

func main() {
    // ...
    
    fmt.Printf("create a test container\n")
    dockerTask, createResult := createContainer()
    if createResult.Error != nil {
        fmt.Printf("%v", createResult.Error)
        os.Exit(1)
    }
    
    time.Sleep(time.Second * 5)
    fmt.Printf("stopping container %s\n", createResult.ContainerId)
    _ = stopContainer(dockerTask, createResult.ContainerId)
}

又到了关键时刻。让我们运行代码!

code 3.18

$ go run main.go          
task: {05cdfbf1-1844-4262-b48c-1ca422ee5996 Task-1 0 1024 1 docker.io/library/ubuntu:24.04  0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC}
task event: {16d0dce7-18d4-41b4-bee4-76d74d131d78 0 {05cdfbf1-1844-4262-b48c-1ca422ee5996 Task-1 0 1024 1 docker.io/library/ubuntu:24.04  0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC} 2025-03-11 15:06:50.23041 +0800 CST m=+0.003119084}
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}
create a test container
{"status":"Pulling from library/postgres","id":"13"}
{"status":"Digest: sha256:a4c9ad5add8a2e4c86e123bfdaf940d120c0541b782f317ab4e630ab3d391650"}
{"status":"Status: Image is up to date for postgres:13"}
Container 9dc7a5e4eb637a9967aee25084b5c436b2e0a185831701bd30afe2dbc7222b30 is running with config {test-container-1 false false false map[] {} [] docker.io/library/postgres:13 0 0 0 [POSTGRES_USER=cube POSTGRES_PASSWORD=secret] }
stopping container 9dc7a5e4eb637a9967aee25084b5c436b2e0a185831701bd30afe2dbc7222b30
2025/03/11 15:06:59 Attempting to stop container 9dc7a5e4eb637a9967aee25084b5c436b2e0a185831701bd30afe2dbc7222b30
Container  has been stopped and removed

至此,我们已经搭建好了编排系统的基础。我们能够创建、运行、停止和移除容器,而这些操作正是我们 “任务(Task)” 概念的技术实现方式。我们系统中的其他组件,也就是工作节点(Worker)和管理节点(Manager),将使用这个 “任务” 实现来履行它们各自的必要职责。

总结

任务(Task)这一概念及其技术实现,是我们编排系统的基本单元。系统里的其他所有组件 —— 工作节点(Worker)、管理节点(Manager)和调度器(Scheduler),其存在的目的都是为了启动、停止和检查任务。

Docker API 提供了以编程方式操作容器的能力。其中最重要的三个方法是 ContainerCreateContainerStart 和 ContainerStop。借助这些方法,开发者在代码里能够实现与在命令行中(即使用 docker rundocker start 和 docker stop 命令)相同的操作。

容器有其自身的配置。这些配置可以细分为以下几类:标识(即如何识别容器)、资源分配、网络设置以及错误处理。

任务是我们编排系统中执行的最小工作单元,可以把它类比为在你的笔记本电脑或台式机上运行一个程序。

在本书中我们使用 Docker,是因为它将许多底层操作系统的相关问题进行了抽象处理。我们本可以把编排系统实现为让任务作为常规的操作系统进程来运行。然而,这样做就意味着我们的系统需要深入了解进程在不同操作系统(比如 Linux、Mac、Windows)上是如何运行的细节。

一个编排系统由多台机器组成,这些机器构成了一个集群。