Skip to content
Sinan Mohd
Go back

Disaster recovery for bare-metal Kubernetes at scale

A disaster recovery setup is evaluated on two metrics. The recovery point objective (RPO) is how much data you can afford to lose, measured as the time between the last good backup and the failure. The recovery time objective (RTO) is how long it takes to get back up and running after a failure.

Our goal was point-in-time recovery, with an RPO we can configure for the entire cluster and near-zero RPO for databases. Every backup had to be encrypted, and we wanted an RTO under an hour if we lose the whole cluster. Everything also had to be infrastructure as code, so the whole setup is reproducible and can be reused on different sites and on client clusters.

Our Kubernetes clusters run on bare-metal nodes instead of a managed cloud service. That gives us full control to tune the infrastructure to our own workloads, costs less, and avoids vendor lock-in. But that also means there’s no cloud provider underneath to hand out one-click snapshots and a recovery plan. This post walks through how we built disaster recovery for these clusters.

Table of contents

Open Table of contents

Picking a snapshot layer for PVs

Kubernetes can ask for a volume snapshot through CSI, but it cannot take one itself. Something below the PV, a volume manager or a copy-on-write filesystem, has to freeze the volume at one instant and keep that copy around while the app keeps writing. Whatever layer is picked sits under every PV in the cluster, so the candidates were benchmarked before choosing one.

We wrote a script that runs fio against throwaway volumes on a fresh server with nothing else running on it. It covers ext4 and XFS on LVM with thick and thin snapshots, and btrfs on a thick LV and ZFS on both a thick LV and the raw drives, using their native snapshots. Each case ran once. native-zfs ran afterwards on the same server, reinstalled with the pool directly on the drives.

MachineHetzner AX52
CPUAMD Ryzen 7 7700 (8 cores, 16 threads)
Disks2x Samsung PM9A1 1 TB (MZVL21T0HCLR)
OSNixOS 26.11 (Zokor)
Kernel6.18.59
LVM2.03.41
ZFSOpenZFS 2.4.4
fio3.42, libaio, direct=1

Thirteen cases were tested.

On a filesystem the tests run against a 16 GiB file that is allocated but never written, so the first pass still writes into new space. The page cache is dropped before every test. The snapshot is an LVM snapshot of the LV (with the filesystem frozen while it is taken), a read-only btrfs subvolume snapshot, or a ZFS snapshot.

Each case runs the same tests in this order.

#TestfioWhat it measures
1randwrite-first4k randwrite, qd32 x 4 jobs, one full passwrites into unallocated space
2randwrite-rewritesame pass againwrites into already-allocated space
3fsync16k16k randwrite, qd1, fdatasync every IOdatabase-style commit latency
4randread4k randread, qd32 x 4 jobsread path through the mapping
5seqwrite1M write, qd8streaming throughput
x1randrw8k8k random, 70% reads, qd16 x 4 jobsPostgres-style mixed IO
x2syncwrite4k randwrite, O_SYNC, qd1 x 8 jobsparallel committers
x3walsync16k sequential writes, fdatasync every IOWAL-style appends (filesystems only)
6fsync16k-snaptest 3 after taking a snapshotcommits while a backup snapshot exists
7randwrite-snaptest 1 after test 6, snapshot still therea full pass that breaks every shared block
x4syncwrite-snapx2 after test 7parallel committers after test 7

By the time the snapshot is taken every block of the region has been written, so every block is shared with it, and the first write to each one has to copy it (a COW exception on thick, a chunk break on thin). Tests 6 and 7 are what applications go through while a snapshot exists. x4 runs after the full pass of test 7 has already broken all sharing, so it measures much the same thing as x2, and is left out of the charts.

Results

These charts compare ext4 and XFS on thick and thin LVs with btrfs and ZFS, one test at a time, with the hatched bars showing thin with zeroing on. The first eight tests ran without a snapshot.

randwrite-first

IOPS on a full 4k random-write pass into unwritten space for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

A full 4k random-write pass into space nothing has written yet.

