支持滚动升级

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 和对象版本固定 中所述)为 ironic-api 和 ironic-conductor 服务固定 RPC 和 IronicObject 版本到相同的 FromVer

  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 字段和一个替换 extra 的新 meta 字段。

  • 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 影响

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 和 IronicObject 版本,如上文 RPC 和对象版本固定 中所述。

本规范不涉及尝试停止/回滚到之前的发布版本。

开发人员影响

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

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

  • 新的 DB 模型变更策略

  • 贡献指南将指向设计文档,这与新功能应该如何根据滚动升级进行实现有关

实现

负责人

主要负责人

  • xek

  • rloo

其他贡献者

  • mario-villaplana-j (文档)

工作项

  1. 添加新的配置选项 [DEFAULT]/pin_release_version 以及发布版本的 RPC/对象版本映射。

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

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

  4. 添加测试。

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

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

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

依赖项

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

  • 需要多节点 grenade CI 正常工作。

测试

  • 单元测试

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

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

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

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

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

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

升级和向后兼容性

无;REST API 或 Driver API 没有变化。

文档影响

将为以下对象添加文档:

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

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

参考资料