如何将代码部署时间减少95%?
本文作者所在的公司Plaid是一家金融科技公司,该公司搭建了一个技术平台,使应用程序能够与用户的银行账户建立联系。随着公司的发展,基础设施规模在不断扩大。目前,这家公司运行着20多个内部服务,每天在核心服务上部署50多个代码提交。因此,最小化部署时间对于最大化迭代速度至关重要,一个快速的部署过程能够迅速进行Bug修复并运行平稳的连续部署系统。
几个月前,我们注意到,银行集成服务部署缓慢正在影响团队发布代码的能力。工程师要花至少30分钟才能通过多个过渡环境和生产环境构建、部署和监视变更,这将消耗大量宝贵的工程时间。随着团队越来越大,我们每天发布的代码也越来越多,这一点变得越来越不可接受。
虽然我们计划实现长期改进,比如将基于Amazon ECS服务的基础设施迁移到Kubernetes上,但是,为了在短期内提高迭代速度,有必要快速解决下这个问题。因此,我们决定实践自定义的“快速部署”机制。
Amazon ECS部署的高延迟
我们的银行集成服务由4000个Node.js进程组成,这些进程运行在专用的Docker容器上,这些容器托管并部署在Amazon的容器编排服务ECS上。在分析了我们的部署过程之后,我们将增加的部署延迟归结到三个不同的组件上:
· 启动任务会导致延迟。除了应用程序启动时间之外,ECS健康检查也会导致延迟,它决定容器何时准备好开始处理流量。控制这个过程的三个参数是interval、retry和startPeriod。如果没有对健康检查进行仔细调优,容器可能会卡在“启动”状态,即使它们已经准备好为流量服务。
· 关闭任务会导致延迟。当我们运行ECS服务更新时,SIGTERM信号被发送到所有正在运行的容器。为了处理这个问题,我们在应用程序代码中使用了一些逻辑,以便在完全关闭服务之前占用现有资源。
我们启动任务的速度限制了部署的并行性。尽管我们将MaximumPercent参数设置为200%,但是ECS start-taskAPI调用的硬限制是每个调用只能执行10个任务,而且速度有限。我们需要调用400次才能将所有容器投入生产。
方法探索
我们考虑并试验了一些不同的潜在解决方案,以逐步实现总体目标:
· 减少生产中运行的容器总数。这当然是可行的,但它涉及到对服务架构进行重大修改,以使其能够处理相同的请求吞吐量,在进行这样的修改之前,还需要进行更多研究。
· 通过修改健康检查参数来调整ECS配置。我们尝试通过减少interval和startPeriod的值来加强健康检查,但是ECS在启动时将健康的容器错误地标记为不健康,导致我们的服务永远无法完全稳定在100%健康状态。由于根本问题(ECS部署缓慢)依然存在,对这些参数进行迭代是一个缓慢而费力的过程。
· 在ECS集群中启动更多实例,以便可以在部署期间同时启动更多任务。这样做可以减少部署时间,但不会减少太多。从长远来看,这也不划算。
· 通过重构初始化和关机逻辑优化服务重启时间。只需要做一些小小的修改,我们就能够在每个容器中节省大约5秒的时间。
尽管这些更改将总体部署时间减少了几分钟,但是我们仍然需要将时间提高至少一个数量级,才能认为问题已解决。这将需要一个根本不同的解决方案。
初步解决方案:利用Node Require Cache“热重载”应用程序代码
Node require cache是一个JavaScript对象,它根据需要缓存模块。这意味着多次执行require(‘foo')或import * as foo from 'foo’只会在第一次时请求foo模块。神奇的是,删除require cache中的条目(我们可以使用全局require.cache对象访问)将迫使Node在下次导入模块时从磁盘重新读取该模块。
为了绕过ECS部署过程,我们尝试使用Node的require cache在运行时执行应用程序代码的“热重载”。一旦接收到外部触发(我们将其实现为银行集成服务上的gRPC端点),应用程序将下载新代码来替换现有的构建,清除require cache,从而强制重新导入所有相关模块。通过这种方法,我们能够消除ECS部署中存在的大部分延迟,优化整个部署过程。
在Plaiderdays(我们的内部黑客马拉松)期间,来自不同团队的一组工程师聚在一起,为我们所谓的“快速部署”实现了一个端到端的概念验证。当我们一起设法构建一个原型时,有一件事似乎出了问题:如果下载新构建的Node代码也试图使失效缓存,那么下载器代码本身将如何重新加载就不清楚了。(有一种方法可以解决这个问题,就是使用Node EventEmitter,但是会给代码增加相当大的复杂性)。更重要的是,还存在运行未同步代码版本的风险,这可能导致应用程序意外失败。
由于我们不愿意在银行集成服务的可靠性上妥协,这种复杂性需要重新考虑“热重载”方法。
最终解决方案:重新加载进程
在过去,为了在所有服务中运行一系列统一的初始化任务,我们编写了自己的进程封装器,它的名称非常贴切,叫做Bootloader。Bootloader的核心包含设置日志管道、转发信号和读取ECS元数据的逻辑。每个服务都是通过将应用程序可执行文件的路径以及一系列标志传递给Bootloader来启动的,这些文件在执行初始化步骤之后会作为子进程执行。
我们没有清除Node的require cache,而是在下载预期的部署构建后,使用特殊的退出代码来调用process.exit实现服务更新。我们还在Bootloader中实现了自定义逻辑,以触发使用此代码退出的任何子进程的进程重载。与“热重载”方法类似,这使我们能够绕过ECS部署的成本并快速引导新代码,同时避免“热重载”的陷阱。此外,Bootloader层的这种“快速部署”逻辑允许我们将其推广到在Plaid运行的任何其他服务。
下面是最终解决方案:
· Jenkins部署管道向银行集成服务的所有实例发送RPC请求,指示它们“快速部署”特定的提交散列。
· 应用程序接收gRPC请求进行快速部署,并根据接收到的提交散列从Amazon S3下载构建好的压缩包。然后,它替换文件系统上的现有构建,并使用Bootloader识别的特殊退出代码退出。
· Bootloader看到应用程序使用这个特殊的“Reload”退出代码退出,然后重新启动应用程序。
· 服务运行新的代码。
下面这张图简单说明了这个过程。
如何将代码部署时间减少95%?
结果
我们能够在3周内交付这个“快速部署”项目,并将90%生产容器的部署时间从30多分钟减少到1.5分钟。
如何将代码部署时间减少95%?
上图显示了我们为银行集成服务部署的容器数量(按提交表示为不同的颜色)。如果注意下黄线,就可以看到它在12:15左右增长趋于平稳,这代表我们的容器长尾仍然在占用资源。
这个项目极大提高了Plaid集成工作的速度,允许我们更快地发布特性及进行Bug修复,并将浪费在上下文切换和监视仪表板上的工程时间最小化。这也证明了我们的工程文化,即通过黑客马拉松得来的想法实现具有实质性影响的项目。