这篇文章是一系列文章的第一篇,在这一系列文章中,我们想要分享我们如何使用Docker、Docker-Compose和Rancher完成容器部署工作流的故事。我们想带你从头开始走过pipeline的革命历程,重点指出我们这一路上遇到的痛点和做出的决定,而不只是单纯的回顾。幸好有很多优秀的资源可以帮助你使用Docker设置持续集成和部署工作流。这篇文章并不属于这些资源之一。一个简单的部署工作流相对比较容易设置。但是我们的经验表明,构建一个部署系统的复杂性主要在于原本容易的部分需要在拥有很多依赖的遗留环境中完成,以及当你的开发团队和运营组织发生变化以支持新的过程的时候。希望我们在解决构建我们的pipeline的困难时积累下的经验会帮助你解决你在构建你的pipeline时遇到的困难。
在这第一篇文章里,我们将从头开始,看一看只用Docker时我们开发的初步的工作流。在接下来的文章中,我们将进一步介绍Docker-compose,最后介绍如何将Rancher应用到我们的工作流中。
为了为之后的工作铺平道路,假设接下来的事件都发生在一家SaaS提供商那里,我们曾经在SaaS提供商那里提供过长时间服务。仅为了这篇文章的撰写,我们姑且称这家SaaS提供商为Acme Business Company, Inc,即ABC。这项工程开始时,ABC正处在将大部分基于Java的微服务栈从裸机服务器上的本地部署迁移到运行在AWS上的Docker部署的最初阶段。这项工程的目标很常见:发布新功能时更少的前置时间(lead time)以及更可靠的部署服务。
为了达到该目标,软件的部署计划大致是这样的:
这个过程从代码的变更、提交、推送到git仓库开始。当代码推送到git仓库后,我们的CI系统会被告知运行单元测试。如果测试通过,就会编译代码并将结果作为产出物(artifact)存储起来。如果上一步成功了,就会触发下一步的工作,利用我们的代码产出物创建一个Docker镜像并将镜像推送到一个Docker私有注册表(private Docker registry)中。最后,我们将我们的新镜像部署到一个环境中。
要完成这个过程,如下几点是必须要有的:
一个源代码仓库。ABC已经将他们的代码存放在GitHub私有仓库上了。
一个持续集成和部署的工具。ABC已经在本地安装了Jenkins。
一个私有registry。我们部署了一个Docker registry容器,由Amazon S3支持。
一个主机运行Docker的环境。ABC拥有几个目标环境,每个目标环境都包含过渡性(staging)部署和生产部署。
这样去看的话,这个过程表面上简单,然而实际过程中会复杂一些。像许多其它公司一样,ABC曾经(现在仍然是)将开发团队和运营团队划分为不同的组织。当代码准备好部署时,会创建一个包含应用程序和目标环境详细信息的任务单(ticket)。这个任务单会被分配到运营团队,并将会在几周的部署窗口内执行。现在,我们已经不能清晰地看到一个持续部署和分发的方法了。
最开始,部署任务单可能看起来是这样的:
DEPLOY-111:
App: JavaService1, branch "release/1.0.1"
Environment: Production
部署过程是:
部署工程师用了一周时间在Jenkins上工作,对相关的工程执行”Build Now“,将分支名作为参数传递。之后弹出了一个被标记的Docker镜像。这个镜像被自动的推送到了注册表中。工程师选择了环境中的一台当前没有在负载均衡器中被激活的Docker主机。工程师登陆到这台主机并从注册表中获取新的版本。
docker pull registry.abc.net/javaservice1:release-1.0.1
找到现存的容器。
docker ps
终止现存容器运行。
docker stop [container_id]
开启一个新容器,这个容器必须拥有所有正确启动容器所需的标志。这些标志可以从之前运行的容器那里,主机上的shell历史,或者其它地方的文档借鉴。
docker run -d -p 8080:8080 … registry.abc.net/javaservice1:release-1.0.1
连接这个服务并做一些手工测试确定服务正常工作。
curl localhost:8080/api/v1/version
在生产维护窗口中,更新负载均衡器使其指向更新过的主机。
一旦通过验证,这个更新会被应用到环境中所有其它主机上,以防将来需要故障切换(failover)。
不可否认的是,这个部署过程并不怎么让人印象深刻,但这是通往持续部署伟大的第一步。这里有好多地方仍可改进,但我们先考虑一下这么做的优点:
运营工程师有一套部署的方案,并且每个应用的部署都使用相同的步骤。在Docker运行那一步中需要为每个服务查找参数,但是大体步骤总是相同的:Docker pull、Docker stop、Docker run。这个过程非常简单,而且很难忘掉其中一步。
当环境中最少有两台主机时,我们便拥有了一个可管理的蓝绿部署(blue-green deployment)。一个生产窗口只是简单地从负载均衡器配置转换过来。这个生产窗口拥有明显且快速的回滚方法。当部署变得更加动态时,升级、回滚以及发现后端服务器变得愈发困难,需要更多地协调工作。因为部署是手动的,蓝绿部署代价是最小的,并且同样能提供优于就地升级的主要优点。
好吧,现在看一看痛点:
重复输入相同的命令。或者更准确地说,重复地在bash命令行里敲击输入。解决这一点很简单:使用自动化技术!有很多工具可以帮助你启动Docker容器。对于运营工程师,最明显的解决方案是将重复的逻辑包装成bash脚本,这样只需一条命令就可以执行相应逻辑。如果你将自己称作一个开发-运营(devops)工程师,你可能会去使用Ansible、Puppet、Chef或者SaltStack。编写脚本或剧本(playbooks)很简单,但是这里仍有几个问题需要说明:部署逻辑到底放在那里?你怎样追踪每个服务的不同参数?这些问题将带领我们进入下一点。
即便一个运营工程师拥有超能力,在办公室工作一整天后的深夜里仍能避免拼写错误,并且清晰的思考,他也不会知道有一个服务正在监听一个不同的端口并且需要改变Docker端口参数。问题的症结在于开发者确实了解应用运行的详细信息(但愿如此),但是这些信息需要被传递给运营团队。很多时候,运营逻辑放在另外的代码仓库中或这根本没有代码仓库。这种情况下保持应用相关部署逻辑的同步会变得困难。由于这个原因,一个很好的做法是将你的部署逻辑只提交到包含你的Dockerfile的代码仓库。如果在一些情况下无法做到这点,有一些方法可以使这么做可行(更多细节将在稍后谈到)。把细节信息提交到某处是重要的。代码要比部署任务单好,虽然在一些人的脑海中始终认为部署任务单更好。
可见性。对一个容器进行一个故障检测须要登陆主机并且运行相应命令。在现实中,这就意味着登陆许多主机然后运行“docker ps”和“docker logs –tail=100”的命令组合。有很多解决方案可以做到集中登陆。如果你有时间的话,还是相当值得设置成集中登陆的。我们发现,通常情况下我们缺少的能力是查看哪些容器运行在那些主机上的。这对于开发者而言是个问题。开发者想要知道什么版本被部署在怎样的范围内。对于运营人员来说,这也是个主要问题。他们须要捕获到要进行升级或故障检测的容器。
基于以上的情况,我们开始做出一些改变,解决这些痛点。
第一个改进是写一个bash脚本将部署中相同的步骤包装起来。一个简单的包装脚本可以是这样的:
!/bin/bash
APPLICATION=$1
VERSION=$2
docker pull "registry.abc.net/${APPLICATION}:${VERSION}"
docker rm -f $APPLICATION
docker run -d --name "${APPLICATION}" "registry.abc.net/${APPLICATION}:${VERSION}"
这样做行得通,但仅对于最简单的容器而言,也就是那种用户不需要连接到的容器。为了能够实现主机端口映射和卷挂载(volume mounts),我们须要增加应用程序特定的逻辑。这里给出一个使用蛮力实现的方法:
APPLICATION=$1
VERSION=$2
case "$APPLICATION" in
java-service-1)
EXTRA_ARGS="-p 8080:8080";;
java-service-2)
EXTRA_ARGS="-p 8888:8888 --privileged";;
*)
EXTRA_ARGS="";;
esac
docker pull "registry.abc.net/${APPLICATION}:${VERSION}"
docker stop $APPLICATION
docker run -d --name "${APPLICATION}" $EXTRA_ARGS "registry.abc.net/${APPLICATION}:${VERSION}"
现在这段脚本被安装在了每一台Docker主机上以帮助部署。运营工程师会登陆到主机并传递必要的参数,之后脚本会完成剩下的工作。部署时的工作被简化了,工程师的需要做的事情变少了。然而将部署代码化的问题仍然存在。我们回到过去,把它变成一个关于向一个共同脚本提交改变并且将这些改变分发到主机上的问题。通常来说,这样做很值得。将代码提交到仓库会给诸如代码审查、测试、改变历史以及可重复性带来巨大的好处。在关键时刻,你要考虑的事情越少越好。
理想状况下,一个应用的相关部署细节和应用本身应当存在于同一个源代码仓库中。有很多原因导致现实情况不是这样,最突出的原因是开发人员可能会反对将运营相关的东西放入他们的代码仓库中。尤其对于一个用于部署的bash脚本,这种情况更可能发生,当然Dockerfile文件本身也经常如此。
这变成了一个文化问题并且只要有可能的话就值得被解决。尽管为你的部署代码维持两个分开的仓库确实是可行的,但是你将不得不耗费额外的精力保持两个仓库的同步。本篇文章当然会努力达到更好的效果,即便实现起来更困难。在ABC,Dockerfiles最开始在一个专门的仓库中,每个工程都对应一个文件夹,部署脚本存在于它自己的仓库中。
Dockerfiles仓库拥有一个工作副本,保存在Jenkins主机上一个熟知的地址中(就比如是‘/opt/abc/Dockerfiles’)。为了为一个应用创建Docker镜像,Jenkins会搜索Dockerfile的路径,在运行”docker build“前将Dockerfile和伴随的脚本复制进来。由于Dockerfile总是在掌控中,你便可能发现你是否处在Dockerfile超前(或落后)应用配置的状态,虽然实际中大部分时候都会处在正常状态。这是来自Jenkins构建逻辑的一段摘录:
if [ -f docker/Dockerfile ]; then
docker_dir=Docker
elif [ -f /opt/abc/dockerfiles/$APPLICATION/Dockerfile ]; then
docker_dir=/opt/abc/dockerfiles/$APPLICATION
else
echo "No docker files. Can’t continue!"
exit 1
if
docker build -t $APPLICATION:$VERSION $docker_dir
随着时间的推移,Dockerfiles以及支持脚本会被迁移到应用程序的源码仓库中。由于Jenkins最开始已经查看了本地的仓库,pipeline的构建不再需要任何变化。在迁移了第一个服务后,仓库的结构大致是这样的:
我们使用分离的仓库时遇到的一个问题是,如果应用源码或打包逻辑任意一个发生改变,Jenkins就会触发应用的重建。由于Dockerfiles仓库包含了许多项目的代码,当改变发生时我们不想触发所有的仓库重建。解决方法是:使用在Jenkins Git插件中一个很隐蔽的选项,叫做Included Regions。当配置完成后,Jenkins将一个变化引起的重建隔离在仓库的某个特定子集里面。这允许我们将所有的Dockerfiles放在一个仓库里,并且仍然能做到当一个改变发生时只会触发特定的构建(与当改变发生在仓库里特定的目录时构建所有的镜像相比)。
关于这个初步的工作流的另一个方面是部署工程师必须在部署前强制构建一个应用镜像。这将导致额外的延迟,尤其是构建存在问题并且开发人员需要参与其中的时候。为了减少这种延迟,并为更加持续的部署铺平道路,我们开始为熟知分支中的每一个提交构建Docker镜像。这要求每一个镜像有一个独一无二的版本标识符,而如果我们仅仅依赖官方的应用版本字符串往往不能满足这一点。最终,我们使用官方版本字符串、提交次数和提交sha码的组合作为版本标识符。
commit_count=$(git rev-list --count HEAD)
commit_short=$(git rev-parse --short HEAD)
version_string="${version}-${commit_count}-${commit_short}"
这样得到的版本字符串看起来是这样的:1.0.1-22-7e56158
在结束pipeline的Docker file部分的讨论之前,还有一些参数值得提及。如果我们不会在生产中操作大量的容器,我们很少用到这些参数。但是,它们被证明有助于我们维护Docker集群的线上运行。
重启策略(Restart Policy)-一个重启策略允许你指定当一个容器退出时,每个容器采取什么动作。尽管这个可以被用作应用错误(application panic)时的恢复或当依赖上线时保持容器再次尝试连接,但对运营人员来说真正的好处是在Docker守护进程(daemon)或者主机重启后的自动恢复。从长远来看,你将希望实现一个适当的调度程序(scheduler),它能够在新主机上重启失败的容器。在那天到来之前,节省一些工作,设置一个重启策略吧。在现阶段的ABC中,我们将这项参数默认为“–restart always”,这将会使容器始终重启。简单地拥有一个重启策略就会使计划的(和非计划的)主机重启变得轻松得多。
资源约束(Resource Constraints)-使用运行时的资源约束,你可以设置容器允许消耗的最大内存和CPU。它不会把你从一般的主机过载(over-subscription)中拯救出来,但是它可以抑制住内存泄漏和失控的容器。我们先对容器应用一个充足的内存限制(例如:–memory=”8g”) 。我们知道当内存增长时这样会产生问题。尽管拥有一个硬性限制意味着应用最终会达到内存不足(Out-of-Memory)的状态并产生错误(panic),但是主机和其它容器会保持正确运行。
结合重启策略和资源约束会给你的集群带来更好的稳定性,与此同时最小化失败的影响,缩短恢复的时间。这种类型的安全防护可以让你和开发人员一起专注于“起火”的根本原因,而不是忙于应付不断扩大的火势。
简而言之,我们从一个基础的构建pipeline,即从我们的源码仓库中创建被标记的Docker镜像开始。从使用Docker CLI部署容器一路到使用脚本和代码中定义的参数部署容器。我们也涉及了如何管理我们的部署代码,并且强调了几个帮助运营人员保持服务上线和运行的Docker参数。
此时此刻,在我们的构建pipeline和部署步骤之间仍然存在空缺。部署工程师会通过登入一个服务器并运行部署脚本的方法填补这个空缺。
尽管较我们刚开始时有所改进,但仍然有进一步提高自动化水平的空间。所有的部署逻辑都集中在单一的脚本内,当开发者需要安装脚本以及应付它的复杂性时,会使本地测试会变得困难得多。此时此刻,我们的部署脚本也包含了通过环境变量处理任何环境特定信息的方法。追踪一个服务设置的环境变量以及增加新的环境变量是乏味且容易出错的。
在下一篇文章中,我们将看一看怎样通过解构(deconstructing)共同的包装脚本解决这些痛点,并使部署逻辑向使用Docker Compose的应用更近一步。
您也可以下载免费的电子书《Continuous Integration and Deployment with Docker and Rancher》,这本书讲解了如何利用容器帮助你完成整个CI/CD过程。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。