XFS converts an unwritten extent on almost every write into the preallocated file, which is why it lags so far behind ext4 here. On this pass the filesystem’s own allocation is the bottleneck, which hides the cost of thin. It is also the only pass where zeroing costs anything, since the pool wipes a chunk only the first time it is allocated. btrfs and ZFS pay for copy-on-write and checksums on every write.

randwrite-rewrite

IOPS on the same random-write pass again for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

The same pass again, now into allocated space.

With the extents already converted, XFS no longer pays for them. A thin LV has to look up the chunk mapping on every IO. btrfs allocates new space on every write anyway, so the second pass is no easier for it. On ZFS each 4k write has to read and rewrite the 16k record it lands in.

randread

4k random-read IOPS for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

4k random reads.

Reads on a thin LV go through the same chunk mapping. ZFS has primarycache=metadata, so every read goes to the drives, and each 4k read fetches and checksums a whole 16k record.

seqwrite

Streaming write throughput in MiB/s for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

Streaming 1M writes, in MiB/s.

The drives’ throughput sometimes dropped for part of this test and the next, whichever layer was on top, so small gaps between cases here are noise.

randrw8k

Read IOPS on mixed 8k random IO with 70% reads for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

Mixed 8k random IO, 70% reads, with read IOPS shown.

Mixed IO combines the costs of the random reads and writes above.

fsync16k

Single-threaded fdatasync commits per second for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

Single-threaded fdatasync commits per second.

All of the commit tests are bound by the drive’s flush. The PM9A1 is a client drive without power-loss protection, so a flush has to actually reach the NAND. A raw LV needs one flush per commit, while ext4 and XFS also commit their journal. On thin, commits only slow down once a filesystem is on top.

ZFS only has to write the change to its intent log. The write itself lands in memory, and ZFS only puts it in its final place in the next batch of writes, a few seconds later. So a commit costs one small log write and one flush, close to what a raw LV pays. The price is that without a separate log device, every committed block is written to the same drives twice, once to the intent log and once more in that batch.

syncwrite

IOPS of 8 parallel O_SYNC writers for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

8 parallel O_SYNC writers.

On ZFS, parallel committers can share a single log write and flush.

walsync

WAL-style 16k appends with fdatasync, commits per second for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

WAL-style 16k appends with an fdatasync after each.

Appends behave like the single-threaded commits. ZFS with the pool straight on the drives behaves the same as on mdadm and LVM, so those are not what holds it back.

fsync16k-snap

Single-threaded fdatasync commits per second with a snapshot present for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

Single-threaded fdatasync commits with a snapshot present.

ZFS never overwrites a block in place, so a snapshot only decides whether an old block is freed or kept, and adds no copy to the write path. btrfs is copy-on-write too. On a thick LV the write copies the old block before it completes. On a thin LV the write stays fast and the flush grows instead, likely the pool committing metadata for the chunks it just broke.

randwrite-snap

IOPS on a full random-write pass with a snapshot present for lvmthick-ext4, lvmthick-xfs, lvmthin-ext4, lvmthinzero-ext4, lvmthin-xfs, lvmthinzero-xfs, lvmthick-btrfs-nodatacow, lvmthick-btrfs, lvmthick-zfs and native-zfs.

A full random-write pass with the snapshot still there, breaks every block it shares.

On a thick LV every first write to a block has to copy the original into the COW store and update the exception table before it can complete. Thin breaks sharing once per 512 KiB chunk in the pool instead. btrfs-nodatacow has to copy on write once per shared extent.

That makes ZFS a strong fit for commit-heavy databases, but it gives up an order of magnitude on everything else. btrfs-nodatacow keeps up with thin LVM only by turning off what btrfs is for.

Thick of it

We decided to go with LVM thin when we need local snapshots, and LVM thick when we use application-specific backups, mostly for databases.

Note

HA PVs also sit on top of LVM, but they are served over NVMe-oF and use higher-level snapshots instead of LVM snapshots.

