engine_version 只写 8.4:用 Terraform 管生产 RDS for MySQL 的版本、Multi-AZ 和维护窗口

缘起

我们 prod 有 9 台 RDS for MySQL 主库,外加 1 台只读副本,全部用 Terraform 管。搭环境的时候,在”版本和维护”这件事上定了四条:

  1. engine_version 只写大版本 8.4,不写小版本;
  2. 打开小版本自动升级;
  3. 主库全部开 Multi-AZ;
  4. 每台库的维护窗口互相错开。

单看每一条都是很普通的设置,当时也没多想。直到有一天被问到一句话:”prod 的 RDS 会不会自己重启?”

为了回答这个问题,我把这四条对着文档和线上状态重新过了一遍,才发现它们是互相咬合的,而且有几处和我原先以为的不一样:写 8.4 并不保证拿到最新的小版本;开了自动升级也不等于紧跟最新版;Multi-AZ 挡不住小版本升级时的停机。

这篇把这四条各自解决什么问题、Terraform 里怎么写、哪里容易想当然,一起记下来。最后还有一段后续:这套方案后来被领导否了,prod 改成了版本锁死、手工升级。两套方案的对比也写在里面。

先看全貌

我们的 RDS 是在社区模块 terraform-aws-modules/rds/aws 外面包了一层自己的模块。每个环境的 stack 里放一份默认值,各实例只写自己不一样的部分。和这篇有关的内容,精简之后是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
locals {
rds_defaults = {
engine = "mysql"
engine_version = "8.4" # major version only
family = "mysql8.4"
major_engine_version = "8.4"

multi_az = true

backup_retention_period = 35
deletion_protection = true
apply_immediately = true
}
}

module "rds" {
source = "../../../modules/rds"

databases = {
for name, config in var.rds_instances : name => merge(
local.rds_defaults,
{ for k, v in config : k => v if v != null },
{ identifier = name },
)
}
}

和升级有关的两个开关,最初是写在我们自己那层模块里的:

1
2
3
4
5
# Maintenance
maintenance_window = each.value.maintenance_window
auto_minor_version_upgrade = true
apply_immediately = lookup(each.value, "apply_immediately", false)
allow_major_version_upgrade = lookup(each.value, "allow_major_version_upgrade", false)

维护窗口和备份窗口每台实例都不一样,写在 terraform.tfvars 里,后面会讲到。

只锁大版本:engine_version = “8.4”

先说为什么不把小版本写死。

如果写成 engine_version = "8.4.9",同时又开着自动升级,那么 AWS 哪天把实例升到了更新的小版本,Terraform 就会认为线上和代码不一致,plan 里出现一条想把版本改回 8.4.9 的变更。RDS 不支持降级,这条变更 apply 不下去,只能回头改代码去追 AWS 的版本。每升一次,追一次。

AWS provider 对这个场景有专门的支持,aws_db_instance 的文档是这么写的:

If auto_minor_version_upgrade is enabled, you can provide a prefix of the version such as 8.0 (for 8.0.36). The actual engine version used is returned in the attribute engine_version_actual.

也就是说,engine_version 可以只写一个前缀,实际跑的版本记在另一个属性里。我们 prod 的 state 里是这样的:

1
2
engine_version        = "8.4"
engine_version_actual = "8.4.9"

分工很清楚:代码声明”我要 8.4 这条线”,具体跑在哪个小版本由 engine_version_actual 记录。实例实际跑在 8.4.9 上,terraform plan 是 No changes;小版本以后再变,变的也只是 engine_version_actual。

大版本这一头也要锁住,靠的是另外三处:

  • allow_major_version_upgrade 保持 false。大版本升级本来就必须手工发起,这个开关再加一道保险:有人手滑把 8.4 改成更高的大版本,API 会直接拒绝。
  • family = "mysql8.4" 决定参数组属于哪个大版本。
  • major_engine_version = "8.4" 决定选项组属于哪个大版本。

将来真要升大版本,这几处要一起改,那是一次单独的、有计划的变更。

写 8.4 不等于拿到最新的小版本

这是第一处和我原先以为的不一样的地方。我本来的想法是:只写大版本,首次创建时 AWS 自然会给最新的小版本。

