从头构建 PlanetScale:基础设施
摘要
作者详细介绍了构建 Homescale 的过程,这是一个类似于 Docker 映像和容器模型的数据库工具,能够从不可变快照中创建可写数据库实例和时间点分支,而无需完整数据复制。
暂无内容
查看缓存全文
缓存时间: 2026/07/16 13:51
# 从零构建 PlanetScale:基础设施篇
来源:https://onatm.dev/2026/07/16/homescale-part-1/
Homescale —— 在家运行的 PlanetScale。
多年前我曾在一家数据库工具公司工作,有幸在 PlanetScale 流行之前就构建了几个数据库克隆工具。那些工具注定不会像 PlanetScale 那样成功,但它们自有其用途。这些工具背后的思路很简单:将存储层与计算层分离,并使用最佳的存储技术来克隆数据库文件。
这个想法最近又回到我的脑海中,当时我正在发推文,谈及我与一支 F1 车队的面试经历,他们正在寻找一位有 Ceph 实操经验的 Rust 开发者。提到 Ceph 让我想起我曾参与过的一个失败的产品尝试,于是我开玩笑地告诉朋友,我要构建 Homescale —— 在家运行的 PlanetScale。
## Homescale
> 我正在 github.com/homescale-dev/homescale (https://github.com/homescale-dev/homescale) 上构建 Homescale。
Homescale 从不可变快照创建可写数据库实例和时间点分支,而无需复制整个数据库。我借鉴了 Docker 的镜像和容器模型来表示数据库状态:
- **数据库镜像** 是一个不可变的起点。
- **数据库容器** 是从该镜像创建的可写克隆。
- **分支** 是从现有容器当前状态创建的另一个容器。
我设想的 CLI 如下所示:
```
homescale image create --engine postgres postgres-base
homescale container create --image postgres-base dev-db
homescale container connect dev-db
homescale branch create --container dev-db feature-login
homescale container connect feature-login
```
本系列中的示例都使用 Postgres,但 **Homescale 的存储模型与数据库无关**。镜像、容器和分支模型适用于任何满足以下条件的数据库引擎:将其持久化状态保存在由块设备支持的文件系统上,并且能够为可恢复快照做好准备。Postgres 是我计划支持的第一个引擎,它在构建存储和编排层时为我提供了一个具体的用例。
引擎特定的行为属于适配器(adapter)的职责。该适配器负责初始化镜像、启动数据库进程、暴露连接详情,并在必要时准备数据库快照。Homescale 则负责管理其生命周期:卷、不可变状态、可写克隆、工作负载和血缘关系。
`branch` 一词描述了容器之间的关系。执行上述命令后,`dev-db` 和 `feature-login` 都是可写数据库容器。`feature-login` 只是从创建分支时 `dev-db` 的状态启动的。在底层,血缘关系如下所示:
```
flowchart LR
Image[/postgres-base 镜像/]
Dev[dev-db 可写]
State[/read-only 状态/]
Feature[feature-login 可写]
Image -->|克隆| Dev
Dev -->|快照| State
State -->|克隆| Feature
```
镜像已经是不可变的,因此 Homescale 可以直接从它创建 `dev-db`。从可写容器创建分支需要一个中间状态。Homescale 首先捕获 `dev-db` 在某个时间点的状态,然后从该状态创建 `feature-login`。创建 `feature-login` 不能意味着逐字节复制 `dev-db`。一个 100 GB 的数据库在分支启动之前就需要另外 100 GB 的存储空间。分支最初应该与其父状态共享数据。只有之后发生变化的数据才需要额外的存储。这就要求分支在数据库进程之下、在存储层中执行。
## 存储与计算分离
将数据库存储与计算分离并非新想法。标准的 Amazon RDS 引擎将数据库和日志文件存储在 EBS 卷上。Google Cloud SQL 在虚拟机中运行数据库进程,该虚拟机挂载了网络块存储,根据机器系列使用 Persistent Disk 或 Hyperdisk。数据库看到的仍然是一个普通的块设备,但其持久化数据并不依赖于计算主机的本地磁盘。
Aurora、AlloyDB 和 Azure SQL Hyperscale 更进一步。它们的计算实例连接到专门为数据库设计的共享或分布式存储系统。计算实例可以替换或扩展,而无需为每个实例创建数据的完整副本。
Homescale 更接近前一种模型。数据库进程在看似块设备的文件系统上读写文件。我只需要该设备背后的存储具有独立于使用它的进程的生命周期。
```
flowchart TD
Database[数据库进程]
Filesystem[文件系统]
Device[块设备]
Storage[(持久化存储)]
Database -->|文件读写| Filesystem
Filesystem -->|块 I/O| Device
Device --> Storage
```
这个边界让 Homescale 可以在启动数据库进程之前创建存储,并在进程停止后保留它。更重要的是,它允许分支在存储层发生,而无需数据库引擎自己实现分支。两个 Postgres 进程对它们共享的历史一无所知。每个进程都看到自己的可写块设备。另一个支持的引擎也会看到相同的存储边界。引擎适配器会改变,但快照、克隆和血缘关系模型不会改变。Homescale 仍然需要一种方法来创建可写块设备,该设备与其父设备共享未更改的数据。
## COW 是秘密武器
写时复制(COW)允许可写克隆与创建它时所依据的不可变状态共享未更改的数据。当 Homescale 创建 `dev-db` 时,新容器最初从 `postgres-base` 读取其数据。`dev-db` 所做的写入存储在容器中,而不改变镜像。创建 `feature-login` 重复相同的过程。Homescale 捕获 `dev-db` 的当前状态,然后从中创建一个新的可写容器。捕获的状态 `dev-db@feature-login` 是只读的。它保留了这两个容器分离的时间点。`dev-db` 可以继续更改,而 `feature-login` 则在该捕获状态之上存储自己的更改。
当 `feature-login` 读取它尚未更改的数据时,存储层会沿着链回溯到其父级。这可以持续经过几代,直到找到数据。在第一次写入共享数据时,存储层将相关的分配单元复制到可写容器中,并在那里应用更改。因此,一个 100 GB 数据库的分支看起来就像一个完整的 100 GB 数据库,而无需预先要求另一个 100 GB 的副本。其初始成本主要是元数据。随着 `dev-db` 和 `feature-login` 产生分歧,存储使用量会增长,尽管一次小的数据库写入可能导致底层更大的存储分配。
这种模型也会产生依赖关系。`feature-login` 依赖于 `dev-db@feature-login` 来获取尚未复制到自己容器中的数据。Homescale 不能在该分支仍然依赖它时删除该中间状态。因此,Homescale 需要持久化块设备、不可变快照和可写 COW 克隆。
## Ceph
Ceph 通过 RBD 提供这些操作。数据库只看到一个普通的块设备。Ceph 在同一个底层系统上提供对象、文件和块存储。我只关心块接口,称为 RADOS Block Device(RBD)。
RBD 镜像是一个虚拟块设备,可以映射到机器,格式化为文件系统,并像普通磁盘一样挂载。在之前的存储栈中,底部的持久化存储就是一个 Ceph RBD 镜像。数据库仍然通过相同的文件系统和块设备接口进行读写。在该 RBD 镜像之下,Ceph 将设备拆分为对象,并将它们存储在其对象存储中。
Ceph 文档 (https://docs.ceph.com/en/latest/man/8/rbd/) 将 RBD 镜像描述为在 RADOS 上条带化存储的块设备。默认对象大小为 4 MB,尽管它是可配置的。这就是为什么数据库的一次小写不一定在克隆中产生同样小的分配。当克隆第一次写入仍与父级共享的数据时,Ceph 可能需要在应用写入之前将相应的 RADOS 对象复制到子级中。
RBD 快照是镜像在某个时间点的只读视图。克隆是一个可写的 RBD 镜像,它引用回该快照以获取尚未拥有的数据。Ceph 称此为快照分层 (https://docs.ceph.com/en/latest/rbd/rbd-snapshot/)。Ceph 可以在不知道该卷被哪个数据库使用的情况下对其进行快照。数据库能否安全地从该快照启动是另一个问题。
该操作的一个直接 RBD 序列是:
```
rbd snap create homescale/dev-db@feature-login
rbd snap protect homescale/dev-db@feature-login
rbd clone \
homescale/dev-db@feature-login \
homescale/feature-login
```
Homescale 用一个命令表示该序列,尽管 Kubernetes 和 Ceph CSI 将执行后端的存储操作:
```
homescale branch create --container dev-db feature-login
```
`dev-db@feature-login` 是两个容器之间的只读状态。Ceph 在它受到保护时不会删除它。`feature-login` 是一个普通的可写 RBD 镜像,可以挂载、快照并再次分支。
### RADOS、OSD 和 RBD
RBD 位于 RADOS 之上,RADOS 通过 OSD 存储其对象:
```
flowchart TB
RBD[RBD 镜像]
Objects[RADOS 对象]
PG[Placement group]
OSDs[Acting OSD set]
Disks[(存储设备)]
Pool[池策略 副本和 CRUSH]
RBD -->|拆分为| Objects
Objects -->|哈希到| PG
Pool -.->|控制放置| PG
PG -->|映射到| OSDs
OSDs -->|持久化于| Disks
```
RADOS 是 Ceph 的分布式对象存储。RBD 在其上提供块接口。当数据库写入由 RBD 支持的文件系统时,RBD 将这些块操作转换为对 RADOS 对象的读写。这些对象存在于池中。池定义了其数据的存储方式,包括副本数量以及用于放置的 CRUSH 规则。Ceph 不会将每个对象直接映射到磁盘。它首先将对象映射到一个放置组,然后将该放置组映射到一个或多个 OSD。这使得 Ceph 可以在 OSD 添加、移除或失败时移动数据,而无需为每个对象的位置保留中央表。
Ceph 架构文档 (https://docs.ceph.com/en/latest/architecture/#data-placement) 描述了相同的路径:对象到放置组,然后放置组到 OSD。
OSD 将 RADOS 对象存储在存储设备上,并处理其读、写和复制。Ceph 可以将一个设备分割给多个 OSD,但文档化的布局是每个 OSD 对应一个存储驱动器 (https://docs.ceph.com/en/latest/start/hardware-recommendations/#storage-drives)。我将使用该布局。Homescale 不会直接与 RADOS 通信。它的存储边界是 RBD:创建卷、拍摄快照、克隆。操作员将通过 Kubernetes PVC 和 `VolumeSnapshot` 资源请求这些操作。
## Kubernetes 作为控制平面
我不希望 Homescale 直接管理数据库进程、卷挂载和网络端点。它将通过 Kubernetes 资源描述所需的数据库和存储状态,然后依赖控制器来实现该状态。CLI 与 Homescale API 通信。Homescale 创建数据库工作负载、PVC 和 `VolumeSnapshot` 资源。Ceph CSI 将存储资源转换为 RBD 操作,而 Rook 则负责运维 Ceph 集群本身。
```
flowchart TB
CLI[Homescale CLI] --> Control
subgraph Kubernetes 控制["Homescale"]
Databases
StorageAPI["存储 API PVC、快照、CSI"]
Ceph["Ceph 存储 RBD 和 OSD"]
end
Control -->|调谐| Databases
Control -->|创建| StorageAPI
StorageAPI --> Ceph
Databases --> Ceph
Ceph --> Disks[(存储设备)]
```
Kubernetes 控制器将这些资源调谐到它们期望的状态。如果一个数据库 Pod 消失,其控制器会替换它。如果一个 PVC 通过兼容的 `StorageClass` 请求动态供应,CSI 驱动会提供其存储。Homescale 可以利用这些机制,而无需实现自己的进程监督和存储挂载逻辑。
特定于应用的资源以及调谐它们的控制器属于下一部分。这里重要的边界是 Kubernetes 存储 API:Homescale 请求 PVC 和 `VolumeSnapshot` 资源,而 Ceph CSI 将这些请求转换为 RBD 操作。
### 本地与复本部署
目前,Homescale 将在 macOS 上的单节点 Kubernetes 集群中本地运行,可能使用 Colima 或 Lima。此本地配置文件包含一个 Kubernetes 节点、一个 Ceph OSD 和一个存储设备。我也可能将 Homescale 部署到我在 Hetzner Cloud 上运行的私有 Talos 集群 (https://onatm.dev/2026/05/30/provisioning-a-private-talos-kubernetes-cluster-on-hetzner-cloud/)。该集群的 Terraform 已经支持独立的工作节点池,因此我可以添加一个 Ceph 节点池,而无需将 OSD 放在现有的应用工作负载旁边。
一个带有 `size: 3` 和 `failureDomain: host` 的复制池可以将每个对象的副本放置在这些节点上。如果磁盘或节点发生故障,Ceph 可以从幸存的副本继续提供卷服务。在本地,数据只有一份副本。在 Hetzner 上,我可以针对分布在存储节点上的复制 Ceph 池运行相同的 Homescale 操作器。
## Rook 连接 Kubernetes 与 Ceph
`CephCluster` 资源告诉 Rook 要运行哪个 Ceph 版本、可以在哪里放置监视器和管理器,以及可以消耗哪些设备作为 OSD。`CephBlockPool` 描述 RBD 池及其复制和故障域设置。Rook 操作器监视这些资源,并创建或更新相应的 Ceph 守护进程和配置。
划分如下所示:
```
flowchart TB
Pod[数据库 Pod] -->|挂载| PVC[PVC]
Snapshot[VolumeSnapshot] -->|作为源| NewPVC[新 PVC]
PVC --> CSI[Ceph CSI]
NewPVC --> CSI
CSI -->|在| Ceph[Ceph 集群] 中供应
CRs[Ceph 资源] --> Rook[Rook 操作器]
Rook -->|调谐| Ceph
```
Rook 配置用于供应和挂载存储的 Ceph CSI 驱动。Homescale 可以使用 PVC 和 `VolumeSnapshot` 资源,而无需自行运行 `rbd` 命令或携带 Ceph 凭据。Rook 快照文档 (https://rook.io/docs/rook/latest-release/Storage-Configuration/Ceph-CSI/ceph-csi-snapshot/) 描述了相同的恢复流程:从 PVC 创建 `VolumeSnapshot`,然后将该快照用作另一个 PVC 的数据源。这使得 Homescale 独立于 Ceph 部署形态。本地集群可以使用一个 OSD 和一个副本的池。Hetzner 集群可以使用三个 OSD 和跨主机的复制。只要两者都暴露兼容的 `StorageClass` 和 `VolumeSnapshotClass` 资源,Homescale 操作器就遵循相同的调谐流程。
## 下一步是什么?
下一部分将涵盖 Homescale 服务:CLI 使用的 API、管理数据库资源的控制器以及 Postgres 适配器。我将从镜像和容器路径开始。镜像请求需要初始化一个 Postgres 卷并将其捕获为不可变的 `VolumeSnapshot`。容器请求需要将该快照克隆到一个新的 PVC 中,启动 Postgres,等待它准备就绪,然后返回连接详情。一旦该路径正常工作,我就可以添加从运行中的容器创建分支的功能。该服务流程必须为源数据库准备一个快照,创建中间状态,记录其血缘关系,并从中调谐另一个可写容器。
相似文章
让768台服务器看起来像1台
PlanetScale介绍了如何使用数据库分片将关系型数据库从单台服务器扩展到768台服务器,并解决诸如写入限制和资源争用等瓶颈问题。
@PlanetScale:数据库太慢?本地挂载 NVMe 存储带来无上限 IOPS,把数据中心级性能搬进云端……
PlanetScale 推出基于本地 NVMe 存储的高性能云托管方案,为 Vitess 与 Postgres 提供无上限 IOPS 与横向扩展能力。
@jhleath: https://x.com/jhleath/status/2065408690992148698
作者解释了如何构建一个能够在恒定时间内每秒启动数百万个沙箱的计算平台,重点介绍了使用Cassandra和S3进行解耦调度和能力聚合。
@motatoeshq: 我们如何将 opencomputer.dev 扩展到 100 万个沙盒
OpenComputer 为 AI 智能体提供长期运行、持久化的云虚拟机,支持有状态、始终在线的计算,并允许动态调整资源,作为临时沙盒的替代方案。
我在造一朵云
Tailscale 联合创始人 David Crawshaw 宣布为 exe.dev 完成 A 轮融资。这家新兴云提供商试图修复当今云平台的根本抽象错位:僵硬的虚拟机规格、受限的 PaaS 层等。