dqlite Datastore Vacuum (Freelist Bloat → Snapshot Amplification)¶
Service: microk8s control plane (pvek8s) First executed: 2026-07-02 Issue: pgk8s#577 — full execution log and results Upstream: canonical/microk8s#5153 (retry amplification), canonical/microk8s#3064 (snapshot write volume)
When to run this¶
The dqlite SQLite database never returns freed pages (auto_vacuum=0, and
dqlite cannot replicate VACUUM). Over months of write churn the file
becomes mostly freelist, and because raft snapshots serialise the entire
page file, every snapshot writes the full bloated image to disk on every
node — at high write rates that is a large fsync burst every ~100 seconds,
which creates the SQLITE_BUSY windows that feed
write-contention storms and
watch-cache freezes.
Trigger: snapshot files > ~50 MB, checked on any node:
Confirm the bloat ratio (2026-07-02 baseline: 268,790 pages / 257,710 free = 96% waste):
sudo /snap/microk8s/current/bin/dqlite \
-s file:///var/snap/microk8s/current/var/kubernetes/backend/cluster.yaml \
-c /var/snap/microk8s/current/var/kubernetes/backend/cluster.crt \
-k /var/snap/microk8s/current/var/kubernetes/backend/cluster.key \
k8s 'PRAGMA page_count; PRAGMA freelist_count'
Critical warnings¶
microk8s dbctl backupis silently broken on v1.35. It produces a tiny (~212 byte) tarball containing an empty directory and exits 0. The migrator'sbackup-dqlitemode lists prefix/which kine answers with an empty result; thebackup(etcd-client) mode dies onsortOrder is unsupported. Always inspect the backup archive before any destructive step.- Restoring into the existing datastore does not shrink anything — SQLite keeps its freelist. The shrink only happens by restoring into a freshly bootstrapped datastore.
- A wiped node only bootstraps a fresh single-voter dqlite if
cluster.yamlandinfo.yamlare absent. With them present it loopsno known leaderforever.
Outage profile¶
- Full control-plane outage for the window (60–90 min). Workload containers keep running; anything needing the apiserver pauses.
- The two rejoined nodes get a
leave/joincycle, which restarts their pods — one node at a time, jiva health-checked between. - Expect collateral: pods interrupted mid-write may leave dirty EXT4 journals on jiva volumes (mount-time fsck failures afterwards — see jiva-ctrl-eviction-iscsi-ro-filesystem.md for the repair pattern), and anything keeping encryption keys on an emptyDir will regenerate them (dependency-track KEK incident, pgk8s#578).
Procedure¶
Phase 0 — Preparation¶
- Nagios downtime for pvek8s checks.
- Pause churn: scale
argocd-application-controller(sts) andgharc-controller-gha-rs-controller(deploy) to 0. Record what was scaled — re-enable in Phase 4. - All nodes Ready, all jiva volumes
RW, no Pending pods.
Phase 1 — Export (cluster running) and raw backups¶
- Static
etcdctlon one node (kine speaks etcd v3): - Paginated export of
/registry/(NOT/— that returns empty):etcdctl get <start> /registry0 --limit=200 -w jsonper page, start inclusive from the previous page's last key (dedupe it — a NUL byte cannot be passed in argv), values base64 in the JSON = binary-safe. Write out migrator-formatN.key/N.datafiles. A ready-made script is attached to pgk8s#577. - Verify the export against the live apiserver before stopping
anything: key count sane, PV/PVC/secret/CRD counts match
kubectl get ... | wc -lexactly. Copy the archive off-node. - Stop the cluster — followers first, dqlite leader last:
microk8s stopper node. - Raw rollback copies on every node:
Phase 2 — Fresh datastore on the first node¶
- Clear ALL state except TLS material:
microk8s start— bootstraps a fresh single-voter datastore (empty cluster state is expected).- Stop kubelite (leave k8s-dqlite running) so the GC controller cannot reap restored children ordered before their owners: (The restore direction of the migrator works; only backup is broken.)
- Verify object counts match the export; check
PRAGMA page_count / freelist_count— expect thousands / 0.
Phase 3 — Rejoin remaining nodes, one at a time¶
Per node:
- Clear state exactly as Phase 2 step 1 (including the identity yamls).
microk8s start→ wait active →microk8s leave.- On the first node:
microk8s remove-node <node> --force, thenmicrok8s add-node→ run the printed join on the node. - Verify: node Ready, dqlite member added, canary pod schedules AND
appears in the node's
resourceVersion=0cache read (see control-plane-watch-cache-freeze.md step 4), all jiva replicas back toRWbefore the next node.
Phase 4 — Close out¶
- RV=0 canary on all nodes; snapshot sizes ~10 MB; lock-error rate ≈ 0;
heartbeat cronjob completing;
microk8s statusshows HA. - Re-enable ArgoCD application-controller and ARC controller. This step is easy to lose track of if the window gets interrupted — pending runners/listeners stuck for hours afterwards means this was missed.
- End Nagios downtime. Post before/after page stats to the tracking issue.
Rollback (any phase)¶
microk8s stop everywhere → wipe backend/ → extract each node's raw
tarball back in place → start leader-node first → Phase 4 verifications.
Results achieved (2026-07-02)¶
| Metric | Before | After |
|---|---|---|
| page_count / freelist | 268,790 / 257,710 | 6,450 / 0 |
| Snapshot size | 237 MB | 12.7 MB |
| Snapshot write load | ~2.4 MB/s | ~150 KB/s |
| Quorum read latency (storm) | 3.1 s | 0.10 s |
References¶
- pgk8s#577 — execution log, export script, full results
- dqlite-write-contention.md — the storms this prevents
- control-plane-watch-cache-freeze.md — the downstream freeze
- canonical/microk8s#5153 — retry amplification (unfixed upstream; vacuum removes the dominant trigger, not the bug)