实际上只写大版本时,RDS 用的是这个大版本的默认小版本,它往往比最新版落后一两个版本。可以这样查:

1
2
3
4
aws --no-cli-pager rds describe-db-engine-versions \
--profile prod --region ap-southeast-1 \
--engine mysql --engine-version 8.4 --default-only \
--query 'DBEngineVersions[].EngineVersion' --output text

写这篇的时候,我们所在的区域返回的是 8.4.11,而同一区域已经能选到 8.4.13。

如果新建实例时一定要某个小版本,就在创建时显式指定,或者建好之后手工升一次。只写大版本这个写法解决的是 plan 不漂移的问题,不是版本够不够新的问题。

小版本自动升级:开了也不等于追最新

auto_minor_version_upgrade 在 provider 里默认就是 true,我们在模块里显式写了出来。

它的机制,AWS 文档讲得比较细,要点是这几条:

  • 生效有两个条件:开关打开,并且备份保留期大于 0。
  • 不是每出一个新的小版本就升。RDS 会在某个大版本下指定一个小版本作为自动升级的目标,指定之前要看已知安全问题、MySQL 社区版的 bug、这个版本发布以来整体的稳定性。
  • 升级发生在实例自己的维护窗口里。
  • 升级有停机。

一个小版本是不是自动升级目标,可以查出来:

1
2
3
4
5
aws --no-cli-pager rds describe-db-engine-versions \
--profile prod --region ap-southeast-1 \
--engine mysql --engine-version 8.4.9 \
--query 'DBEngineVersions[].ValidUpgradeTarget[].{version:EngineVersion,auto:AutoUpgrade}' \
--output table

写这篇时的结果:

1
2
3
4
5
6
7
8
9
--------------------------
|DescribeDBEngineVersions|
+----------+-------------+
| auto | version |
+----------+-------------+
| False | 8.4.10 |
| False | 8.4.11 |
| False | 8.4.13 |
+----------+-------------+

从 8.4.9 往上有三个可升级的版本,但没有一个被标成自动升级目标。这和我们非生产环境的现状对得上:dev、qa 一直开着自动升级,现在分别还停在 8.4.8 和 8.4.9。

所以这是第二处要纠正的理解。”自动小版本升级”的意思是:AWS 认为某个版本该升的时候,在你的维护窗口里替你升。它不是”永远跟着最新版跑”。文档里也有一句原话:

Automatic minor version upgrades periodically update your database to recent database engine versions. However, the upgrade might not always include the latest database engine version.

那为什么还要开?因为小版本里主要是安全补丁和缺陷修复,AWS 的建议一直是打开:RDS FAQ 建议把实例保持在较新的小版本;AWS Config 有一条托管规则 rds-automatic-minor-version-upgrade-enabled,开关为 false 就判不合规;Checkov 里对应的是 CKV_AWS_226。配合上一节的”只锁大版本”,开着它也不会给 Terraform 带来任何漂移。

Multi-AZ:管得了系统补丁,管不了引擎升级

multi_az = true 写在默认值里,9 台主库全部生效。只读副本是单可用区,在它自己的配置里覆盖成 false。

Multi-AZ 平时的价值不用多说。我们 preprod 有一台库,某天中午主库无响应,RDS 自己切到了备库,全程没有人工介入。

和这篇有关的是另一个问题:维护的时候,Multi-AZ 能把停机降到多少? 这是第三处,也是我误会最深的一处。我原先以为不管哪种维护,都是”先动备库,切换,再动原主库”,应用只感觉到一次短暂断连。

对着文档看,要分两种情况:

维护类型 Multi-AZ 实例上的过程 应用侧看到的
操作系统更新 先更新备库,把备库提升为主库,再更新原主库 一次故障转移,通常不到一分钟
数据库引擎升级(包括小版本) 主库和备库同时升级 升级完成之前,实例不可用

文档原话:

If you upgrade the database engine for your DB instance in a Multi-AZ deployment, Amazon RDS modifies both primary and secondary DB instances at the same time. In this case, both the primary and secondary DB instances in the Multi-AZ deployment are unavailable during the upgrade.

