跨更新向后兼容性
极狐GitLab 部署可以分解为许多组件。更新极狐GitLab 不是原子操作。因此,许多组件必须向后兼容。
常见陷阱
从某种意义上说,这些场景都是瞬时状态。但它们在生产环境中可能会持续数小时。因此,我们必须像对待永久状态一样谨慎对待它们。
修改 Sidekiq worker 时
例如,当更改参数时:
- 如果作业以旧签名入队但由新的月度版本执行,可以吗?
- 如果作业以新签名入队但由上一个月度版本执行,可以吗?
添加新的 Sidekiq worker 时
如果这些作业因为 Sidekiq 节点尚未更新 而数小时未执行,可以吗?
修改 JavaScript/Vue 时
如果 Rails 代码更改(Rails 控制器、REST API 或 GraphQL API)已在上一个月度版本中合并并发布,JavaScript 可以无问题地向该 Rails 代码发出请求。
如果 Rails 代码更改尚未发布,JavaScript 可以向该 Rails 代码发出请求,但需要位于默认禁用的功能标志后面,或者能够优雅地失败。例如,如果你在 18.3 中添加了一个 GraphQL 查询,则需要等到 18.4 才能在没有功能标志的情况下在前端使用该查询。
向现有查询添加 GraphQL 字段时,你可以使用 @gl_introduced 指令 来优雅地失败。向 REST API 添加字段时,如果响应中不存在新字段,你可以回退到旧字段来优雅地失败。
添加预部署迁移时
如果预部署迁移已执行,但 Web、Sidekiq 和 API 节点仍在运行上一个版本,可以吗?
添加部署后迁移时
如果所有极狐GitLab 节点都已更新,但部署后迁移直到几天后才执行,可以吗?
添加后台迁移时
如果所有节点都已更新,然后部署后迁移在几天后执行,然后后台迁移需要一周才能完成,可以吗?
升级 Rails 等依赖项时
如果某些节点具有新的 Rails 版本,而某些节点具有旧的 Rails 版本,可以吗?
更新过程演练
更新过程中的向后兼容性问题通常非常微妙。这就是为什么值得熟悉以下内容:
为了说明这些问题是如何产生的,请看这个例子:
- 🚢 新版本
- 🙂 旧版本
在此示例中,你可以想象我们正在按一个月度版本进行更新。但请参考代码必须向后兼容多长时间?。
| 更新步骤 | PostgreSQL 数据库 | Web 节点 | API 节点 | Sidekiq 节点 | 兼容性问题 |
|---|---|---|---|---|---|
| 初始状态 | 🙂 | 🙂 | 🙂 | 🙂 | |
| 运行预部署迁移 | 🚢 除了部署后迁移 | 🙂 | 🙂 | 🙂 | 🙂 中的 Rails 代码正在对 🚢 进行数据库调用 |
| 更新 Web 节点 | 🚢 除了部署后迁移 | 🚢 | 🙂 | 🙂 | 🚢 中的 JavaScript 正在对 🙂 进行 API 调用。🚢 中的 Rails 代码正在将作业入队,这些作业将由 🙂 中的 Sidekiq 节点运行 |
| 更新 API 和 Sidekiq 节点 | 🚢 除了部署后迁移 | 🚢 | 🚢 | 🚢 | 🚢 中的 Rails 代码在没有部署后迁移或后台迁移的情况下进行数据库调用 |
| 运行部署后迁移 | 🚢 | 🚢 | 🚢 | 🚢 | 🚢 中的 Rails 代码在没有后台迁移的情况下进行数据库调用 |
| 后台迁移完成 | 🚢 | 🚢 | 🚢 | 🚢 |
此示例并非详尽无遗。极狐GitLab 可以以多种不同方式部署。甚至每个更新步骤也不是原子操作。例如,在滚动部署中,群组内的节点会暂时处于不同版本。你应该假设更新步骤之间会经过大量时间。这在 JihuLab.com 上通常是正确的。
极狐GitLab Next
JihuLab.com 运行一个 canary 阶段,该阶段运行即将部署到生产环境的下一个版本。这意味着我们会在较长时间内运行多个版本的极狐GitLab。
我们将一小部分流量路由到 canary 以测试下一个版本。用户还可以通过设置 cookie 选择加入极狐GitLab Next。我们还将以 gitlab-org 或 gitlab-com 开头的路径路由到 canary,这通常会暴露许多多版本兼容性问题,这些问题会一直持续到 canary 中的版本部署到生产环境,这可能需要几个小时。
问题发生的原因是 API 请求不以相同的路径前缀开头,因此这些从较新的 canary 前端代码发出的 API 请求最终会到达较旧的主节点。
以下是 GraphQL 请求可能发生这种情况的示例:
Rendering chart...
用户可以通过设置 canary cookie 来解决此问题,以便两个请求都到达 canary 节点。但我们不能依赖此解决方法,因此我们需要代码向后兼容。
代码必须向后兼容多长时间?
对于遵循零停机更新说明 的用户,答案是一个月度版本。例如:
- 13.11 => 13.12
- 13.12 => 14.0
- 14.0 => 14.1
对于 JihuLab.com,每天可能有多个小版本更新,因此 JihuLab.com 不会限制更改必须向后兼容多远。
许多用户会跳过一些月度版本,例如:
- 13.0 => 13.12
这些用户在更新期间会接受一些停机时间。不幸的是,我们不能完全忽略这种情况。例如,13.12 可能会执行来自 13.0 的 Sidekiq 作业,这说明了为什么我们要避免在主要版本之前从作业中删除参数。主要问题是:更新完成后部署是否会达到良好状态?
极狐GitLab 可以分解为哪些类型的组件?
1000 RPS 或 50,000 用户参考架构 在 48 个以上的节点上运行极狐GitLab。JihuLab.com 比那更大,此外一部分基础设施在 Kubernetes 上运行,还有一个最先接收更新的 "canary" 阶段。
但问题不仅仅在于节点众多。更大的问题在于部署可以划分为不同的上下文。而且并非只有 JihuLab.com 这样做。一些可能的划分:
- "Canary Web 应用节点":处理来自一部分用户的非 API 请求
- "Git 应用节点":处理 Git 请求
- "Web 应用节点":处理 Web 请求
- "API 应用节点":处理 API 请求
- "Sidekiq 应用节点":处理 Sidekiq 作业
- "PostgreSQL 数据库":处理内部 PostgreSQL 调用
- "Redis 数据库":处理内部 Redis 调用
- "Gitaly 节点":处理内部 Gitaly 调用
在更新期间,将会有两个不同版本的极狐GitLab 在不同的上下文中运行。例如,一个 Web 节点可能会将作业入队,而这些作业在旧的 Sidekiq 节点上运行。
更新步骤的顺序不重要吗?
是的!我们有针对零停机更新 的具体说明,因为它允许我们忽略某些兼容性排列。这就是为什么我们不担心 Rails 代码对旧的 PostgreSQL 数据库模式进行数据库调用。
你已经发现了一个潜在的向后兼容性问题,你可以做些什么?
协调
对于 Rails 或 Puma 的主要或次要版本更新:
- 让质量团队彻底测试 MR。
- 在合并之前,在 MR 上通知 @gitlab-org/release/managers。
功能标志
功能标志 是处理向后兼容性问题的工具,而不是策略。
例如,如果前端和 API 更改默认情况下都处于禁用状态,则可以安全地添加带有前端和 API 更改的新功能。这可以通过多个合并请求完成,以任何顺序合并。在所有更改部署到 JihuLab.com 后,可以在 ChatOps 中启用该功能并在 JihuLab.com 上进行验证。
但是,默认启用该功能不一定安全。 如果功能标志被移除,或者默认值被翻转为启用,在与代码合并相同的版本中,那么执行零停机更新 的客户最终将针对上一个版本的 API 运行新的前端代码。
如果你不确定一次性启用所有更改是否安全,那么一种选择是在当前版本中启用 API,并在下一个版本中启用前端更改。这是扩展和收缩模式 的一个例子。
或者,你可以通过修改前端以针对上一个版本的 API 优雅降级 来避免延迟一个版本。
优雅降级
例如,在添加带有前端和 API 更改的新功能时,可能可以编写前端,使新功能针对旧的 API 响应优雅降级。这可能有助于避免需要将更改分散到 3 个版本中。
扩展和收缩模式
保证私有化部署实例零停机更新的一种方法是遵循扩展和收缩模式。
这意味着每个破坏性更改都分为三个阶段:扩展、迁移和收缩。
- 扩展:引入破坏性更改,同时保持软件向后兼容。
- 迁移:所有使用者都更新为使用新的实现。
- 收缩:移除向后兼容性。
这三个阶段必须是不同里程碑的一部分,以允许零停机更新。
根据功能的支持级别,收缩阶段可能会延迟到下一个主要版本。
扩展和收缩示例
路由更改、更改 Sidekiq worker 参数以及数据库迁移都是破坏性更改的完美示例。让我们看看如何安全地处理它们。
路由更改
在更改路由时,我们应该注意确保从新版本生成的路由可以被旧版本服务,反之亦然。正如你所见,不这样做可能会导致中断。这种类型的更改可能看起来像是两种实现之间的即时切换。然而,特别是在 canary 阶段,两个版本的代码在生产环境中共存的时间会延长。
- 扩展:添加一个新路由,指向与旧路由相同的控制器。但应用程序中没有任何内容为新路由生成链接。
- 迁移:现在集群中的每台机器都能理解新路由,我们可以使用新路由生成链接。
- 收缩:旧路由可以安全移除。(如果旧路由可能被广泛共享,例如指向仓库文件的链接,我们可能希望添加重定向并保留旧路由更长时间。)
更改 Sidekiq worker 的参数
此主题在跨更新 Sidekiq 兼容性 中有详细说明。
当我们需要向 Sidekiq worker 类添加新参数时,我们可以将其拆分为以下步骤:
- 扩展:worker 类添加一个带有默认值的新参数。
- 迁移:我们将新参数添加到 worker 的所有调用中。
- 收缩:我们移除默认值。
乍一看,将扩展和迁移捆绑到一个里程碑中似乎是安全的,但如果 Puma 在 Sidekiq 之前重新启动,这会导致中断。Puma 使用旧 Sidekiq 无法处理的额外参数将作业入队。
数据库迁移
下图是部署的简化可视化表示,它指导我们理解扩展和收缩如何在我们迁移策略中实现。
这里有一个特殊的考虑。使用我们的部署后迁移框架,我们可以将所有三个阶段捆绑到一个里程碑中。
Rendering chart...
如果我们从数据库的角度来看这个模式,我们可以看到两个部署馈送到一个极狐GitLab 部署中:
- 从 Schema A 到 Schema B
- 从 Schema B 到 Schema C
这些部署与应用程序更改完美对齐。
- 开始时,我们在 Schema A 上运行 Version N。
- 然后我们有一个较长的过渡期,Version N 和 Version N+1 都在 Schema B 上运行。
- 当我们只在 Schema B 上运行 Version N+1 时,模式再次更改。
- 最后,我们在 Schema C 上运行 Version N+1。
考虑到所有这些细节,让我们假设我们需要替换一个查询,并且该查询有一个索引来支持它。
- 扩展:这是从 Schema A 到 Schema B 的部署。我们添加新索引,但应用程序暂时忽略它。
- 迁移:这是 Version N 到 Version N+1 的应用程序部署。新代码已部署,此时只有新查询运行。
- 收缩:从 Schema B 到 Schema C(部署后迁移)。不再有任何东西使用旧索引,我们可以安全地移除它。
这只是一个例子。更复杂的迁移,特别是需要后台迁移时,可能需要多个里程碑。有关详细信息,请参阅我们的迁移风格指南。
以往事件示例
一些议题和 MR 的链接已损坏
当我们移动 MR 路由时,新服务器上的用户被重定向到新的 URL。当这些用户在 Markdown(或其它任何地方)中分享这些新 URL 时,对于旧服务器上的用户来说,这些链接是损坏的。
更多信息,请参阅相关议题。
议题或合并请求描述和评论中的过时缓存
我们提升了 Markdown 缓存版本,并发现了一个错误:当用户编辑从不同 Markdown 缓存版本生成的描述或评论时,保存后缓存的 HTML 未正确生成。在大多数情况下,这不会发生,因为用户会在选择编辑之前查看 Markdown,这意味着 Markdown 缓存已刷新。但由于我们运行混合版本,这种情况更可能发生。另一个使用不同版本的用户可以查看同一页面,并在后台将缓存刷新为另一个版本。
更多信息,请参阅相关议题。
项目服务模板被错误复制
我们更改了指示服务是否为模板的列。当我们创建服务时,我们从模板复制属性并将此列设置为 false。旧服务器仍在更新旧列,但这没问题,因为我们有一个数据库触发器,可以从旧列更新新列。但对于新服务器,它们只更新新列,而同一个触发器现在对我们不利,并将其设置回错误的值。
更多信息,请参阅相关议题。
某些用户的侧边栏无法加载
我们更改了一个 GraphQL 字段的数据类型。当用户从新服务器打开议题页面,而 GraphQL AJAX 请求发送到旧服务器时,发生了类型不匹配,导致 JavaScript 错误,阻止了侧边栏加载。
更多信息,请参阅相关议题。
CI 产物上传失败
我们向列添加了 NOT NULL 约束,并将其标记为 NOT VALID 约束,以便不对现有行强制执行。但即便如此,这仍然是一个问题,因为旧服务器仍在插入具有空值的新行。
更多信息,请参阅相关议题。
canary 和生产部署之间的发布功能停机
为了解决此问题,我们向现有表添加了一个带有 NOT NULL 约束的新列,但没有指定默认值。换句话说,这要求应用程序为该列设置一个值。
旧版本的应用程序没有设置 NOT NULL 约束,因为该实体/概念以前不存在。
问题在 canary 部署完成后立即开始。那时,数据库迁移(添加列)已成功运行,canary 实例开始使用新的应用程序代码,因此 QA 成功。不幸的是,生产实例仍使用旧代码,因此它开始无法插入新的发布条目。
更多信息,请参阅此与 Releases API 相关的议题。
由于节点类型之间的部署时间不同导致构建失败
在一个生产问题 中,使用了 parallel 关键字并依赖于变量 CI_NODE_TOTAL 为整数的 CI 构建失败。这是因为在用户推送提交后:
- 新代码:Sidekiq 创建了一个新的流水线和新构建。build.options[:parallel] 是一个 Hash。
- 旧代码:Runners 从运行先前版本的 API 节点请求作业。
- 结果,[新代码](https://gitlab.com/gitlab-org/gitlab/-/blob/42b82a9a3ac5