支持滚动升级

https://bugs.launchpad.net/ironic/+bug/1526283

本文档提出支持 ironic 的“滚动升级”,这将允许操作员在不必同时重启所有服务的情况下,将新代码部署到 ironic 服务。升级 ironic 服务时,几乎没有停机时间。在 ironic 升级期间,实例不受影响;它们应继续运行并具有网络访问权限。不过,在裸机节点上执行 ironic 操作可能会稍有延迟。

对滚动升级的支持将满足 OpenStack 治理中提到的标准 [1]

问题描述

运营 OpenStack 云的人员面临同样的问题:如何将云升级到最新版本,以便享受新功能和更改。

通常一次升级云的一个组件。例如,可以按以下顺序升级 OpenStack 的这些组件

  1. 升级 keystone

  2. 升级 glance

  3. 升级 ironic

  4. 升级 nova

  5. 升级 neutron

  6. 升级 cinder

Ironic 已经支持“冷升级” [14],其中 ironic 服务在升级期间必须关闭。对于耗时的升级,服务不可用很长时间可能无法接受。执行冷升级的步骤 [13]可能是

  1. 停止所有 ironic-api 和 ironic-conductor 服务

  2. 卸载旧代码

  3. 安装新代码

  4. 更新配置

  5. DB 同步(最耗时的任务)

  6. 启动 ironic-api 和 ironic-conductor 服务

滚动升级将为云的用户和操作员提供更好的体验。在 ironic 的滚动升级上下文中,这意味着不必像冷升级那样同时升级所有 ironic-api 和 ironic-conductor 服务。滚动升级允许一次升级单个 ironic-api 和 ironic-conductor 服务,而其余服务仍然可用。此次升级将具有最少的停机时间。

虽然我们希望此处提出的滚动升级解决方案与 nova 的升级流程相同 [2],但 nova 和 ironic 之间存在一些差异,这些差异阻止我们使用相同的解决方案。这些差异将在本规范的其他部分中提到。

已经进行了多次关于滚动升级的讨论 ([3][9][10])。

提议的变更

为了使滚动升级具有最少的停机时间,应该至少有两个 ironic-api 服务和两个 ironic-conductor 服务正在运行。

由于 ironic 不支持数据库降级,因此不支持回滚。

为了支持 ironic 的滚动升级,需要以下内容

  • 代码更改

  • 开发人员和审查人员的贡献指南

  • 一个多节点 grenade CI,以防止破坏滚动升级机制

  • 操作员的滚动升级操作指南。

以下子部分描述了

  • ironic 版本之间的滚动升级支持

  • 提议的滚动升级流程

  • 需要进行的更改

版本之间的滚动升级

Ironic 遵循带有中间版本发布的发布周期模型 [6]。这些版本是语义化的 [7],格式为 <major>.<minor>.<patch>。我们将 ironic 的“命名版本”称为与开发周期(如 Mitaka)关联的版本。

此外,ironic 遵循标准的弃用策略 [8],该策略规定弃用期必须至少三个月且跨越一个周期边界。这意味着两个命名版本之间永远不会同时存在已弃用和删除的内容。

将支持以下滚动升级:

  • 命名版本 N 到 N+1。(如果此功能合并到 Ocata 中,N 将从 Newton 开始。)

  • 任何命名版本到其最新的修订版本,其中包含回移植的错误修复。由于这些错误修复可能包含升级过程的改进,操作员应在命名版本之间升级之前修补系统。

  • 最新的命名版本 N(以及比 N 新的语义版本)到 master。与上述项目符号类似,我们可能会在 master 分支中引入一个错误或功能,我们希望在发布命名版本之前将其删除。弃用策略允许在 3 个月的时间范围内执行此操作 [8]。如果该功能在中间版本中包含并删除,应添加发行说明,其中包含有关如何从受影响的版本或版本范围升级到 master 的说明。这通常会指示操作员先升级到特定的中间版本,然后再升级到 master。

滚动升级流程