RDS for MySQL 的升级文档里还有一句更直接的:Multi-AZ 实例做一次小版本升级,停机可能持续几分钟。

所以对 MySQL 来说,开了 Multi-AZ,小版本自动升级仍然是一次真实的停机,而不是一次切换。想进一步缩短,文档给的方向是 Blue/Green Deployments,那是另一个话题。

这个结论直接影响下一节:既然每一次引擎升级都是几分钟的不可用,那它发生在什么时候、会不会好几台库一起发生,就必须认真安排。

错开维护窗口

维护窗口是什么

每台 RDS 实例有一个每周一次的维护窗口,格式是 ddd:hh24:mi-ddd:hh24:mi,时间是 UTC,最短 30 分钟。不指定的话,AWS 会在区域对应的时间段里随机挑一天、挑 30 分钟。

有一点容易误会:窗口到了,不代表一定会发生什么。 只有实例上有待处理的维护(小版本升级、系统更新等)时,AWS 才会在窗口里动手。有没有待处理的维护,可以随时查:

1
2
aws --no-cli-pager rds describe-pending-maintenance-actions \
--profile prod --region ap-southeast-1

为什么要错开

如果 9 台主库用同一个窗口,那么某个小版本被指定为自动升级目标的那一周,9 台库会在同一个小时里各停几分钟。更糟的情况是这个小版本和我们的业务有兼容问题,那就是 9 台库同一晚一起出事,没有任何缓冲。

错开之后,每晚最多动一两台。第一台升完如果有问题,只影响一个业务域,后面几台还来得及处理。

我们的排法

一周七天,每天第一档放一台主库;多出来的实例放第二档,比第一档晚一小时;只读副本和它的主库同一天,排在第二档。

实例 维护窗口(UTC) 本地时间(UTC+8)
prod-mysql05-partner-master mon:16:00-mon:17:00 周二 00:00-01:00
prod-mysql03-cashflow-master tue:16:00-tue:17:00 周三 00:00-01:00
prod-mysql09-infra-master tue:17:00-tue:18:00 周三 01:00-02:00
prod-mysql01-account-master wed:16:00-wed:17:00 周四 00:00-01:00
prod-mysql06-simulation-master wed:17:00-wed:18:00 周四 01:00-02:00
prod-mysql08-workflow-master thu:16:00-thu:17:00 周五 00:00-01:00
prod-mysql04-loan-master fri:16:00-fri:17:00 周六 00:00-01:00
prod-mysql04-loan-read01 fri:17:00-fri:18:00 周六 01:00-02:00
prod-mysql02-auth-master sat:16:00-sat:17:00 周日 00:00-01:00
prod-mysql07-warehouse-master sun:16:00-sun:17:00 周一 00:00-01:00

Terraform 里就是每台实例各写一行,把本地时间写进注释。UTC 的周几和本地的周几差一天,不写注释的话,过两个月自己都要重新算:

1
2
3
4
5
6
7
8
9
10
11
12
rds_instances = {
"prod-mysql01-account-master" = {
backup_window = "22:00-23:00" # UTC+8 06:00-07:00
maintenance_window = "wed:16:00-wed:17:00" # UTC+8 00:00-01:00 (Thu)
}
"prod-mysql06-simulation-master" = {
backup_window = "22:30-23:30" # UTC+8 06:30-07:30
# Wed slot 2: staggered 1h after account on the same weekday
maintenance_window = "wed:17:00-wed:18:00" # UTC+8 01:00-02:00 (Thu)
}
# ...
}

改 maintenance_window 是就地修改,不会重启实例,也不会重建。

维护窗口不能单独排,要和备份一起排

这是排窗口时最花时间的地方。一台 RDS 实例在夜里其实有三件事要做,它们互相不能撞:

  1. 维护窗口。
  2. RDS 自动备份窗口(backup_window)。它和维护窗口不能重叠,重叠的话 API 直接拒绝。
  3. AWS Backup 的跨区备份。 我们用 AWS Backup 每天给 RDS 打快照,再拷贝到另一个区域做灾备。这个任务如果落在维护窗口或自动备份窗口里面,或者离它们太近,会直接启动失败,报错是 Backup job could not start because it is either inside or too close to the weekly maintenance window configured in RDS instance。

