更新过程中的向后兼容性
GitLab 部署可以分解为多个组件。更新 GitLab 不是原子操作。因此,许多组件必须保持向后兼容。
常见陷阱
从某种意义上说,这些场景都是临时状态。但在实际的生产环境中,它们可能会持续数小时。因此,我们必须像对待永久状态一样谨慎处理它们。
修改 Sidekiq worker 时
例如,当修改参数时:
- 如果作业使用旧签名入队,但由新版月度版本执行,是否可以?
- 如果作业使用新签名入队,但由旧版月度版本执行,是否可以?
添加新的 Sidekiq worker 时
如果因为Sidekiq 节点尚未更新,这些作业在数小时内无法执行,是否可以?
修改 JavaScript 时
当浏览器有新的 JavaScript 代码,但 Rails 代码在旧版月度版本上运行时,是否可以:
- 在 REST API 上?
- 在 GraphQL API 上?
- 在控制器中的内部 API 上?
添加预部署迁移时
如果预部署迁移已执行,但 web、Sidekiq 和 API 节点仍在运行旧版本,是否可以?
添加后部署迁移时
如果所有 GitLab 节点都已更新,但后部署迁移要等到几天后才执行,是否可以?
添加后台迁移时
如果所有节点都已更新,然后后部署迁移在几天后执行,然后后台迁移需要一周才能完成,是否可以?
升级 Rails 等依赖时
如果某些节点有新版本的 Rails,但某些节点有旧版本的 Rails,是否可以?
更新过程演示
更新过程中的向后兼容性问题通常非常微妙。因此,值得你熟悉:
为了说明这些问题是如何产生的,请看这个示例:
- 🚢 新版本
- 🙂 旧版本
在这个示例中,你可以想象我们正在通过月度版本进行更新。但请参考代码必须向后兼容多久?。
| 更新步骤 | PostgreSQL 数据库 | Web 节点 | API 节点 | Sidekiq 节点 | 兼容性问题 |
|---|---|---|---|---|---|
| 初始状态 | 🙂 | 🙂 | 🙂 | 🙂 | |
| 执行预部署迁移 | 🚢(除后部署迁移外) | 🙂 | 🙂 | 🙂 | 🙂 中的 Rails 代码对 🚢 进行数据库调用 |
| 更新 web 节点 | 🚢(除后部署迁移外) | 🚢 | 🙂 | 🙂 | 🚢 中的 JavaScript 对 🙂 进行 API 调用。🚢 中的 Rails 代码将作业入队,由 🙂 中的 Sidekiq 节点执行 |
| 更新 API 和 Sidekiq 节点 | 🚢(除后部署迁移外) | 🚢 | 🚢 | 🚢 | 🚢 中的 Rails 代码进行数据库调用,没有后部署迁移或后台迁移 |
| 执行后部署迁移 | 🚢 | 🚢 | 🚢 | 🚢 | 🚢 中的 Rails 代码进行数据库调用,没有后台迁移 |
| 后台迁移完成 | 🚢 | 🚢 | 🚢 | 🚢 |
这个示例并不全面。GitLab 可以通过许多不同的方式部署。甚至每个更新步骤也不是原子的。例如,在滚动部署中,组内的节点会暂时处于不同版本。你应该假设更新步骤之间会经过很长时间。这在 GitLab.com 上通常是真实的。
代码必须向后兼容多久?
对于遵循零停机时间更新说明的用户,答案是一个月度版本。例如:
- 13.11 => 13.12
- 13.12 => 14.0
- 14.0 => 14.1
对于 GitLab.com,每天可能有多个小版本更新,因此 GitLab.com 不限制变更必须向后兼容的范围。
许多用户会跳过一些月度版本,例如:
- 13.0 => 13.12
这些用户接受更新期间的一些停机时间。不幸的是,我们无法完全忽略这种情况。例如,13.12 可能会执行来自 13.0 的 Sidekiq 作业,这说明了为什么我们在主要版本发布前才移除作业参数。主要问题是:更新完成后,部署是否会达到良好状态?
GitLab 可以分解为哪些类型的组件?
1000 RPS 或 50,000 用户参考架构在 48+ 个节点上运行 GitLab。GitLab.com比这更大,部分基础设施运行在 Kubernetes 上,还有“金丝雀"阶段首先接收更新。
但问题不仅仅是节点众多。更大的问题是部署可以划分为不同的上下文。而 GitLab.com 并不是唯一这样做的。一些可能的划分:
- “金丝雀 web 应用节点”:处理部分用户的非 API 请求
- “Git 应用节点”:处理 Git 请求
- “Web 应用节点”:处理 web 请求
- “API 应用节点”:处理 API 请求
- “Sidekiq 应用节点”:处理 Sidekiq 作业
- “PostgreSQL 数据库”:处理内部 PostgreSQL 调用
- “Redis 数据库”:处理内部 Redis 调用
- “Gitaly 节点”:处理内部 Gitaly 调用
在更新期间,不同上下文中会运行两个不同版本的 GitLab。例如,web 节点可能将作业入队,由旧的 Sidekiq 节点执行。
更新步骤的顺序是否重要?
是的!我们有特定的零停机时间更新说明,因为它允许我们忽略某些兼容性排列组合。这就是为什么我们不担心 Rails 代码对旧版 PostgreSQL 数据库模式进行数据库调用。
你发现了一个潜在的向后兼容性问题,该怎么办?
协调
对于 Rails 或 Puma 的主要或次要版本更新:
- 让质量团队彻底测试 MR。
- 在合并前通知
@gitlab-org/release/managers。
功能标志
功能标志是处理向后兼容性问题的工具,而不是策略。
例如,如果前端和 API 变更都默认禁用,那么添加具有前端和 API 变更的新功能是安全的。这可以通过多个合并请求完成,以任何顺序合并。所有变更都部署到 GitLab.com 后,可以在 ChatOps 中启用该功能并在 GitLab.com 上验证。
但是,默认启用该功能不一定安全。 如果在同一版本中移除了功能标志,或将默认值更改为启用,那么执行零停机时间更新的客户最终会使用新前端代码运行旧版本的 API。
如果你不确定是否可以一次性启用所有变更,一个选择是在当前版本中启用 API,在下一个版本中启用前端变更。这是扩展和收缩模式的一个示例。
或者,你可以通过修改前端来优雅降级以适应旧版本的 API,从而避免延迟一个版本。
优雅降级
例如,当添加具有前端和 API 变更的新功能时,可以编写前端代码,使新功能能够优雅地降级以适应旧的 API 响应。这有助于避免需要将变更分散在 3 个版本中。
扩展和收缩模式
保证本地实例零停机时间更新的一种方法是遵循扩展和收缩模式。
这意味着每个破坏性变更都分为三个阶段:扩展、迁移和收缩。
- 扩展:引入破坏性变更,同时保持软件向后兼容。
- 迁移:所有消费者都更新为使用新实现。
- 收缩:移除向后兼容性。
这三个阶段必须属于不同的里程碑,以允许零停机时间更新。
根据功能的支持级别,收缩阶段可以推迟到下一个主要版本。
扩展和收缩示例
路由变更、修改 Sidekiq worker 参数和数据库迁移都是破坏性变更的完美示例。让我们看看如何安全地处理它们。
路由变更
更改路由时,我们应注意确保从新版本生成的路由可以被旧版本提供服务,反之亦然。正如你所见,不这样做可能导致服务中断。这种变更看起来像是两个实现之间的即时切换。然而,特别是在金丝雀阶段,两个版本的代码在生产环境中共存的时间会很长。
- 扩展:添加新路由,指向与旧路由相同的控制器。但应用程序中没有任何内容为新路由生成链接。
- 迁移:现在集群中的每台机器都能理解新路由,我们可以使用新路由生成链接。
- 收缩:可以安全地移除旧路由。(如果旧路由可能被广泛共享,如仓库文件的链接,我们可能需要添加重定向并保留旧路由更长时间。)
修改 Sidekiq worker 的参数
这个主题在Sidekiq 更新兼容性中有详细说明。
当我们需要为 Sidekiq worker 类添加新参数时,可以将其分为以下步骤:
- 扩展:worker 类添加一个带有默认值的新参数。
- 迁移:我们在所有 worker 调用中添加新参数。
- 收缩:我们移除默认值。
乍一看,将扩展和合并到一个里程碑中似乎是安全的,但如果 Puma 在 Sidekiq 之前重启,这会导致服务中断。Puma 会使用旧版 Sidekiq 无法处理的额外参数将作业入队。
数据库迁移
下图是部署的简化可视化表示,它指导我们理解如何在迁移策略中实现扩展和收缩。
这里有一个特殊的考虑。使用我们的后部署迁移框架,我们可以将所有三个阶段捆绑到一个里程碑中。
gantt
title 部署
dateFormat HH:mm
section 部署框
运行迁移 :done, migr, after schemaA, 2m
运行后部署迁移 :postmigr, after mcvn , 2m
section 数据库
架构 A :done, schemaA, 00:00 , 1h
架构 B :crit, schemaB, after migr, 58m
架构 C. : schemaC, after postmigr, 1h
section 机器 A
版本 N :done, mavn, 00:00 , 75m
版本 N+1 : after mavn, 105m
section 机器 B
版本 N :done, mbvn, 00:00 , 105m
版本 N+1 : mbdone, after mbvn, 75m
section 机器 C
版本 N :done, mcvn, 00:00 , 2h
版本 N+1 : mbcdone, after mcvn, 1h
如果我们从数据库的角度看这个架构,可以看到两个部署输入到一个 GitLab 部署中:
- 从
架构 A到架构 B - 从
架构 B到架构 C
这些部署与应用变更完美对齐。
- 开始时我们在
架构 A上有版本 N。 - 然后有一个较长的过渡期,在
架构 B上同时有版本 N和版本 N+1。 - 当我们在
架构 B上只有版本 N+1时,架构再次变更。 - 最后我们在
架构 C上有版本 N+1。
了解了所有这些细节后,假设我们需要替换一个查询,并且这个查询有一个索引来支持它。
- 扩展:这是从
架构 A到架构 B的部署。我们添加新索引,但应用程序暂时忽略它。 - 迁移:这是
版本 N到版本 N+1的应用部署。新代码部署,此时只有新查询运行。 - 收缩:从
架构 B到架构 C(后部署迁移)。不再使用旧索引,我们可以安全地移除它。
这只是一个示例。更复杂的迁移,特别是需要后台迁移的,可能需要多个里程碑。详细信息请参考我们的迁移风格指南。
过去事件示例
一些链接和 MR 被破坏
当我们移动 MR 路由时,新服务器上的用户被重定向到新 URL。当这些用户在 Markdown(或其他任何地方)共享这些新 URL 时,对于旧服务器上的用户来说,这些是断开的链接。
有关更多信息,请参阅相关问题。
问题或合并请求描述和评论中的陈旧缓存
我们更新了 Markdown 缓存版本,发现了一个错误:当用户编辑由不同 Markdown 缓存版本生成的描述或评论时。保存后缓存的 HTML 没有正确生成。在大多数情况下,这不会发生,因为用户在选择编辑之前会查看 Markdown,这意味着 Markdown 缓存会刷新。但由于我们运行混合版本,这更有可能发生。另一个不同版本的用户可以查看同一页面,并在后台将缓存刷新到另一个版本。
有关更多信息,请参阅相关问题。
项目服务模板错误复制
我们更改了指示服务是否为模板的列。当我们创建服务时,我们从模板复制属性并将此列设置为 false。旧服务器仍在更新旧列,但这没问题,因为我们有一个数据库触发器可以从旧列更新新列。但对于新服务器,它们只更新新列,而同一个触发器现在对我们不利,将其设置回错误的值。
有关更多信息,请参阅相关问题。
某些用户的侧边栏未加载
我们更改了一个 GraphQL 字段的数据类型。当用户从新服务器打开问题页面,而 GraphQL AJAX 请求发送到旧服务器时,发生了类型不匹配,导致 JavaScript 错误,阻止了侧边栏加载。
有关更多信息,请参阅相关问题。
CI 工件上传失败
我们向一列添加了 NOT NULL 约束并将其标记为 NOT VALID 约束,以便不对现有行强制执行。但即使如此,这仍然是个问题,因为旧服务器仍在插入具有 null 值的新行。
有关更多信息,请参阅相关问题。
金丝雀和部署生产之间的发布功能停机
为了解决这个问题,我们在现有表中添加了一个新列,并添加了 NOT NULL 约束,没有指定默认值。换句话说,这要求应用程序为该列设置值。
旧版本的应用程序没有设置 NOT NULL 约束,因为该实体/概念以前不存在。
问题在金丝雀部署完成后立即开始。那时,数据库迁移(添加列)已成功运行,金丝雀实例开始使用新的应用程序代码,因此 QA 成功。不幸的是,生产实例仍在使用旧代码,因此开始无法插入新的发布条目。
有关更多信息,请参阅与发布 API 相关的问题。
由于节点类型部署时间不同导致构建失败
在一个生产问题中,CI 构建使用了 parallel 关键字,并且依赖于变量 CI_NODE_TOTAL 是整数,这失败了。这是因为用户推送提交后:
- 新代码:Sidekiq 创建了新的流水线和新的构建。
build.options[:parallel]是一个Hash。 - 旧代码:Runner 从运行旧版本的 API 节点请求作业。
- 结果,新代码没有在 API 服务器上运行。Runner 的请求失败了,因为旧的 API 服务器试图返回
CI_NODE_TOTALCI/CD 变量,但没有发送整数值(例如 9),而是发送了序列化的Hash值({:number=>9, :total=>9})。
如果你查看部署流水线,你会看到所有节点都是并行更新的:
然而,尽管更新开始时间大致相同,但完成时间差异显著:
| 节点类型 | 持续时间(分钟) |
|---|---|
| API | 54 |
| Sidekiq | 21 |
| K8S | 8 |
使用 parallel 关键字并依赖于 CI_NODE_TOTAL 和 CI_NODE_INDEX 的构建在 Sidekiq 更新后的时间内会失败。由于 Kubernetes(K8S)也运行 Sidekiq pod,时间窗口可能长达 46 分钟或短至 33 分钟。无论如何,在部署完成后启用功能标志可以防止这种情况发生。