Neon Storage Controller 节点 Drain/Fill 机制解析:无感知集群重启的实现原理与操作指南
【免费下载链接】neonNeon: Serverless Postgres. We separated storage and compute to offer autoscaling, code-like database branching, and scale to zero.项目地址: https://gitcode.com/GitHub_Trending/ne/neon
导读
本文基于 Neon 开源仓库中的设计文档 docs/rfcs/033-storage-controller-drain-and-fill.md,深入讲解 Storage Controller 如何通过「节点排空(Drain)与回填(Fill)」两类后台操作,配合节点调度策略状态机,让 pageserver 的滚动重启与重新部署对租户(tenant)几乎无感知。读完本文,你将掌握:Drain/Fill HTTP API 的完整调用方式与返回码语义、NodeSchedulingPolicy状态机的流转规则、各类故障场景的处理策略,以及对应实现在仓库中的关键源码位置。
背景:为什么需要无感知重启
问题来源
在 Neon 的分层架构中,pageserver 承担存储职责,租户的读写路径依赖 pageserver 提供页面服务。pageserver 重启会直接造成租户的读可用性中断(RFC 中记录了 2024-04-03 一起真实案例:某租户因 pageserver-3 重启,按需激活时随机等待了约 30 秒)。
更隐蔽的问题是:负载较高的 pageserver 上,大量关闭流程无法在 systemd 强制 10 秒超时 内完成,导致未刷盘的 ephemeral layer 被迫丢弃,重启后必须重新摄取数据才能服务请求,进一步恶化首请求延迟。
尽管当时 Storage Controller 托管的 pageserver 租户密度较低、问题尚不尖锐,但 Neon 计划最终将所有 pageserver 迁移到 Storage Controller 管理之下,因此提前解决该问题具有明确价值。
解决思路
核心思路是:在重启前把节点上的租户分片"挪走",重启后再"挪回来"。Storage Controller 新增两类后台操作:
- Draining(排空):将某个 pageserver 上所有可迁移的租户分片,分发给集群中的其他节点;
- Filling(回填):对称过程,将租户分片迁回该 pageserver,直到集群恢复均衡、静默的分布状态。
配合两类辅助概念:节点调度策略(Node Scheduling Policies)充当调度器的约束(例如节点处于Paused策略时不再为其调度新分片);部署编排器(Deployment Orchestrator)指代驱动部署的流程(当前是 Ansible playbook)。
设计需求与非目标
RFC 明确列出的需求清单,也是判断实现是否到位的验收标准:
- pageserver 重新部署期间租户停机时间最小化;
- Storage Controller 暴露用于排空/回填的 HTTP API,供编排进程或人工操作员使用;
- 提供取消排空/回填后台操作的 HTTP API;
- 排空/回填失败不应致命,此时集群重启照常进行(接受停机);
- 排空/回填进度可通过 metrics 观测。
明确排除的范围:不与 control plane 集成;不为大型非 HA 租户做无感知重启。
整体流程:编排器视角的操作协议
RFC 假设 pageserver 已经按顺序逐个重启,编排器为每个 pageserver 的重启实现「序言(Prologue)+ 尾声(Epilogue)」两步协议。
Prologue(重启前:排空)
- 编排器先从 control plane 或 pageserver 本身获取节点 ID;
- 向 Storage Controller 发出
PUT /control/v1/node/:node_id/drain开始排空;所有错误响应以短退避重试; - 收到
202 (Accepted)即排空已开始; - 轮询节点状态接口(
GET /control/v1/node/:node_id),直到响应中policy字段变为PauseForRestart,表明排空完成,可以重启 pageserver。
序言受整体超时约束,量级在数分钟,且随托管 pageserver 负载升高应相应增大。
Epilogue(重启后:回填)
- 重启完成后,编排器发出
PUT /control/v1/node/:node_id/fill启动回填,所有错误码均可短退避重试; - 该调用本身是同步原语:若 pageserver 尚未重新附着到 Storage Controller,回填会被拒绝;
- 收到
202 (Accepted)即回填已开始; - 轮询节点状态接口,直到
policy变为Active,回填完成,可继续处理下一个 pageserver。
尾声同样有整体超时,初始可与序言一致,也可依赖 Storage Controller 的后台优化并采用更短超时。若编排器超时,则尝试取消回填(短退避重试);若最终取消失败,需要人工介入将调度策略设置为Active——不立即处理虽无大碍,但会持续约束调度器。
节点调度策略状态机
无故障、无超时时的理想流转为:
Active -> Draining -> PauseForRestart -> Active -> Filling -> ActiveRFC 给出了完整状态机图(docs/rfcs/033-storage-controller-drain-and-fill.md 中 "Node Scheduling Policy State Machine" 一节),要点包括:
Draining状态下,排空完成后进入PauseForRestart;- 排空失败(取消 / pageserver 重新附着 / Storcon 重启)则回到
Active; Filling状态下,回填完成后回到Active;- pageserver 重启后重新附着时,从
PauseForRestart回到Active。
源码中的策略枚举
调度策略枚举定义于 storage_controller/src/node.rs,完整取值包括:Active、Deleting、Draining、Filling、Pause、PauseForRestart。每个策略对调度器的约束在may_schedule()中体现(storage_controller/src/node.rs):
Active:可调度(受利用率影响);Filling:可调度(受利用率影响);Deleting/Draining/Pause/PauseForRestart:不可调度。
Drain/Fill HTTP API
触发接口
排空/回填的触发接口在 storage_controller/src/http.rs 的路由表中注册(storage_controller/src/http.rs):
| 方法 | 路径 | 语义 |
|---|---|---|
PUT | /control/v1/node/:node_id/drain | 触发排空 |
DELETE | /control/v1/node/:node_id/drain | 取消排空 |
PUT | /control/v1/node/:node_id/fill | 触发回填 |
DELETE | /control/v1/node/:node_id/fill | 取消回填 |
节点状态轮询接口为GET /control/v1/node/:node_id(路由注册见 storage_controller/src/http.rs,处理器handle_node_status位于 storage_controller/src/http.rs)。
说明:RFC 草案中的路径写作
PUT /v1/control/node/:node_id/{drain,fill},仓库实际实现的路由为/control/v1/node/:node_id/{drain,fill},两者语义一致,请以当前仓库代码为准。
返回码语义
触发接口的 HTTP 非成功返回码全部可从 Storage Controller 视角安全重试:
- 404:节点未知;
- 503:节点已知但不可用;
- 412:排空前置条件失败——没有其他节点可排空,或节点调度策略禁止排空;
- 409:该节点已有排空/回填在进行中(每节点同时只允许一个后台操作)。
排空/回填被接受并开始时返回202;取消成功返回200,错误可重试。
触发前置校验(源码依据)
start_node_drain的实现位于 storage_controller/src/service.rs,依次执行:
- 节点必须已注册(否则 404);
- 无其他正在进行的后台操作(否则 409 Conflict,见 storage_controller/src/service.rs);
- 节点必须可用(否则 503,见 storage_controller/src/service.rs);
- 集群中必须存在至少一个可调度节点作为排空目标(否则 412,见 storage_controller/src/service.rs);
- 当前调度策略必须是
Active或Pause;若是Draining则返回 409,其他策略一律 412(见 storage_controller/src/service.rs)。
校验通过后,将节点调度策略置为Draining并持久化到内存与数据库(node_configure落库实现见 storage_controller/src/persistence.rs),随后把操作注册进ongoing_operation并tokio::task::spawn一个独立任务执行排空(storage_controller/src/service.rs)。
回填的校验类似但更严格:起始策略只接受Active(start_node_fill位于 storage_controller/src/service.rs)。当节点重新附着时,若原策略为PauseForRestart或Draining(排空可能的终态),会自动重置为Active——这一重附着重置逻辑在数据库启动加载节点时同样生效(见 storage_controller/src/persistence.rs)。
Drain 与 Fill 的实现细节
Drain 过程
RFC 描述与 storage_controller/src/service.rs 的drain_node实现一致:
- 对排空节点上每个已附着的租户分片,将其降级为 secondary 并尝试重新调度到其他节点;
- 调度可能因约束无法满足而失败,但这可以接受——排空是**尽力而为(best effort)**的过程,不一定能切换所有分片;
- 后台任务限流并发 reconcile 数,避免淹没目标 pageserver、并让其他重要 reconcile 得以推进(上限为
MAX_RECONCILES_PER_OPERATION = 64,见 storage_controller/src/background_node_operations.rs); - 触发的 reconcile 全部结束或超时后,将节点策略置为
PauseForRestart以宣告排空结束(storage_controller/src/service.rs)。若此步骤失败并非致命:轮询方会因整体超时兜底,策略会在节点重附着或回填时被修正。
关于非 HA 租户的特别说明:非 HA 租户没有 secondary,按上述描述不会被迁移。RFC 认为跳过它们(尤其是大型租户)是合理的——迁移后目标 pageserver 需要按需下载整个工作集,可能比重启本身的扰动更大;未来可考虑将小型非 HA 租户纳入。
Fill 过程
fill_node(storage_controller/src/service.rs)与fill_node_plan(storage_controller/src/service.rs)实现如下:
- 生成回填计划:优先把"该节点上有 secondary、且该节点在其 home AZ"的分片迁回;若迁移后仍未达到该节点的公平份额,再从附着分片最多的节点提升分片(但跳过 home AZ 不匹配的分片);
- 对每个回填节点担任 secondary 的租户分片执行secondary 提升(promote),直到分片耗尽或集群附着分片数重新均衡;
- 与排空一样,并发 reconcile 受限。
后台操作的类型抽象(Operation::{Drain, Fill, Delete})、取消令牌(CancellationToken)与错误枚举(OperationError)定义在 storage_controller/src/background_node_operations.rs。
故障模式与处理策略
RFC 的核心设计哲学是:任何失败都尽量把节点状态归位到中性的Active,以简化实现与推理,代价是状态机增加了若干转移边。
| 故障场景 | 处理方式 |
|---|---|
| Storage Controller 崩溃重启 | 启动时将Draining/Filling/PauseForRestart状态的节点全部重置为Active——重启后已丢失编排器意图上下文 |
| 排空中的 pageserver 崩溃 | pageserver 重启时会重新附着,附着时策略自动重置为Active,调度器恢复可用 |
| 排空目标节点崩溃 | 两个合理选项:取消排空专注故障切换,或两者并行但优先故障切换;由于 drain/fill 产生的并发 reconcile 受限,后者免费获得,RFC 建议采用 |
| 回填中的 pageserver 崩溃 | 同排空崩溃:重附着时重置为Active |
| 排空/回填期间节点不可用 | drain/fill 任务提前停止;当心跳检测到节点恢复在线时,将其策略重置为Active;若发生的是重启,走崩溃分支 |
| 编排器排空超时 | 仍继续重启;pageserver 重附着时策略回到Active |
| 编排器回填超时 | 尝试取消回填;若失败,回填继续直至静默,节点停留在Filling策略(约束调度器但无害),由人工重置为Active或未来在 Storage Controller 内置回填超时 |
指标观测
排空/回填进度通过 metrics 暴露。仓库中与之直接相关的指标包括:storage_controller_reconcile_spawn、storage_controller_reconcile_complete、storage_controller_pending_reconciles(因并发限制被阻塞的 reconcile 数)等(storage_controller/src/metrics.rs);后续演化中还增加了storage_controller_drain_and_fill_long_waits,用于统计 drain/fill 期间等待节点状态变化的长时间等待次数(storage_controller/src/metrics.rs)。
优化方向:Location Warmth
切换 secondary 时,Storage Controller 会等待其"变热"(下载足够多的租户数据),导致部分 reconcile 显著偏慢、占用宝贵的 reconcile 配额。RFC 提出两项优化:
- Drain 阶段只切换已经"热"的租户;
- Fill 阶段优先回填"最热"的租户。
鉴于近期托管租户数量仍较少,首版实现可直接查询租户的 secondary 状态;随着租户规模增长,未来需要 pageserver 新增 API 端点直接报告"热/冷"节点集合。
备选方案:纯调度约束实现
RFC 还对比了一种替代设计——将排空/回填完全表达为调度约束(而非独立后台任务)。作者认为该方案理论上优雅但实现、运维和推理都更困难:取消操作需要重建调度意图状态并重新应用;难以甄别哪些 reconcile 属于某个 drain/fill(为 reconcile 打标签会变得混乱);且 reconcile 需要产生持久化副作用(排空完成时写库),作者在概念上并不认同。因此最终选择了显式后台任务 + 状态机的方案。
附:相关实现入口速查
| 关注点 | 仓库位置 |
|---|---|
| RFC 原文 | docs/rfcs/033-storage-controller-drain-and-fill.md |
| HTTP 路由注册 | storage_controller/src/http.rs |
| 触发/取消排空与回填 | storage_controller/src/service.rs、storage_controller/src/service.rs |
| 排空/回填主流程 | storage_controller/src/service.rs、storage_controller/src/service.rs |
| 后台操作抽象与并发上限 | storage_controller/src/background_node_operations.rs |
| 调度策略枚举与约束 | storage_controller/src/node.rs |
| 策略数据库持久化与启动重置 | storage_controller/src/persistence.rs、storage_controller/src/persistence.rs |
| 相关指标 | storage_controller/src/metrics.rs |
该 RFC 还附带一个实现了除优化与部分故障处理外几乎所有内容的 POC(对应 PR #7682),可作为阅读代码前的高层参照。生产使用前请以当前仓库 storage_controller 目录下的最新实现为准。
【免费下载链接】neonNeon: Serverless Postgres. We separated storage and compute to offer autoscaling, code-like database branching, and scale to zero.项目地址: https://gitcode.com/GitHub_Trending/ne/neon
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考