灾难恢复
恢复 DeviceChain 实例需要两样东西:数据库备份,以及实例的密钥存储根密钥。大多数备份流程会保存前者,却漏掉后者。本页介绍根密钥的作用、如何保存可用于恢复的副本,以及如何结合数据库备份和根密钥重建实例。
两套备份
实例的数据分别存放在两台数据库服务器上,需要分别备份和恢复:
| 核心数据(PostgreSQL) | 事件数据(TimescaleDB) | |
|---|---|---|
| 内容 | 租户、身份、设备、设备配置、检测规则、仪表板、连接器、最近已知状态,以及所有已存储的密钥和凭据 | 测量值、事件历史、汇总数据 |
| 大小 | 通常为兆字节级;随设备群的配置增长 | 数据量较大;随时间增长 |
| 丢失的后果 | 无法重建实例 | 历史数据丢失,但实例仍能运行 |
| 是否需要根密钥 | 需要 | 不需要 |
这种划分来自平台实际的数据存储方式,并非对同一个数据库施加的策略:
event-management负责事件存储,包括其 schema、超表和保留策略,也是唯一向其中写入遥测数据的服务。- 其余服务都将自己的数据保存在关系数据库服务器上。
- 另一个会访问 TimescaleDB 的服务只有
user-management。它使用访客连接,不创建 schema,也不执行迁移,仅用于删除已清除租户的数据行。
没有任何写入跨越两个存储,因此不需要在它们之间维护事务一致性。
但如果两部分恢复到不同时间点,有一件事需要特别注意。关系数据库中的租户记录驱动上述删除操作,并在删除完成后被移除。如果将事件数据恢复到租户清除之前,而核心数据恢复到其记录移除之后,就会重新引入已经没有任何机制负责清除的遥测数据。
规划恢复时应考虑以下两点:
- 备份计划不同。 两个存储都通过基础备份加持续归档的预写日志进行备份,但基础备份频率和保留期限不应相同。核心数据较少,只有配置发生变化时才会变化。事件数据量大,主要以追加方式写入,而且已经受数据生命周期保留策略管理。为生命周期协调器即将删除的数据块继续保存基础备份,相当于为同一批数据支付两次存储费用。两个存储的恢复窗口也不同:默认情况下,核心数据可恢复到最近 30 天内的任意时间点,事件数据则为最近 7 天(参见恢复窗口)。无论使用
--restore-rdb-at还是--restore-tsdb-at,目标时间都必须位于对应存储的窗口内;更早的数据已不在归档中。因此,若要将两个存储恢复到同一时间点,必须选择较短窗口内的时间。若核心数据恢复到更早的时间,而事件数据恢复到较晚的时间,就会出现反方向的不匹配:在核心数据目标时间之后创建的租户仍有遥测数据,却没有租户记录来驱动删除。 - 恢复目标不同。 只恢复核心数据也能得到一个可以工作的实例:设备能够重新连接、检测规则能够运行、命令能够派发、密钥能够解密。恢复事件数据则为实例补回历史。缺少事件数据的实例处于降级状态(历史组件为空),而非停机,因此两部分可以采用不同的恢复时间目标。
根密钥仅影响核心数据。事件数据不包含密文,因此 TimescaleDB 恢复不需要本页介绍的密钥步骤。下文的密钥处理都针对核心数据。
为什么根密钥需要单独处理
DeviceChain 保存的每项秘密都使用独立的数据密钥进行静态加密,包括为登录令牌签名的密钥、出站连接器凭据、SMTP 密码和 AI 提供商密钥。每个数据密钥又由实例级的根密钥封装,也称密钥加密密钥(KEK;参见架构)。无论使用哪种配置,每个实例都会保存令牌签名密钥,因此每个实例都依赖根密钥:没有它,任何人都无法登录。
根密钥保存在实例的 Kubernetes Secret 中,也就是保存在 etcd 中。数据库备份不包含 etcd。PostgreSQL 备份只归档 PostgreSQL,TimescaleDB 备份只包含 TimescaleDB,两者都没有保存根密钥的任何字节。
这会导致一种常见恢复演练发现不了的故障:
- 在原集群中就地恢复数据库时,一切正常,因为 etcd 中仍有密钥。这种演练会带来错误的信心。
- 恢复到新集群时,也就是实际灾难场景中,加密数据行可以完整恢复,恢复操作也报告成功,但其中每项秘密都永久无法读取。新集群生成了另一个根密钥,而旧密钥无法从剩余资料中推导出来。
过去,这种问题往往要到很久以后才以无法解释的解密错误暴露,此时有用的备份可能早已轮换删除。现在有两项检查,分别在不同阶段依据不同信息发现问题:
- 创建任何资源之前,
dcctl bootstrap会查询关系存储中该实例已有的内容。如果数据库存在,却并非由当前集群创建,说明它在原集群消失后保留了下来。此时 bootstrap 会停止,避免生成新密钥覆盖恢复条件。这项检查用于防止错误发生。 - 服务启动时,保存秘密的服务会用根密钥尝试打开自己的存储记录;若无法解密,就拒绝提供服务。这项检查发现的是错误密钥,例如恢复时指定了错误的托管文件,而不是缺少密钥。仅查看存储内容无法识别这种情况。
启动检查会阻止整个 API 运行,而不只是集成功能。user-management 保存令牌签名密钥,因此也会拒绝启动。其他服务都必须拿到 user-management 的签名密钥后才会报告就绪,所以它们同样无法提供服务,任何人都无法登录。
两项检查都会让问题立即明确暴露,避免以后出现零散错误,但它们都不能恢复丢失的密钥。密钥一旦丢失,就无法找回。
根密钥由 256 位随机数据组成,封装后的数据密钥无法通过暴力破解恢复。密钥丢失意味着秘密丢失,提交支持工单也无法找回。其中包括令牌签名密钥,因此根密钥丢失或错误的实例无法启动:user-management 会拒绝启动,其他服务也无法就绪。这是 DeviceChain 中无法补救的一项状态,所以默认启用下述密钥托管。
密钥托管文件
dcctl bootstrap 会写入一个加密的密钥托管文件:这是一个小型文本文件,包含使用你选择的口令加密封装的根密钥。
~/.devicechain/escrow/<instance>-rootkey.escrow
文件本身包含说明。即使某人在多年后第一次看到它,也能从文件中了解它是什么、保护哪些内容、丢失会有什么后果,以及恢复时应执行的完整命令,无须查阅本页。
两个属性尤其重要:
- 它与实例分开存储。 文件不会放在
~/.devicechain/instances/<instance>/中,因为dcctl destroy会删除该目录。dcctl会拒绝指向该目录内部的--escrow-file路径。 - 它包含明文密钥指纹。 无须提供口令,就能判断托管文件是否仍对应当前密钥。参见验证。
选择口令
Bootstrap 按以下顺序使用第一个可用的口令来源:
| 来源 | 用途 |
|---|---|
--escrow-passphrase-file <path> | 配合秘密管理器进行自动化;会移除末尾的换行符。 |
DCCTL_ESCROW_PASSPHRASE | CI 和脚本化安装。如果变量已设置但为空,会报告错误,不会退回其他来源。 |
| 交互提示 | 在终端中手动操作时使用。需要输入两次,避免直到恢复时才发现拼写错误。 |
如果这些来源都不可用,而且没有终端可供询问,bootstrap 会失败。这是有意设计的,否则就会悄悄创建一个秘密随集群消失而丢失的实例。
把两者放在同一处,一次入侵就可能使保护失效。把两者都放在集群中,一次灾难就可能让备份完全失效。
选择不托管
对于可以直接丢弃的实例,例如 CI 运行、演示或本地实验,可以传入 --no-escrow。--dev 会隐含启用该选项。
dcctl bootstrap local scratch --dev # no escrow, disposable by construction
dcctl bootstrap local scratch --yes --no-escrow
Bootstrap 汇总会以红色明确提示。不要将其用于任何需要保留秘密的实例。
恢复实例
恢复过程会创建一个新集群,并在其中创建新实例。流程没有“恢复到运行中的实例”这一步:服务已经创建自己的 schema,此时在其下方恢复数据库,需要删除服务正在使用的表,并可能与迁移发生竞争。请通过重建进行恢复。
使用 --backup-snapshot-class 时,下述两个步骤仍从备份存储恢复,而不会读取快照:它们使用最新的每周基础备份,以及此后归档的日志。因此,每个步骤可能需要重放长达一周的日志,耗时比使用每日基础备份更长。快照属于被替换的旧集群,重建流程不会读取它们。旧集群消失后,快照仍保留在云提供商处,新集群也不会清理它们。快照包含数据库内容,包括租户删除操作已经移除的数据。验证恢复后的实例后,应从提供商的快照列表中删除它们(Google Kubernetes Engine 用户参见拆除集群)。
两个数据库需要按顺序使用两个命令恢复,因为它们的归属不同:
- 关系数据库在每个集群中只安装一次,保存所有实例的数据,因此由
dcctl install恢复。 - 事件存储属于单个实例,因此由
dcctl bootstrap恢复该实例的事件存储。
开始前,请确定实例名称。恢复时必须使用原实例名称,参见使用实例原名恢复。
1. 准备集群时,恢复共享关系数据库。
dcctl install local --restore-rdb-from dc-rdb
--restore-rdb-from 指定备份存储桶内的文件夹。对于从未恢复过的存储,该文件夹为 dc-rdb,因为归档使用集群自身的名称写入。
对于另一类灾难,例如错误迁移或误删已经成功破坏数据,而你需要恢复到操作之前的状态,应添加 --restore-rdb-at。其值为严格早于破坏发生时间的 RFC 3339 时间戳。
传入原安装使用的同一个 --backup-credentials-file,让新集群读取旧集群写入的归档。
该恢复标志仅在关系存储创建时生效。如果目标集群已有关系存储,就不会移动任何数据;dcctl 会在应用任何内容之前明确提示,避免执行不完整的恢复。请在尚无关系存储的集群中安装并恢复。
恢复后,dcctl 会自行选择新的归档写入位置,没有用于设置该位置的标志。如果恢复后的数据库继续向读取恢复数据的原路径归档,会触发自身安全检查并在启动时卡住。dcctl 会分配独立路径,并在后续运行中保留该路径。
数据库备份没有根密钥。恢复存储中的每项秘密仍由写入它的实例密钥封装,而该密钥只存在于刚刚丢失的集群中。在步骤 2 中,必须使用 --restore-root-key 重建每个实例。
2. 使用原根密钥重建实例,并恢复事件数据。
dcctl bootstrap local my-instance \
--restore-root-key ~/backups/my-instance-rootkey.escrow \
--restore-tsdb-from dc-tsdb-my-instance-1a2b3c4d
实例的密钥存储根密钥从托管文件中导入,而不会重新生成。实例继续使用原先加密秘密的密钥,因此能够解密步骤 1 恢复的数据行。系统会询问托管文件的口令,也可以通过 --escrow-passphrase-file / DCCTL_ESCROW_PASSPHRASE 提供。
dcctl bootstrap 会拒绝其他做法;这就是前文所述两项检查中的第一项。它在写入任何内容之前,会查询恢复后的存储中该实例名称已有的数据;没有 --restore-root-key 的运行会停止,并提示所需标志。
这种拒绝机制是最后一道防线。只有托管文件仍存在的实例才能恢复。使用 --no-escrow 创建的实例没有根密钥的第二份副本,任何方式都无法再打开这些数据行。
--restore-tsdb-from 为可选项,与根密钥恢复独立。事件存储有意保留自己的时间线:将遥测数据回退到昨天,并不意味着控制平面也应回退,而且步骤 3 不依赖事件恢复。它同样支持使用 --restore-tsdb-at 指定时间点。每个实例的事件存储都向独立路径归档,因此应从归档中读取路径,不要猜测。单独的 dc-tsdb 是类似关系存储的命名方式,归档中不会存在这个路径。
如果要在现有集群中恢复某个实例的事件存储,例如回到误删之前,请先使用 dcctl destroy --keep-backups 删除实例,然后使用 --restore-tsdb-from(以及 --restore-tsdb-at)重新 bootstrap,不要使用 --restore-root-key。
这样重建的实例具有空的控制平面。Destroy 也会删除共享关系存储中的实例数据库,因此租户、设备、用户和已保存的秘密都会被删除,且不会恢复;只有事件历史会回来。如果要同时恢复两部分,请按上述步骤恢复整个集群。
由于实例数据库已被删除,没有任何数据需要使用托管密钥解密,所以 bootstrap 会拒绝这里的 --restore-root-key,并为重建的实例生成新根密钥。请先移走原托管文件,因为 bootstrap 不会覆盖已有文件(参见 --restore-root-key),并保留它:它仍是打开 destroy 之前关系数据库备份的唯一密钥(参见 dcctl destroy 之后)。
如果实例备份存放在集群自身的对象存储中,不带 --keep-backups 的 destroy 会删除恢复所要读取的归档。
恢复是少数允许对已有实例执行的操作之一,因为恢复过程中可能发生中断,需要重试。更严格的保护机制只允许在托管文件携带的密钥与实例当前运行的密钥相同时重试,从而保证安全。
3. 使用 dcctl secrets escrow verify 确认根密钥与托管文件一致(参见验证密钥托管)。
能够登录,是密钥正确的第一个信号:只有根密钥能够打开封装的令牌签名密钥,user-management 才会启动。读取一个依赖秘密的对象,例如出站连接器或通知渠道,是更有力的检查。步骤 1 恢复对象所在的存储后,即可进行检查。仅恢复出数据行并不足以证明恢复成功;实际解密出的值才是证据。
4. 如果恢复了事件数据,检查后台机制,而不只是行数。 恢复后的事件存储可能拥有每一行数据,却已悄悄失去时间序列数据库的后台功能。表仍存在,查询仍有结果,但后台任务停止了。这种存储可以继续正常响应查询,直到磁盘被写满。
在事件存储的主节点上打开 shell。使用 CloudNativePG Operator 时,节点内的 psql 无须密码。下述命令中的两个名称都来自实例 ID,但不是同一个字符串:命名空间为 dci- 加实例 ID,而数据库名称只包含实例 ID,没有前缀。
kubectl -n dci-<instance-id> exec -it dc-tsdb-1 -c postgres -- psql -U postgres -d <instance-id>
接下来检查两件事。首先,事件表是否仍为超表?
SELECT hypertable_schema, hypertable_name FROM timescaledb_information.hypertables;
事件表位于 event-management schema,应该全部出现在列表中。如果恢复后变成普通表,这项检查就能发现。Schema 导出中普通表与超表看起来相同,无法据此识别。(不必查找 measurement_rollups。它是连续聚合,底层超表属于内部表,因此未出现在此列表中是正常的。)
第二项更关键:任务调度器是否真的在当前集群上运行?执行以下查询,等待一两分钟,再执行一次:
SELECT job_id, proc_name, total_runs, last_run_status
FROM timescaledb_information.job_stats ORDER BY job_id;
total_runs 必须增加。 这就是检查标准。total_runs 是计数器,观察到它增长,才能证明当前集群实际在执行任务。
next_start 或 scheduled 标志判断即使恢复后的调度器从未启动,所有任务仍可能显示为已调度,带有看似合理的未来 next_start,并永远保持这种状态。它看起来健康,是因为数据已恢复,而不是因为任务将来会执行。
next_start 很容易被当作检查依据,但在这里不能说明问题。保存它的表是普通表,物理恢复会带回旧集群的值。
确认每个数据库都已完成恢复
dcctl install 和 dcctl bootstrap 会等待每个数据库处于健康状态,且所有实例均已加入;如果 15 分钟内未达到要求,命令会以错误结束。大型归档的日志重放可能超过这个时间,因此恢复可能需要多次运行。无法访问归档的恢复会一直等待,而不会立即失败。数据库健康并不能证明数据行已恢复,因此在认定恢复成功之前,请检查数据库本身:
kubectl get clusters.postgresql.cnpg.io --all-namespaces
关系数据库(dc-rdb)位于 dc-system 中;事件存储(dc-tsdb)位于实例自己的命名空间中。
应看到 Cluster in healthy state。停留在 Setting up primary 的集群尚未完成恢复。最常见的原因是归档无法访问,或者 --restore-rdb-from / --restore-tsdb-from 指定了存储桶中不存在的路径。
使用实例原名恢复
恢复必须使用实例原来的名称。关系数据库以实例命名,因此步骤 1 会以旧名称恢复数据库,所有者为该实例自己的数据库登录身份。
如果在不同名称下使用 --restore-root-key 执行 bootstrap,命令会在存储中找不到对应数据库。如果集群中也没有同名的半建成实例,它会在写入任何内容之前停止:
--restore-root-key recovers the key that opens instance "<new-name>"'s stored secrets, and
there is nothing here for it to open: the relational store holds no database for "<new-name>" ...
它拒绝继续,是因为否则会创建一个由恢复密钥保护的空实例:外部看起来恢复成功,实际上没有任何需要恢复的数据。同次运行中稍早打印的黄色 note: ... was escrowed for instance "<old-name>" 只是警告,不是拒绝执行的原因。
要继续恢复,请使用原名称重新运行步骤 2:也就是 dcctl secrets escrow show 对该托管文件显示的 Instance:,同时也是恢复后数据库使用的名称。
拒绝执行发生在关系数据库恢复之后,因此应在开始恢复前确定名称,而不要等到处理事故时才决定。
托管文件会记录写入时的实例名称,该名称也受到完整性验证保护:编辑名称会使文件失效。实例重命名属于迁移,而非恢复;平台目前不提供此操作。
在需要之前验证密钥托管
不再匹配运行密钥的托管文件,在真正使用前看起来与有效文件没有区别。请在日常维护时就进行检查:
dcctl secrets escrow verify ~/backups/my-instance-rootkey.escrow --instance my-instance
此命令将托管文件中的指纹与实例实际运行中的密钥比较。它无须口令,若不匹配则以非零状态退出,因此可集成到 cron 作业或 CI 检查中。不匹配意味着实例没有可用的托管副本,最常见的原因是写入文件后实例又被重新 bootstrap。
如果只想查看文件说明而不解密:
dcctl secrets escrow show ~/backups/my-instance-rootkey.escrow
verify 无法证明什么它只能证明文件指向正确的密钥,不能证明文件仍能解密打开;后者需要口令。应定期演练真正的恢复:指纹检查只是报警器,不能替代恢复演练。
Bootstrap 和升级期间的凭据
有两个命令处理实例凭据:dcctl bootstrap 创建凭据,dcctl upgrade 保留凭据。
dcctl bootstrap 会生成实例的每项凭据,因为这些凭据尚不存在。这也是它拒绝对运行中的实例执行的原因:无论按什么顺序替换运行实例的凭据,都无法保证安全。
- 重写根密钥会使所有已保存的秘密永久无法读取。
- 重写消息代理凭据可以补救,但会造成中断。消息代理和服务由不同机制、按不同时间更新,新凭据会产生一段双方互相拒绝认证的窗口,在此期间启动的 Pod 可能完全无法连接消息代理。
dcctl upgrade 用于操作运行中的实例,不会生成任何新凭据。它读取并保留下列所有内容:
- 密钥存储根密钥;
- 消息代理的认证授权信息和登录凭据(callout issuer seed、服务密码和系统密码);
- 数据库密码(共享所有者、provisioner、实例自己的登录身份和事件存储所有者);
- 服务间认证秘密;
- 启用监控和集群内备份时的 Grafana 管理员密码和对象存储凭据。
版本变更不能变成凭据变更。
完成中途失败的 bootstrap
拒绝重复执行的判断依据有两个:接近运行末尾才写入的实例配置文档,以及实例声明中表示首次 bootstrap 尚未完成的记录。只有成功结束的运行才会移除该记录。没有配置文档或仍带有该记录的实例属于半建成状态,而非已上线状态;重新运行同一个 bootstrap 是完成创建的受支持方式。它会复用前次运行留在集群中的根密钥、消息代理凭据和数据库密码,报告还会显示超级用户的自动生成密码,而失败的前次运行尚未执行到这一步。首次 bootstrap 由旧版本启动的实例没有这条记录。参见 bootstrap 执行的操作。
消息代理是必须保留这一重试窗口的原因。它的配置比实例本身提前几个步骤完成。如果 bootstrap 在两者之间失败,就会留下一个运行中的消息代理,而后续运行无法仅通过集群识别其凭据:消息代理只保存公钥和两个密码哈希,无法反推出服务所需的原始凭据。
因此,dcctl 在配置消息代理之前,还会将其凭据保存到执行命令的本地机器,位于 ~/.devicechain/instances/<instance>/ 下。如果尚无实例可供查询,后续运行就会从该文件复用凭据。文件仅当前用户可读,dcctl destroy 会将其与其他实例本地状态一并删除。
每次升级都会核对托管文件
检查托管文件无须口令。文件记录了所保护密钥的指纹,将它与实例当前运行的密钥比较不涉及解密文件。因此,dcctl upgrade 每次都会进行检查,无须用户提供任何内容:
| 托管文件状态 | 升级时的操作 |
|---|---|
| 与运行中的密钥匹配 | 确认匹配,并保留原文件。 |
| 不匹配 | 明确警告,并说明最可能的情况:这是同名旧实例的托管文件。用它恢复会得到一个无法读取自身秘密的集群。 |
| 缺失 | 如果传入了 --escrow-passphrase-file(或设置了 DCCTL_ESCROW_PASSPHRASE),就写入一份。这也是最初使用 --no-escrow 创建的实例后来获得托管副本的方式。如果没有可用口令,则发出警告,不会询问:升级应能无人值守执行,而且到这个阶段实例已经完成更新。 |
这些结果都不会导致升级失败。托管问题关系到未来的灾难恢复,而当前升级关系到正在运行的实例。无法升级的运维人员可能会绕过检查,而不是修复问题。
dcctl destroy 之后
dcctl destroy 会删除实例的 Helm release、NATS 消息代理、事件存储、数据库及数据库登录身份、命名空间和本地状态。它不会删除托管文件,因为文件特意保存在该目录之外。Destroy 结束时会提示托管文件的位置。
Destroy 不会删除集群,也不会删除 dcctl install 安装的前置资源:其他实例使用的关系数据库以及备份对象存储仍保留。对于存放在集群自身对象存储中的备份,destroy 会删除被销毁实例自己的事件存储备份。用户提供的外部对象存储中的备份从不会被删除,--keep-backups 也可以保留集群内备份;参见实例备份的处理方式。对于使用卷快照基础备份的集群,无论是否保留备份,实例快照都会随其命名空间一起移除。
只要仍保留该实例任何数据库备份,就必须保留托管文件,它是仍能读取这些备份的唯一密钥。应在备份全部删除之后再删除托管文件,不能提前删除。