Support backups of persistent data #42
Labels
No labels
blocked
bug
duplicate
effort
high
effort
low
effort
medium
effort
minimal
enhancement
help wanted
invalid
pr
feedback
pr
reviewing
question
status
backlog
status
document
status
done
status
implement
status
pending
status
planning
status
reviewing
waiting
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tepichord/milner#42
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal: replace the plain filesystem on the external 4TB with a ZFS dataset mounted at the identical path, so one zfs snapshot is an atomic point-in-time of every durable qcow2
— with no changes to Talos, Terraform, or the StorageClasses.
Sizing caveat: there's no churn data yet, so treat step 9c as a guess. Start from the current ~71 MiB of durable data and assume the cluster grows to 4× size and utilization;
even then snapshots stay under 10% of the drive after a year, which is why the policy below prunes on schedule rather than on capacity.
Stage the durable data somewhere that isn't the 4TB. Copy milner-worker-1-data.qcow2, milner-worker-1-vault.qcow2, and preserved/manifest.json to the host's internal disk
or a USB stick — it's only 71 MiB — and verify the SHA-256s against the manifest. This is your abort path, because step 5 destroys the drive.
Make the host capable of ZFS before anything depends on it. Install OpenZFS on Fedora Asahi and confirm modprobe zfs plus zpool status work against the Asahi kernel. Doing
this first means a kernel/module mismatch is discovered while there's nothing at stake.
Record the exact paths the new filesystem must reproduce. Read the pool path with virsh pool-dumpxml talos-images and the volume id/key host paths out of
data/terraform.tfstate. This matters because the libvirt provider resolves volumes by host path and silently forgets them if the path moves (§9e) — so these paths are a
hard requirement, not a preference.
Stop every writer — the only downtime in this entire exercise. Shut down both nodes, confirm virsh list --all shows them off, then virsh pool-destroy talos-images and
unmount /mnt/data. This is a one-time migration window; the recurring snapshot job in step 9 stays fully online.
Swap the filesystem while keeping the paths identical. Remove any old /etc/fstab entry for /mnt/data, then zpool create -o ashift=12 -O compression=lz4 -O atime=off -m
/mnt/data tank followed by zfs create -o recordsize=64K tank/milner (which auto-mounts at /mnt/data/milner). The dataset now occupies the same directory tree the
old filesystem did, so libvirt, Terraform, and the StorageClasses observe nothing at all.
Put the data back and make Terraform confirm it recognises it. Recreate the pool directory tree, copy the qcow2s to their recorded paths, verify digests, then run terraform
-chdir=data plan and expect No changes. A plan that wants to create the volumes means a path moved, and you stop rather than apply.
Bring the cluster back and prove it's healthy. virsh pool-start talos-images, start the domains (or terraform -chdir=control-plane apply), then check talosctl health,
kubectl get nodes, PVCs Bound, Vault unsealed, and a Woodpecker login. This reuses your §9d drill ladder as the acceptance evidence that the migration lost nothing.
Take the first snapshot and prove it's usable. Run zfs snapshot tank/milner@baseline, then zfs clone tank/milner@baseline tank/verify and confirm the qcow2s are present and
readable there. The offsite copy is deliberately deferred — see 9f.
Automate the snapshot policy
9a. Frequency is free; pick it from what you'll accept losing. Snapshots are instant and consume no space when created, so only retention costs anything. Recommendation:
15-minute snapshots to cover "I just broke Vault," hourly for the last day, daily for the last month, monthly for the last year — or drop the 15-minute tier entirely if a
1-hour RPO is fine for you.
9b. Retention is churn × days, and it's the only real cost. ZFS shares unchanged blocks, so a snapshot of a 40 GiB disk with 100 MB/day of churn costs ~100 MB/day, not 40
GiB. Sized for 4× growth at a generous ~1 GB/day, that's roughly 30 GB of dailies and a few hundred GB per year at worst — under 10% of 4 TB, so you'll never prune to
reclaim space.
9c. Let the tool enforce retention so it can't be forgotten. sanoid with autosnap = yes and autoprune = yes in the template gives you 70 retained snapshots on a fixed
schedule; zfs-auto-snapshot is the lighter alternative. The point is that pruning is config, not a chore you remember.
9d. Monitor so you find out before capacity bites. Check zpool list -H -o capacity tank weekly and alert at 80%, since ZFS performance degrades as the pool fills and writes
fail at 100%. USEDSNAP in zfs list -o space tank/milner is your snapshot bill — if it ever dominates, shorten retention rather than adding a disk.
9e. Know how a snapshot is actually consumed. Restore is path-preserving: stop the VM, zfs rollback tank/milner@autosnap_…, start the VM — the qcow2s revert in place, so the
paths libvirt and Terraform depend on never change. Use zfs clone for a what-if instead, since zfs rollback -r destroys newer snapshots.
9f. Defer the offsite leg, and write down that you deferred it. With no second machine, everything above is a restore-point layer, not a backup — it saves you from a bad
migration or a corrupted database, and dies with the drive. When a destination exists, add syncoid --recursive tank/milner root@dest:tank/milner, and zfs bookmark any
snapshot you intend to use as an incremental base, since a pruned base breaks the -i chain and forces a full resend.
9g. Replace the guess once real workloads are running. Sample zfs get -H -o value written tank/milner 24 hours apart to get true bytes/day, then re-check that 9b's retention
still fits. Until then, 9b is a placeholder sized to grow, not a measurement.
Done when: terraform -chdir=data plan is clean, talosctl health passes with both PVCs bound and Vault unsealed, zfs list -t snapshot tank/milner shows the scheduled tiers
filling in, one rollback drill has succeeded, and you've accepted in writing that until 9f lands, a lost 4 TB is a lost cluster.
Naina:
Overview
Migrate the external 4 TB drive from a plain filesystem to a ZFS dataset mounted at the identical pool path, so one
zfs snapshot tank/milner@<name>is an atomic point-in-time of every durable qcow2. The work is a documented one-time runbook (README rejects per-change migration scripts) plus committed sanoid policy and systemd timers for scheduling, pruning, and capacity monitoring. Offsite replication viasyncoidis deferred until a second machine exists and must be recorded as such.Technical Approach
Freeze and stage the durable set off the drive (abort path).
--sparseis mandatory: a plaincpmaterializes the 20 GiB vault qcow2 as 20 GiB of real bytes. unverified (rsync does need--sparse, but GNUcp --sparse=autodetects holes by default, so thecphalf of this rationale is doubtful)Build OpenZFS against the Asahi 16K kernel.
The Fedora
zfs-dkmsRPM is unusable here — its spec hard-requireskernel-devel, Asahi shipskernel-16k-devel(openzfs#16429, still open)1. Do this before step 5 so a module failure costs nothing.Record the path the new filesystem must reproduce.
The
${data_root_path}/images/talos-kvmcomposition is the pool'starget.path2.Stop every writer (the only downtime).
Swap the filesystem in place.
-f+wipefs -aare required; barezpool createrefuses a vdev holding a preexisting filesystem3.Restore data, ownership, and the pool.
Prove Terraform sees no drift (replaces the issue's
-chdir=data).No
data/root exists; the pool is declared incontrol-plane/main.tf4 and the durable volume inworker/main.tf5. Any plan that wants to create a volume means a path moved — stop, don't apply.Bring the cluster back and run the §9d drill ladder. unverified (OPERATIONS.md at HEAD ends at §6, so no §9d exists to run yet)
Baseline snapshot + proof it is usable.
Commit and install the snapshot policy.
Rollback drill.
zfs rollbackis path-preserving, so the qcow2s revert in place;zfs cloneis the non-destructive alternative (rollback -rdestroys newer snapshots)6.Write down the deferral. OPERATIONS.md §7 must state: until a second machine exists, snapshots are a restore-point layer, not a backup — a lost 4 TB is a lost cluster; the future command is
syncoid --recursive tank/milner root@dest:tank/milner, withzfs bookmarkon the incremental base so pruning cannot break the-ichain7.Implementation Details
OPERATIONS.mdzfs/sanoid.conf/etc/sanoid/sanoid.conf.zfs/systemd/sanoid-prune.{service,timer}sanoid --cronevery 15 min.zfs/systemd/sanoid-monitor.{service,timer}sanoid --monitor-health --monitor-capacity --monitor-snapshots8.flake.nixsanoidtobuildInputs(sanoid/syncoid/findoid on PATH innix develop).tests/test_zfs.pyREADME.md,worker/README.md,vault/README.mdtank/milner; commands in OPERATIONS.md §7.zfs/sanoid.conf:The
capacity_*root-pool-section requirement andfrequent_period = 15default are documented in sanoid's own defaults file9.zfs/systemd/sanoid-prune.timer→OnCalendar=*:0/15,Persistent=true,WantedBy=timers.target;zfs/systemd/sanoid-prune.service→Type=oneshot,ExecStart=/usr/local/bin/sanoid --cron.Dependencies: host-side OpenZFS built from source against
kernel-16k-devel;wipefs(util-linux, already present); sanoid from nixpkgs10. No new Terraform providers.Constraints:
virsh pool-dumpxml talos-images --xpath 'string(//target/path)'must stay/mnt/data/milner/images/talos-kvm— libvirt resolves volumes by host path.data_root_pathstays host-local (terraform.auto.tfvars)11 and the pool staystype = "dir"12.qemu:libvirtownership, or libvirt volume operations fail14.modprobe zfs/zpool status.Out-of-scope notes:
data/Terraform root — does not exist and is not created; use the existing roots' plans.zpool scruband SMART — recommended follow-up (a single-disk pool has no other integrity check) but not part of this issue.syncoid— deferred per 9f; documented, not implemented. unverified (OPERATIONS.md at HEAD has no §9f)Acceptance Criteria
virsh pool-dumpxml talos-images --xpath 'string(//target/path)'returns/mnt/data/milner/images/talos-kvm;zfs list -H -o mountpoint tank/milnerreturns/mnt/data/milner;zfs get -H -o value recordsize tank/milneris64K.terraform -chdir=control-plane planandterraform -chdir=worker planboth report "No changes".talosctl healthpasses, PVCs areBound, and Vault reports"sealed":false.zfs list -t snapshot tank/milnercontainsbaselineplus the scheduled tiers, and a clone of@baselineyields qcow2s that passqemu-img check.zfs rollback tank/milner@<snap>, worker boots, Vault unseals.systemctl is-active sanoid-prune.timer sanoid-monitor.timerbothactive;sanoid --monitor-capacityuses the 80/95 thresholds.python -m pytest tests/ -vpasses (includingtests/test_zfs.py).Testing Plan
New
tests/test_zfs.py(offline, no host dependencies):python -m pytest tests/ -vandterraform fmt -check -recursive control-plane/ worker/must pass.Related Issues/PRs
libvirt_pool.talos-images, pool path).milner-worker-1-vault.qcow2).OPERATIONS.md§4e,ARCHITECTURE.md§4/§8,worker/README.md"Vault data volume".Research Findings
zfs-dkmsRPM hard-requireskernel-develwhile Asahi shipskernel-16k-devel(openzfs#16429, open since 2024) — build from source againstkernel-16k-devel.dirpool is a documented configuration usingrecordsize=64K+compression=lz4, though OpenZFS prefers zvols/raw files; watch preallocation/detect-zeroesfortxg_syncstalls.zfs rollbackrestores a qcow2's data and metadata atomically and path-preservingly, so the VM boots immediately afterwards — the VM must be stopped first.@baselineis fully consistent.sanoid/syncoid/findoidfor aarch64; zfs-auto-snapshot's last upstream commit was 2019.zpool createrefuses a vdev holding an old ext4/xfs filesystem without-f; clear signatures withwipefs -afirst and useashift=12(immutable per vdev).zfs bookmark(or--create-bookmark) keeps the incremental chain alive after pruning, which matters for the deferred offsite leg.Pre-Mortem
kernel-16k-develfails on configure/make, ormodprobe zfsstops working on the next Asahi kernel update, leaving the drive half-migrated with no supported fallback. Every step from 5 onward is blocked until the module loads.wipefs -apluszpool create -fon a single disk, with the only staging copy in~/milner-preservedon a different but unverified-capacity filesystem, means a failed restore or a sparse-flag mistake is not recoverable. A wrong device selection in step 5 is equally fatal.tests/test_zfs.pyonly asserts INI text and string presence, so the host-only acceptance criteria (rollback drill, cloneqemu-img check, Vault unsealing, Terraform "No changes") can be skipped and the breakage surfaces later as libvirt path drift or a non-booting worker.