将 ironic 从版本 FromVer 升级到下一个版本 ToVer 的滚动升级流程如下:

  1. 在升级 ironic 之前,升级 Ironic Python Agent 镜像。

  2. 通过 ironic-dbsync upgrade 将 DB 模式升级到 ToVer。Ironic 已经具备执行此操作的代码。但是,需要记录新的 DB 迁移策略(如下文 新的 DB 模型更改策略 中所述)。

  3. 通过下面描述的新配置选项,通过将 RPC 和 IronicObject 版本固定到 ironic-api 和 ironic-conductor 服务的相同 FromVer,从而固定 RPC 和 IronicObject 版本。

  4. 升级代码并一次重启一个 ironic-conductor 服务。

  5. 升级代码并一次重启一个 ironic-api 服务。

  6. 取消固定 RPC 和对象版本,以便服务现在可以使用 ToVer 中的最新版本。通过更新下面描述的新配置选项(在 RPC 和对象版本固定 中)并重新启动服务来完成此操作。应先重新启动 ironic-conductor 服务,然后再重新启动 ironic-api 服务。这样可以确保在未固定的 API 服务上公开新功能(通过 API 微版本)时,它可以在后端可用。

  7. 运行新命令 ironic-dbsync online_data_migration,以确保所有 DB 记录都“升级”到新的数据版本。这个新命令在单独的 RFE 中讨论 [12](并且是这项工作的一个依赖项)。

  8. 升级 ironic 客户端库(例如 python-ironicclient)和其他使用新引入的 API 功能并依赖于新版本的服务。

上述流程将导致 ironic 服务以以下顺序运行 FromVerToVer 版本(其中“步骤”指的是上述步骤)

步骤

ironic-api

ironic-conductor

0

全部 FromVer

全部 FromVer

4.1

全部 FromVer

部分 FromVer,部分 ToVer-固定

4.2

全部 FromVer

全部 ToVer-固定

5.1

部分 FromVer,部分 ToVer-固定

全部 ToVer-固定

5.2

全部 ToVer-固定

全部 ToVer-固定

6.1

全部 ToVer-固定

部分 ToVer-固定,部分 ToVer

6.2

全部 ToVer-固定

全部 ToVer

6.3

部分 ToVer-固定,部分 ToVer

全部 ToVer

6.4

全部 ToVer

全部 ToVer

新的 DB 模型更改策略

这不是代码更改,但它会影响 SQLAlchemy DB 模型,并且需要开发人员和审查人员充分记录。新的 DB 模型更改策略如下:

  • 支持将新项目添加到 DB 模型。

  • 删除列/表和相应的对象字段受 ironic 弃用策略 [8] 的约束。但是,它的 alembic 脚本必须等待一个额外的弃用周期,否则当 FromVer 服务访问 DB 时,将抛出“未知列”异常。这是因为 ironic-dbsync upgrade 会升级 DB 模式,但 FromVer 服务仍然在其 SQLAlchemy DB 模型中包含已删除的字段。

  • 不再支持 alter_column,例如重命名或调整大小。这必须拆分为多个操作,例如添加列,然后删除列。某些更改可能需要拆分为多个版本才能保持与旧 SQLAlchemy 模型的兼容性。

  • PostgreSQL 中某些 ALTER TABLE 的实现(例如添加外键)可能会施加表锁并导致停机。如果无法避免更改并且影响很大(表可以频繁访问和/或存储大型数据集),则必须在发行说明中提及这些情况。

RPC 和对象版本固定

为了使 ironic(ironic-api 和 ironic-conductor)服务在滚动升级期间同时运行旧版本和新版本,服务需要能够处理不同的 RPC 版本和对象版本。

[4] 很好地描述了为什么我们需要 RPC 版本控制,并描述了 nova 如何处理它。本文档建议在 ironic 中采用类似的方法。

