部署安全回滚实践:如何确保软件部署期间能够安全回滚
海外某些公司在构建解决方案时会遵循一项重要原则避免做出“单向门”式决策。所谓“单向门”是指那些一旦做出就难以撤销、难以扩展或难以修正的选择。无论是设计产品、功能、API 和后端系统还是实施软件部署这一原则都会贯穿软件开发的全过程。本文将重点说明如何将这一原则应用于软件部署并确保部署期间能够安全回滚。部署的本质是将软件环境从一种状态也就是一个版本转换到另一种状态也就是另一个版本。软件在这两个稳定状态下都可能运行良好但是在向前迁移也就是升级或前滚的过程中或者在向后迁移也就是降级或回滚的过程中软件仍然可能出现异常。即使迁移完成后系统也可能因为版本之间的兼容性问题而表现不佳。一旦软件运行异常就可能引发服务中断影响客户体验和服务可靠性。本文假设软件的两个版本本身都能按预期运行重点讨论如何确保软件部署过程中的前滚和回滚不会引发错误。在发布新版本软件之前我们通常会在 beta 或 gamma 等测试环境中从功能、并发性、性能、规模以及下游故障处理等多个维度进行验证。这类测试可以帮助团队发现并修复新版本中的问题。不过仅靠这些测试有时仍不足以保证部署一定成功。在生产环境中系统可能遇到意料之外的情况软件也可能表现不佳。在成熟的软件工程实践中团队通常希望避免陷入这样一种局面当新版本出现问题时回滚部署反而会在客户侧引发更多错误。为了避免这种情况团队需要在每次部署之前充分做好回滚准备。能够在回滚时不出错并且不会破坏先前版本中可用功能的软件版本通常被称为具备向后兼容性的版本。为此团队需要在规划和验证软件变更时确保每一个修订版本都具备向后兼容能力。在详细讨论软件部署和回滚策略之前我们先来看独立软件部署与分布式软件部署之间的区别。独立软件部署与分布式软件部署对于在单台设备上以单个进程运行的独立软件而言部署通常具有原子性。也就是说系统中不会同时运行两个不同版本的软件。如果这类软件会维护持久化状态那么新版本必须能够读取也就是反序列化旧版本写入的数据反过来旧版本也应当能够读取新版本写入的数据。只有满足这一条件部署才能安全地前滚和回滚。在分布式系统中部署要复杂得多。为了避免影响可用性部署通常采用滚动更新的方式进行。新版本会先部署到一部分主机上其余主机则继续处理请求。通常这些主机会通过远程过程调用RPC或共享的持久化状态进行通信例如共享元数据、检查点等。通信和共享状态都会带来额外挑战写入方和读取方可能运行着不同版本的软件因此它们对同一份数据的解释可能不同。更严重的是读取方甚至可能完全无法读取这些数据从而导致服务中断。协议变更部署无法安全回滚的常见原因在实践中导致部署无法安全回滚的最常见原因之一是协议变更。举例来说假设某次代码变更开始在将数据持久化到磁盘时对数据进行压缩。一旦新版本写入了压缩后的数据系统就无法安全回滚到旧版本了。原因很简单旧版本并不知道从磁盘读取数据后还需要先解压缩。如果数据存储在 Blob 或文档存储中那么即使部署仍在进行其他服务器也可能无法读取这些数据。如果这些数据在两个进程或两台服务器之间传递接收方同样可能无法正确读取。有时协议变更可能非常细微。设想有两台服务器通过连接进行异步通信。为了相互确认对方仍在正常运行它们约定每隔五秒发送一次心跳。如果其中一台服务器在规定时间内没有收到心跳就会认为另一台服务器已经下线并关闭连接。现在假设团队要部署一项更改将心跳周期从 5 秒增加到 10 秒。从代码提交来看这似乎只是改了一个数字变化很小。然而这项更改会让前滚和回滚都变得不安全。在部署期间运行新版本的服务器每 10 秒发送一次心跳而运行旧版本的服务器如果超过 5 秒没有收到心跳就会断开与新版本服务器之间的连接。在大规模服务器集群中这种情况可能同时发生在大量连接上从而导致可用性下降。这类细微变化很难仅靠阅读代码或设计文档识别出来。因此团队需要显式验证每一次软件部署在前滚和回滚时是否安全。两阶段部署实现安全回滚的关键技术确保安全回滚的一种常见方法是采用“两阶段部署”技术。下面以一个假设场景为例某项服务负责管理某海外云服务商对象存储服务上的数据并执行数据写入和读取操作。为了实现扩展性和可用性该服务运行在跨多个可用区的服务器集群中。当前该服务使用 XML 格式持久化数据。也就是说所有服务器都写入并读取 XML 数据。出于业务原因团队希望改用 JSON 格式持久化数据。如果在一次部署中直接完成这一变更那么接收到新版本的服务器会开始以 JSON 格式写入数据但尚未升级的服务器并不知道如何读取 JSON 数据于是就会出错。因此这类变更应当拆分为两个部分并通过两阶段部署完成。第一阶段称为“准备”阶段。在这一阶段团队会让所有服务器都具备读取 JSON 数据的能力同时仍然能够读取 XML 数据但是这些服务器仍会继续写入 XML 数据。从系统行为上看这一阶段并不会改变实际读写结果所有服务器仍然可以读取 XML 数据所有数据也仍然以 XML 格式写入。如果此时决定回滚服务器会回到无法读取 JSON 数据的状态。这并不会造成问题因为此时还没有任何数据以 JSON 格式写入。第二阶段称为“激活”阶段。在这一阶段团队会激活服务器使其开始使用 JSON 格式写入数据。每台服务器接收到这一变更后就会开始以 JSON 格式写入数据。尚未接收到该变更的服务器仍然能够读取 JSON 数据因为它们已经在第一阶段完成了准备工作。如果此时决定回滚那么曾短暂进入“激活”阶段的服务器所写入的数据会是 JSON 格式尚未进入“激活”阶段的服务器写入的数据仍然是 XML 格式。这是一种安全状态因为回滚后的服务器仍然能够读取 XML 和 JSON 两种格式的数据。虽然上面的例子展示的是从 XML 到 JSON 的序列化格式变更但这一技术同样适用于其他类型的协议变更。以前面的心跳周期变更为例如果需要将服务器之间的心跳周期从 5 秒增加到 10 秒那么在“准备”阶段可以先将所有服务器对心跳间隔的容忍时间放宽到 10 秒但仍然让所有服务器每 5 秒发送一次心跳到了“激活”阶段再将心跳发送频率改为每 10 秒一次。两阶段部署的注意事项采用两阶段部署时需要特别注意一些关键细节。虽然下面仍以前文示例为参照但这些注意事项适用于大多数两阶段部署场景。许多部署工具会根据最低健康主机数量来判断部署是否成功。也就是说只要有一定数量的主机接收到变更并报告运行状况正常工具就可能将部署标记为成功。有些海外部署工具中就提供了类似minimumHealthyHosts的部署配置。然而在前面的两阶段部署示例中一个关键假设是第一阶段结束时所有服务器都已升级并且都能够同时读取 XML 和 JSON 数据。如果第一阶段中有一台或多台服务器升级失败那么它们在第二阶段期间以及之后就可能无法读取 JSON 数据。因此团队必须显式验证所有服务器是否都已在“准备”阶段成功接收变更。在某海外数据库服务的一次实践中团队曾决定更改大量服务器之间的通信协议而这些服务器分布在多个微服务中。相关负责人需要协调所有微服务之间的部署确保所有服务器先进入“准备”阶段再进入“激活”阶段。为了防止意外发生团队在每个阶段结束时都会显式验证每台服务器上的部署是否成功。对于这类涉及需求、开发、测试、发布和知识沉淀的复杂研发流程团队也可以借助 PingCode 这类智能化研发管理工具将部署计划、评审记录、测试验证、发布上线和经验复盘串联起来让研发管理过程更加自动化、数据化和可追踪。虽然两个阶段中的每个阶段都可以回滚但不能同时回滚这两项变更。以前面的例子来说在“激活”阶段结束后服务器已经开始以 JSON 格式写入数据。而在“准备”和“激活”变更之前使用的软件版本并不知道如何读取 JSON 数据。因此作为一项预防措施团队通常会在“准备”阶段和“激活”阶段之间留出足够长的间隔。这段时间通常被称为“烘烤期”或“观察期”持续时间可能为数天。通过等待足够长的时间团队可以确认自己不需要回滚到更早的版本。在“激活”阶段之后也不能立即移除软件读取 XML 的能力。这样做并不安全因为在“准备”阶段之前写入的数据仍然都是 XML 格式。只有在确认每个对象都已被重写为 JSON 格式之后团队才能移除读取 XML 数据的能力。这个过程通常称为“回填”。回填可能需要额外工具并且这些工具需要能够在服务持续读写数据的同时并发运行。序列化最佳实践降低部署回滚风险大多数软件都会涉及数据序列化无论是为了实现持久化存储还是为了通过网络传输数据。随着软件不断演进序列化逻辑通常也会发生变化。变更可能小到添加一个新字段也可能大到完全更换数据格式。多年实践表明以下序列化最佳实践有助于降低部署和回滚风险。通常避免开发自定义序列化格式自定义序列化的初始逻辑看起来可能很简单甚至可能带来更好的性能。但是随着格式不断演进后续迭代会带来不少挑战而这些挑战已经由许多成熟框架解决例如 JSON、Protocol Buffers、Cap’n Proto 和 FlatBuffers。只要使用得当这些框架能够提供转义、向后兼容、字段存在性跟踪等安全能力。所谓字段存在性跟踪是指能够区分某个字段是被显式设置还是被隐式赋予了默认值。每次变更后都为序列化程序明确分配不同版本这一版本控制应当独立于源代码版本或构建版本。团队还应将序列化程序版本与序列化后的数据一起存储或者以元数据形式存储。较旧版本的序列化程序应当能够在新软件中继续正常运行。在实践中团队通常还可以针对所写入或读取的数据版本发布指标。一旦出现错误这些指标就能为运维人员提供可见性和故障排查信息。上述做法同样适用于 RPC 和 API 的版本管理。避免序列化无法控制的数据结构例如团队可以通过反射来序列化 Java 的集合对象。但是当 JDK 升级时这类底层实现可能发生变化从而导致反序列化失败。类似风险也存在于团队之间共享的库类中。如果某个数据结构不受当前团队控制就不应轻易将其作为长期序列化格式的一部分。通常将序列化程序设计为允许未知属性存在在可行的情况下序列化程序应当在回写数据时保留未知属性。这样一来即使运行新版本软件的服务器在序列化数据时加入了新属性运行旧版本软件的服务器在更新同一份数据时也不会清除这些属性。在某些情况下这种设计可以避免将部署拆分为两个阶段。当然任何最佳实践都需要结合具体场景判断。上述准则并不适用于所有应用程序也不适用于所有系统架构。升级—降级测试验证变更是否能够安全回滚通常团队会通过“升级—降级测试”显式验证软件变更是否能够安全地前滚和回滚。在这个过程中需要搭建一个能够代表生产环境的测试环境。长期实践表明在搭建测试环境时应当避免以下几类常见问题。第一类问题是测试环境过于简化。例如有些测试环境中每项服务只有一台服务器。因此所有部署实际上都是原子性的完全排除了不同版本软件并发运行的可能性。在这种环境中即使变更通过了所有测试部署到生产环境后仍然可能出错。更稳妥的做法是即使测试环境的流量不如生产环境大也应当尽量像生产环境一样让每项服务都运行在不同可用区的多台服务器之后。成熟团队通常都重视成本效率但为了保证质量不应让成本节约成为牺牲测试有效性的理由。第二类问题是测试环境虽然有多台服务器但为了加快测试速度部署会一次性应用到所有服务器。这种做法同样会阻止新旧版本软件同时运行从而无法发现前滚过程中的问题。更好的做法是在测试环境和生产环境中使用相同或足够接近的部署配置。对于涉及微服务间协调的变更团队应当在测试环境和生产环境中采用相同的跨微服务部署顺序。不过前滚顺序和回滚顺序可能并不相同。例如在序列化相关场景中通常需要遵循特定顺序前滚时读取方应先于写入方升级回滚时写入方应先于读取方回滚。在测试环境和生产环境中都应遵循相应顺序。若参与方较多还可以通过 worktile这类通用项目协作系统统一管理任务、排期、文档、日历和审批让跨团队部署协作更清晰减少因沟通遗漏带来的回滚风险。当测试环境的设置尽可能接近生产环境后团队还需要尽量严密地模拟生产流量。例如可以快速创建并读取多条记录或消息让所有 API 持续运行。随后将测试过程分为三个阶段每个阶段都持续一段合理时间以便识别潜在错误。这段时间必须足够长确保所有 API、后端工作流和批处理作业至少运行一次。第一阶段将变更部署到服务器集群中大约一半的设备上以确保新旧软件版本共存。第二阶段完成部署使所有服务器都运行新版本软件。第三阶段启动回滚部署并遵循类似步骤直到所有服务器都重新运行旧版本软件。如果在这三个阶段中没有出现错误或意外行为就可以认为这项变更通过了升级—降级测试。结论通过安全回滚提升软件部署可靠性确保部署能够在不造成客户中断的情况下回滚是保障服务可靠性的关键。显式测试回滚安全性可以减少对人工分析的依赖因为人工分析往往容易遗漏细微但高风险的兼容性问题。当团队发现某项变更不适合直接回滚时通常可以将其拆分为两项或多项更小的变更并确保每项变更都能够安全地前滚和回滚。通过向后兼容设计、两阶段部署、充分的观察期、必要的回填机制以及接近生产环境的升级—降级测试团队可以显著降低部署风险提高系统在持续交付过程中的可靠性。