AWS Backup 这边还有两个时间参数。start_window 是任务允许晚多久启动,最小只能设 60 分钟;completion_window 是任务必须在多久之内完成。所以一个定在 18:00 的任务,最晚可能 19:00 才真正开始。按我们的经验,实例上的本地快照要 20 到 40 分钟,跨区拷贝除第一次之外只要几分钟。

把三件事排在一条时间线上,就是现在的样子:

时间(UTC) 本地时间(UTC+8) 做什么
16:00-18:00 00:00-02:00 维护窗口,每晚最多两台,各占一小时
18:00 起 02:00 起 AWS Backup 跨区备份,start_window 60 分钟,completion_window 180 分钟
21:30 到次日 00:30 05:30-08:30 RDS 自动备份,各实例之间再错开

这三段的顺序不是一开始就排对的。最早维护窗口在 19:00-21:00 UTC,跨区备份在 16:30 UTC。后来有需求要把跨区备份改到 18:00,一算就发现不行:18:00 触发,最晚 19:00 启动,再加上几十分钟的快照时间,正好压在所有实例的维护窗口上。最后的做法是把全部维护窗口整体前移 3 小时,挪到备份之前,同时确认了本地时间 00:00-02:00 没有依赖数据库的批处理任务。

这次调整让我意识到,维护窗口从来不是一个可以单独决定的参数。动备份时间之前,要先看维护窗口;反过来也一样。我们现在把这条约束直接写在 terraform.tfvars 的注释里:

1
2
3
# Cross-region AWS Backup: daily 18:00 UTC, start_window 60min,
# completion_window 180min (may run until 21:00 UTC). Maintenance windows
# must end before 18:00 UTC and RDS automated backup_window must start >= 21:30 UTC.

方案被否了:两套方案的对比

回到开头那个问题:prod 的 RDS 会不会自己重启?

按上面的配置,答案是:平时不会,但只要 AWS 指定了新的自动升级目标,就会在那台实例的维护窗口里升级,并且停机几分钟。星期几、几点是我们定的,但哪一周发生、升到哪个版本,是 AWS 定的。

前面这四条是我定的方案,其中打开小版本自动升级是 AWS 官方明确推荐的做法。它上线跑了一段时间之后,被领导否了。他的要求是两句话:

  1. 生产环境的版本要锁死;
  2. 小版本升级和操作系统升级,都在维护窗口里手工做。

背后的考虑是变更管控:生产数据库在我们事先不知情的情况下重启,本身就是一个要解释的事件,不管它是不是发生在凌晨、是不是只有几分钟。

有一点要先说明:打开小版本自动升级,本身也是风控的要求。我们用 Checkov 扫描 Terraform 代码,其中的 CKV_AWS_226 要求 RDS 实例必须打开这个开关。所以这不是”要不要风控”的分歧,而是风控里的两条要求撞在了一起:一条要补丁及时,一条要变更可控。

被否之后,我把两套方案放在一起认真比了一遍。

两套方案其实只差一个开关

先说一个容易忽略的事实:这两套方案的差别比听上去小得多。

Multi-AZ 和错开维护窗口,两边都保留。操作系统更新在两套方案里的处理方式也完全一样:可选更新 AWS 不会自动安装,只能手工;强制更新到了截止日期,AWS 会在维护窗口里自动安装。也就是说,领导要求的”操作系统升级手工做”,在我原来的方案里本来就是如此,自动小版本升级这个开关管不到操作系统。

真正的差别只有一处:数据库引擎的小版本,由谁来触发升级。

维度 我的方案:自动升级(AWS 官方推荐) 领导的方案:锁死,手工升级
哪一周升、升到哪个版本 AWS 决定,我们只定了星期几和几点 我们自己决定
prod 的版本是否先在 preprod 验证过 不保证 可以保证:先 preprod,再 prod,升到同一个版本
升级时有没有人在场 没有,半夜无人值守 有,可以先打快照,升完做验证
变更记录 事后才从 RDS 事件里知道 事前有变更单,每次重启都是自己发起的
补丁及时性 有 AWS 兜底,但很保守 完全取决于有没有人排期
运维成本 接近零 每台实例都要有人看通知、排期、夜里执行
合规扫描 CKV_AWS_226、AWS Config 规则默认通过 不通过,要跳过这条检查并写明原因
engine_version 的写法 只写大版本 8.4 只写大版本或写死完整版本号,对应后面的两种实现
升级时的停机 主备同时升级,几分钟不可用 相同