对于对象版本控制,ironic 使用 oslo.versionedobjects。 [5] 描述了 nova 处理此问题的方法。不幸的是,ironic 的解决方案不同,因为 ironic 的情况更复杂。在 nova 中,所有数据库访问(读取和写入)都通过 nova-conductor 服务完成。这使得 nova-conductor 服务成为处理不同对象版本之间转换的唯一服务。(有关更多详细信息,请参阅 [5]。)对于 nova 滚动升级,所有非 nova-compute 服务都会关闭,然后使用新版本重新启动;nova-conductor 是第一个重新启动的服务 ([2])。因此,nova-conductor 服务始终运行相同的版本,并且不需要处理彼此之间不同的对象版本。一旦它们运行新版本,就可以处理来自运行旧版本或新版本的其他服务的请求。

与 ironic 形成对比,ironic-api 和 ironic-conductor 服务都会访问数据库进行读取和写入。这两个服务都需要了解不同的对象版本。例如,ironic-api 可以创建 Chassis、Ports 和 Portgroups 等对象,直接保存到数据库,而无需通过 conductor。我们无法像 nova-conductor 服务一样关闭 ironic-conductor,因为 ironic-conductor 所做的事情远不止与数据库交互,并且至少需要一个 ironic-conductor 在滚动升级期间运行。

将添加一个新的配置选项。它将用于固定所有 ironic 服务的 RPC 和 IronicObject(例如 Node、Conductor、Chassis、Port 和 Portgroup)版本。通过此配置选项,服务将能够正确处理不同版本服务之间的通信。

新的配置选项是:[DEFAULT]/pin_release_version。默认值为空表示 ironic-api 和 ironic-conductor 将使用 RPC 和 IronicObjects 的最新版本。其可能的值是发布版、命名版(例如 ocata)或语义版本(例如 7.0)。

在内部,ironic 将维护一个映射,指示与每个版本关联的 RPC 和 IronicObject 版本。此映射将手动维护。(添加映射的自动化过程是可能的,但超出本规范的范围。)这是一个例子

  • objects_mapping

    {'mitaka': {'Node': '1.14', 'Conductor': '1.1',
                'Chassis': '1.3', 'Port': '1.5', 'Portgroup': '1.0'},
     '5.23': {'Node': '1.15', 'Conductor': '1.1',
              'Chassis': '1.3', 'Port': '1.5', 'Portgroup': '1.0'}}
    
  • rpc_mapping

    {'mitaka': '1.33', '5.23': '1.33'}
    

在滚动升级期间,使用新版本的服务应将此值设置为旧版本的名称(或版本)。这将指示使用新版本的服务,它们应该与使用旧版本的服务兼容的 RPC 和对象版本。

处理 RPC 版本

ConductorAPI.__init__() 已经设置了一个 version_cap 变量,将其设置为最新的 RPC API 版本,并将其作为初始化参数传递给 RPCClient。此 version_cap 用于确定 RPCClient 可以发送的最大请求消息版本。

为了使与先前版本兼容的 RPC 调用,代码将被更改,以便将 version_cap 设置为固定版本(对应于先前版本),而不是最新的 RPC_API_VERSION。然后,每个 RPC 调用将根据此 version_cap 自定义请求。

处理 IronicObject 版本

在内部,ironic 服务(ironic-api 和 ironic-conductor)将处理最新版本的 IronicObject。只有在这些边界处,当 IronicObject 进入或离开服务时,才需要处理对象版本控制。

  • 从数据库获取对象:转换为最新版本

  • 将对象保存到数据库:如果已固定,则保存为固定版本;否则保存为最新版本

  • 序列化对象(通过 RPC 发送):如果已固定,则发送固定版本;否则发送最新版本

  • 反序列化对象(接收来自 RPC 的对象):转换为最新版本

ironic-api 服务还需要根据 API 版本和对象版本是否支持某个功能来处理 API 请求/响应。例如,当 ironic-api 服务被固定时,它只能允许对象固定版本中可用的操作,并且不允许仅适用于该对象最新版本的操作。