The thin pool uses 512 KiB chunks, which match the mdadm chunk size, and kept zeroing on. Zeroing only costs anything when a chunk is first allocated. On the first random-write pass it cut lvmthin-raw from 622k to about 502k IOPS and lvmthin-ext4 by 7%, left lvmthin-xfs unchanged, and every other test stayed within run-to-run noise. Without it, a newly allocated chunk is not wiped, so a partial write leaves whatever the previous owner of those blocks wrote readable in the rest of the chunk, and the PVs belong to unrelated apps.

The pool is declared in the NixOS disko config alongside the rest of the volume group.

disko.devices.lvm_vg.vg1 = {
  type = "lvm_vg";
  lvs.vg1_thinpool = {
    size = "512G";
    lvm_type = "thin-pool";
    extraArgs = [ "--chunksize" "512k" "--zero" "y" ];
  };
};

Putting it all together

Backup architecture. Two NVMe drives sit under an mdadm array and an LVM volume group, which holds a thin pool for snapshot-backed PVs and thick LVs for the databases. OpenEBS serves them through the mayastor, lvm-thin and lvm-thick StorageClasses, with Mayastor backed by the thin pool. Velero uploads cluster resources through an S3 encryption proxy and PV snapshots through its Kopia data mover. Postgres (CloudNativePG with pgBackRest), SeaweedFS (filer.backup) and ClickHouse (clickhouse-backup with an S3 encryption proxy) are backed up by tooling in their umbrella charts. Everything is encrypted before it reaches the S3 provider.

How backups flow from the drives to the S3 provider.

What stays out of Velero

Velero backs up most of the cluster. Applications like ClickHouse and SeaweedFS are backed up with their own tooling instead. They hold by far the largest datasets in the cluster, and they can grow insanely big, since analytics tables in ClickHouse and object storage in SeaweedFS, which also hosts the OCI registry, only ever accumulate. Both replicate their data inside the cluster too, so a Velero backup would upload every replica of the same data and multiply an already huge dataset in the S3 provider.

How databases are handled

The Postgres setup uses CloudNativePG, with backups done by pgBackRest. It archives every WAL segment to the object store as soon as the segment is complete, and takes periodic base backups on top. A restore replays the WAL over the nearest base backup, so it can recover to any point in time, not only to the last backup. pgBackRest also encrypts the repository natively, so base backups and WAL are already encrypted when they leave the cluster.

SeaweedFS is backed up with weed filer.backup. It follows the filer’s metadata log and copies each file to the backup sink as it is created or changed, so the backup trails the live data by seconds.

The Altinity ClickHouse setup is backed up with clickhouse-backup. ClickHouse never changes a data part once it is written, so the tool freezes the tables, which hardlinks their current parts without copying anything, and uploads those. Incremental backups only upload the parts the previous backup does not have, which keeps the ever-growing analytics tables cheap to back up. clickhouse-backup does not encrypt what it uploads, so it talks to the S3 provider through an S3 encryption proxy running as a sidecar, which encrypts every object on the way out.

Why not Kopia file system backup

Velero can also back up volumes without snapshots, through its file system backup, which runs Kopia against the live volume mounted in the pod. It works on any storage, but the result is not a consistent state of the volume. Kopia copies each file at a different moment while the app keeps writing, so Postgres would get data files from different points in time, out of sync with the WAL. A snapshot freezes the whole volume at one instant, so restoring it is like booting after a power cut, which Postgres knows how to recover from.

Kopia is still involved, since the data mover uses it to upload snapshot contents to the S3 provider, but there it reads from a frozen snapshot.

Encrypting Velero backups

A Velero backup has two parts. The PV contents go through the data mover into a Kopia repository, and Kopia encrypts everything it writes with the repository password, so that data is encrypted before it leaves the cluster. The cluster resources, the manifests Velero exports from the API server, are uploaded as a tarball by Velero itself, which has no native encryption for them yet. Client-side encryption is being worked on in velero-io/velero#9054. Until it lands, Velero uploads cluster resources through a transparent S3 encryption proxy, which encrypts them before they reach the S3 provider.


Share this post
Previous Post
Precedence Constrained Knapsack and package managers