笔者坚信不疑的一个观点是,万物想“通”,这个“通”更多是从高阶的抽象的角度,设计一个高可用的系统和在家里给女儿做番茄炒蛋是一个道理。我们以番茄炒蛋为例,如果要让孩子中午能吃到美味健康的番茄炒蛋,我们需要先清洗番茄,然后把番茄切碎,在洗番茄和切番茄的时候,如果还有人手,可以并行的准备鸡蛋液,当所有的材料准备好后,就可以热油,油热了后,先炒鸡蛋,鸡蛋炒好后,洗锅,然后超西红柿,西红柿炒的七分熟,然后倒入鸡蛋,翻炒3分钟,出锅前调味,然后中午孩子就有美味又健康的家常菜西红柿炒鸡蛋吃了。你如果把上边的整个过程和计算机程序做个对比,你会发现有串行处理,并行处理,多线程等待(比如西红柿还没有切好的时候,即便是把鸡蛋炒好了,也不能进入下一步),资源互斥(锅在炒鸡蛋的时候,就不能炒西红柿)等等。给孩子中午做西红柿炒鸡蛋是一项工作,我们将这项任务拆解为多个任务来完成,而从某种抽象层面来看编排系统的任务的话,其实并没有两样。
对于大部分企业来说,基本都会有自己的官方网站,网站会运行在Apache web服务器上,这就是一个任务。大部分网站都有数据库支持来提供数据,数据库服务器部署在单独的服务器上,安装了MYSQL或者PostgreSQL,来持久化保存动态更新的数据,这也是一个任务(Task)。我们在做西红柿炒蛋的时候,并不是空手就可以做出来,需要特定的“运行环境”,首先需要有厨房,厨房里有电冰箱来提供所需的食材,另外我们需要煤气灶和锅,刀子,案板,打蛋器和橱柜,碗等。同理运行一个任务也需要“运行环境”,特别对于编排系统来说,需要一台物理机,以及容器化平台,比如Docker。Docker平台给我们的任务提供了运行需要的所有资源,比如CPU,内存,网络接口(le0和lo)等等。对于像任何编排系统,任务都是最最基础的组成元素(还记得笔者上篇文章介绍动态配置,POD是整个Kubernetes的基石),如下图所示,我们对第一篇文章中介绍的编排系统模型进行了一点优化:
如上图所示,编排系统最终选择了工作节点#2来运行我们提交的任务T1,图中的蓄念表示,虽然编排系统的调度器考虑了工作节点#1和#3,但是最终还是选择了工作节点#2来运行我们提交的任务。那么我们如何让Task任务在工作节点上运行起来呢?这是笔者这篇文章的主体,我们会让Task结构的代码完整起来,让我们测试用的代码可以在本地Docker环境上运行起来。因此你可以看到Docker提供的容器化运行环境是核心,为了让内容更加完整,我们从Docker的一些基础知识开始。
相信关注笔者系列文章的同学应该对Docker不会陌生,虽然说最新版本的Kubernetes已经把Containerd作为默认的容器运行时,但是考虑到读者的续写成本以及体感,我们的julia平台会构建在Docker容器化平台之上,笔者的确考虑后续迁移到Containerd上,切换成本不会太高。简单来说,容器技术让开发人员将自己的应用程序,依赖,甚至包括整个操作系统的文件系统打包成标准镜像,镜像会被推到企业的中心仓库,这样Devops团队就不用担心兼容性,依赖等问题,因为容器在打包的时候,已经基本把依赖也打包了,比如说访问数据库的三方库等。
Docker不光提供了一套完善和企业级部署ready的打包工具,同时也提供了一套部署工具,我们很容易在安装了Docker的机器上,通过docker run来启动一个容器实例,比如一台Postgres数据库实例,来支持我们的日常开发,这比下载安装包,安装到本机,进行配置后启动要方便的多。如下边的命令,在安装了Docker的机器上,可以顺利启动一台指定版本的postgrep数据库服务器:
docker run -it -p 5432:5432 --name postgres -e POSTGRES_USER=julia -e POSTGRES_PASSWORD=yunpandaughter postgres:13
当数据库运行起来之后,我们首先通过pg的客户端登陆到数据库实例:psql -h localhost -p 5439 -U julia,然后就可以创建数据库,创建表等,这些SQL操作和使用传统方式安装的数据库服务器并无两样。
当容器运行起来之后,我们还可以使用docker inspect来获取容器实例的详细信息,docker inspect和笔者在Kubernetes系列文章中介绍的kubectl describe pod yunpan-ssl类似,会返回非常全面的容器实例状态信息,为了节省篇幅,并且我们已经多次在前边的文章中展示过,大家可以在自己的本地环境验证。
最后,当任务的工作已经完成,或者我们需要主动退出,可以使用docker stop yunpan-ssl(容器的ID或者名字)命令,虽然说这个命令不会有任何打印输出,但是如果我们再使用docker inspect命令查看容器实例的状态时,会发现这个时候容器实例的状态从running变成exited。
在我们的julia容器编排平台上,工作节点负责启动,停止和提供任务运行所需要的上下文环境信息(可以回想一下西红柿炒鸡蛋的例子,厨房,冰箱,刀具,厨具等)。工作节点不能自己单独完成任务启动,停止工作,需要借助于Docker平台提供的API。幸运的是,Docker提供的操作容器实例的API可以通过HTTP协议来访问,这就意味着我们可以使用httpclient,curl这样的工具或者客户端变成代码库来控制容器的启动和运行。比如我们在自己的Docker环境中,通过命令行curl --unix-socket /run/docker.sock http://docker/containers/12345678910/json 就能获取和docker inspect返回的相同容器实例信息。
当然对于我们即将要开发实现的julia编排平台来说,我们可以使用Go的http客户端端代码库,虽然说可行,但是我们要处理很多细节,比如HTTP方法,状态码,序列化,反序列化等,因此为了更加聚焦核心工作,我们会使用Docker提供的SDK。SDK封装了Docker API的细节,给开发人员提供了更加友好的编程接口,比如创建,运行和停止任务等,Docker的SDK提供如下所示的6个方法,我们会在代码中重度依赖:
- NewClientWithOpts方法: 创建访问Dokcer客户端的实例并返回给调用者。
- ImagePull方法: 从仓库pull容器进项到工作节点。
- ContainerCreate方法: 基于提供的配置信息创建容器实例
- ContainerStart方法: 给Docker引擎发送启动新创建容器实例的请求
- ContainerStop方法: 发送请求给Docker引擎来停止指定的容器实例
- ContainerRemove: 从工作节点上删除容器实例
坦白讲,我们在自己本地环境中使用docker命令来操作容器,本质上使用的也是Docker SDK,如下图所示:
如上图所示,使用代码操作容器实例和使用docker命令行工具并没有实质的区别,并且使用SDK可以让我们的开发工具得到极大的简化。
为了让我们的应用程序能在Docker上运行起来,我们需要提供配置信息给Docker引擎。配置信息就如同我们做西红柿炒蛋的菜谱一样,比如我们需要2个鸡蛋,3个西红柿,并且且西红柿,热油以及炒鸡蛋需要按照确定的流程,并且每个子任务有固定的时间。对于启动容器实例来讲,我们最少需要提供镜像的ID,名称,进程运行的端口号,需要的硬件资源等信息。
具体来讲,在julia编排系统中,我们使用Config结构来管理配置信息。Config结构封装了容器任务运行需要的所有配置信息,如下图所示:
如上图所示的是julia编排平台上,任务的配置管理结构。坦白讲,对于熟悉Kubernetes平台的同学来说,加上笔者对这些字段基本上都给了注释,因此不太需要太多的画蛇添足般解释。但是未了内容的完整性,我们稍微啰嗦一下。
Name字段用来在julia平台上唯一识别一个任务,并且容器的名称也是直接拷贝自这个字段;Image字段用来记录容器进程运行的软件包(镜像);Memory和Disk字段在julia平台有来个作用,调度器用这个信息来帮任务找到合适的工作节点运行起来,而worker进程用这两个字段的值来请求Docker分配足够的资源来运行应用程序。
Env字段用来给容器进程传递配置信息(这是我们的老朋友了,笔者前边几篇关于Kubernetes配置管理的文章,就是为了让大家对这些概念有深入的理解,不至于到这篇文章看不懂),在我们后续的例子中,我们会使用两个环境变量:“-e POSTGRES_USER=julia”和“-e POSTGRES_PASSWORD=secret”来分别指定postgres数据库的用户名和密码(由于我们的目的是开发学习用的调度平台,我知道这样不安全,大家在Kubernetes中部署应用的话,记得要使用Secret对象)。
最后,RestartPolicy字段少不了,用来告诉Docker daemon当应用crash的时候,该咋办。这个字段对于编排平台来说异常重要,因为这是容器实现“健壮”的根本,我们的julia平台上只接:受空字符串,always,unless-stopped和on-faliure。当我们将重启策略设置为always的时候,容器实例会在所有情况下重启。unless-stopped只有在容器执行docker stop之后才会重启,而on-falure只有容器出现运行错误的时候才会重启(返回非0的状态码)。
完成了任务的配置文件定义之后,终于到了最激动人心的时刻了,我们来具体实现如何启动和停止一个任务。从第一篇文章开始,笔者多次强调,在我们的julia编排系统上,worker对象负责启动任务。
我们先从给task代码文件增加Docker结构开始,如下图所示:
如上图所示的Docker结构定义,字段都有注释,因此笔者就不累述了。为了简化变成,我们增加一个DockerResult结构,用来封装访问调用后的返回信息。DockerResult结构中有error字段,用来记录错误信息;Action字段用来记录具体执行的操作是什么,比如start或者stop;ContainerId字段用来记录具体的容器实例,以及Result字段用来记录Action操作后的更多信息。
好了,有了这些结构后,接下来,我们来实现为任务创建和运行容器实例的代码。具体来说,我们为Docker结构增加叫Run的方法,Run方法的第一步是从镜像仓库下载镜像。为了pull镜像,Run方法首先需要创建一个context,用来在不同的上下文传递数据。
接着我们的Run方法调用Dockre客户端实例的ImagePull方法,这个方法接受我们上边创建的context对象,镜像名称,以及其他需要的信息。ImagePull方法会返回两个对象:一个实现了io.ReadCloser对象和一个error对象,这两个对象分别保存在reader和err变量中。
接下来,代码逻辑会检查error值,如果不为nil,那么方法直接打印error message并返回一个DockerResult对象。最后,Run方法会把通过io.Copy方法把reader变量的值拷贝到os.Stdout。
注:io.Copy是Golang标准库io代码库提供的方法,用来把数据从源(reader)拷贝的目标(os.Stdout)。
当我们顺利从仓库下载完镜像数据后,接下来,我们的“菜谱上”写的是准备运行容器需要的配置信息。由于我们即将使用Docker SDK客户端来访问Docker API,因此先了解一下ContainerCreate方法的具体签名很有必要,如下图所示:
如上图所示,ContainerCreate方法的第一个参数是context,这个我们前边介绍过;第二个参数是任务的配置信息,我们会将自己定义的Config结构的信息拷贝过来;第三个参数是个container.HostConfig类型的指针,这个参数设置了任务对可运行工作节点的要求,比如必须是一台安装了linux某个版本操作系统的机器;第四个参数是网络接口,用来指定网络详细信息,比如network id,ip地址等。
第五个参数是指向specs.Platform类型的指针,用来设置诸如CPU架构等信息;最后一个参数是容器的名称,为字符串类型。有了对这个方法输入参数的深入理解,我们接下来编写代码将信息从Config类型转换成方法需要的参数类型,具体的代码如下图所示:
如上图所示,首先我们设置重启策略,创建变量rp,并把我们的Config结构中的重启策略嵌入进去。接下来定义变量r用来声明任务需要的计算资源;接下来我们创建容器配置变量cc,类型为container.Config,我们会把在自己的Config类型把Image和Env信息拷贝进去;最后,我们把rp和r变量组合成类型为container.HostConfig的变量hc,并增加PublishAllPorts=true字段,来告诉Docker将应用暴露在随机选择的端口上对外提供服务。
好了,到这里为止,我们的准备工作已经做完了,我们终于可以创建容器并启动起来了。由于我们也介绍过ContainerCreate方法的签名了,因此直接调用这个方法,大家需要注意我们的第四个和五个参数为nil,意味当前不会用到这个参数,可以忽略。代码如下图所示:
如上图所示,ContainerCreate方法返回两个值,一个container.ContainerCreateCreatedBody类型的指针和一个error类型。我们把error信息保存在err变量上,接下来检查err变量,如果有任何错误,我们就打印出来,并返回对应的DockerResult。
接下来我们就启动容器,ContainerStart方法接受容器的ID,这个ID来自于ContainerCreate的第一个类型为container.ContainerCreateCreatedBody的返回值。由于我们目前还没有启动的其他选项,因此传递一个空的types.ContainerStartOptions类型的参数。ContainerStart方法只有一个error返回值,因此我们的代码检查时候方法调用是否有错误,如果有就打印出来,并封装到DockerResult返回。
到这里为止,如果一切顺利,我们的容器实例就成功在Docker平台上运行起来了,最后我们做一些收尾工作,将container ID加入配置对象,调用ContainerLogs来将返回值写到stdout,如下图所示:
注:Run方法中启动容器的代码和Docker run基本一致,也就是说我们通过docker run运行容器,背后指定的代码逻辑就是我们的Run方法。
完成了容器启动方法,最后我们来编写Stop方法,坦白讲Stop方法比Run方法简单多了,直接上代码如下:
如上图所示,读者需要注意的是,Stop方法和我们执行docker stop的处理机制完成一样。
在运行我们的代码之前,还剩下最后一步,更新main方法,我们需要增加createContainer方法来设置任务的配置,以及增加stopContainer方法来停止容器是的运行。接着,我们在main方法中分别调用这两个方法,来创建和停止容器实例。如下图所示:
代码写好了,赶紧运行起来看看,在自己的环境中执行 go run main.go,如果一切顺利,你就会看到如下图所示的输出,说明一切按预期工作。
好了,今天这篇文章内容就这么多了,关于julia编排平台的源代码,笔者很快会在下篇文章末尾放出来(还在完善中..),敬请关注!