为了支持这一点

  • 在所有 IronicObject 的数据库表(SQLAlchemy 模型)中添加一个名为 version 的新列。该值是保存到数据库中的对象的版本。

    此版本列最初将为空,并将由数据迁移脚本填充适当的版本。如果在 Ocata 中有需要迁移数据的更改,我们将检查新版本列中是否为空。

    没有项目使用版本列机制来实现此目的,但如果没有它会更复杂。例如,Cinder 有一个跨越 4 个版本的迁移策略,其中数据会重复一段时间。Keystone 使用触发器在一个发布周期内维护重复数据。此外,版本列可能证明对零停机时间升级(未来)有用。

  • 添加一个新的方法 IronicObject.get_target_version(self)。这将返回目标版本。如果已固定,则返回固定版本。否则,返回最新版本。

  • 添加一个新的方法 IronicObject.convert_to_version(self, target_version)。此方法会将对象转换为目标版本。目标版本可以是比对象现有版本更新或更旧的版本。大部分工作将在新的辅助方法 IronicObject._convert_to_version(self, target_version) 中完成。具有新版本的子类应重新定义此方法以执行实际转换。

  • 添加一个新的方法 IronicObject.do_version_changes_for_db(self)。这将在 将对象保存到数据库(API/conductor -> DB) 中描述。

  • 添加一个新的方法 IronicObjectSerializer._process_object(self, context, objprim)。这将在 通过 RPC 接收对象(API/conductor <- RPC) 中描述。

在以下内容中,

  • 旧版本是 FromVer;它使用版本 ‘1.14’ 的 Node 对象。

  • 新版本是 ToVer;它使用版本 ‘1.15’ 的 Node 对象 – 该版本有一个已弃用的 extra 字段和一个新的 meta 字段,该字段取代了 extra

  • db_obj[‘meta’] 和 db_obj[‘extra’] 是这些节点字段的数据库表示形式。

从数据库获取对象(API/conductor <– DB)

ironic-api 和 ironic-conductor 服务都从数据库读取值。这些值通过现有方法 IronicObject._from_db_object(context, obj, db_object) 转换为 IronicObject。此方法将被更改,以便即使对象在数据库中是旧版本,IronicObject 也将处于最新版本。无论服务是否已固定,都执行此操作。

请注意,如果对象转换为较新版本,则该 IronicObject 将保留因转换而产生的所有更改(以防对象稍后保存为最新版本)。

例如,如果数据库中的节点是版本 1.14 并且 db_obj[‘extra’] 已设置

  • 一个 FromVer 服务将获得一个 Node,其中 node.extra = db_obj[‘extra’](并且不知道 node.meta,因为它不存在)。

  • 一个 ToVer 服务(已固定或未固定)将获得一个 Node,其中

    • node.meta = db_obj[‘extra’]

    • node.extra = None

    • node._changed_fields = [‘meta’, ‘extra’]

将对象保存到数据库(API/conductor –> DB)

保存 IronicObject 到数据库所使用的版本确定如下

  • 对于未固定的服务,对象将保存为最新版本。由于对象始终处于最新版本,因此不需要进行任何转换。

  • 对于已固定的服务,对象将保存为固定版本。由于对象始终处于最新版本,因此在保存之前需要将对象转换为固定版本。

新的方法 IronicObject.do_version_changes_for_db() 将处理此逻辑,返回一个包含已更改字段及其新值的字典(类似于现有的 oslo.versionedobjects.VersionedObjectobj.obj_get_changes())。由于我们不内部跟踪对象的数据库版本,因此对象的 version 字段始终是这些更改的一部分。

滚动升级过程(在步骤 6.1 处)确保在对象可以保存为最新版本时,所有服务都正在运行较新版本(尽管其中一些可能仍然已固定),并且可以处理最新的对象版本。

当服务如步骤 6.1 中所述时,可能会发生一种有趣的情况。有可能将 IronicObject 保存为较新版本,然后将其保存为较旧版本。例如,一个 ToVer 未固定的 conductor 可能会保存一个版本为 1.5 的节点。随后的请求可能会导致一个 ToVer 已固定的 conductor 替换并保存相同的节点为版本 1.4!

通过 RPC 发送对象(API/conductor -> RPC)