领导的方案站得住的地方

版本一致性。 自动升级加上错开窗口,意味着升级那一周里各台实例的小版本是不一致的。而且 prod 和 preprod 各有各的维护窗口,谁的窗口先到谁先升,同一类库完全可能是 prod 先升。”生产上跑的版本,一定是先在预发验证过的版本”,在我的方案里没有任何保证。

有人在场。 RDS 不支持降级。半夜无人值守地升上去,如果新版本和业务有兼容问题,要到第二天早上才有人发现,而且退不回去。手工升级至少可以先打快照,升完当场验证。

变更可追溯。 每一次重启都能对应到一张事先提交的变更单,而不是事后去翻 RDS 事件解释”这是 AWS 升的”。

我的方案站得住的地方

它是 AWS 官方推荐的做法。 这不是我个人的偏好。AWS 数据库博客里关于 RDS for MySQL 安全实践的那篇,原话是:

It’s recommended to enable the Auto minor version upgrade flag for your RDS instances and configure your maintenance window accordingly to stay up to date with the latest fixes.

打开自动升级,再用维护窗口控制它发生的时间,正是前面那四条在做的事。AWS Config 的托管规则和 Checkov 的 CKV_AWS_226 也都把”没有打开自动小版本升级”当作不合规。

它也是风控的要求。 Checkov 的这条检查要求开关必须打开。按我的方案,扫描直接通过,不需要任何例外。

有兜底,而且不花人力。 没有人需要记得”该升级了”。

锁死方案最大的风险是没人管。 这不是理论上的担心。写这篇的时候,prod 的 9 台主库都还在 8.4.9,而区域的默认小版本已经是 8.4.11,最新是 8.4.13;每台实例上还挂着一条可选的操作系统更新,一直没有装。锁死之后,这些差距不会自己消失。

不过公平地说,我的方案在补丁及时性上的优势,目前也只停留在纸面上:前面查过,现在没有任何一个 8.4.x 被标成自动升级目标,一直开着自动升级的 dev 还停在 8.4.8。

最后按领导的方案落地

prod 把自动小版本升级关掉了;preprod 后来为了和 prod 保持一致也关了,dev、qa 保持打开。改动有三步。

第一步,把模块里写死的 true 改成可配置,默认值不变,这样其他环境不产生任何 diff:

1
2
3
4
5
6
# variables.tf, inside the databases object type
# Set false to pin the minor engine version (e.g. prod); defaults to AWS-managed minor upgrades.
auto_minor_version_upgrade = optional(bool, true)

# main.tf
auto_minor_version_upgrade = each.value.auto_minor_version_upgrade

第二步,prod 的默认值里显式关掉:

1
2
# Pin minor engine version in prod; upgrades are planned and applied explicitly.
auto_minor_version_upgrade = false

plan 出来全部是就地修改,不重启、不重建。engine_version 仍然写 8.4,关掉之后 plan 里也没有出现过版本相关的漂移。

第三步,在 .checkov.yaml 里跳过 CKV_AWS_226,并且把原因写清楚。跳过一条安全检查而不写原因,半年后没人知道这是有意为之还是漏了:

1
2
3
4
5
6
7
skip-check:
- CKV_AWS_226 # Ensure DB instance gets all minor upgrades automatically
# Intentionally disabled on prod RDS (auto_minor_version_upgrade = false in
# environments/prod/03-data). AWS applies minor upgrades during the maintenance
# window with a restart and no rollback; prod minor/CU upgrades are planned,
# tested in preprod, and applied explicitly via Terraform engine_version changes.
# Non-prod environments keep the module default (true).

这一步要说得直白一点:跳过检查只是让扫描不再报错,检查背后的要求并没有消失。它要的是数据库及时打上小版本补丁;关掉开关之后,这件事从 AWS 身上转到了我们自己身上。

