Docker 在 2013 年三月实现了开源发布,它的出现让软件开发行业对于现代化应用的打包以及部署方式发生了巨大的变化。紧随着 Docker 的发布,各种具有竞争性、致敬性以及支持性的容器技术纷纷涌现,为这一领域带来了极大的关注度,同时也引起了人们的反思。这一系列文章将解答读者的各种困惑,对如何在企业中实际使用容器进行分析。
这一系列文章首先将对容器背后的核心技术进行观察,了解开发者目前如何使用容器,随后将分析在企业中部署容器的核心挑战,例如如何将容器技术与持续集成和持续交付管道进行集成,并对监控方式进行改进,以支持不断变化的负载,以及使用短期容器的潜在需求。本系列文章的总结部分将对容器技术的未来进行分析,并探讨无核化技术(unikernels)目前在处于技术前沿的组织中所扮演的角色。
本文是本系列文章“实际应用中的容器 —— 远离炒作”中的其中一篇。你可以通过RSS订阅该系列文章,以获取更新的通知。
相信读者中有很大一部分已经试用过Docker 了,即使只是运行由 Docker Hub 上下载的预构建镜像。或许你的团队已经通过各种实验,认识到 Docker 为构建微服务所带来的益处,以及这项技术为开发、测试、集成以及最终上线所带来的优势。
不过,在将容器部署至在线环境之前,你必须创建一个完善的构建管道。
将容器技术集成至持续交付管道绝不是一件简单的任务。尽管 Docker 为我们带来了种种益处,但随之而来的则是在技术与流程方面的各种挑战。本文将为读者列举实现一个全自动化持续部署管道,使其能够构建微服务并部署至 Docker 容器所需的步骤。
持续部署管道
持续部署管道是在每次代码提交时会执行的一系列步骤。管道的目的是执行一系列任务,将一个经过完整测试的功能性服务或应用部署至生产环境。唯一一个手工操作就是向代码仓库执行一次签入操作,之后的所有步骤都是自动完成的。这种流程可(在一定程度上)消除人为错误的因素,从而增加可靠性。并且可让机器完成他们最擅长的工作(运行重复性的过程,而不是创新性思考),从而增加系统吞吐量。之所以每次提交都需要通过这个管道,原因就在于“持续”这个词。如果你选择延迟这一过程的执行,例如在某个 sprint 结束前再运行,那么整个测试与部署过程都不再是持续的了。
如果你选择延迟测试与生产环境的部署,那么你同样也延误了发现系统潜在问题的时机,导致的结果是修复这些问题需要投入更多的精力。如果在问题发生一个月后再去尝试修复,比起在问题发生一周内进行修复的成本就要高得多。与之类似,如果在代码提交的几分钟内立即发出 bug 的通知,那么定位该 bug 所需的时间就是微不足道的了。不过,持续部署的意义不仅在于节省维护与 bug 修复的投入,它还能够让你更快地将新特性发布至生产环境中。特性的开发与最终为用户使用之间的时间越短,你就能够越快地从中受益。
我们先不必列出管道中应当包含的全部构建步骤,而是先从一个最小的子集开始,探讨一种可能的方案。这个最小子集能够让你对服务进行测试、构建以及部署。这些任务都是必不可少的。缺少了测试,我们就无法保证该服务能够正常运行。缺少了构建,就没有什么东西可部署。而缺少了部署,用户就无法从新的发布中受益。
测试
传统的软件测试方式是对源代码进行单元测试,这种方式虽然能够带来较高的代码覆盖率,但不见得一定能够保证特性按照预期的方式工作,也无法保证单独的代码单元(方法、函数、类等等)的行为符合设计。为了对特性进行验证,你需要进行功能性测试,这种方式偏向于墨盒测试,与代码没有直接的关联。功能性测试的一个问题在于系统的依赖。Java 应用可能需要一个特定的 JDK,而 Web 应用可能需要在大量的浏览器上进行测试。很有可能需要在不同的系统条件组合中对相同的测试集进行重复的测试。
一个令人遗憾的事实是,许多组织的测试并不充分,这无法确保一次新的发布能够在没有人工干预的情况下部署至生产环境中。即使这些测试本身是可靠的,但往往没有将这些测试在所有可能在生产环境中出现的相同条件下运行。出现这一问题的原因与我们对基础设施的管理方式有关。以人工方式对基础设施进行设置的代价是非常高的。为了配置用户可能会使用的所有浏览器,你需要设置多少台服务器?10 台还是 100 台?如果某个项目的运行时依赖与其他项目不同,你又该怎样处理?
大多数企业都需要用到多种不同的环境。比方说某个环境需要运行 Ubuntu 而另一个需要运行 Red Hat,或者是某个环境需要 JDK8 而另一个环境需要 JDK7。这是一种非常费时费力的途径,尤其当这些环境作为静态环境(与之相对的是通过云计算托管的“创建与销毁”途径)时更为明显。即使你为了满足各种组合而设置了足够的服务器,你仍然会遇到速度与灵活性的问题。举例来说,如果某个团队决定开发一个新服务,或是使用不同的技术对某个现有的服务进行重构,从请求搭建新环境直至该环境具备完整的可操作性为止也会浪费大量时间。在这段过程中,持续部署过程将陷入停顿。如果你在这种环境中添加微服务,则浪费的时间将产生指数级的增长。在过去,开发者通常只需关注有限的几个应用程序,而如今则需要关注几十个、几百个、乃至上千个服务。毕竟,微服务的益处包括为某个用例选择最佳技术的灵活性,以及高速的发布。你不希望等到整个系统开发完成,而是希望完成某个属于单一微服务的功能后立即进行发布。这样的瓶颈只要存在一个,就可能会大大地降低整体速度。而在许多情况下,基础设施就是瓶颈所在。
你可以通过使用 Docker 容器轻松地处理各种测试问题。对服务或应用进行测试所需的各种元素需要、也应当能够设置在某个容器中。请参考一下这个 Dockerfile.tests 文件,这是一个用于测试微服务的容器,它在后端使用 Scala 开发,前端使用了 Polymer ,而数据库则采用了 MongoDB。它所测试的是一个具备完全自治性的服务切面,与系统中的其他服务相互分离。我并不打算深入讨论 Dockerfile 定义的细节,只是简单列举其中所包含的内容。该定义包含 Git、NodeJS、Gulp 以及用于前端的 Bower、Scala、SBT 以及用于后端的 MongoDB。某些测试需要用到 Chrome 和 Firefox。此外还包括了该服务的源代码与所有依赖。我并不是说你的服务也应当选择相同的技术栈,而是想指出,在许多情况下,测试需要用到大量的运行时与系统依赖。
如果你需要搭建一台这样的服务器,就意味着大量的等待时间,直到所有元素都设置完成。而当其他服务也提出了类似的请求之后,你就很可能会遇到各种冲突与问题,毕竟服务器本身就不是为了托管无数存在潜在冲突的依赖而存在的。你也可以选择为测试某个服务创建 VM,但这意味着对资源的极大浪费与缓慢的初始化过程。通过使用 Docker 容器,这一工作就不再属于基础设施团队,而转交开发者负责。开发者们会在测试过程中选择应用所需的组件,在 Dockerfile 中进行定义,通过团队所用的持续部署工具构建并运行容器,让这一容器执行所需的各种测试。当代码通过全部测试之后,你就可以迈入下一阶段,对服务本身进行构建。测试所用的容器应当在 Docker 注册表(可选择私有或公有)中进行注册,以便之后重用。除了我们已经提到的各种益处之外,在测试执行结束之后,你就可以销毁该容器,使 host 服务器回到原来的状态。如此一来,你就可以使用同一台服务器(或服务集群)对你所开发的全部服务进行测试了。
下图中的流程已经开始显得有些复杂了。
构建
当你执行完所有测试后,就可以开始创建容器,并最终将其部署至生产环境中了。由于你很可能会将其部署至一个与你构建所用不同的服务器中,因此你同样应当将其注册在 Docker 注册表中。
当你完成测试并构建好新的发布后,就可以准备将其部署至生产服务器中了。你所要做的就是获取对应的镜像并运行容器。
部署
当容器已上传至注册表之后,你就可以在每次签入之后部署你的微服务,并以前所未有的速度将新的特性交付给用户。业务负责人会非常高兴并对你进行奖励,而你也会感觉到你的工作是伟大而有意义的。
但目前所定义的流程还远远谈不上一个完整的持续部署管道。它还遗漏了许多步骤、需要考虑的内容以及必需的路径。让我们依次找出这些问题并逐个解决。
通过蓝-绿流程实现安全的部署
整个管道中最危险的步骤可能就是部署了。如果我们获取了某个新的发布并开始运行,Docker Compose 就会以新的发布取代旧的发布。也就是说,在过程中会出现一定程度的停机时间。Docker 需要停止旧的发布并启动新的发布,同时你的服务也需要进行初始化。虽然这一过程可能只需几分钟、几秒钟甚至是几微秒,但还是造成了停机时间。如果你实施了微服务与持续部署实践,那么发布的次数会比之前更频繁。最终,你可能会在一天之内进行多次部署。无论你决定采用怎样的发布频率,对用户的干扰都是你应当避免的。
应对这一问题的解决方案是蓝-绿部署。如果你初次接触这一主题,欢迎阅读我的文章“蓝-绿部署”。简单地说,这个过程将部署一个新发布,使其与旧发布并行运行。可将某个版本称为“蓝”,另一个版本称为“绿”。由于两者是并行运行的,因此不会产生停机时间(至少不会由于部署流程引起停机时间)。并行运行两个版本的方式为我们带来了一些新的可能性,但同时也造成了一些新的挑战。
在实践蓝-绿部署时要考虑的第一件事就是如何将用户的请求从旧的发布重定向至新的发布。在此之前的部署方式中,你只是简单地将旧的发布替换为新的发布,因此他们将在相同的服务器与端口上运行。而蓝-绿部署将并行运行两个版本,每个版本将使用自己的端口。有可能你已经使用了某些代理服务( NGINX 、 HAProxy 等等),那么你可能会面对一个新的挑战,即这些代理不能是静态的了。在每次新发布中,代理的配置需要进行持续变更。如果你在集群中进行部署,那么该过程将变得更为复杂。不仅端口需要变更,IP 地址也需要变更。为了有效地使用集群,你需要将服务部署至在当时最适合的服务器上。决定最适合服务器的条件包括可用的内存、磁盘和 CPU 的类型等等。通过这种方式,你就能够以最佳的方式分布服务,并极大地优化可用资源的利用率。而这又造成了新的问题,最紧迫的问题是如何找到你所部署的服务的 IP 地址与端口号。对这个问题的答案是使用服务发现。
服务发现包括三个部分。你首先需要通过一个服务注册表以保存服务的信息。其次,你需要某个进程对新的服务进行注册,并撤消已中止的服务。最后,你需要通过某种方式获取服务的信息。举例来说,当你部署一个新的发布时,注册进程需要在服务注册表中保存 IP 地址与端口信息。随后,代理可发现这些信息,并通过信息对本身进行重新配置。常见的服务注册表包括 etcd 、 Consul 和 ZooKeeper 。你可以使用 Registrator 用于注册和撤消服务以及 confd ,并用 Consul Template 实现服务发现与模板创建。如果读者希望了解有关服务发现和相关工具的更多知识,欢迎阅读我的“ Service Discovery: Zookeeper vs etcd vs Consul ”一文。
现在,你已经找到了一种保存及获取服务信息的机制,可利用该机制对代理进行重新配置,(目前)唯一一个还未解答的问题就是要部署哪个版本(颜色)。当你进行手工部署时,你自然知道之前部署的是哪个颜色。如果你之前部署了绿色,那么现在当然要部署蓝色。如果一切都是自动化执行的,你就需要将这一信息保存起来,让部署流程能够访问它。由于你在流程中已经建立了服务发现功能,你就可以将部署颜色与服务 IP 地址和端口信息一同保存起来,以便在必要时获取该信息。
在完成了以上工作后,管道将变为下图中所显示的状态。由于步骤的数量提高了,因此我将这些步骤划分为预部署、部署以及部署后三个组。
运行预集成以及集成后测试
你或许已经注意到,部署管道中的第一个步骤是运行测试。虽然测试的运行至关重要,并且为你提供了代码(很可能)能够按预期运行的信心,但它无法验证要部署至生产环境中的服务是否真的能够按预期运行。许多环节都有可能产生错误,可能是没有正确地安装数据库、或是防火墙阻碍了对服务的访问。服务在生产环境上无法正常工作的原因是多种多样的。即使代码按预期工作,也不代表你已验证了部署的服务已得到正确的配置。即便你搭建了一个预发布服务器以部署你的服务,并且进行了又一轮测试,也无法使你完全确信在生产环境中总是能够得到相同的结果。为了区分不同类型的测试,我将其称为“预部署”测试。我有意避免使用更准确的名称,因为你在早期阶段所运行的测试类型对于每个项目来说都是不同的。他们有可能表示单元测试、功能测试或是其他类型的测试。无论是哪种类型的测试,他们的共同点在于,你会在构建与部署服务之前运行这些测试。
蓝-绿流程为你展现了一种新的机会。由于旧发布与新发布是并行运行的,你就可以对新发布进行测试,随后再对代理进行重新配置,以指向新的发布。通过这种方式,你就可以放心地将新发布部署至生产环境并进行测试,而代理仍会将你的用户重定向至旧的发布。我倾向于将这一阶段的测试称为“预集成”测试。这个名字或许不是最好的,因为许多开发者更熟悉它的另一个名字“集成测试”。但在这个特殊的场景中,它意味着你在将新发布与代理服务集成之前(在代理进行重新配置之前)所需运行的测试。这些测试可以让你忽略预发布环境(这种环境与生产环境永远做不到完全一致),使用对代理重新配置之后用户将使用的完全相同配置对新发布进行测试。当然,“完全”这个词并不太准确,区别在于你会在不使用代理的情况下对服务进行测试,而用户将无法访问这些服务。与预部署测试一样,预集成测试的结果将指示你是继续走完工作流,还是中止这一流程。
最后,当我们重新配置代理之后,还需要再进行一轮测试。这一轮测试称为“集成后”测试,这一过程应当能够快速完成,因为唯一需要验证的就是代理是否确实正确地配置了。通常来说,只需对 80(HTTP)与 443(HTTPS)端口进行几次请求作为测试就足够了。
在你实施了 Docker 之后,就应当以容器方式运行所有这些测试,与我建议你进行预部署测试的方式相同,也能够带来相同的益处。并且在很多情况下,同样的测试容器可用于所有的测试类型。我倾向于通过一个环境变量表示所运行的测试的类型。
回滚与清理
在我们回顾这套测试步骤的成败之前,让我们首先定义生产环境的理想状态。我们的逻辑很简单,如果整个流程有任何一部分出错,整个环境就应当保持与该流程尚未初始化之前相同的状态。我们要实现的并不复杂,只是为发生的问题触发某种形式的通知,并且在团队中建立一种文化:修复破坏流程的问题是第一优先任务。问题在于,回滚并不像听上去那么简单。幸运的是,Docker 容器使这一操作比使用其他任何途径都要简单,因为它对环境本身会产生的副作用非常小。
根据预部署测试的结果决定下一步非常简单,因为此时你还没有进行任何实际的部署,因此可随意决定继续流程还是中止流程。但另一方面,在你获得预集成测试的结果前所执行的步骤会使生产环境处于一个非理想状态。你已经部署了新的发布(蓝或绿),如果该发布有错误,你必须撤消这一发布。在撤消发布之后,你还需要删除这些发布所生成的任何服务数据。由于代理仍然指向旧发布,因此用户实际上仍然在使用旧发布的特性,而不会注意到你在尝试部署新的特性。而最后的测试过程(即集成后测试)会带来一些额外的困难,因为代理已经重新定向至新的发布,你需要将其恢复成原先的设置。而部署颜色(版本)的注册也同样需要成原先的设置。
虽然我之前仅谈到了由测试所引起的故障,但并不意味着流程中的其他步骤不会失败。这些步骤同样会出错,应当使用类似的逻辑以解决这些问题。无论是哪个步骤失败了,系统环境都需要恢复成之前的状态。
即使整个过程如计划般一样顺利执行,也仍然有一些清理工作需要处理。你需要停止旧的发布,并删除其注册信息。
目前为止,我还没有提到数据库,而数据库往往会给回滚阶段造成最大的挑战。这一主题已经超出了本文的范围,所以我只打算描述一下我认为最主要的规则:始终确保新发布中产生的模式变更是向后兼容的,并通过大量的测试来确认这一点。你必须在预部署测试阶段运行这些测试。经常有人向我抱怨,保证向后兼容性是不可行的。在某些场景中,这种说法没错。但在多数情况下,这种观点总是来自于瀑布开发过程,团队每个月、甚至每年才会发布一次产品。而如果团队能够保持管道周期的简短,并且在每次签入时都能够执行(假设我们每天至少部署一次),那么影响到数据库的变更通常来说是比较小的,在这种情况下可以比较简单地实现向后兼容性。
决定每个步骤的执行环境
决定每个步骤的执行环境是至关重要的。按照一般的规则来说,尽量不要在生产服务器中执行。这表示除了部署相关的任务都应当在一个专属于持续部署的独立的集群中执行。在下图中,我将这些任务标记为黄色,并将需要在生产环境中执行的任务标记为蓝色。请注意,即使是蓝色的任务也不应当直接在生产环境中执行,而是通过工具的 API 执行。举例来说,如果你使用 Docker Swarm 进行容器的部署,那么无需直接访问主节点所在的服务,而是创建 DOCKER_HOST 变量,将最终的目标地址通知本地 Docker 客户端。
完成整个持续部署流
现在我们已经能够可靠地将每次签入部署至生产环境中了,但我们的工作只完成了一半。另一半工作是对部署进行监控,并根据实时数据与历史数据进行相应的操作。由于我们的最终目标是将代码签入后的一切操作实现自动化,因此人为的交互将会降至最低。创建一个具备自恢复能力的系统是一个很大的挑战,它需要你进行持续地调整。你不仅希望系统能够从故障中恢复(响应式恢复),同时也希望能够第一时间防止这些故障出现的可能(预防性恢复)。
如果某个服务进程出于某种原因中止了运行,系统应当再次将其初始化。如果产生故障的原因是某个节点变得不可靠,那么初始化过程应当在另一个(健康的)服务器中运行。响应式恢复的要点在于通过工具进行数据收集、持续地监控服务、并在发生故障时采取行动。预防性恢复则要复杂许多,它需要将历史数据记录在数据库中,对各种模式进行评估,以预测未来是否会发生某些异常情况。预防性恢复可能会发现访问量处于不断上升的情况,需要在几个小时之内对系统进行扩展。也可能是每个周一早上是访问量的峰值,系统在这段时间需要扩展,随后在访问量恢复正常之后收缩成原来的规模。如果你有兴趣这方面的更多细节,欢迎阅读我的文章“ Self-Healing Systems ”与“ Centralized Logging and Monitoring ”。
工具
在持续部署流程中使用的工具取决于个人的偏好,以及项目的特点。因此,我不打算提出一个明确的提议,而是分享一下我个人的偏好。
对于任何基于微服务的架构来说,Docker 是一个很明显的选择。我甚至可以说,如果没有容器技术(Docker 或其他类型的工具)的出现,那么微服务所造成的问题将超过解决方案。你可以在“ Microservices: The Essential Practices ”这篇文章中找到更多的信息。在代理方面,NGINX 与 HAProxy 的表现都很出色,虽然他们各自有所缺陷,但总的来说不失为一个好的选择。
如果你的集群具有一定规模,则必须使用一个编排工具。我倾向于选择 Docker Swarm ,与其他工具相比,它提供了更大的自由度。但从另一方面来说,它为分布式所提供的工具较少。你需要自行打造所需的工具(可以说,这就是自由的代价)。Kubernetes 相对来说更为成熟,所提供的功能也更多。Mesos 在最初设计时并非打算用于 Docker,但目前已经开始提供了对 Docker 的支持。如果你希望了解更详细的对比信息,欢迎阅读我的文章“ Docker Clustering Tools Compared: Kubernetes vs Docker Swarm ”。
最后,我偏爱的 CI/CD 服务器是 Jenkins 。请记住,实现本文中所描述的流程是非常艰难的,而将一些无固定风格的作业连接在一起也会造成高昂的维护成本。因此,我偏爱的方式是使用 Pipeline 及 CloudBees Docker Pipeline 插件。如果希望了解 Jenkins Pipeline 的更多信息,欢迎阅读我的文章“ The Need For Jenkins Pipeline ”及“ Jenkins Pipeline ”。
The DevOps 2.0 Toolkit
如果本文有幸得到你的喜爱,那么你或许同样会对我的这本书籍感兴趣《The DevOps 2.0 Toolkit: Automating the Continuous Deployment Pipeline with Containerized Microservices》。本书对包括微服务、容器以及持续部署在内的内容进行了更为深入的探讨。
本书的主旨是表现如何使用不同的技术帮助我们以更好、更高效的方式设计软件,将微服务以不可变容器的方式打包,在通过配置管理工具自动设置的服务器上进行持续地测试与部署。目标是在零停机时间的基础上实现快速、可靠、持续的部署,并具备回滚能力。此外还包括扩展至任意数量的服务器上、对能够从硬件与软件故障中恢复的自恢复系统的设计、以及集群的集中式日志记录与监控。
换句话说,本书描述了在微服务开发与部署生命周期中对某些最新、以及最佳实践和工具的应用。涵盖的内容包括Docker、Kubernetes、Ansible、Ubuntu、Docker Swarmt 和Docker Compose、Consul、etcd、Registrator、confd 及Jenkins 等等。我在书中依次讲解了大量的实践以及更多的工具
读者可在 Leanpub 或 Amazon 上(包括 Amazon.com 与全球的其他网站)订购本书。
关于作者
Viktor Farcic在 CloudBees 担任高级顾问。他曾使用各种语言进行编码工作,从 Pascal 开始(是的,他年纪不轻了),包括 Basic(当时还没有 Visual 前缀)、ASP(当时还没有.NET 后缀)、C、C++、Perl、Python、ASP.NET、Visual Basic、C#、JavaScript 等等。不过,他从来没有 Fortran 的编码经验。他目前最喜爱的技术是 Scala 与 JavaScript,虽然在办公室使用最多的还是 Java。他对于微服务、持续集成、交付和部署(CI/CD)以及测试驱动开发(TDD)充满了热情。
Docker 在 2013 年三月实现了开源发布,它的出现让软件开发行业对于现代化应用的打包以及部署方式发生了巨大的变化。紧随着 Docker 的发布,各种具有竞争性、致敬性以及支持性的容器技术纷纷涌现,为这一领域带来了极大的关注度,同时也引起了人们的反思。这一系列文章将解答读者的各种困惑,对如何在企业中实际使用容器进行分析。
这一系列文章首先将对容器背后的核心技术进行观察,了解开发者目前如何使用容器,随后将分析在企业中部署容器的核心挑战,例如如何将容器技术与持续集成和持续交付管道进行集成,并对监控方式进行改进,以支持不断变化的负载,以及使用短期容器的潜在需求。本系列文章的总结部分将对容器技术的未来进行分析,并探讨无核化技术(unikernels)目前在处于技术前沿的组织中所扮演的角色。
本文是本系列文章“实际应用中的容器 —— 远离炒作”中的其中一篇。你可以通过RSS订阅该系列文章,以获取更新的通知。
评论