当服务发出 RPC 请求时,作为该请求发送的任何 IronicObject 都会序列化为实体或原始类型(通过 oslo.versionedobjects.VersionedObjectSerializer.serialize_entity())。用于对象序列化的版本如下

  • 对于未固定的服务,对象将序列化为最新版本。由于对象始终处于最新版本,因此不需要进行任何转换。

  • 对于已固定的服务,对象将序列化为固定版本。由于对象始终处于最新版本,因此在序列化之前需要将对象转换为固定版本。转换后的对象将包括因转换而产生的更改;如果 RPC 请求的另一端的服务将对象保存到数据库,则这是必需的。

IronicObjectSerializer.serialize_entity() 方法将被修改以执行任何 IronicObject 转换。

通过 RPC 接收对象(API/conductor <- RPC)

当服务接收 RPC 请求时,请求中的任何实体都需要反序列化(通过 oslo.versionedobjects.VersionedObjectSerializer.deserialize_entity())。对于表示 IronicObject 的实体,我们希望反序列化过程产生处于最新版本的 IronicObject,无论它们发送的版本如何,无论接收服务是否已固定。同样,任何转换的对象都将保留因转换而产生的更改,如果该对象稍后保存到数据库,这将很有用。

反序列化方法调用 VersionedObjectSerializer._process_object() 来反序列化并获取 IronicObject。我们将添加 IronicObjectSerializer._process_object() 将 IronicObject 转换为其最新版本。

例如,一个 FromVer ironic-api 可以发出一个带有版本 1.4 的节点的 update_node() RPC 请求,其中 node.extra 已更改(因此 node._changed_fields = [‘extra’])。此节点将序列化为版本 1.4。接收的 ToVer 已固定的 ironic-conductor 对其进行反序列化并将其转换为版本 1.5。结果节点将具有 node.meta 设置(设置为 v1.4 中 node.extra 的更改值),node.extra = None,以及 node._changed_fields = [‘meta’, ‘extra’]。

备选方案

可以执行冷升级,但这意味着 ironic 服务将在升级期间不可用,这可能需要很长时间。

与其让服务始终以最新版本处理对象,可以使用不同的设计,例如,已固定的服务以固定版本处理其对象。但是,经过一些实验,这被证明具有更多的(边缘)情况需要考虑,并且更难以理解。这种方法将使将来维护和故障排除更加困难,假设审查者能够同意它首先有效!

如果我们更改 ironic-api 服务,使其只对 DB 具有只读访问权限,并且所有写入都通过 ironic-conductor 服务进行,会简化支持滚动升级所需的更改吗?也许吧;也许不会。(尽管作者认为无论如何,让所有写入都由 conductor 完成会更好。)无论是否进行此更改,我们需要确保对象不会保存为较新版本(即,比旧版本中的版本更新),直到所有服务都运行新版本 – 滚动升级过程 的步骤 5.2。本文档中描述的解决方案从步骤 6.1 开始将对象保存为最新版本,因为如果服务未固定,仅在最新版本保存对象在概念上易于理解。

当然,可能还有其他方法来处理此问题,例如让所有服务“注册”它们正在数据库中运行的版本并以某种方式利用这些数据。Dmitry Tantsur 思考过是否可以使用一些远程同步内容(例如 etcd)让服务了解升级过程。

理想情况下,ironic 应该使用“OpenStack 偏好的”方式来实现滚动升级,但这似乎不存在,因此这尝试利用 nova 所做的工作。

数据模型影响

采用 DB 迁移策略并在 新的 DB 模型更改策略 中介绍。

将添加一个新列 version 到所有 IronicObject 对象的数据库表中。其值将是保存到数据库中的对象的版本。

状态机影响

REST API 影响

此版本列最初将为空,并将由数据迁移脚本填充适当的版本。

在滚动升级过程中,API 服务可能同时以不同的版本运行。这两个 API 服务版本都应与旧版本中的 python-ironicclient 库兼容(我们已经使用微版本来保证这一点)。新的 API 功能仅在完成升级过程(包括取消固定 ironic 服务的内部 RPC 通信版本)后才可用。因此,在完成升级之前,不应为新的功能(即,使用新的 API 微版本)发出 API 请求,因为无法保证它们能正常工作。

作为未来的增强功能(超出此规范的范围),我们可以禁止 API 服务固定时请求新的功能。