另外,.checkov.yaml 里的 skip-check 对整次扫描生效,不区分环境。跳过之后,dev、qa 如果哪天被误关了自动升级,Checkov 也不会再报。

锁死之后,还有什么是锁不住的

“版本锁死”做不到绝对。关掉这个开关,并不等于实例从此不会被 AWS 动。至少还有两类:

操作系统更新。 这是 RDS 主机操作系统的补丁,和引擎版本无关,没有开关可以关。它分两种:可选更新不会自动安装;强制更新带一个截止日期,过了日期,RDS 会在维护窗口里自动安装。Multi-AZ 实例上表现为一次故障转移。

安全问题和版本到期。 文档写得很明确:遇到严重安全问题,或者某个版本到了支持终止日期,即使没有打开自动小版本升级,RDS 也会升级。

所以锁死方案实际能做到的,是”我们先手”:赶在 AWS 强制执行之前,自己挑时间做掉。维护窗口和 Multi-AZ 这两条也没有因此失去意义,它们仍然决定了这些”锁不住的维护”发生在什么时候、影响有多大。

对操作系统更新,我们加了一条通知。RDS 在有新的系统更新可用时会发出事件 RDS-EVENT-0230,用 EventBridge 接住,转到已有的告警通道:

1
2
3
4
5
6
7
8
9
10
11
12
resource "aws_cloudwatch_event_rule" "rds_os_update_available" {
name = "${var.env_name}-rds-os-update-available"
description = "RDS operating system update available (RDS-EVENT-0230)"

event_pattern = jsonencode({
source = ["aws.rds"]
"detail-type" = ["RDS DB Instance Event"]
detail = {
EventID = ["RDS-EVENT-0230"]
}
})
}

这条事件里不带截止日期。收到通知之后,要去待处理维护里看 system-update 这一项有没有 AutoAppliedAfterDate:有,就是到期会自动执行的强制更新,要在那之前自己排期;没有,就是可选更新,什么时候装由我们定。

当时也考虑过加一个 Lambda,收到事件后自动去查截止日期,只把强制更新发出来。最后没有做:多一个函数就多一份成本和维护,非必要不加,判断这一步留给人。

领导的方案有两种实现

“版本锁死、手工升级”落到 Terraform 上,有两种做法。两种都关掉自动升级,区别在于:小版本号写不写进代码,升级这个动作在哪里执行。

实现一:代码只写大版本,升级在 Terraform 之外做。 我们现在就是这种。

1
2
engine_version             = "8.4"
auto_minor_version_upgrade = false

升级时直接操作 RDS,用控制台或者命令行:

1
2
3
4
5
aws --no-cli-pager rds modify-db-instance \
--profile prod --region ap-southeast-1 \
--db-instance-identifier <db-instance-id> \
--engine-version 8.4.13 \
--no-apply-immediately

--no-apply-immediately 是把升级排进这台实例的下一个维护窗口,换成 --apply-immediately 就是立刻开始。

Terraform 这边什么都不用改。provider 的文档说前缀写法是给打开自动升级的场景用的,但在我们用的 6.60.0 的源码里,前缀比较并不看这个开关:只要代码里的 engine_version 是实际版本的前缀,就不算漂移。所以升完之后变的只是 engine_version_actual,plan 仍然是干净的。

实现二:代码写死完整版本号,升级就是改代码。

1
2
engine_version             = "8.4.9"
auto_minor_version_upgrade = false

升级时把这一行改成目标版本,plan,apply。这里有一个细节,我们 prod 的 apply_immediately 是 true,apply 的那一刻升级就开始了,不会等到维护窗口。所以”在维护窗口里做”,靠的是人在窗口里执行 apply。

两种实现放在一起看:

实现一:只写大版本,Terraform 之外升级 实现二:写死版本,改代码升级
现在跑的是哪个小版本,看哪里 AWS,或者 state 里的 engine_version_actual 代码
升级动作 控制台或 modify-db-instance 改 engine_version,plan,apply
升级留下的记录 变更单和 CloudTrail 变更单,加上 Git 提交和代码评审
preprod 和 prod 的版本对齐 靠人核对 两边代码写同一个值
新建或重建一台实例 拿到当时的默认小版本,可能和其他实例不一致 和代码一致
AWS 强制升级之后 plan 不漂移,Terraform 这边没有感知 plan 漂移,要改代码追上去
每次升级要不要改代码 不用 要

