为任务骨架添加细节 本章内容包括:
回顾如何通过命令行启动和停止Docker容器 介绍用于启动和停止容器的Docker API调用 实现启动和停止容器的任务概念
想象一下烹饪你最喜欢的美食。假设你喜欢制作自制披萨。为了从烤箱中取出一块美味的热披萨,你需要执行许多任务。如果你喜欢在披萨上放洋葱、青椒或其他蔬菜,你需要将它们切碎。你必须揉面团并将其摊在烤盘上。接下来,你将番茄酱均匀地涂在面团上,并撒上奶酪。最后,在奶酪上放上蔬菜和其他配料。在编排系统中,任务类似于制作披萨的每一个独立步骤。如今,大多数公司都有一个网站。该公司的网站运行在一个Web服务器上,可能是无处不在的Apache Web服务器。这就是一个任务。网站可能使用一个数据库,如MySQL或PostgreSQL,来存储动态内容。这也是一个任务。
在我们的披萨制作类比中,披萨并不是在真空中制作的。它是在一个特定的环境中制作的,这个环境就是厨房。厨房提供了制作披萨所需的资源:存放奶酪的冰箱、存放披萨酱的橱柜、烤披萨的烤箱以及切披萨的刀。同样,任务也在特定的环境中运行。在我们的例子中,这个环境将是一个Docker容器。就像厨房一样,容器将提供任务运行所需的资源:根据任务的需求提供CPU周期、内存和网络。
需要提醒的是,任务是编排系统的基础。图1.1展示了我们在第1章中构建的思维模型的修改版本。
图3.1 编排系统的主要目的是接受用户提交的任务并在系统的工作节点上运行它们。在这里,我们看到一个用户将任务提交给管理节点,管理节点选择Worker2来运行任务。虚线连接的Worker1和Worker3表示这些节点被考虑过,但最终没有被选中来运行任务。
在本章的其余部分,我们将详细实现上一章中编写的任务骨架。但首先,让我们快速回顾一些Docker的基础知识。
3.1 Docker:通过命令行启动、停止和检查容器 如果你是一名开发人员,你可能已经使用Docker容器在笔记本电脑上运行你的应用程序及其后端数据库,以便在编写代码时进行测试。如果你是一名DevOps工程师,你可能已经将Docker容器部署到公司的生产环境中。容器允许开发人员将他们的代码及其所有依赖项打包,然后将容器部署到生产环境中。如果DevOps团队负责生产环境的部署,那么他们只需担心部署容器,而不必担心运行容器的机器是否具有应用程序连接数据库所需的正确版本的PostgreSQL库。
提示:如果你需要更详细地了解Docker容器及其控制方法,请参阅《Docker实战》第二章(http://mng.bz/PRq8)。
要运行一个Docker容器,我们可以使用docker run命令,下面的示例展示了这一点。在这里,docker run命令启动了一个PostgreSQL数据库容器,该容器可能在开发新应用程序时用作后端数据存储。
示例3.1 运行Postgres数据库服务器作为Docker容器
这个命令在前台运行容器(-it),这意味着我们可以看到它的日志输出,给容器命名为postgres,并设置POSTGRES_USER和POSTGRES_PASSWORD环境变量。
一旦容器运行起来,它就会执行与在笔记本电脑或台式机上作为常规进程运行时相同的功能。在示例3.1中的Postgres数据库的情况下,我现在可以使用psql命令行客户端登录到数据库服务器,并创建一个表,如下所示。
示例3.2 登录Postgres服务器并创建表 由于我们在前面的docker run命令中指定了-p 5432:5432,我们可以告诉psql客户端连接到本地机器上的该端口。
一旦容器启动并运行,我们可以使用docker inspect命令获取有关它的信息。该命令的输出非常详细,所以我只列出State信息。
示例3.3 使用docker inspect命令
最后,我们可以使用docker stop cube-book命令停止Docker容器。该命令没有任何输出,但如果我们现在运行docker inspect cube-book命令,我们会看到状态已经从running变为exited。
示例3.4 在docker stop cube-book之后运行docker inspect cube-book
3.2 Docker:通过API启动、停止和检查容器 在我们的编排系统中,工作节点将负责启动、停止和提供有关其运行任务的信息。为了执行这些功能,工作节点将使用Docker的API。该API可以通过HTTP协议访问,使用像curl这样的客户端或编程语言的HTTP库。下面的示例展示了如何使用curl获取与之前docker inspect命令相同的信息。
示例3.5 使用curl HTTP客户端查询Docker API
注意,我们在curl命令中传递了–unix-socket标志。默认情况下,Docker监听一个Unix套接字,但它可以配置为监听TCP套接字。URL http://docker/containers/6970e8469684/json包含要检查的容器ID,我从我的机器上的docker ps命令中获取了该ID。最后,curl的输出被管道传输到jq命令,该命令以比curl更可读的格式打印输出。
我们可以在我们的编排系统中使用Go的HTTP库,但这会迫使我们处理许多低级细节,如HTTP方法、状态码以及序列化请求和反序列化响应。相反,我们将使用Docker的SDK,它为我们处理所有低级HTTP细节,并允许我们专注于主要任务:创建、运行和停止容器。SDK提供了以下六个方法,能够满足我们的需求:
NewClientWithOpts:一个帮助方法,实例化客户端并返回给调用者 ImagePull:将镜像拉取到本地机器 ContainerCreate:使用给定配置创建一个新容器 ContainerStart:向Docker引擎发送请求以启动新创建的容器 ContainerStop:向Docker引擎发送请求以停止运行中的容器 ContainerRemove:从主机中移除容器 注意:Docker的Golang SDK有详细的文档(https://pkg.go.dev/github.com/docker/docker),值得一读。特别是关于Go客户端的文档(https://pkg.go.dev/github.com/docker/docker/client)与我们在本书其余部分的工作密切相关。
我们在上一节中回顾的docker命令行示例在底层使用了Go SDK。在本章后面,我们将实现一个使用ImagePull、ContainerCreate和ContainerStart方法的Run()方法,以创建和启动容器。图3.2提供了我们自定义代码和使用SDK的docker命令的图示。
通过在我们的编排系统中使用Go SDK来控制Docker容器,我们不必重新发明轮子。我们可以简单地重用每天由docker命令使用的相同代码。
图3.2 无论起点如何,所有创建和运行容器的路径都通过Docker SDK。
容器运行
3.3 任务配置 为了将我们的任务作为容器运行,它们需要一个配置。什么是配置呢?回想一下本章开头的披萨类比。制作披萨的任务之一是切洋葱(如果你不喜欢洋葱,可以换成你喜欢的蔬菜)。为了完成这个任务,我们会使用刀和砧板,并以特定的方式切洋葱。也许我们将它们切成薄片,或者切成小方块。这些都是切洋葱任务的“配置”部分。(好吧,我可能把披萨的类比拉得有点远,但我想你明白我的意思。)
在我们的编排系统中,我们将使用Config结构体来描述任务的配置,如示例3.6所示。这个结构体封装了关于任务配置的所有必要信息。注释应该能让每个字段的意图变得显而易见,但有几个字段值得特别强调。
Name字段将用于在我们的编排系统中标识一个任务,并且它将作为运行容器的名称。在本书的其余部分,我们将使用这个字段来命名我们的容器,例如test-container-1。
Image字段,如你所料,包含容器将运行的镜像名称。记住,镜像可以被视为一个包:它包含运行程序所需的文件和指令集合。这个字段可以设置为一个简单的值,如postgres,也可以设置为包含版本的更具体的值,如postgres:13。
Memory和Disk字段将有两个用途。调度器将使用它们来找到能够运行任务的集群节点。它们还将用于告诉Docker守护进程任务所需的资源数量。
Env字段允许用户指定将传递到容器中的环境变量。在我们运行Postgres容器的命令中,我们设置了两个环境变量:-e POSTGRES_USER=cube指定数据库用户,-e POSTGRES_PASSWORD=secret指定该用户的密码。
最后,RestartPolicy字段告诉Docker守护进程如果容器意外死亡该怎么办。这个字段是我们编排系统中提供弹性的机制之一。正如注释所示,可接受的值是空字符串、always、unless-stopped或on-failure。将此字段设置为always,顾名思义,如果容器停止,将重新启动容器。将其设置为unless-stopped,将重新启动容器,除非它已被停止(例如,通过docker stop)。将其设置为on-failure,如果容器由于错误退出(即非零退出代码),将重新启动容器。文档中有一些详细信息(http://mng.bz/1JdQ)。
我们将在下一个示例中将Config结构体添加到第2章的task.go文件中。
示例3.6 用于编排任务配置的Config结构体
3.4 启动和停止任务 现在我们已经讨论了任务的配置,让我们继续讨论如何启动和停止任务。记住,在我们的编排系统中,工作节点将负责为我们运行任务。这项责任主要涉及启动和停止任务。
让我们从将示例3.7中的Docker结构体代码添加到task.go文件开始。这个结构体将封装我们需要的一切,以便将任务作为Docker容器运行。Client字段将保存一个Docker客户端对象,我们将使用它与Docker API交互。Config字段将保存任务的配置。一旦任务运行,它还将包含ContainerId。这个ID将允许我们与运行中的任务进行交互。
示例3.7 Docker结构体
为了方便起见,让我们创建一个名为DockerResult的结构体。我们可以使用这个结构体作为启动和停止容器方法的返回值,提供一个包装常见信息的方式。该结构体包含一个Error字段,用于保存任何错误消息。它有一个Action字段,可以用来标识正在执行的操作,例如启动或停止。它有一个ContainerId字段,用于标识结果所涉及的容器。最后,还有一个Result字段,可以保存提供有关操作结果的更多信息的任意文本。
示例3.8 DockerResult结构体
现在我们准备好进行激动人心的部分:实际编写代码以将任务作为容器创建和运行。为此,让我们从为我们之前创建的Docker结构体添加一个方法开始。我们称这个方法为Run。
我们Run方法的第一部分将从容器注册表(如Docker Hub)中拉取任务将使用的Docker镜像。容器注册表只是一个镜像的存储库,允许轻松分发它所托管的镜像。为了拉取镜像,Run方法首先创建一个上下文,这是一个可以跨API和进程边界传递值的类型。通常使用上下文在请求API时传递截止日期或取消信号。在我们的例子中,我们将使用从Background函数返回的空上下文。
接下来,Run在Docker客户端对象上调用ImagePull方法,传递上下文对象、镜像名称和拉取镜像所需的任何选项。ImagePull方法返回两个值:一个实现io.ReadCloser接口的对象和一个错误对象。它将这些值存储在reader和err变量中。
方法的下一步检查ImagePull返回的错误值。如果该值不为nil,方法将打印错误消息并返回一个DockerResult。最后,方法通过io.Copy函数将reader变量的值复制到os.Stdout。io.Copy是Golang标准库中io包的一个函数,它只是将数据从源(reader)复制到目标(os.Stdout)。因为我们在运行编排系统的组件时将从命令行工作,所以将reader变量写入Stdout是传达ImagePull方法中发生了什么的有用方式。
示例3.9 我们Run()方法的开始部分
类似于从命令行运行容器,该方法首先拉取容器的镜像。
一旦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的第六个也是最后一个参数是容器名称,以字符串形式传递。
示例3.10 Docker客户端的ContainerCreate方法
现在我们知道在 ContainerCreate
方法中需要传递哪些信息了,所以让我们从 Config
类型中收集这些信息,并将其转换为 ContainerCreate
方法可以接受的适当类型。最终的结果如清单3.11所示。
首先,我们将创建一个名为 rp
的变量。这个变量将保存一个 container.RestartPolicy
类型,并包含我们在 Config
结构体中定义的 RestartPolicy
(见清单3.6)。
在 rp
变量之后,让我们声明一个名为 r
的变量。这个变量将保存容器所需的资源,类型为 container.Resources
。在我们的编排系统中,最常用的资源是内存。
接下来,我们创建一个名为 cc
的变量来保存我们的容器配置。这个变量的类型是 container.Config
,我们将从 Config
类型中复制两个值到其中。第一个是容器将使用的 Image
,第二个是任何环境变量,它们将被放入 Env
字段。
最后,我们将定义的 rp
和 r
变量添加到第三个变量 hc
中。这个变量是 container.HostConfig
类型。除了在 hc
变量中指定 RestartPolicy
和 Resources
外,我们还将 PublishAllPorts
字段设置为 true
。这个字段的作用是什么呢?还记得我们在清单3.2中的示例 docker run
命令吗?在那个命令中,我们使用 -p 5432:5432
告诉 Docker 我们希望将运行容器的主机上的端口5432映射到容器内部的端口5432。其实,这并不是暴露容器端口的最佳方式。有一种更简单的方法。我们可以将 PublishAllPorts
设置为 true
,Docker 会自动通过随机选择主机上的可用端口来暴露这些端口。
以下清单创建了四个变量来保存配置信息,这些信息将被传递给 ContainerCreate
方法。
清单3.11 运行容器的下一阶段
我们已经完成了所有必要的准备工作,现在可以创建容器并启动它了。我们已经在清单3.10中提到过 ContainerCreate
方法,所以现在只需要像清单3.12那样调用它。不过需要注意的是,我们将 nil
值作为第四和第五个参数传递,这些参数分别是网络和平台参数。我们不会在我们的编排系统中使用这些功能,所以现在可以忽略它们。
与之前的 ImagePull
方法一样,ContainerCreate
返回两个值:一个响应,它是指向 container.ContainerCreateCreatedBody
类型的指针,以及一个错误类型。ContainerCreateCreatedBody
类型存储在 resp
变量中,而错误存储在 err
变量中。接下来,我们检查 err
变量是否有任何错误,如果发现错误,则打印并在 DockerResult
类型中返回它们。
太好了!我们已经将所有的原料组合在一起,并形成了一个容器。现在只剩下启动它了。要执行这最后一步,我们调用 ContainerStart
方法。
除了上下文参数外,ContainerStart
还需要一个现有容器的 ID,我们从 ContainerCreate
返回的 resp
变量中获取这个 ID,以及启动容器所需的任何选项。在我们的例子中,我们不需要任何选项,所以我们只需传递一个空的 types.ContainerStartOptions
。ContainerStart
只返回一个类型,即错误,所以我们像之前的方法调用一样检查它。如果有错误,我们打印它并在 DockerResult
中返回它。
清单3.12 倒数第二阶段
此时,如果一切顺利,我们已经有一个正在运行任务的容器了。现在只剩下一些后续工作,如清单3.13所示。我们首先将容器 ID 添加到配置对象中(最终会被存储,但我们先不急)。类似于将 ImagePull
操作的结果打印到标准输出,我们也将启动容器的结果打印到标准输出。这是通过调用 ContainerLogs
方法,然后使用 stdcopy.StdCopy(os.Stdout, os.Stderr, out)
将返回值写入标准输出来完成的。
清单3.13 创建和运行容器的最后阶段
提醒一下,我们在清单3.9、3.11、3.12和3.13中编写的 Run
方法执行的操作与 docker run
命令相同。当你在命令行中输入 docker run
时,底层的 docker 二进制文件使用的 SDK 方法与我们在代码中使用的方法相同,以创建和运行容器。
现在我们可以创建并启动一个容器了,让我们编写代码来停止一个容器。与我们的 Run
方法相比,Stop
方法要简单得多,如清单3.14所示。因为停止容器不需要必要的准备工作,这个过程只涉及调用 ContainerStop
方法并传递 ContainerID
,然后调用 ContainerRemove
方法并传递 ContainerID
和必要的选项。同样,在这两个操作中,代码检查从方法返回的 err
值。与 Run
方法一样,我们的 Stop
方法执行的操作与 docker stop
和 docker rm
命令相同。
清单3.14 停止容器
现在,让我们更新我们在第2章中创建的 main.go
程序,以创建和停止一个容器。
首先,将清单3.15中的 createContainer
函数添加到 main.go
文件的底部。在函数内部,我们将为任务设置配置并存储在一个名为 c
的变量中,然后我们将创建一个新的 Docker 客户端并存储在 dc
中。接下来,让我们创建 d
对象,它的类型是 task.Docker
。从这个对象中,我们调用 Run()
方法来创建容器。
清单3.15 createContainer
函数
其次,在 createContainer
下面添加 stopContainer
函数。这个函数接受一个参数 d
,它是 createContainer
中创建的 d
对象。剩下的就是调用 d.Stop()
。
清单3.16 stopContainer
函数
最后,我们在 main()
函数中调用我们创建的 createContainer
和 stopContainer
函数。为此,将清单3.17中的代码添加到 main
函数的底部。
如你所见,代码相当简单。它首先打印一个有用的信息,表示它将创建一个容器;然后调用 createContainer()
函数并将结果存储在两个变量 dockerTask
和 createResult
中。接着通过将 createResult.Error
的值与 nil
进行比较来检查是否有错误。如果发现错误,它会打印错误并通过调用 os.Exit(1)
退出。要停止容器,main
函数只需调用 stopContainer
并传递之前调用 createContainer
返回的 dockerTask
对象。
清单3.17 调用 createContainer
和 stopContainer
函数
又到了见证真相的时刻。让我们运行代码!
清单3.18 运行代码以创建和停止容器
此时,我们的容器已经创建并正在运行。你可以使用 docker ps
和 docker logs test-container-1
来检查它。
最后,我们停止容器并将其移除。再次,你可以使用 docker ps
来验证操作是否成功,查看它是否正在运行,以及使用 docker ps -a
来查看它是否已被移除。
在这里,我们可以看到我们的代码正在为容器拉取镜像。
此时,我们已经建立了编排系统的基础。我们可以创建、运行、停止和移除容器,这为我们的任务概念提供了技术实现。系统中的其他组件——即 Worker 和 Manager——将使用这个任务实现来执行它们的必要角色。
总结
- 任务概念及其技术实现是我们编排系统的基本单元。其他所有组件——worker、manager 和 scheduler——都存在于启动、停止和检查任务的目的。
- Docker API 提供了以编程方式操作容器的能力。三个最重要的方法是
ContainerCreate
、ContainerStart
和ContainerStop
。这些方法允许开发人员从代码中执行与命令行(即docker run
、docker start
和docker stop
)相同的操作。 - 容器有一个配置。配置可以分为以下几类:标识(即如何标识容器)、资源分配、网络和错误处理。
- 任务是我们编排系统执行的最小工作单元,可以类似于在你的笔记本电脑或台式机上运行一个程序。
- 本书中使用 Docker 是因为它抽象了底层操作系统的许多问题。我们可以实现我们的编排系统以将任务作为常规操作系统进程运行。然而,这样做意味着我们的系统需要非常熟悉跨操作系统(例如 Linux、Mac、Windows)运行进程的细节。
- 一个编排系统由多台机器组成,称为集群。