客户端 (CLI) 影响

“ironic” CLI

“openstack baremetal” CLI

RPC API 影响

RPC API 本身没有变化,尽管需要进行更改以支持不同的 RPC API 版本。version_cap 将设置为固定版本(先前版本)以使先前版本的 RPC 调用兼容。

开发人员在更改现有的 RPC API 调用时应记住这一点。

驱动程序 API 影响

Nova 驱动程序影响

由于 REST API 没有变化,因此无需更改 nova 驱动程序。ironic 应该在 nova 之前升级,并且 nova 使用升级后的 ironic 中仍然受支持的特定微版本调用 ironic。因此,所有内容都可以在不更改 nova 驱动程序的情况下正常工作。

Ramdisk 影响

安全影响

其他最终用户影响

可扩展性影响

性能影响

涉及数据迁移的操作在升级期间可能需要更长时间。每次此类更改都应在受影响版本的发布说明中提及。

其他部署者影响

在滚动升级期间,部署程序将使用新的配置选项 [DEFAULT]/pin_release_version 来固定和取消固定如上所述在 RPC 和对象版本固定 中使用的 RPC 和 IronicObject 版本。

此规范不涉及尝试停止/回滚到以前的版本。

开发人员影响

代码贡献指南 [11] 将更新,以描述开发人员需要了解和遵循的内容。它将提及

  • 在发布之前,手动添加发布(命名或语义版本化)与关联的 RPC 和对象版本的映射

  • 新的 DB 模型更改策略

  • 贡献指南将指向设计文档,因为它与如何根据滚动升级实现新功能有关。

实现

负责人

主要负责人

  • xek

  • rloo

其他贡献者

  • mario-villaplana-j(文档)

工作项

  1. 添加新的配置选项 [DEFAULT]/pin_release_version 和 RPC/Object 版本映射到发布。

  2. 使对象与先前版本兼容,并处理与 DB 和服务的交互。

  3. 使 IronicObjectSerializer 在通过 RPC 传递时降级对象。

  4. 添加测试。

  5. 添加文档和指针,用于 RPC 和 oslo 对象版本控制。

  6. 添加有关新的 DB 模型更改策略的文档。

  7. 添加管理员文档,供操作员使用,描述如何执行滚动升级,包括应升级所有相关服务的顺序。

依赖项

  • 需要新的命令 ironic-dbsync online_data_migration [12]

  • 需要多节点 grenade CI 工作。

测试

  • 单元测试

  • 多节点榴弹 CI。 这将测试滚动升级过程从 fromVertoVer 是否继续有效。

    • 榴弹执行完全升级,不进行固定版本。 这将测试旧的 API/调度器和新的 API/调度器未固定版本的情况。 我们不会运行在线数据迁移,因此新的服务将读取旧格式的数据。

    • 榴弹多节点将在子节点上运行旧的 API/调度器,该子节点不会被升级。 主节点将只有调度器,它会被升级,但会被固定版本。 这将测试旧的 API + 旧的数据,以及 1. 旧的调度器,和 2. 新的调度器与固定版本。 测试将在升级前后运行。

    • 我们也可以将 API 移动到主节点,升级它,并固定版本,以测试新的 API 代码与固定版本。 由于所有对象转换都在对象层进行,这可能不需要,因为它可能不会测试到已经通过调度器测试过的很多内容。

    • 如果榴弹可以设置为停止子节点上的旧 API 服务,并在完成第一个测试后在升级后的主节点上启动它,则可以将上述两个测试合并。

测试应尽可能涵盖 版本间的滚动升级 中描述的使用场景。

升级和向后兼容性

无;REST API 或 Driver API 没有更改。

文档影响

将为

  • 部署者添加文档。 这将描述滚动升级过程以及他们需要采取的步骤。 这应该记录在升级指南中,或链接到升级指南 [13]

  • 开发者。 这将描述开发者需要了解的内容,以便滚动升级过程继续有效。 这包括关于 RPC 和 oslo 对象版本控制,以及数据库模型更改策略的文档。

参考资料