实现一的好处是轻:代码长期不动,升级想立刻做还是排进维护窗口,一个参数就能选。代价是版本不在代码里,”prod 现在是哪个小版本、是不是和 preprod 一致”要去 AWS 上查;而且哪天重建一台实例,它拿到的是当时的默认小版本,现在是 8.4.11,和其他还在 8.4.9 的实例就不一致了。

实现二的好处是代码就是版本的唯一出处,升级和其他基础设施变更走同一套评审流程。代价是每次升级都要改代码;另外,如果 AWS 因为安全问题或版本到期强制升了版本,plan 会出现一条想把版本改回去的变更,而 RDS 不能降级,只能改代码去追。这一点也可以反过来看:有定时 drift 检查的话,这条漂移正好是一个”版本被动过了”的信号。

操作系统更新在两种实现下没有区别,它不归 Terraform 管,都是用命令行触发。可以立即执行,也可以排进下一个维护窗口:

1
2
3
4
5
aws --no-cli-pager rds apply-pending-maintenance-action \
--profile prod --region ap-southeast-1 \
--resource-identifier <db-instance-arn> \
--apply-action system-update \
--opt-in-type next-maintenance

顺带一提,前面那段 Checkov 注释里写的”通过 Terraform 改 engine_version 来升级”,说的其实是实现二,和我们现在的实现一对不上。到写这篇为止,prod 还没有做过一次小版本升级,两种实现之间还可以再选。选定之后,注释要跟着对齐。

怎么选

如果没有额外的约束,自动升级是默认该选的方案,这也是 AWS 官方的建议。

领导的方案是为了”版本先验证、重启可追溯”而做的有意偏离。偏离是有成本的:Checkov 的检查要跳过,”补丁及时”这条风控要求改由人来兑现,而 AWS 的强制更新仍然锁不住。它成立的前提,是把这些落到具体的人和日程上:谁接收系统更新的通知,多久看一次版本差距,升级按什么顺序、在哪个窗口做。没有这些,锁死方案比自动升级更差,因为自动升级至少还有 AWS 兜底。

至于两种实现,取决于团队把什么当作版本的出处:以 AWS 上的实际状态为准,就用实现一;要求生产的一切变更都从代码评审里走,就用实现二。

多说几句

第一,engine_version 只写大版本,解决的是 Terraform 和 AWS 抢着管版本的问题。 代码声明大版本,实际小版本记在 engine_version_actual 里,谁升的都不会让 plan 漂移。但它不保证新建实例拿到最新的小版本,拿到的是默认小版本。

第二,自动小版本升级是”AWS 认为该升的时候替你升”,不是”紧跟最新”。 只有被 RDS 指定为自动升级目标的版本才会升。想知道现在会不会升、升到哪,查 ValidUpgradeTarget 里的 AutoUpgrade。

第三,Multi-AZ 管得了操作系统补丁和主机故障,管不了 MySQL 的引擎升级。 前者是一次不到一分钟的切换,后者是主备同时升级、几分钟不可用。不要因为开了 Multi-AZ 就认为升级没有停机。

第四,维护窗口要和备份一起排。 RDS 自动备份窗口不能和它重叠,AWS Backup 的任务不能落在它里面或紧挨着它。把这三段时间画在一条线上再定,并且把约束写进代码注释。

第五,自动升级和锁死手工升级,只差”引擎小版本由谁触发”这一个开关。 没有严格的变更管理要求、也没有专人盯数据库,就开着,这是 AWS 推荐的默认做法,也最省心。要求生产版本必须先验证、每次重启必须可追溯,就锁死,但要同时接受两件事:操作系统强制更新和到期强制升级仍然锁不住,需要有通知;小版本升级从此要有人负责排期。只关开关、跳过检查而没有后面这两样,等于把一个自动的流程换成了一个没人管的流程。

参考资料