Haify user guide¶
This guide explains how Haify is organised and how to do the day-to-day work with it. It assumes a cluster that is already up. See deployment-guide.md to build one and node-prerequisites.md for what each node needs installed.
Every command here is haify, which talks to the controller over gRPC on
port 3374. The same operations are available in the web UI and, for AI
assistants, through haify-mcp.
Contents¶
- The mental model
- Talking to the cluster
- Nodes
- Pools
- Resources: your storage
- Using a resource
- Changing a resource's shape
- Snapshots
- Backups: the only copy that survives losing the cluster
- Gateways: exporting to clients
- High availability
- Database and object-store applications
- Cross-site replication (WAN DR)
- Storage tiering
- Encryption
- Alerts and notifications
- Access control
- Kubernetes and virtual machines
- The AI Copilot
- Routine operations
- When something is wrong
1. The mental model¶
Haify works with four layers, each inside the one above:
node a Linux machine Haify reaches over SSH
└─ pool an LVM volume group or ZFS pool on that node's disks
└─ resource a replicated block device: the same bytes on several nodes
└─ volume one block device inside the resource (usually just one)
The resource is the unit you work with. It is a DRBD device: you write to it on one node and the bytes land on every replica synchronously. One replica is Primary (mounted, writable); the others are Secondary (receiving, not mountable). Only one node may be Primary at a time, which stops two machines from writing to the same filesystem and destroying it.
Three ideas explain most of Haify's behaviour:
Quorum. A replica set decides by majority who is allowed to serve I/O. Two diskful replicas cannot form a majority when one is lost, so Haify adds a third member with no disk, a tiebreaker, that votes but stores nothing. Without it, losing either node suspends I/O on both.
Promotion is automatic, by drbd-reactor. You do not usually run primary
yourself. You declare what should run with the resource (a mount, a virtual IP,
some services) and drbd-reactor promotes a node and starts them, moving the
whole set elsewhere if that node dies. See High availability.
Snapshots, replicas and backups protect against different failures, and only one of them is a backup:
| Protects against | Does not protect against | |
|---|---|---|
| Replica | Losing a machine | rm -rf (it replicates) |
| Snapshot | rm -rf |
Losing the pool it lives in |
| Backup | Losing the whole cluster | Nothing else, if it is verified |
2. Talking to the cluster¶
haify node list # against 127.0.0.1:3374
haify -c 192.0.2.250:3374 node list # against a remote controller
With Self-HA enabled the controller moves between nodes; point -c at the
floating VIP rather than at a node, so it never goes stale:
haify ha self status # prints the VIP and the active node
haify -c 192.0.2.250:3374 node list # 192.0.2.250 = that VIP
If the cluster has RBAC or token auth on, supply a token with --token, the
HAIFY_TOKEN environment variable, or a file at ~/.haify/token or
/etc/haify/token (checked in that order).
If the controller serves TLS, connect with --tls, adding --tls-ca when its
certificate is not signed by a CA in the system trust store, and
--tls-cert/--tls-key when it requires client certificates. Each flag has an
environment variable (HAIFY_TLS, HAIFY_TLS_CA, HAIFY_TLS_CERT, HAIFY_TLS_KEY,
HAIFY_TLS_SERVER_NAME, HAIFY_TLS_INSECURE).
Most commands that change something on the nodes wait up to 10 minutes for
the controller; backups and pool rebuilds wait longer. Most listings and status
commands give up after 30 seconds. When a command gives up, the controller may still finish the
work, so check with the matching list or status before you run it again.
Two commands to learn first:
haify health-check # can the controller reach every node, and is the stack installed
haify resource status <name> # everything about one resource: roles, disks, replication
3. Nodes¶
haify node register --name node1 --address 192.0.2.11
haify node list
haify node get node1
--address is the management IP Haify uses for SSH. If replication should run
over a different network, such as a dedicated 10G link, name it separately:
haify node register --name node1 --address 192.0.2.11 \
--replication-address 10.0.0.11
More than one replication network. With two links between the nodes (two NICs, two switches), give each node its address on the second network, in the same order on every node:
haify node set-paths node1 10.1.0.11
haify node set-paths node2 10.1.0.12
haify node set-paths node3 10.1.0.13
haify node set-paths node1 --clear # back to one network
Each pair of nodes then gets a DRBD path on every network they share. If the link in use fails, DRBD reconnects over the other one within about half a minute, and the resource stays UpToDate. Haify rewrites and adjusts the node's resources in place, and their connections stay up while paths are added or removed.
DRBD does not connect at all while one of a connection's paths has no local
address. A node whose second NIC is down at boot, or that lost its address
when the link went down, would then be cut off on the network that still
works too. To prevent this, every node with set-paths runs a timer
(haify-paths-guard.timer). Every ten seconds it drops paths that have no
local address, and it puts them back when the address returns.
Labels describe where a node physically is. Auto-placement uses them to keep replicas apart, or together:
haify node label node1 rack=A zone=east
haify node label node1 rack= # trailing = deletes the label
haify node label node1 zone=west --replace # drop every other label
haify.tiebreaker=false keeps automatic selection from picking a node as a
resource's quorum tiebreaker (you can still name the node explicitly). Set it on an off-site DR node, which is not on the
replication network.
Renumbering a node (its IP changed, or it moved subnet) takes one command once the node answers on the new address:
haify node set-address node1 192.0.2.21
haify node set-address node1 192.0.2.21 --replication-address 10.0.0.21
The command first checks that the new address reaches the same machine. It
then updates the node in the registry, rewrites the node's entry in
/etc/hosts on every node, and rewrites the DRBD config of each resource the
node is part of. Each of those resources reconnects on the new address.
When several nodes changed address, for example because a DHCP server handed every node a new lease, renumber them in one command. One at a time does not work here, for two reasons. The controller would rewrite each node's resources through peers it still knows only by their old addresses. And when two nodes swap addresses, the configs would pass through a state where both nodes have the same address.
haify node set-address node1=192.0.2.21 node2=192.0.2.22 node3=192.0.2.23
If every node moved, the controller cannot start: its database is on a DRBD
resource whose peers can no longer find each other. Bring that resource back
by hand first. Replace all old addresses with the new ones in a single pass;
a chain of sed substitutions goes wrong when two nodes swap addresses:
# on every node, with each node's old → new address
perl -pi -e 'my %m = ("192.0.2.11" => "192.0.2.21", "192.0.2.12" => "192.0.2.22",
"192.0.2.13" => "192.0.2.23");
s/\b(\d+\.\d+\.\d+\.\d+)(?=:)/exists $m{$1} ? $m{$1} : $1/ge' /etc/drbd.d/haify-meta.res
drbdadm adjust haify-meta
# once the controller is up on its VIP:
haify node set-address node1=192.0.2.21 node2=192.0.2.22 node3=192.0.2.23
resource repair <resource> also writes the registry's addresses into a
resource's config. If a renumbering could not reach a resource (because a node
was down), repair that resource afterwards.
Draining a node marks it maintenance and moves every resource that is
Primary there to another replica. Drain before maintenance, not after:
haify node drain node1
# ... reboot, replace a disk, upgrade ...
haify node undrain node1
A maintenance node gets no new replicas or tiebreakers (resource create,
CSI provisioning) and keeps that state through health checks and
re-registration until undrain. Undrain moves nothing back.
How each Primary moves:
- HA resources, gateways, apps and haify-meta go through drbd-reactor's eviction,
the same as
haify ha evict; drbd-reactor picks the new node. - Everything else is demoted, then promoted on the first replica in the
resource's node list that is diskful,
UpToDate, connected, and neither drained, offline, nor a WAN resource's DR node. If that promote fails, the original node is promoted back.
A resource that cannot move (no qualifying replica, a mounted volume, a failed eviction) stays Primary where it is; the drain moves the rest and names each one it left, with the reason. The node stays drained either way.
Unregistering is for a node that is never coming back:
haify node unregister node4
It refuses while anything still uses the node, and names each resource and its role there:
node node4 is still in use by: data (replica, NFS gateway); logs (tiebreaker); move or remove these first
Clear each role first: a replica with resource remove-replica, a tiebreaker
with ha set-tiebreaker (move it, or --remove), a diskless client with
resource diskless detach, a gateway with gateway delete. A WAN resource's
DR node can only be released by deleting that resource. Once nothing is left,
unregistering removes the node from the registry; it does not touch the node.
4. Pools¶
A pool is the storage a node contributes. There are three kinds, plus a fourth, thin on VDO, described below:
haify pool create --name data-pool --type lvm --nodes node1,node2 --devices /dev/sdb
haify pool create --name thin-pool --type lvm-thin --nodes node1,node2 --devices /dev/sdc
haify pool create --name tank --type zfs --nodes node1,node2 --devices /dev/sdd
haify pool list
haify pool get --name thin-pool --node node1
On the nodes the volume group (or zpool) is named with an haify_ prefix:
thin-pool becomes haify_thin-pool. Commands accept either form except
pool convert-thin, which takes the prefixed name.
A ZFS pool compresses data; OpenZFS 2.2 and later use lz4 by default.
--compression <algorithm> (zstd, zstd-3, gzip-6, off, ...) picks
another algorithm and --dedup turns on deduplication. Both are set on the
pool's root dataset at creation, so every volume inherits them. Dedup costs RAM
on every write and only pays off for data that is known to repeat. pool get
(and the MCP pool listing) show the algorithm and the ratio ZFS achieves
(Compression: zstd, 1.85x achieved). Encrypted resources can only use LVM
pools, so ciphertext, which does not compress, never lands on a ZFS pool.
A pool on a file. A node without a spare disk (a laptop, a test VM) can build a pool on an image file:
haify pool create --name data --type lvm-thin --nodes node1,node2 \
--devices file:/var/lib/haify-pools/data.img:50G
For a thin pool the file is sparse; for a thick pool it is allocated in full.
Haify attaches the file as a loop device and builds the pool on it as on a
disk, so thin volumes, snapshots and everything else work the same. A unit
attaches the file again at boot, before DRBD starts. Every write also passes
through the node's filesystem, so use a real disk for a pool that matters. The
file must be on the node's own disk. A path under /var/lib/haify (the
controller's own replicated volume) or on any DRBD device is refused.
pool delete removes the file.
Omitting --type gives the controller's [storage] default_pool_type, which
is thin_pool unless changed. Choose thin unless you have a reason not to.
A thick (plain lvm) pool cannot hold a useful snapshot history. LVM makes
every thick snapshot reserve its copy-on-write area up front, so a 10 GiB pool
backing a 6 GiB volume fits only two snapshots, even if nothing ever changes. A thin snapshot costs only
the blocks that diverge.
Converting later is possible but is a rebuild, one node at a time:
haify pool convert-thin --node node1 --pool haify_data-pool
It destroys that node's copy and resyncs it in full from the peers. The resource
keeps serving throughout, and the node is diskless until the resync is done. It
refuses to start if the node holds a Primary, if a peer is not UpToDate, if a resync
is already running, or if this is one of only two diskful copies. The rebuilt copy
of an encrypted resource is encrypted again, under a new key made on the node.
Growing a pool. A new disk in a server shows up in node disks and on the
web UI's Pools page under "Unused disks":
haify node disks # every node; --node to narrow
haify pool add --pool data-pool --nodes node1 --devices /dev/sde
node disks says what each disk is: free (empty), wipe (holds only old
data: a filesystem, a partition table, the label of an array or pool that is not
running), pool (in a Haify pool) or busy (mounted, the system disk, another
volume group, a DRBD backing disk). pool create, pool add and
pool replace-disk accept free disks. They accept wipe disks only with
--wipe, which erases them. They always refuse a busy disk, so a mistyped
device name cannot destroy a disk in use. A device can also be named by its
/dev/disk/by-id link, which does not change when a reboot renumbers /dev/sdX.
The disk joins the volume group. If the group holds a thin pool, Haify then
extends that pool so thin volumes can use the new disk straight away. It first
grows the pool's metadata area in proportion, then extends the pool into the
same share of the group's free space that pool create uses: 95% minus
[storage.thin] reserve_percent, so 85% by default. A ZFS pool takes the disk as a new vdev (zpool add). If the
thin pool could not be extended, running the same pool add again finishes it.
Volumes already in the pool keep their size until you resize them. A replicated volume can only grow as far as its fullest node allows, so add a disk on every node that holds a replica.
Disks. pool disks lists the disks under the pools on every node, with
their health from SMART or the NVMe health log:
haify pool disks # every node; --pool, --node to narrow
The health is one of:
ok;warn: an NVMe drive past 90% of its rated endurance or with media errors, or an ATA disk with reallocated or pending sectors;fail: the drive's own SMART verdict, an NVMe critical warning or worn-out drive, or uncorrectable sectors;unknown: nosmartctlon the node, or a device that has no SMART, such as a virtual disk.
The check needs smartmontools on the nodes. The daily inspection runs
the same check (disk.health) and raises a disk.health event for every disk
at warn (warning) or fail (critical).
Taking a disk out moves its data to the group's other disks first (pvmove),
while the pool stays in use, and only then drops it from the pool:
haify pool remove-disk --pool data-pool --node node1 --disk /dev/sdc
haify pool replace-disk --pool data-pool --node node1 --disk /dev/sdc --new-disk /dev/sdf
haify pool jobs # what is running; --all includes finished ones
remove-disk is refused when the other disks have no room for this disk's
data, and when it is the pool's only disk. replace-disk works in both cases:
the new disk joins, the old disk's data moves to it, then the old disk leaves.
Both run as a job on the node, so they survive a controller restart or
failover. When a job ends, it raises a pool.disk_moved event. A move takes
about as long as reading the whole disk once.
Keeping thin pools from filling. When a thin pool is full, writes to it
fail at once, DRBD drops the disk, and the resource carries on with one replica
fewer. Three things in [storage.thin] prevent that:
- Trim. Every day (
trim_schedule,30 2 * * *by default) the controller runsfstrimon every mounted ext4 and XFS filesystem on DRBD, on the node serving it. The discards reach every replica, so each node's thin pool gets back the blocks the filesystem freed. To trim now, runhaify pool trim(--nodefor one node). Each scheduled run raisespool.trimmedwith how much was discarded. - No zeros from resyncs. A resync onto a thin volume writes every block it
copies, holes included, unless DRBD knows the device can discard. Resources
on thin pools get
rs-discard-granularityset automatically, and the controller adds it to older resources that lack it when it starts. - Autoextend. A new thin pool leaves
reserve_percent(10) of its volume group free. When a pool's data or metadata use reachesautoextend_threshold(80%), the controller grows that part into the free room. Metadata is doubled. Data grows byautoextend_percent(20) of the pool's size, or by what the group has left if that is less. When both cross the threshold, metadata grows first. Each extension raisespool.extended. When the group has no room left,pool.extendedwarns once: that is the time topool adda disk.autoextend_threshold = 0turns it off.
[storage.thin]
trim_schedule = "30 2 * * *" # "" turns it off
reserve_percent = 10 # of a new pool's volume group, left for autoextend
autoextend_threshold = 80 # 0 turns it off; otherwise 50-95
autoextend_percent = 20 # 5-100
Deleting a pool is per node, and an LVM pool that still holds any volume is refused; the freed disks have their PV labels wiped:
haify pool delete --name data-pool --node node1
Thin pools on VDO (--type lvm-thin-vdo). The thin pool's data area sits
on a VDO volume, so everything written to it is deduplicated and compressed;
thin volumes, thin snapshots and DRBD above it behave as on a plain thin pool.
It suits data that repeats, such as VM images built from the same template,
backups and logs.
haify pool create --name dedup --type lvm-thin-vdo --nodes node1,node2 --devices /dev/sdf
Each node needs the dm-vdo kernel module (kernel 6.9 or later, or kmod-kvdo on
EL), vdoformat from the vdo package, and lvm2 2.03.24 or later
(--pooldatavdo); pool create checks for all three before it touches the
disk. Ubuntu 24.04's GA kernel does not ship dm-vdo; use the HWE kernel.
What to know before choosing it:
- Physical space is the figure to watch. The thin pool's usage shows the
logical space handed out. The VDO pool under it fills at whatever rate is
left after dedup and compression. When VDO runs out of physical space, writes
fail with I/O errors although the thin pool still shows room, and DRBD
drops the disk.
pool getshows both (VDO: 41.00% physical used, 63.00% saved by dedup/compression), andpool.vdo_physical_near_full/pool.vdo_physical_fullfire at the same thresholds as the thin pool alerts. - It deduplicates per node. VDO is under DRBD, so DRBD replicates every logical block in full; it saves disk on each node, not network.
- No encryption. An encrypted resource is refused on a VDO pool: ciphertext neither deduplicates nor compresses.
- Deleted snapshots do not give space back to VDO on current lvm2 (the thin pool does not pass the discard down); the space is reused for new writes inside the pool, but VDO's physical usage does not drop.
- Growing is by hand.
pool addadds the disk to the group but does not grow the pool: extend the VDO pool's physical size first, then the thin pool's logical size, in the ratio you expect dedup to achieve. - VDO costs memory (roughly 1 GB per TB of physical space for its index and block map, more with a larger index) and CPU on every write.
This is new in Haify; validate it on your hardware and kernel before production.
An SSD or NVMe cache in front of a thin pool (pool add-cache,
pool remove-cache) is covered in Storage tiering.
Quotas. A thin pool accepts volumes as long as it has room for the data
already written, so it can promise many times its real size. [quota]
max_overcommit_ratio caps that. A new resource, replica, volume or resize is
refused on a node when the sum of the thin volumes in that node's thin pool
(snapshots not counted) would pass that many times the pool's real size, and
auto-placement skips the node. The default, 0, means no limit.
A project is a set of resources that share a label (project=<name> by
default, set by [quota] project_label). A project's quota caps the number of
its resources and their total size. Each resource counts once, at the size
asked for, not once per replica:
[quota]
max_overcommit_ratio = 3.0
[[quota.projects]]
name = "team-a"
max_gb = 2000
max_resources = 50
haify resource create --name a1 --size 100G --label project=team-a ...
5. Resources — your storage¶
The simplest form:
haify resource create --name db --size 100G --port 7000 --nodes node1,node2
That creates a 100 GiB replicated device on two nodes, adds a diskless
tiebreaker on a third if one is available, and brings it up, Secondary on every
node. Each resource needs its own port (7000+ by convention; it must be free
on every node). Without --pool the volume goes into a pool named data-pool.
The tiebreaker is added only to a resource with exactly two diskful replicas,
never to a WAN resource, and only while [resource] auto_tiebreaker is on (the
default). With no spare node the resource is created without one and
resource list marks it ⚠quorum-risk.
Placement. Omit --nodes and Haify picks by free space:
haify resource create --name db --size 100G --port 7000 --replicas 2
haify resource create --name db --size 100G --port 7000 --replicas 3 \
--replicas-on-same zone --do-not-place-with web
--replicas-on-same zone keeps every replica in one zone (latency);
--do-not-place-with web keeps this resource off the nodes web uses
(anti-affinity); --replicas-on-different rack gives each replica a different
rack value. All three take a label key, are repeatable, and apply only when
--nodes is omitted. Placement considers only online nodes that host the
requested pool and have room for the volume in it.
Fault domains. Nodes that fail together (VMs on one physical host, servers
in one rack) should not hold two copies of the same data. Tell Haify which
nodes share a machine with a host label:
haify node label node1 host=hv1
haify node label node2 host=hv1
haify node label node3 host=hv2
Automatic placement, resource profile adjust and the CSI driver then put
replicas on different hosts first, and only then look at free space. The quorum
tiebreaker goes to a host that holds none of the replicas: a tiebreaker on the
same host as a replica fails together with it, and the surviving replica loses
quorum. add-replica uses
the node you name and does not check the label. When the cluster cannot spread
(all VMs on one machine), the resource is still created and the CLI prints a
warning. resource list flags every resource where losing one host would lose
all copies or the quorum majority: ⚠one-failure-domain(host=hv1).
The label key is [resource] fault_domain_label in controller.toml (default
host; a StorageClass sets its own with faultDomainLabel). A node without
the label counts as its own domain, so a cluster without the label places
replicas exactly as it would without this feature. --replicas-on-different host makes the spread a hard requirement.
Profiles group resources that should be alike. A resource created with
--profile, or attached to it later, is a member, and what is set on the profile
reaches every member:
haify resource profile create --name db-tier --pool thin-pool --protocol C --replicas 2 \
--replicas-on-different host --drbd-options net/max-buffers=4000
haify resource create --name db --size 100G --port 7000 --profile db-tier
haify resource set-profile legacy-db db-tier # attach an existing resource
haify resource list --profile db-tier # the members
haify resource profile get db-tier # settings and members
# one change, every member: saved on the profile, applied to each resource
haify resource profile set-options db-tier --drbd-options net/max-buffers=8000
# after raising --replicas, or for a member attached with fewer copies:
haify resource profile adjust db-tier --dry-run # what would change
haify resource profile adjust db-tier # apply options, add missing replicas
haify resource profile max-size db-tier # largest volume a new member could get
haify resource profile list
haify resource profile delete db-tier
A flag given to resource create overrides the profile's value for that
resource. adjust applies the profile's DRBD options to every member and adds
replicas where the profile asks for more, placed by its pool and label
constraints. It never removes one: a member with more copies than the
profile is reported and left alone. A profile with members cannot be deleted;
take them out first with resource set-profile <resource> --none.
DRBD options can be set at creation or changed later:
haify resource create --name db ... --drbd-options on-no-quorum=suspend-io
haify resource set-options db --drbd-options disk/c-max-rate=200M,net/max-buffers=8000
Keys take the form section/key; a bare key goes to the resource-level
options section, so on-no-quorum needs no prefix but a net or disk
option does. disk/ options go into every volume, both at creation and with
set-options. set-options rewrites the config on every node and runs
drbdadm adjust.
Labels on a resource (--label app=postgres, repeatable) are free-form tags
shown by resource list; a profile can carry default labels. Change them later
the way node labels are changed, or in the web UI under the resource's detail:
haify resource label db # show them
haify resource label db app=postgres # set one
haify resource label db app= # trailing = deletes the label
Integrations set labels of their own: haify.pve/managed-by (Proxmox VE),
haify.csi/managed-by (Kubernetes), haify.openstack/managed-by (Cinder) and
haify.libvirt/domain (KVM). The web UI uses them to find each integration's
guests.
A Kubernetes volume also carries haify.csi/pvc=<namespace>/<name>, naming its
claim. The web UI shows it in place of the kubernetes tag, alerts show it
after the resource name, and the resource search finds the volume by it.
Storage type follows the pool automatically for LVM: a thin pool gets a
thin volume without you asking for it. ZFS is the exception and must be named:
--storage-type zfs.
6. Using a resource¶
A fresh resource is a raw block device, Secondary everywhere. Promote it on one node, put a filesystem on it there and mount it:
haify resource primary db node1 # <resource> <node>
haify resource fs db 0 ext4 --node node1 # <resource> <volume-id> <fstype>
haify resource mount db 0 /mnt/db --node node1 # <resource> <volume-id> <mount-path>
Volume ids start at 0. A single-volume resource is always volume 0. fs runs
mkfs.<fstype> on /dev/drbd/by-res/<resource>/<volume-id> (without --node,
on the current Primary). mount creates the mount path and mounts the device;
it does not add an fstab entry, so the mount does not survive a reboot.
To give it back:
haify resource unmount db 0 --node node1
haify resource secondary db node1
promote/demote are the same as primary/secondary; primary --force
promotes a node whose data DRBD does not consider up to date.
For anything that should survive a node dying, do not mount it by hand. Declare it as HA instead and let drbd-reactor do the mounting. See High availability.
To see what you have:
haify resource list
haify resource get db # port, protocol, nodes, tiebreaker, volumes, profile, labels
haify resource status db # roles, disk states, replication, per node
Read status like this: exactly one node should be Primary, every node's disk
should be UpToDate, and replication should be Established. Anything else is
covered in When something is wrong.
Diskless clients let a node mount a resource without storing a copy; it reads and writes over the DRBD network:
haify resource diskless attach db node3
haify resource primary db node3 # after unmounting and demoting elsewhere
haify resource mount db 0 /mnt/db --node node3
# ... later: unmount and demote on node3, then
haify resource diskless detach db node3
This suits a compute node that needs the data but has no disks to spare. The one-Primary-at-a-time rule still applies. If you attach the node that is the resource's tiebreaker, it becomes a client and keeps its quorum vote.
Dual-primary exists for one job: live-migrating a VM, when the source and target hypervisor both hold the disk open during the hand-off.
haify resource dual-primary db on
haify resource dual-primary db off
Do not use it to share one volume between two machines: an ordinary filesystem
mounted twice is corrupted. WAN resources are refused. The setting is
runtime-only: a reboot or drbdadm adjust returns the resource to
single-primary.
Deleting a resource first removes its HA configuration and any app or gateway on it, then unmounts it, takes it down on every node, and deletes its backing volumes. It does not ask for confirmation:
haify resource delete db
There is no force option, because errors do not stop the delete. If a node
fails to take the resource down, or a backing volume cannot be removed, the
delete carries on and logs a warning in the controller log. Remove a volume
left behind by hand on its node (lvremove or zfs destroy). The delete is
refused while any snapshot of the resource is locked (see Snapshots).
7. Changing a resource's shape¶
All of these run on a live resource.
# more capacity: <resource> <volume-id> <size>. Refused when a replica's pool
# cannot hold the growth: the new area is written to every replica, and a full
# thin pool drops the disk (--ignore-free-space overrides).
haify resource resize-volume db 0 200G
# another local replica (refused if the node's pool has less free space than
# the volume: the sync writes all of it; --ignore-free-space overrides).
# On a node that is already a diskless client or the tiebreaker, the node gets
# a disk in place: whatever it has open keeps running while the disk attaches
# and resyncs from the peers.
haify resource add-replica db --node node3
# take one out; the node's volume for it is deleted
haify resource remove-replica db --node node3 --yes
# more volumes in the same resource (--volume is the backing volume's name in
# the pool); remove-volume takes the volume id
haify resource add-volume db --volume db_logs --size 50G --pool thin-pool
haify resource remove-volume db 1
# move a volume to another pool, e.g. onto new disks or from thick to thin
haify resource move-volume db --volume 0 --pool fast
move-volume rebuilds the volume in the target pool one node at a time,
secondaries first. On each node, the controller waits until the peers are
UpToDate and creates the new backing volume. It then detaches the old one,
attaches the new one, and lets DRBD resync it in full from the peers. Only then does it delete the
old backing volume and its snapshots. The resource keeps serving throughout:
while its disk is detached, a Primary reads and writes over the network. The pool must exist on every node holding a replica, and the resource
needs at least two diskful replicas. It is refused for an encrypted resource,
while one of its replicas is being moved to another node, and while a snapshot
is locked. It runs as a
job (haify pool jobs) that resumes after a controller restart. A failed job can
be started again and continues from the nodes not yet moved. The volume's
snapshots on the old pool are deleted with it, so take a backup first if you
need them.
A resource's promoters (its ha create config and its gateway) follow its
replicas. add-replica first checks that the new node could run them: the OCF
agents and services the chain starts, and the gateway's tools. If it could not,
the command refuses. Once the replica is in, the node gets a copy of each
promoter, plus the mount unit an HA config starts, so it can take over as soon
as it is UpToDate. remove-replica removes them from the node that leaves.
Nodes that already hold a promoter are never rewritten, because on the node
running the service a rewrite and reload would restart it. A WAN resource's DR
node never gets a promoter.
remove-replica is refused when the node is Primary, when it is the
tiebreaker or the off-site DR node, or when fewer than two diskful copies would
remain. It first stops the resource on the leaving node, then rewrites the
config on every node and adjusts the survivors. So every node must be
reachable; for a node that never will be, see --lost below. If a step fails,
the command stops with an error and the registry still lists the replica.
After a failed removal, check that every node's
/etc/drbd.d/<resource>.res agrees:
# on each node
md5sum /etc/drbd.d/db.res
Mismatched files mean one node has a stale view: copy the correct one over and
run drbdadm adjust db. Removal also frees the leaver's bitmap slot on the
survivors (drbdsetup forget-peer), which an add-replica later needs.
A node that is gone for good. A normal removal has to reach the leaving
node. When it never will, use --lost:
haify resource remove-replica db --node node3 --lost --yes
Nothing runs on node3. The survivors, the tiebreaker and any clients get a
config without node3, are adjusted, and forget its slot; the registry drops
it. The command is refused while node3 answers over SSH, and while any survivor
is still connected to it over DRBD (a node cut off from the controller is not
necessarily gone). It is also refused unless the survivors still hold quorum
and an UpToDate copy without node3. One remaining diskful copy is enough,
since node3's copy is already lost; add a replica afterwards. node3 keeps its volume and its old config: if it ever
comes back, run drbdadm down db there and delete
/etc/drbd.d/db.res, its haify-*-db.toml promoters and its volume before it
rejoins anything.
If the survivors lost quorum with it (two replicas and no tiebreaker), give
them one first: haify ha set-tiebreaker db <node> works with a member that is
gone (no SSH, and no survivor connected to it), skipping it. Then remove it
with --lost.
resource repair <resource> rewrites the config on every participant
(replicas, tiebreaker, diskless clients) so they agree on the volumes and the
registry's node addresses, then runs drbdadm adjust. Use it when a tiebreaker
or client of a multi-volume resource stays Connecting and its kernel log says
a packet arrived "for volume N, which is not configured locally". Repair also
puts the resource's promoters on exactly its primary-site replicas, as
add-replica does. Use it after an older version added or removed a replica,
or gave a DR node a promoter.
Adopting an existing DRBD resource that Haify did not create:
haify resource adopt legacy-vol --nodes node1,node2
Adopting reads the live /etc/drbd.d/<name>.res and records what it finds; it
never changes the resource or its data. --nodes and --port are read from
the config when omitted; --protocol defaults to C.
Tiebreakers can be moved if the node holding one is going away. The change is config-only: nothing resyncs and a promoted resource keeps serving.
haify ha set-tiebreaker db --node node4
haify ha set-tiebreaker db --remove # drop it, accepting the quorum risk
Adding a replica while a member is away. add-replica rewrites every
member's config, so it is refused when a member does not answer. With
--allow-unreachable it goes ahead on the members that answer, as long as
they are the majority. The members that were away are recorded, and they get
the new config (a resource repair) as soon as they answer again.
Moving a replica adds the new one, waits until it is UpToDate and only then removes the old one, so the resource is never a copy short. The command returns once the new replica is added. The removal follows by itself, and resumes after a controller restart or failover. A Primary is not moved: drain the node first.
haify resource move-replica db --from node2 --to node4
Rebalancing never happens on its own, because each move is a full sync (1 TB takes tens of minutes on 10GbE, hours on 1GbE). You get a plan and apply it. The plan moves replicas from the node holding the most allocated capacity to nodes that hold less, until the spread is within a tenth of the mean. It leaves out the controller's metadata, WAN resources and CSI volumes (a PersistentVolume's node affinity is fixed when it is created), and says so.
haify rebalance # the plan; nothing changes
haify rebalance --apply # run it, one move at a time
Preferred nodes set the order in which drbd-reactor tries nodes for an HA
resource: the further down the list a node is, the longer it waits. With
--policy start-only (drbd-reactor 1.9+) the order only decides where the
resource starts. With always, the resource also moves back when a more
preferred node returns, and that move is a failover in itself. The order is
only a preference; DRBD quorum is what prevents split brain.
haify ha set-preferred db --nodes node1,node2 --policy start-only
Self-healing¶
A node that dies leaves every resource it held a copy of one replica short.
With [self_heal] auto_evict = "on" the controller replaces those replicas
itself. With "dry-run" it makes the same decisions but only reports what it
would do (node.evicted events). It acts only when all of these hold:
- the node has been offline for
after_minutes(default 60; the time is kept across controller failovers, andhaify node listshows it); - at most
max_offline_percent(default 34) of the nodes are offline, and the controller reaches a majority (otherwise it may be the one cut off); - for each resource, the
--lostguard above: the node does not answer over SSH, no surviving member is connected to it over DRBD, and the survivors hold quorum and an UpToDate copy; - the node is not drained and not labelled
haify.io/auto-evict=false, and the resource is not the controller's own metadata, not WAN-replicated and not labelledhaify.io/auto-evict=false.
It replaces one replica at a time and waits for each sync before the next. The new replica goes where placement would put it. Once the node holds no replica, it is evicted: it gets no new replicas, and if it comes back it still holds its old configs and volumes. Then:
haify node restore node3 --dry-run # what would be deleted on it
haify node restore node3 # delete it, and let it take replicas again
haify node lost node3 # or: it is not coming back
node restore works on that node only. It takes down every resource the node
is no longer a member of, and deletes the resource's config, its promoter
configs and the volumes Haify named after it. node lost removes every replica the node still holds the --lost
way and keeps it evicted. Both are on the two-person approval list.
8. Snapshots¶
Snapshots live in the same pool as the resource. They are instant and cheap on a thin pool, and they are not a backup: losing the pool loses both.
haify resource snapshot create --resource db --name before-upgrade
haify resource snapshot list --resource db
haify resource snapshot restore --resource db --name before-upgrade
haify resource snapshot delete --resource db --name before-upgrade
A manual snapshot covers volume 0's backing volume on one node (--node,
by default the resource's first replica), in the resource's own pool (--pool
overrides). Use the same --node for list, restore and
delete. On a thin volume it is a thin snapshot; on a thick one it reserves a
copy-on-write area of --size (default 1G). A ZFS resource needs
--storage-type zfs on each of these commands.
restore is refused while the resource is Primary on any node, so unmount and
demote it first. Stop a gateway with gateway stop. For an ha create
resource, run ha delete before the unmount, or drbd-reactor promotes it
again. The restore takes the resource down on every node and merges the
snapshot back on its node. Every other replica then resyncs that volume from
it, and until the resync finishes there is only one complete copy.
Snapshots of the whole resource. snapshot create snapshots one node.
A replicated snapshot covers every volume on every diskful replica at once.
I/O is suspended on all of them for the moment it takes. A systemd timer on
each node resumes I/O after a minute, whatever happens to the controller:
haify resource snapshot replicated create --resource db --name before-upgrade
haify resource snapshot replicated list --resource db
haify resource snapshot replicated rollback --resource db --name before-upgrade
haify resource snapshot replicated delete --resource db --name before-upgrade
Each copy carries its replica's DRBD metadata, so a rollback restores every
replica together and resyncs nothing. A rollback needs the resource to be
Secondary everywhere. The backing snapshots are named <backing>_snap_<name>
(on ZFS, <dataset>@snap_<name>). The Proxmox plugin takes VM snapshots this
way. A replicated snapshot refuses an encrypted resource: snapshot its volumes
one by one instead.
Exact sizes and renaming. resource create --size 10737418752 --exact-size
makes the device exactly that many bytes (rounded up to a 512-byte sector)
instead of whole GiB; the backing volume is still allocated in GiB and the
DRBD device capped. A resize keeps it exact. haify resource rename <old> <new>
renames a resource that is not Primary anywhere and that no HA config,
gateway, schedule, backup or snapshot refers to. WAN-replicated and encrypted
resources cannot be renamed.
Scheduled snapshots with grandfather-father-son retention:
haify resource snapshot schedule create --resource db \
--cron "0 * * * *" \
--keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 6
haify resource snapshot schedule list
haify resource snapshot schedule delete --resource db
A resource has at most one schedule. Running create on a resource that
already has one replaces it (--disabled saves it without running it). Each run snapshots
every volume of the resource on every diskful node, named
<backing-volume>_sched_<UTC timestamp>.
The cron field is standard 5-field syntax. Retention is applied after each run,
and only to that node's scheduled snapshots of that volume. Manual snapshots
are never touched. Each tier keeps the newest snapshot in each of its most recent
periods: --keep-hourly 24 keeps one per hour for the last 24 hours that have
one, --keep-daily 7 one per day, and so on through --keep-yearly. A
snapshot any tier keeps survives. A policy of all zeros keeps everything.
Deleting a schedule keeps the snapshots it already made.
Retention counts snapshots; it does not look at the pool. On a thin pool each
snapshot holds the blocks written since it was taken, so a busy volume's
history can fill the pool while staying inside its policy. A full pool makes
the replica's own writes fail. So after retention, if the pool is still past
85% (data or metadata), the scheduler removes that volume's oldest scheduled
snapshots on that node, one at a time, until the pool is below 85%. It always
keeps the newest two. It logs each one it removes and raises a pool.snapshots_removed
event.
Locked snapshots. The near-full rule removes the oldest snapshots. A volume being encrypted by ransomware rewrites every block, so it fills a thin pool fast, and the oldest snapshots are the clean ones from before the attack. A schedule can lock what it takes:
haify resource snapshot schedule create --resource db \
--cron "0 * * * *" --keep-hourly 24 --keep-daily 7 --lock-days 14
Until a scheduled snapshot is --lock-days old (counted from the time in its
name), Haify does not delete it. That covers retention, the near-full rule,
snapshot delete, and a ZFS restore that would roll back past it. While
any snapshot of the resource is locked, the schedule cannot be deleted, the
resource and its volumes cannot be deleted, a replica cannot be removed (except
with --lost) or moved to another node, and the lock can be raised but not
lowered. Replacing the schedule without
--lock-days keeps its lock. Restoring a locked LVM thin snapshot merges it
and takes it again under the same name, so its lock is unchanged; a locked
thick snapshot cannot be restored until its lock passes. The limit is 365 days.
On ZFS, each locked snapshot also carries a haify-lock hold, so zfs destroy
on the node (a cleanup script, a zfs destroy -r of the dataset) fails too
until Haify releases the hold when the lock has passed. Root can zfs release
it, so the hold guards against mistakes, not against root.
Locks use the time the controller has counted since it started, not the
system clock. Moving the clock forward (a date -s, a spoofed NTP answer) to
end the locks early has no effect, and raises a
controller.clock_jumped event.
The cost is space: size the pool for --lock-days of change. If a pool is past
the near-full line and only locked snapshots are left, nothing is removed. A
critical pool.snapshots_locked event is raised, and if the pool fills, that
replica's writes fail. The lock also binds only Haify and its API. Root on a
storage node can still lvremove anything. To protect against root, back up to
a target with S3 Object Lock (see Backups).
Freezing a schedule. A frozen schedule keeps taking snapshots but removes
none, either by retention or to relieve a full pool. Until the freeze ends,
every scheduled snapshot of the resource is locked: through Haify, neither the
snapshots nor the schedule nor the resource can be deleted. A freeze can be
extended but not shortened. Only unfreeze ends it early, and that needs a
second person under two-person approval.
haify resource snapshot schedule freeze --resource db --hours 72 --reason "investigating"
haify resource snapshot schedule unfreeze --resource db
Write anomalies. Ransomware that encrypts a volume rewrites it as fast as
the disks allow. With [alert] enabled, the health poll reads how much DRBD
wrote to the answering node's disk (also exported as
haify_drbd_written_bytes). Each resource learns its usual write rate, overall
and by hour of the week; resync traffic is not counted. After 30 normal polls
it has learned enough. From then on, if the write rate is at least
[alert.write_anomaly] factor times the usual (default 5) and at least
min_mbps (default 20 MB/s) on two polls in a row, Haify raises a critical
resource.write_anomaly, freezes the resource's schedule for freeze_hours
(default a week) and takes a snapshot right away. The event resolves after
three normal polls. The freeze stays until it ends or is lifted.
It reports a rate, not an attack: a bulk import, a reindex or a restore look
the same, and unfreeze is there for those cases. Encryption slowed down to
look normal does not trip it. The resource needs a snapshot schedule for there
to be anything to freeze.
Schedules live in the controller database and survive a restart or a failover; the node that becomes active picks them up.
9. Backups — the only copy that survives losing the cluster¶
A backup is a compressed image of a resource, stored outside the cluster. Targets are S3-compatible object stores, SMB shares, or WebDAV.
The node that reads a backup uses rclone to talk to the target, so install it
on every storage node (apt install rclone). A node without it is refused
before anything is snapshotted.
Define a target. The secret is never a command-line flag, because it would land in your shell history:
export HAIFY_BACKUP_SECRET='...'
haify backup target add --name offsite --kind s3 \
--bucket haify-backups --endpoint https://s3.example.com --user AKIAEXAMPLE
haify backup target add --name nas --kind smb \
--host nas.example.com --share backups --user backupuser --secret-file -
haify backup target list # secrets are never returned
An SMB host may carry a non-standard port (--host nas.example.com:4450).
Take and restore a backup:
haify backup create --resource db --target offsite
haify backup list
haify backup restore <backup-id> --node node1
haify backup delete <backup-id>
Incremental after the first. The first backup of a resource to a target is
a full image. On thin pools every later one carries only the blocks that changed
since the previous one: the thin pool's own metadata says which (thin_delta),
so nothing is read or hashed to find out. A 1 GiB volume, for example, takes
20 s and 161 MB as a full backup, and 4.5 s and 26 MB for the next one after
26 MB of changes.
haify backup create --resource db --target offsite # incremental when it can be
haify backup create --resource db --target offsite --full # start a new chain
A backup is full instead, and the controller log says why, when:
- there is no earlier completed backup on that target;
- the backup is read on a different node than the last one;
- a volume was added, resized or moved to another backing volume since then;
- the base snapshot is gone;
- the volume is thick; or
- 30 incrementals already follow the last full one.
What an incremental costs:
- A base snapshot stays on the node. The snapshot of the last backup is kept
(named
<volume>_bk_<time>) and holds whatever the volume has overwritten since, the same space a scheduled snapshot of that age would hold. It moves forward with each backup and goes when its backup is deleted. - A chain restores as a whole. Restoring an incremental writes its full backup and then every incremental after it, oldest first. None of them can be deleted while a later one exists; delete newest first.
Which replica is read. Without --node, Haify picks the node holding the
last backup's base snapshot, if it is UpToDate. Otherwise it picks an UpToDate
Secondary, so the cost of the snapshot lands away from the workload. It picks
the Primary only when no Secondary is UpToDate. It never picks the DR replica
of a WAN resource, which may be behind. A thick volume's snapshot reserves free
space in its volume group up front: 20% of the volume, at least 256 MiB. A
replica whose group lacks that room is passed over for one that has it.
Scheduled backups. Each resource can have one schedule per target. The
active controller runs it, and it carries on after a controller failover. Each
backup records the schedule that took it (schedule= in backup list, and in
its manifest, so the link survives an import). Retention counts only the
schedule's own backups, as snapshot schedules do, and never deletes a backup
taken by hand. One more rule: an incremental that is kept also keeps every
backup back to its full one, whoever took it. So a daily schedule that keeps 7
can hold up to a month of backups before the chain restarts with a full one. A
schedule must keep at least one tier; --disabled saves it without running
it.
haify backup schedule create --resource db --target offsite \
--cron "30 18 * * *" --keep-daily 7 --keep-weekly 4 --keep-monthly 3
haify backup schedule list # last run, the backup it made or why it failed, next run
haify backup schedule run db@offsite # run now, retention included, and wait
haify backup schedule delete db@offsite # its backups stay
The cron is in the controller's time zone, usually UTC on a server. If a run
is still going when the next one is due, that next run is skipped. A failed run raises a
backup.failed event, delivered like any alert (it needs [alert] enabled),
and resolves with the next completed run. Failed records older than the newest
completed backup are removed by the schedule. Backup schedules run on the
snapshot scheduler, so [schedule] enabled = false in controller.toml stops
them too.
When the controller's database is gone, or the backups belong to another
cluster. Each backup leaves a manifest.json next to its images on the
target. backup import reads them and rebuilds the records, incremental chains
included:
haify node register ... # a rebuilt cluster: nodes first
haify backup target add --name offsite ... # same bucket and --prefix as before
haify backup import --target offsite
haify resource create --name db --size 20G ... # at least as large as the backup
haify backup restore <backup-id> --resource db
Importing twice does not create duplicate records. A backup whose images are not all on the target is skipped, and the import names it. The next backup after an import is full unless the newest backup's base snapshot is still on that node of this cluster. The images are raw block data, so a backup restores onto another architecture or pool type: a chain taken from an arm64 node's thin pool restores onto a thick LVM volume on x86.
Immutable backups: S3 Object Lock. Whoever controls the cluster (with a stolen Haify token, or root on a storage node) can delete ordinary backups, and ransomware does that first. A locked target stores every object under S3 Object Lock. Until the lock expires, neither Haify nor anyone using Haify's keys can delete or overwrite it:
haify backup target add --name vault --kind s3 --bucket haify-vault \
--endpoint https://s3.example.com --user AKIAEXAMPLE \
--lock-mode compliance --lock-days 30
- The bucket must be created with Object Lock enabled. This turns on versioning, and on most servers it cannot be added later. AWS S3, MinIO, Ceph RGW, Backblaze B2 and Wasabi support it. Some S3-compatible servers accept the lock headers and ignore them, so every backup reads each object's lock back and is failed if the server stored it unlocked.
- rclone 1.74.0 or later is needed on the nodes. Older versions upload objects unlocked, so with them the backup is refused before anything is snapshotted.
- Modes.
governancecan be lifted by a principal holdings3:BypassGovernanceRetention;complianceby no one, the bucket owner included, until it expires. - Chains. A lock cannot be extended once written, and an incremental needs
every backup back to its full one. So every backup in a chain is locked until
chain start +
--full-every-days(default 7) +--lock-days. A backup taken more than--full-every-daysafter its chain started is a full one. Each backup is locked for at least--lock-days. Plan storage for up to--full-every-days+--lock-daysof backups. - Deleting and retention.
backup deleterefuses a locked backup;--forcedrops only the record and leaves the objects. A schedule's retention leaves locked backups for a later run. After the lock has expired, a delete only puts a delete marker over the object. To get the space back, add a lifecycle rule that expires noncurrent versions (say 7 days after they become noncurrent). - Restores read what was written. A restore reads a locked backup as it
was one hour after the backup finished (
--s3-version-at). A version written over it later, or a delete marker on top, does not change what the restore sees. - After the backups were deleted anyway. On a versioned bucket a delete
only hides an object.
backup import --as-of <RFC3339>reads the target as it was at that time and records what it finds. Restores then read those versions:
haify backup import --target vault --as-of 2026-10-01T00:00:00Z
Object Lock holds only if the keys are restricted. Haify's S3 credentials are on the storage nodes, so assume an attacker has them. They need only:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:GetObject",
"s3:GetObjectVersion", "s3:GetObjectRetention", "s3:DeleteObject",
"s3:ListBucket", "s3:ListBucketVersions"],
"Resource": ["arn:aws:s3:::haify-vault", "arn:aws:s3:::haify-vault/*"]
}]
}
The keys must not hold s3:BypassGovernanceRetention, s3:DeleteObjectVersion,
s3:PutBucketObjectLockConfiguration, s3:PutBucketVersioning or
s3:PutLifecycleConfiguration: any one of them lets the key holder get around the lock.
s3:DeleteObject only adds delete markers on a versioned bucket. What each
layer stops:
| Attacker holds | Object Lock (compliance) | Object Lock (governance) |
|---|---|---|
| a Haify token or admin account | stopped | stopped |
| root on a storage node (and so Haify's S3 keys) | stopped | stopped, if the keys lack the bypass permission |
| root on the controller | stopped | same as above |
| the object store account itself | stopped | not stopped |
Know these limits before you build a backup policy:
- Only a backup listed as
completedcan be restored.runningmeans it is still uploading.failedmeans it is not a usable copy, and its objects were removed. Haify checks completion against the size the target reports, not just the exit code. When the controller restarts, it marks anyrunningbackupfailed. - LVM-backed volumes only. A resource on a ZFS pool is refused.
- Restore overwrites from byte zero. It refuses when the resource is Primary anywhere, when it has a drbd-reactor promoter (a gateway, an HA config or an app), or when the destination is smaller than the image. It promotes the resource on the node it writes through, and demotes it again when done.
- A target that backups still reference cannot be deleted without
--force;backup delete --forcedrops a record whose objects can no longer be removed.
Copying a resource to another cluster¶
resource ship copies a resource straight into another Haify cluster, with no
backup target in between: to seed a second site, move a workload, or keep a
copy somewhere the first cluster's failure cannot reach.
haify resource ship db --to 10.1.0.10:3374 --token-file ~/.haify/dr-token
haify resource ship db --to 10.1.0.10:3374 --as db-copy --pool vg0 --replicas 2
haify pool jobs # progress, then done or failed
Every volume is snapshotted at the same moment on one replica, so the copy is crash-consistent. The other cluster creates the resource. Each volume then streams from a node here to a node there over TLS, with keys made for that one transfer. Both ends checksum the bytes. If the checksums differ, or anything fails, the copy on the other cluster is deleted. The resource here keeps serving throughout.
- The other cluster's nodes must accept connections from this cluster's nodes, on a port chosen for each transfer.
- The transfer runs
haifyon one node of each cluster. Each controller uses the node's own/usr/local/bin/haifyor/usr/bin/haifywhen it can ship. When the node has neither, the controller installs its own copy at/opt/haify/bin/haify. That copy comes from besidehaify-controller, from/usr/local/bin/haifyor from/usr/bin/haifyon the controller's host. A node of another architecture needs a build for it besidehaify-controller, namedhaify-<goarch>(for example/opt/haify/bin/haify-arm64). Without one, the ship stops and names the node and what to install there. - LVM-backed, unencrypted resources only. Encrypted resources hold ciphertext that the other cluster's keys cannot read; back them up and restore them there instead.
- The name must be free on the other cluster. A job interrupted by a controller restart is marked failed; delete the partial copy there and ship again.
10. Gateways — exporting to clients¶
A gateway turns a resource into something a non-Haify machine can mount: NFS, SMB, iSCSI or NVMe-oF. It is a drbd-reactor promoter config, so it fails over with the resource while clients keep talking to a floating service IP.
# NFS
haify gateway nfs create --resource data --service-ip 192.0.2.200/24 \
--export-path /data --allowed-ips 192.0.2.0/24
# iSCSI
haify gateway iscsi create --resource blk \
--iqn iqn.2026-01.com.example:haify.blk --service-ip 192.0.2.201/24
# NVMe-oF
haify gateway nvme create --resource fast \
--nqn nqn.2026-01.com.example:haify.fast --service-ip 192.0.2.202/24
Creating a gateway does four things. It checks that the OCF agents and tools
the chain needs are installed on the resource's diskful nodes (see below). It
formats the state volume (and, for NFS, the exported volume) if it has no
filesystem. It writes the promoter config
(/etc/drbd-reactor.d/haify-<type>-<resource>.toml) to the resource's diskful
nodes only. Then it reloads drbd-reactor there. A gateway needs a small
cluster-private state volume (volume 0) next to the exported data. A
single-volume resource gets one added automatically ([gateway]
auto_state_volume, default on, state_volume_size_gb default 1).
Managing a live gateway:
haify gateway list # or: gateway nfs|iscsi|nvme list; read from the nodes
haify gateway get --resource data
haify gateway status --resource data
haify gateway stop --resource data # demote and stop, config kept
haify gateway start --resource data
haify gateway delete --resource data
Per-protocol details:
haify gateway nfs export add|list|remove ... # extra exports on an NFS gateway
haify gateway nfs export quota --resource data --path team-a --size 500G
# cap an export directory (0 removes)
haify gateway nfs mount --resource data --target /mnt/data --mkdir --sudo
# mount it on this machine
haify gateway iscsi lun add|list|remove ... # LUNs
haify gateway iscsi chap get|set ... # one-way CHAP only; mutual CHAP is not supported
haify gateway iscsi initiator add|list|remove ... # initiator allow-list
haify gateway nvme namespace add|list|remove ... # namespaces
haify gateway nvme host add|list|remove ... # host allow-list
An export directory's quota is an ext4 project quota on the gateway's
filesystem, so one export cannot fill the volume for the others. New gateways
support it: the filesystem is made with the project feature and mounted with
prjquota. For an older gateway, add the feature while the filesystem is
unmounted (tune2fs -O quota,project <device>) and recreate the gateway. The gateway nodes need the quota package (setquota).
Gateways cannot limit a client's IOPS or bandwidth. Their I/O is done by kernel
threads in the root cgroup, which has no I/O limits, and neither LIO nor nvmet
has per-LUN limits. If you need a limit, shape traffic to the service IP with
tc on the gateway nodes. DRBD's c-max-rate limits resync traffic only; it is not QoS.
These edits read the gateway's promoter config from the resource's diskful nodes (not from the machine the controller runs on) and write the result back to all of them. If the nodes hold different copies, Haify uses the copy most of them hold; a tie goes to the first host in sorted order. The controller log names the nodes that differ, and the write makes all copies the same again.
When an edit takes effect.
- Stopped gateway: the edit goes into the
.toml.disabledcopy and takes effect at the nextgateway start. Nothing on the nodes is touched and the gateway stays stopped. - Running gateway: the edit takes effect immediately on the node that runs it, without restarting the gateway. The other diskful nodes are ready to run the edited gateway on failover. Per edit:
| Edit | Applied on the running node by | Clients |
|---|---|---|
iscsi initiator add |
targetcli … acls create <iqn> add_mapped_luns=true (all LUNs mapped) |
new initiator can log in at once |
iscsi initiator remove |
targetcli … acls delete <iqn> |
that initiator's sessions are closed; others unaffected |
iscsi chap set |
targetcli … set auth on every ACL (or the TPG when all initiators are allowed) |
applies at the next login; existing sessions stay |
iscsi lun add / remove |
starts / stops just that LUN's ocf.rs@lu<N>_<res> unit |
other LUNs unaffected |
nvme host add / remove |
links / unlinks the host in the subsystem's allowed_hosts (configfs) |
a removed host keeps an existing connection until it reconnects |
nvme namespace add / remove |
starts / stops just that namespace's unit | other namespaces unaffected |
nfs export add / remove |
starts / stops just that export's exportfs unit |
other exports unaffected |
Removing the last initiator or host makes the target accept every initiator again, also live: an empty allow-list means "allow all". A change that cannot be applied without restarting the gateway (for example, removing CHAP) is refused before anything is written. Stop the gateway, make the edit, and start it again. - Running nowhere (enabled, but no node has it up): the config is written and drbd-reactor is reloaded, which starts the gateway with the edit.
Why the running node is handled this way: drbd-reactor cannot apply a changed
promoter config to a running gateway. On systemctl reload drbd-reactor it
stops the old promoter and starts a new one. Haify writes its promoters with
stop-services-on-exit = true, so a reload with a changed config stops the
whole gateway on that node, and every node then races to promote it again.
So Haify never reloads drbd-reactor for an edit on the node running the
gateway. That node keeps the .toml its drbd-reactor loaded, and gets the
edited config as haify-<type>-<res>.toml.pending, which drbd-reactor ignores.
Haify rewrites the unit drop-ins in /run/systemd/system to the edited chain,
so a unit restart or a later failback uses it. A drop-in on
drbd-reactor.service (50-haify-pending-gateway-config.conf) moves the
.pending file into place before drbd-reactor next starts. The next edit made
while another node runs the gateway also replaces it. Do not run
systemctl reload drbd-reactor on the running node by hand to "pick up" a
gateway edit: it restarts the gateway. If the live step fails, the command
says so. The config is then saved everywhere and a failover uses it. To apply
it now, run haify gateway stop then haify gateway start (this interrupts
clients).
Haify checks target and initiator IQNs (at iscsi create and initiator add)
and host NQNs before anything is written, using the rules LIO and nvmet
apply. An iSCSI name must be iqn.<yyyy-mm>.<domain with at
least two labels>[:<name>] without spaces or _ (iqn.2026-10.test:probe is
refused, iqn.2026-10.lab.test:probe is accepted), or eui. + 16 hex digits,
or naa. + 16 hex digits starting with 1, 2 or 5. A host NQN must be
nqn.<yyyy-mm>.<reversed domain>[:<name>] or
nqn.2014-08.org.nvmexpress:uuid:<uuid>. Without this check, a name LIO
rejects would be accepted here and then fail the target's next start, leaving
the gateway down.
iscsi create also takes --allowed-initiators, --username/--password for
CHAP, and --implementation lio (the default and the only one supported; tgt
and iet are refused). nvme create takes --transport tcp (default) or
rdma; nvmet and the transport's module (nvmet-tcp or nvmet-rdma) are
loaded on the nodes at creation and added to
/etc/modules-load.d/nvmet.conf so they load at boot. rdma also requires an RDMA device under
/sys/class/infiniband.
What clients need. Windows has a built-in iSCSI initiator and a limited NFSv3 client. macOS has a built-in NFS client and no iSCSI initiator. Both have SMB built in (see SMB below). Pick the protocol by what the client can mount.
What creation checks. Every gateway needs the OCF agents from
resource-agents-extra (Ubuntu) or resource-agents (Debian, EL). Each type
also needs:
| Gateway | Checked | Install |
|---|---|---|
| NFS | rpc.nfsd, exportfs |
nfs-kernel-server (Debian/Ubuntu), nfs-utils (EL) |
| iSCSI | targetcli |
targetcli-fb (Debian/Ubuntu), targetcli (EL) |
| NVMe-oF | kernel modules, loaded at creation | linux-modules-extra on Ubuntu cloud kernels |
| SMB | smbd, smbpasswd, smbcontrol, pdbedit; the distribution's smbd not running |
samba |
If anything is missing, creation fails and lists what is missing and what to install. No config is written.
SMB gateways¶
A workgroup SMB server per resource: standalone Samba, local users, no domain.
haify gateway smb create --resource files --service-ip 192.0.2.210/24 [--workgroup OFFICE]
haify gateway smb user set alice --resource files # prompts; or --password-stdin
haify gateway smb share add public --resource files --path public
haify gateway smb share list --resource files
haify gateway smb user list --resource files
Clients connect to \\192.0.2.210\files (the first share is named after the
resource unless --share says otherwise, and covers the whole data volume).
Shares inside shares. Samba checks --valid-users per share, not per
directory. If a share restricted to alice sits inside a share that lets bob in,
bob can reach it through the outer share. share add refuses that. The first
share covers the whole volume and lets in every user unless it was created
with --valid-users. So to give a group a share of its own, either create the
gateway with --valid-users on the first share, or remove the first share and
add shares side by side:
haify gateway smb share remove files --resource files
haify gateway smb share add public --resource files --path public
haify gateway smb share add projects --resource files --path projects --valid-users alice,bob
share add checks only the share being added. Shares that were nested before
this check existed are left as they are, so compare the paths and users in
share list.
How it is built, and what that means:
- Everything Samba keeps is on the gateway's state volume:
smb.conf, the shares (shares.conf), the user database and secrets, the lock and state directories. So after a failover the next node serves the same users and shares. Share and user changes go to the node serving the gateway and apply immediately (smbcontrol reload-config). A stopped gateway refuses them until it is started. - Sessions do not survive a failover. Without CTDB, Samba has no transparent failover. Clients reconnect to the service IP (Windows and macOS do so on their own). An application that holds a file open during the switch sees the same error as after a server restart. Writes acknowledged before the switch are on both replicas (protocol C).
- One smbd per gateway,
haify-smbd@<resource>.service, bound to the service IP only, so several SMB gateways can run on one node. The distribution's ownsmbdmust not run on gateway nodes, because it holds port 445 on every address. Creation refuses while it runs; stop it withsystemctl disable --now smbd nmbd. - Files are owned by one account,
haify-smb, on every share. Access is set per share (--valid-users,--read-only), not by per-user Unix permissions. Each SMB user also gets a local account, because Samba requires one. The unit creates it with the same uid on every node before smbd starts. A node where that uid is taken refuses to start the gateway and says why. - Users cannot be added through MCP, because a password typed into a model
conversation is stored in its transcript. Use the CLI or the web UI. The
password crosses the API the way a CHAP secret does, so use
[tls]. - Removing a share keeps its data; deleting the gateway keeps the volume.
11. High availability¶
ha create declares what should run wherever the resource is Primary.
drbd-reactor then picks a node, mounts the filesystem, starts the services,
raises the VIP, and moves all of it if that node dies. The VIP comes up last,
so clients only reach a node whose services are up, and it is the first thing
taken down. ha create sets net.ipv4.ip_nonlocal_bind on the nodes, so a
service that binds to the VIP itself can start before the VIP exists. (Older
configs put the VIP ahead of the services, and keep that order until they are
recreated.)
haify ha create db \
--vip 192.0.2.210/24 \
--mount /var/lib/postgresql \
--fstype ext4 \
--services postgresql.service
haify ha list
haify ha status db
haify ha delete db
ha status shows the chain on the node running it, one line per unit. Each
line gives what the unit is (promote, mount, service IP, NFS/iSCSI/NVMe-oF/SMB,
database, VM, service), its state, since when, and how often systemd restarted
it. Next it lists every other node holding the promoter and whether that node
could take over now. If it could not, it gives the reason: a unit or OCF agent
not installed there, drbd-reactor not running, or a local disk that is not
UpToDate. Last come the services outside the chain that have processes on the
chain's mounts, and a count of login sessions doing the same. On a failover,
these hold up the unmount. It works for gateway and app resources too.
ha create makes a filesystem of --fstype on the device when it finds none,
and refuses when a --services unit is missing on any of the resource's nodes.
--vm <guest> runs a libvirt guest instead of services. Define the guest
under that name, with the same UUID, on every diskful replica, with autostart
off and its disks on /dev/drbd/by-res/<resource>/<volume>. When the node
running it fails, another replica restarts it. When you start, stop or
live-migrate guests by hand with virsh, the libvirt hook handles the DRBD
side. Both are described in deploy/libvirt/README.md.
haify ha create web1 --vm web1
ha create and the gateways put a drbd-reactor promoter on the resource. They
refuse a resource whose Primary is decided by something else, because the
promoter would fight it for the role. That means a Proxmox VM disk (labelled haify.pve/managed-by=pve
by the plugin, or named pve-<vmid>-... from before it labelled them), a CSI
volume (haify.csi/managed-by=csi), an OpenStack Cinder volume, an XCP-ng
disk, and a resource with diskless clients.
To move it deliberately, for maintenance or to test that failover works:
haify ha evict db
Test your failover before you need it. Evicting moves the resource gracefully. Pulling the power on the active node shows what a real failure does.
The controller's own HA¶
The controller is a single process. Self-HA puts it on the same machinery as everything else: its database lives on a replicated resource, and a VIP follows whichever node is running it.
haify ha self enable --vip 192.0.2.250/24 --pool thin-pool
haify ha self status
haify ha self disable --node node1 # back to a plain service on node1
enable creates the haify-meta resource (1 GB on DRBD port 7999 by default;
--size, --port, --nodes change that). It copies the running controller
binary, its config and its systemd unit to the other nodes, and hands the
controller to drbd-reactor. The binary goes to the path the unit's ExecStart
names. A node with a different architecture gets haify-controller-<goarch>
from the same directory as the running binary instead. If that file is
missing, enable refuses that node before changing anything. The controller
restarts during the handoff, so the command's connection drops; check the
result with ha self status against the VIP. disable copies the database
back to the named node and leaves haify-meta in place for you to delete. haify ha evict haify-meta moves the controller to
another node.
Point clients at the VIP afterwards. Other services can move with the
controller: [self_ha] extra_services = ["haify-ai.service"] in controller.toml
starts and stops the AI Copilot with it.
12. Database and object-store applications¶
haify app runs one database instance (PostgreSQL, optionally with pgvector;
MySQL or MariaDB; or Redis) or an S3-compatible object store (RustFS) on a
resource's DRBD volume, the way ha create
runs a service and a gateway runs an export. drbd-reactor mounts the volume,
starts the database and raises a service IP on the node where the resource is
Primary; when that node fails, another replica does the same. It is storage
failover, not database replication: there is one database, its files are on
DRBD, and clients find it at the service IP wherever it runs.
# A resource with two diskful replicas (give it quorum: a third replica or a tiebreaker)
haify resource create --name orders --port 7010 --size 20G --nodes node1,node2 --pool pool0
haify app create --name orders --engine postgres --service-ip 192.0.2.220/24
haify app create --name embeddings --engine postgres --vector --resource emb --service-ip 192.0.2.221/24
haify app create --name sessions --engine redis --port 6380 --service-ip 192.0.2.222/24
haify app create --name shop --engine mysql --service-ip 192.0.2.223/24
haify app create --name objects --engine rustfs --service-ip 192.0.2.224/24
haify app list
haify app status orders
haify app failover orders # planned switchover
haify app snapshot orders --snapshot before-upgrade
haify app delete orders # stops it; the resource and data stay
haify app delete orders --delete-data --yes # and deletes the resource
--resource defaults to the app's name; --port to 5432, 3306, 6379 or 9000.
create prints the generated password once.
What create does¶
- Refuses before touching any node when the resource does not exist, has
fewer than two diskful replicas, or already has a promoter: an HA config, a
gateway or another app (on the nodes or in the controller's records). One
resource runs one promoter;
ha createand the gateways likewise refuse a resource that runs an app. - Checks every diskful replica: the OCF
FilesystemandIPaddr2agents, the engine's programs,fsfreeze,runuser,setprivandsystemd-run, and that the port is free. It then compares the replicas: the engine must be installed in the same place and the same version (PostgreSQL major; MySQL, MariaDB and Redis major.minor), and the daemon user must have the same uid and gid on every node. The data files belong to numeric ids, so on a node wherepostgreshas another uid the database would not start after a failover. Every problem is reported at once, naming the nodes and ids. - Initializes once on the node already Primary, or the first replica,
promoted for the purpose: formats the volume ext4 if
blkidfinds nothing on it (and refuses one carrying another filesystem or a partition table), mounts it, runsinitdb/mariadb-install-db/mysqld --initialize-insecureor writesredis.conf, sets the password, starts the database once without network access to prove it runs, stops it and unmounts. - Hands it to drbd-reactor: demotes the resource, writes
haify-app-<name>.serviceand the promoter/etc/drbd-reactor.d/haify-app-<name>.tomlto every diskful replica, and reloads drbd-reactor, which starts it. The chain starts the mount, then the database, and the service IP last. The database unit counts as started only once the database answers its health probe (it waits up to 300 seconds, crash recovery included). So clients reach a node only when the database is up there, and on the way out they lose the address first.
A volume that already holds the same engine's app (one deleted without
--delete-data) is kept as it is, data and credentials; create says so and
prints no password. Anything else on the volume is refused.
| Engine | Install on every diskful node | Runs as | Health probe | Freeze for a snapshot |
|---|---|---|---|---|
postgres |
postgresql (same major); with --vector also postgresql-<major>-pgvector (Debian/Ubuntu) or pgvector_<major> (PGDG) |
postgres |
pg_isready on the socket |
CHECKPOINT, then fsfreeze |
mysql |
mariadb-server or mysql-server (same version) |
mysql |
mysqladmin ping |
FLUSH TABLES WITH READ LOCK held while fsfreeze runs |
redis |
redis-server (Debian/Ubuntu) or redis (EL) |
redis |
redis-cli PING |
BGSAVE, wait, then fsfreeze |
The OCF agents come from resource-agents-extra (Ubuntu) or
resource-agents (Debian, EL). The distributions start their own instance of the
engine on install: stop and disable it (postgresql, mariadb/mysql,
redis-server), or create refuses the busy port.
Where things live¶
Everything the database needs to start lives on the volume, so it fails over with the data:
/var/lib/haify-app/<name>/ the volume, mounted on the node running the app
data/ the data directory
conf/ postgresql.haify.conf + pg_hba.conf, my.cnf, or redis.conf
haify/password the generated password (root only, 0600)
haify/client.cnf mysql: credentials for the health probe and the snapshot lock
/etc/systemd/system/haify-app-<name>.service on every replica; never enable or start it by hand
/etc/drbd-reactor.d/haify-app-<name>.toml the promoter, on every replica
/run/haify-app-<name>/ sockets and pid files, while it runs
Tune the engine in its config on the volume, on the node running it. For
PostgreSQL, edit data/postgresql.conf; the few settings Haify relies on (port,
listen address, socket directory, hba_file) are in conf/postgresql.haify.conf,
included last. Apply a change the engine's own way (SELECT pg_reload_conf(),
SET GLOBAL, CONFIG SET) or with haify app failover, which restarts it on
another replica. Do not restart the unit by hand: drbd-reactor treats a stopped
unit as a failure and fails the app over.
Credentials. PostgreSQL's postgres superuser, MySQL's root (local and
'root'@'%') and Redis's requirepass get a generated 32-character password.
haify app create prints it once; afterwards it exists only in
/var/lib/haify-app/<name>/haify/password, readable by root on the node running the
app. It never appears in a command line or a log: it reaches the node over the
SSH stream as a 0600 file. The MCP tool haify_app_create does not return it.
S3 object store (RustFS)¶
--engine rustfs runs RustFS on the volume
as a single-node, single-disk store. Its redundancy comes from the resource's
DRBD replicas, not from erasure coding. Use it for an S3 endpoint that has to
stay up (application uploads, backups of other systems, artifacts), not for
capacity that grows past one volume: the store is as large as the resource and
as fast as the node serving it. To grow it, grow the volume with haify
resource resize-volume, then, on the node serving it, resize2fs /dev/drbd/by-res/<resource>/0 (ext4
grows while mounted).
- On every diskful replica: the
rustfsbinary on thePATHin the same version (the musl build from the RustFS releases runs on any distribution),curl, and a system userrustfswith the same uid and gid everywhere:useradd --system -u <uid> -U -M -s /usr/sbin/nologin rustfs. - Credentials: the access key is
admin; the generated password is the secret key, printed once bycreateand kept as described above. RustFS reads both from files on the volume, owned byrustfs, so neither appears in a command line. Users, policies and buckets created through the S3 or admin API live in the data directory (.rustfs.sys) and fail over with the objects. - Endpoints: S3 at
http://<service IP>:<port>, path-style (setforce_path_style/s3_use_path_stylein the client); the web console athttp://<service IP>:<port+1>/rustfs/console/.createchecks that both ports are free. Traffic is plain HTTP: put a TLS proxy in front when clients are outside a trusted network. - Snapshots freeze the filesystem for their few seconds, as for the other engines; objects being uploaded at that moment wait.
When the app moves to another node (drbd-reactorctl evict, or a failed
node), the service IP answers again within about 3 seconds, and objects
written before the move read back unchanged.
Failover¶
drbd-reactor fails the app over when its node is lost, and when the database
itself dies: the unit is bound to the promoter's target and has no
Restart=, so a crash moves the app rather than restarting it in place. The
replica taking over promotes the resource, mounts it, starts the database and
raises the service IP.
- Crash-consistent. The new node starts from what was on disk, exactly as after a power cut, and the engine recovers (WAL replay, InnoDB redo, AOF).
- RPO 0 with protocol C. DRBD acknowledges a write only once it is on both
replicas, so every transaction the database committed with an fsync is
there: PostgreSQL by default, MySQL/MariaDB with
innodb_flush_log_at_trx_commit = 1andsync_binlog = 1(set bycreate). Redis is configured withappendfsync everysecand can lose up to a second of acknowledged writes; setappendfsync alwaysin itsredis.conffor none. - Recovery time is detection, promotion, the engine's crash recovery and the service IP, usually seconds to tens of seconds; a large uncheckpointed log takes longer.
- Clients lose their connections and must reconnect to the service IP; use a client or pool that retries.
- Quorum is what makes this safe. Give the resource three votes (a third replica or a diskless tiebreaker) so a node cut off from the others loses quorum and stops writing instead of running a second copy.
haify app failover is the planned version, through drbd-reactorctl evict; it
fails when no other replica took over. haify node drain and haify ha evict
<resource> move an app the same way.
A replica added later (haify resource add-replica) gets the unit and promoter
only if it passes the same checks against what the app was created with (engine
path, version, uid and gid). If it does not, add-replica fails and says what
differs; once that is fixed, haify resource repair <resource> places them. A
removed replica loses them.
Snapshots¶
haify app snapshot freezes the database on its node (see the table), takes a
replicated snapshot of every volume on every replica, and thaws.
Before freezing anything, the node arms a transient systemd timer
(haify-app-thaw-<name>) that thaws the database after 60 seconds by itself, so a
controller that dies mid-snapshot cannot leave it frozen. Writes stall for the
seconds the snapshot takes; reads continue. An app that is not running is
snapshotted without a freeze.
To go back to a snapshot: haify app delete <name> (the data stays), haify
resource snapshot replicated rollback --resource <res> --name <snap>, then
haify app create again with the same name, engine and resource; it finds the
data and keeps it.
13. Cross-site replication (WAN DR)¶
WAN replication keeps an asynchronous replica at another site, over the public internet, through a TCP proxy with mTLS. It is opt-in per resource because it uses protocol A (asynchronous): the primary does not wait for the DR site.
A resync across the WAN compares SHA-256 checksums before sending a block
(csums-alg sha256), so blocks both sites already hold cross the link as a
checksum, not as data. Resync speed follows DRBD's dynamic controller; to cap
it for a group of resources, set it on their profile:
haify resource profile set-options <profile> --drbd-options disk/c-max-rate=20M.
WAN resources created before this default can get it with
haify resource set-options <name> --drbd-options net/csums-alg=sha256.
haify resource create --name db --size 100G --port 7000 \
--nodes node1,node2 \
--wan --dr-node dr1 --dr-endpoint dr.example.com
--dr-endpoint is an address or host name, without a port: the WAN port is
--wan-port, and 0 (the default) picks a random port above 3000.
--wan-egress-address pins the outbound side to one interface when the primary
has several. A Kubernetes StorageClass with drNode and drEndpoint creates
every volume it provisions this way (Kubernetes).
A resource that is already running gets its DR replica in place, while it keeps serving:
haify resource add-dr db --dr-node dr1 --dr-endpoint dr.example.com
The DR node joins over one haify-proxy leg per primary-site replica, with
protocol A and pull-ahead, and does not vote: quorum stays with the primary
site. On an encrypted resource the DR copy is encrypted too, under a key the DR
node makes for itself, so the DR node needs cryptsetup and dm-crypt like every
other replica; add-dr refuses a node without them.
When the DR site's address changes, or the primary should dial out from another interface:
haify wan set-endpoint db --dr-endpoint dr2.example.com
haify wan set-endpoint db --egress-address 203.0.113.20 # or --clear-egress
The tunnels are rebuilt on the new address at once. A new endpoint that does
not answer is refused and the old one kept, since the old tunnel was working. For a
DR site whose firewall is not open yet, --skip-check saves it anyway; run
wan repair once it answers. Renumbering the DR node with node set-address
moves an endpoint that was that node's address by itself.
Will the link keep up?¶
wan sizing answers that from what has been measured, for a WAN resource or for
a LAN resource you are thinking of putting on the WAN:
haify wan sizing db # uses the proxy's own link measurement
haify wan sizing db --bandwidth 100Mbit # or say what the link is
haify wan sizing db --sample-compression # read 64 MiB off the volume for a ratio
haify wan sizing db --days 14 --json
The answer is one of four:
| Outcome | Meaning |
|---|---|
link_sufficient |
The link carries even the bursts. The buffer only rides out outages; compression is optional. |
buffer |
Bursts outrun the link, the average does not. The buffer absorbs them and drains in between. |
compress |
Only compression brings the average under the link. The buffer absorbs the bursts. |
async_backup |
Even compressed, the link cannot keep up: the DR copy would fall further behind for good. Ship snapshots on a schedule (haify backup schedule) instead, or get a faster link. |
What it weighs, and says where each figure came from:
- Write rates. The controller records every resource's write rate on each health poll, from DRBD's own counter, and keeps 14 days of it. A WAN resource's proxy also samples its input every second. The average comes from whichever covers more time; the burst rate is the 99th percentile. A poll interval averages away shorter bursts, so history alone understates them, and less than a day of history misses the daily cycle: the answer says so.
- Compression ratio. What the proxy achieves on the live stream, or, with
--sample-compression,zstd -3on 16 chunks of 4 MiB read from the volume's backing device on one replica (all-zero, never-written chunks are left out). Already-compressed or encrypted data shows up here as 1:1. - Link bandwidth.
--bandwidth(100Mbit,1Gbit,12MiB/s), best taken from a test such as iperf3 between the sites. Without it, the proxy's own measurement: the wire rate during seconds when a backlog waited for the link, which exists only once the link has been the bottleneck.
It also recommends a buffer size (twice the backlog the proxy has held, or twice what the longest burst would leave queued), and shows how long the configured buffer holds writes while the link is down, how long a full buffer takes to reach the DR site, and the proxy's CPU at the burst rate.
Buffer and compression¶
haify wan tune db --buffer 2GiB # RAM per connection, 16 MiB to 10 GiB; default 4 GiB
haify wan tune db --compression off # off, default (zstd level 3) or zstd:<1-19>
The buffer is RAM on the primary-site node, used only as far as there is a
backlog. DRBD goes Ahead (keeps writing locally, marks what the DR has not got)
when 90% of it is in flight, and catches up with a resync of only those blocks;
wan tune sets that threshold with the buffer. The tunnels restart on the new
settings; writes in flight are resynced, not lost.
Two things grow with the buffer. A DR failover loses what is in it. And
unmounting the volume, shutting down a VM on it or a planned switchover waits
until the backlog has reached the DR site: a full 4 GiB buffer on a 40 Mbit/s
link takes about 14 minutes. wan sizing shows that figure for your link.
Compression costs CPU on both ends. On data that does not compress the proxy stops trying after a run of frames that did not shrink and tries again every 33 frames, so it costs little; on compressible data it is worth it whenever the link, not the CPU, is the limit.
haify resource status shows the tuning in effect, and for a proxy of version
0.2 or later the write rate (average, 99th percentile, busiest second), the
largest backlog so far, the longest burst and the measured link rate. The same
figures are exported to Prometheus as haify_wan_*.
A resource created by an earlier version has DRBD options that leave most of
the buffer unused. Running wan tune on it with any setting, even the one it
has (haify wan tune db --buffer default), brings them up to date in place.
Outages¶
When the link fails, the primary keeps writing. DRBD notices within seconds that its peer stopped answering and disconnects; when the link returns, the tunnel reconnects by itself and DRBD resyncs only the blocks written in the meantime, comparing checksums so blocks the DR already holds are not sent again. A 60 s outage under 5 MiB/s of writes on a 40 Mbit/s link was back in sync 110 s after the link returned. Application writes do not wait for any of it.
DR is manual on purpose: an automatic cross-site promotion during a network partition would produce two live copies:
haify resource dr-failover db # prints what it will do
haify resource dr-failover db --yes # force-promotes the DR node
Writes still buffered in the WAN link when the primary site died are lost. Mount the volumes on the DR node and resume there.
Before it promotes anything, dr-failover asks the primary-site nodes for
the volume's state, giving each five seconds. If one answers and is Primary,
the site is not lost and it refuses: promoting the DR node then would leave
two Primaries writing different data. Stop the service at the primary site
first. Otherwise it disconnects the DR node from the primary site and turns
DRBD quorum off on the DR node, because a DR node on its own is a minority of
the resource's nodes and DRBD will not promote it with quorum on. This changes
only the running DRBD, not the resource's configuration.
To come back, run dr-failback repeatedly until it says done:
haify resource dr-failback db --wait 30m
It reconnects the primary-site nodes, discarding what they wrote after the
failover in favour of the DR copy, waits for them to resync from the DR,
turns quorum back on at the DR node, then makes a primary-site node (--node,
default the first) Primary again. Each
primary-site node must be unmounted for the first step and the DR node for the
last.
Each primary-site node gets its own tunnel ("leg"). If a node is renumbered or removed, its leg can be left behind:
haify wan repair db --dry-run # show the plan, touch nothing
haify wan repair db
It converges, so running it on a healthy resource reports nothing to do. It
restarts tunnels, so use --dry-run first.
14. Storage tiering¶
Put an SSD in front of a thin pool, and every volume in the pool reads and writes through it (lvmcache).
haify pool add-cache --node node1 --pool thin-pool --device /dev/nvme0n1
haify pool remove-cache --node node1 --pool thin-pool
The device is consumed whole and must be free (no filesystem signature, no
partitions in use, not already a PV) and at least 4 GiB. A pool takes one
cache. haify pool get shows the cache and how much of a writeback cache is
dirty.
Writethrough is the default. --mode writeback must be asked for by name,
because it puts the SSD in the durability path: a node that dies with a dirty
cache takes acknowledged writes with it, and the surviving replica may never
have seen them. remove-cache flushes first and reports failure if the flush
cannot be confirmed, rather than letting you pull a device with data on it.
The cache is per node. DRBD replicates volumes, not the block layer beneath them, so it is fine to cache one replica and not another.
Caching requires a thin pool: a thick pool has no single LV that every volume passes through.
15. Encryption¶
--encrypt wraps each replica's backing volume in its own LUKS2 container, so
the stack is DRBD → LUKS → LVM.
haify resource create --name secrets --size 50G --port 7010 \
--nodes node1,node2 --encrypt
What it does and does not cover:
- At rest only. DRBD sits above the crypt layer, so replication traffic between nodes is plaintext. Encrypt it with replication TLS, below.
- Each node generates and keeps its own key under
/etc/haify/luks, root-only. Nothing is sent anywhere and there is no central escrow. Lose a node's key and that replica is gone; the others are unaffected. - It cannot be enabled later. Decide at creation.
- Every copy is encrypted. A replica added later with
add-replica, a diskless client or tiebreaker given a disk, the off-site copy added withadd-dr, and a copy rebuilt bypool convert-thineach get a container and key of their own, made on that node. A node withoutcryptsetupand dm-crypt is refused before anything is created on it. - Copies stay encrypted. Filling a new volume from a snapshot of an
encrypted one (a Kubernetes restore or clone, or the
PopulateVolumeAPI) reads the snapshot through a temporary read-only LUKS mapping on the node that holds it, with that node's key, and is refused when the new volume is not encrypted. - LVM pools only.
- Backups are plaintext.
haify backupreads each snapshot through a temporary read-only LUKS mapping on the node it backs up from, so the image holds the volume's data and restores onto any replica. The key stays on the node, which means the target receives plaintext: protect it with the target's own encryption and access policy. Backups taken by older versions hold ciphertext and cannot be restored;backup restorerefuses them. The first backup after upgrading is a full one.
Encrypted replication. DRBD 9.2 and later can run a connection over kernel
TLS: the kernel asks tlshd (package ktls-utils) to do the handshake, then
encrypts in place. Install ktls-utils on every node, then:
haify replication-tls setup # every node: key, certificate, tlshd
haify replication-tls status # READY per node, or what is missing
haify resource tls db on # encrypt every connection of db
haify resource status db # each peer shows "tls"
haify resource tls db off
setup has each node make its own key. It then signs a certificate for each
node with the controller's replication CA (kept next to the database), installs
that CA in the node's system trust store, points tlshd at the certificate,
and loads the tls module at boot. A peer whose certificate this CA did not sign is refused
at the handshake. Run setup again to renew certificates; status warns 30
days before one expires.
- Live switch. DRBD cannot change a connection's transport while it is up,
so
resource tlstakes one link down at a time and brings it back while the others keep quorum. The Primary keeps serving; the reconnected peer catches up with a short resync. Switching a resource's three links takes about 15 s, and a continuous write load sees no failed write. - A failed handshake is not retried. DRBD leaves that link StandAlone and
the switch stops, naming it. Fix the node (
journalctl -u tlshd), thenhaify resource repair <resource>. - New members must be ready. Adding a replica, a diskless client or a tiebreaker to an encrypted resource is refused on a node that is not.
- The CA joins the system trust store, because
tlshdbefore ktls-utils 0.10 has no setting for a private one. Anything on a node that validates against that store will accept a certificate it issued; it only ever issues replication certificates. - Not for WAN resources. Their off-site leg already runs mutual TLS through
haify-proxy.
16. Alerts and notifications¶
Detection is on by default. Its settings are in controller.toml:
[alert]
enabled = true # default; false stops the health poll
check_interval_sec = 30 # default
check_nodes = true # default; SSH-probe each node, produces node.unreachable
check_pools = true # default; thin pool data/metadata use
pool_near_full_percent = 85 # default; warning
pool_full_percent = 95 # default; critical
history_size = 500 # default; kept in the database across failovers
watch_drbd_events = true # default; see below
idle_interval_sec = 300 # default
warning_hold_sec = 30 # default; see below
A warning is raised only once its condition has lasted warning_hold_sec:
a replica link that drops and reconnects within it is not reported. Critical
conditions (a full pool, a lost Primary, an unreachable node) are never held.
With watch_drbd_events the controller keeps one drbdsetup events2 stream
open to each node. A DRBD state change is then checked within a few seconds
instead of at the next poll. While every node's stream is up and nothing is
resyncing or verifying, the cluster is polled every idle_interval_sec
instead of every check_interval_sec. That longer interval still applies to
what DRBD does not report, such as a thin pool filling up.
Events: resource.degraded, resource.failover, resource.no_primary,
resource.promoted, node.unreachable, wan.degraded, resource.out_of_sync,
pool.data_near_full, pool.data_full, pool.metadata_near_full,
pool.metadata_full, pool.out_of_space (LVM already refused writes),
pool.vdo_physical_near_full, pool.vdo_physical_full (the physical space
under an lvm-thin-vdo pool),
pool.snapshots_removed (a near-full pool gave up a scheduled snapshot),
pool.snapshots_locked (a near-full pool with only locked snapshots left),
audit.shipping_failed, audit.truncated, approval.requested (see Access control),
controller.clock_jumped, resource.write_anomaly, node.evicted,
resource.replica_moved, resource.volume_moved, pool.trimmed,
pool.extended, pool.disk_moved, disk.health,
channel.test (sent by a channel test, so receivers can ignore it),
backup.failed (a scheduled backup) and inspection.completed (see
Inspection). Each carries a severity
(info/warning/critical) and a status (firing when a condition starts,
resolved when it clears, info for a one-off such as an inspection), so a
receiver can pair an alert with its recovery instead of reading the recovery as
a new fault.
haify event list --resource db --min-severity warning
haify event watch --min-severity critical
haify event watch --type resource.failover --json
haify event watch --replay # retained history first, then live
event list and event watch take --resource, --type, --min-severity
and --since-id (resume after a disconnect).
Sending them somewhere¶
A chat service will not accept an arbitrary JSON document, so a channel has a kind. Feishu, WeCom and DingTalk also report a refusal inside an HTTP 200, so a misconfigured channel looks like it is working until an outage passes unnoticed.
haify channel add --name oncall --kind feishu \
--url https://open.feishu.cn/open-apis/bot/v2/hook/xxxx --min-severity warning
haify channel add --name pager --kind slack \
--url https://hooks.slack.com/services/T00/B00/xxxx --min-severity critical
export HAIFY_NOTIFY_SECRET=SECxxxx # DingTalk 加签, never a flag
haify channel add --name ops --kind dingtalk \
--url 'https://oapi.dingtalk.com/robot/send?access_token=xxxx'
haify channel test oncall # reports what the service itself said
haify channel list
haify channel delete ops
--type limits a channel to some event types
(--type resource.failover,node.unreachable); --header Name=Value adds a
request header, for a receiver that wants a token. Re-running add without a
secret keeps the stored DingTalk secret; --clear-secret removes it.
Kinds: generic (the event JSON unchanged, for a receiver you wrote), feishu,
slack, wecom, dingtalk. A well-known bot URL saved with the wrong kind is
refused up front rather than at delivery time.
Always run channel test after adding one. It is the only way to tell
a working channel from a silent one.
Channels live in the controller database, so adding or muting one takes effect
immediately, without a restart. --muted stores a channel without delivering to it,
which is how you silence a noisy pager during an incident without having to find
the bot URL again afterwards.
Receivers can also be set in controller.toml: webhook_url (with
webhook_min_severity) or [[alert.webhooks]] entries with url,
min_severity and headers under [alert]. They post the generic event JSON,
need a restart to change, and work alongside channels.
The same events are also readable on the REST API ([server] rest_port, default 3375) at
GET /v1/events, streamable as newline-delimited JSON at /v1/events/watch,
and pushed to the web UI's bell over /v1/events/stream.
Metrics¶
The controller serves Prometheus metrics at http://<controller>:9433/metrics
([metrics]: enabled default true, listen_address default 0.0.0.0,
port default 9433). The cluster gauges below are fed by the health poll, so
they stay empty unless [alert] enabled = true; the controller logs a warning
at startup when it is not. Besides the API's own request counts and latencies
(haify_controller_grpc_requests_total, haify_controller_grpc_request_duration_seconds)
and the counts haify_controller_resources{state}, haify_controller_nodes{state}
and haify_controller_gateways{type,state}:
| Metric | Labels | Meaning |
|---|---|---|
haify_drbd_role, haify_drbd_disk_state, haify_drbd_replication_state |
resource, node, role/state | 1 for the state each replica holds now |
haify_drbd_quorum |
resource, node | 0 when the node that answered has lost quorum |
haify_drbd_resource_up |
resource | 0 when no node answered for the resource |
haify_drbd_resync_completed_ratio |
resource, node | 1 when in sync |
haify_drbd_out_of_sync_bytes |
resource, node | data DRBD has marked as differing |
haify_drbd_connection_tls |
resource, node | 1 when that connection is encrypted |
haify_wan_backlog_bytes |
resource | written on the primary site, not yet sent to the DR: what a DR failover now would lose |
haify_wan_connected, haify_wan_buffer_capacity_bytes, haify_wan_buffer_high_water_bytes |
resource | proxy WAN connection, buffer, largest backlog |
haify_wan_input_p99_bytes_per_second, haify_wan_input_peak_bytes_per_second, haify_wan_link_bytes_per_second |
resource | write rate into the proxy and the measured link rate |
haify_wan_input_bytes, haify_wan_wire_bytes, haify_wan_compression_ratio, haify_wan_cpu_seconds, haify_wan_reconnects, haify_wan_buffer_full_events |
resource | counters since the proxy started; take rate() or increase() |
haify_controller_node_reachable |
node | 0 when the controller cannot reach it |
haify_controller_pool_thin_used_percent |
pool, node, kind | thin pool data/metadata use |
haify_controller_storage_capacity_bytes |
pool, node, state | total/used/free |
haify_controller_alerts_firing |
type, severity | conditions raised now |
haify_controller_backup_last_success_timestamp_seconds |
resource, target | newest completed backup |
haify_controller_backup_last_shipped_bytes |
resource, target, kind | what it carried |
haify_controller_resource_fault_domain_risk |
resource, domain | 1 when one domain's loss takes it down |
haify_controller_last_observation_timestamp_seconds |
source | when each source last answered |
The controller also exports haify_controller_up,
haify_controller_operations_total{operation,result},
haify_controller_operation_duration_seconds{operation} and
haify_drbd_written_bytes{resource,node} (bytes DRBD wrote to a node's disk).
A series nobody observed is absent rather than zero: a replica that stopped
answering has no haify_drbd_disk_state, and haify_drbd_resource_up says why.
deploy/monitoring/prometheus-rules.yml has alerting rules on these
(controller down, stale observations, unreachable node, lost quorum, unreadable
resource, replica not UpToDate, out of sync, thin pool near full and full,
backup older than two days, one-failure-domain risk). deploy/monitoring
also has a Docker Compose stack that uses them: Prometheus, plus Grafana with
a "Haify" dashboard.
Inspection¶
Alerts fire when a condition starts. An inspection asks what they do not: did
the alert reach anyone, is a replica that says Connected actually replicating,
is the node still at the address it is registered at. It runs a fixed set of
checks, changes nothing, and stores a report in which every finding has a
status (pass, warn, fail, or error when the check itself could not
run), the evidence, and the command that fixes it.
haify inspect run # now; waits for the report
haify inspect run --area alerts,nodes # only some areas
haify inspect list
haify inspect show # newest; or: haify inspect show 42
haify inspect show latest --json
Each node is probed once over SSH per run; a node that does not answer is a
nodes.ssh failure and error for its other checks, not a failed run.
| Area | Checks |
|---|---|
| resources | a resource under haify ha create has exactly one Primary; on every node: replica Outdated, Inconsistent with no resync, Diskless where it should hold data, not up, quorum lost; a peer StandAlone; a peer no node can reach, reported once per peer: fail for a diskful peer, warn for a tiebreaker or diskless client (the data is still fully redundant); a Connected peer stuck in WFBitMapS/WFBitMapT/WFSyncUUID (or Off between two diskful nodes), judged from every node's own view, because a handshake can be stuck on one side only; two-node quorum risk (warn); single-failure-domain risk (one warn per domain, listing its resources); an HA promoter config missing on a primary-site diskful node (warn), present on a diskless one (warn: it works as a diskless Primary, over the network) or present on a WAN resource's DR node (warn: it could fail over to the asynchronous copy unasked) |
| gateways | every gateway not stopped has exactly one Primary; its promoter config on every diskful node (warn when missing, and when present on a diskless node) |
| nodes | SSH reachable; clock skew against the controller (warn > 2 s, fail > 30 s; SSH latency is not counted), NTP synchronised; root filesystem (warn ≥ 85 %, fail ≥ 95 %); the registered address present on an interface (a public address answering as the registered host is taken as NAT, not drift), and the address answering as the registered host; drbd module loaded; drbd-reactor running; DRBD module, drbd-utils, drbd-reactor and haify-controller binary the same on every node, the binary compared only between nodes of one architecture (a differing binary fails under Self-HA); /etc/hosts mapping a node name to an address it is not registered at |
| pools | thin pool data and metadata against [alert] pool_near_full_percent / pool_full_percent; growth since the previous report, warn when full within 14 days, fail within 3; on a thick pool, free space below the copy-on-write area a snapshot of a volume reserves (20 % of it, at least 256 MiB), naming the volumes whose snapshots and backups will fail (warn); each disk under a pool, by SMART or the NVMe health log: warn on wear past 90 %, media errors, reallocated or pending sectors, fail on a failed SMART verdict, an NVMe critical warning, a worn-out NVMe drive (100 %) or uncorrectable sectors (disk.health; no check without smartctl) |
| backups | each enabled backup schedule: last run failed, target missing, last success older than 1.5 cron intervals (warn) or 3 (fail); snapshot schedules not run for 1.5 / 3 intervals; schedules enabled while [schedule] enabled = false; _bk_ snapshots no backup record refers to |
| alerts | [alert] enabled; at least one enabled channel; each channel's last deliveries succeeded; every warning or critical raised in the last 24 h was accepted by a channel that delivered it. No test message is sent |
| selfha | at least two UpToDate copies of haify-meta; its promoter config active on every diskful candidate (and noted on a diskless one); exactly one haify-controller active, on the haify-meta Primary; a controller binary on every candidate |
| tls | API server certificate, replication CA and every node's replication certificate: warn under 30 days, fail under 7 or expired |
| hygiene | /etc/drbd.d/*.res and Haify-named volumes (<res>_data, <res>_volN, <res>_state*, _sched_ snapshots) of resources the controller no longer has. Listed, never deleted |
[inspect]
enabled = true # default
schedule = "0 1 * * *" # default; cron, controller time zone
keep = 30 # default; reports stored
notify_min = "warn" # default; pass | warn | fail
The schedule runs on the snapshot scheduler, so it runs on the active controller
only and only while [schedule] enabled = true; haify inspect run works either
way. Each run publishes one inspection.completed event when its worst finding
is at least notify_min. The severity is critical for a fail, warning for
a warn or error, and info otherwise. The message holds the counts and the
first failing items. The event goes to notification channels like any alert,
so it needs [alert] enabled = true. A channel filtered to warning hears
from the inspection only when it found something. haify-mcp exposes the same as
haify_inspect_run and haify_inspect_report.
Channel delivery results (last success, last failure and its error, events
given up on) are recorded per channel as alerts are delivered; that record is
what the alerts area reads.
17. Access control¶
With nothing configured, the API accepts every caller. There are two models,
set in controller.toml:
[auth] # one shared token for every caller
enabled = true
token = "..."
[rbac] # per-user tokens with roles; replaces [auth]
enabled = true
[[rbac.users]]
name = "admin"
token = "..."
role = "admin"
Roles are admin, operator, viewer and security-officer. Every API call is classified by
object (pool, resource, gateway, snapshot, backup, node, ha, app, approval, system) and action
(read, write, approve). An operator may write pools, resources, gateways, snapshots,
backups, HA and database apps, and only read nodes and system settings; a viewer can only
read; an admin can do everything. [[rbac.policies]] entries (role,
object, action, either may be *) add grants or define further roles.
haify rbac whoami # your identity and role
haify rbac policies # effective roles and assignments (admin only)
haify rbac user add --name alice --role operator # prints a generated token once
haify rbac user add --name ci --role viewer --user-token <16+ chars> # set the token yourself
haify rbac user set-role alice viewer
haify rbac user remove alice
Users added this way are kept in the controller database. Users declared in
controller.toml are re-applied at every start and cannot be removed through
the API, so an admin always remains. The rbac commands use the gRPC API
like every other command, so --token and the TLS options below apply to them.
Tokens come from --token, HAIFY_TOKEN, ~/.haify/token or /etc/haify/token.
Two-person approval. A role says what a user may do, and an admin may do everything, so one stolen admin token could delete the backup target, the backups and the snapshots. With approval on, the calls that destroy data or weaken what protects it run only after a different user approved that exact call:
[rbac.approval]
enabled = true
ttl_minutes = 60 # how long a request waits, and how long an approved call may then be made
# methods = [...] # default: the list below
$ haify backup target delete offsite # alice
Error: DeleteBackupTarget needs a second person's approval: request 3f9a1c2b7d10 is pending ...
$ haify approval list # bob
$ haify approval approve 3f9a1c2b7d10 # bob; alice cannot approve her own
$ haify backup target delete offsite # alice again: runs, once
The approval covers the method and its exact arguments, so approving the
removal of one target cannot be spent removing another. Who may approve: an
admin, or a security-officer, who can read everything and approve, and change
nothing. Each new request raises an approval.requested event, so approvers
hear of it; an unexpected one may mean a token has been stolen.
The default list:
- deleting pools, ZFS pools and datasets, resources, volumes and snapshots, including whole-resource snapshots;
- restoring snapshots and backups, and rolling back a whole-resource snapshot (all of these overwrite the volume);
- moving a volume to another pool (this deletes its snapshots in the old pool);
- adding, replacing or removing a backup target; deleting backups and snapshot or backup schedules; unfreezing a snapshot schedule;
- replacing a snapshot or backup schedule with one that keeps fewer of any kind, because its next run would prune the rest (creating one, or keeping as many, runs at once);
- deleting a database app (whose
--delete-datadeletes its resource from inside the controller); - marking a node lost and restoring a node;
- adding users, removing them or changing roles, so a stolen token cannot create its own second approver. The web UI's user management is held back the same way.
Approval needs [rbac]: the controller refuses to start with approval on and
RBAC off.
Automation is held back too. The CSI driver deleting a PVC and the Proxmox
plugin removing a VM disk call DeleteResource. The Proxmox plugin rolling
back a VM disk calls RollbackResourceSnapshot, or RestoreSnapshot for a
snapshot taken before whole-resource snapshots existed. These calls then wait
for an approver like anyone else. Where that
is not wanted, list methods yourself without them; the snapshot and backup
locks still protect what they lock.
TLS. [tls] enabled = true with cert_file and key_file puts the gRPC
API (port 3374) on TLS; adding client_ca_file requires a client certificate
signed by that CA. The REST API on [server] rest_port (default 3375) stays
plain HTTP unless [tls] rest = true is also set. Then it is served over TLS
with the same certificate, but without client certificates: REST callers carry
a bearer token, and the web UI proxies to it over a pinned loopback
connection.
REST clients such as the Proxmox plugin then use https://. Clients:
haify --tls-ca /etc/haify/ca.crt node list # verify against this CA
haify --tls-cert me.crt --tls-key me.key --tls-ca ca.crt node list # mutual TLS
--tls alone verifies against the system trust store; --tls-server-name
overrides the name checked; --tls-insecure encrypts without verifying. Each
has an environment variable (HAIFY_TLS, HAIFY_TLS_CA, HAIFY_TLS_CERT,
HAIFY_TLS_KEY, HAIFY_TLS_SERVER_NAME, HAIFY_TLS_INSECURE).
Audit. Every state-changing API call is recorded with caller, target,
outcome and latency in the controller database ([audit] enabled, default
true; include_reads = true records reads too). Read it at GET /v1/audit
on the REST API, or in the web UI. Entries are kept for retention_days
(default 180), up to max_entries (default 200000) in total, so a flood of
calls cannot fill the metadata volume. If the cap drops an entry before its
retention period is over, a critical audit.truncated event is raised.
Whoever controls the controller host can rewrite the database trail. To keep a copy they cannot rewrite, send the trail off the cluster as it is written:
[audit]
syslog = "tls://logs.example.com:6514" # or tcp://host:514, udp://host:514
syslog_ca = "/etc/haify/logs-ca.pem" # tls only; empty = system roots
webhook_url = "https://audit.example.com/haify"
webhook_token = "..." # sent as Authorization: Bearer
Syslog gets one RFC 5424 message per entry, facility 13 ("log audit"),
newline-framed over TCP. The webhook gets each batch as a JSON POST,
{"source": "haify-controller", "records": [{"seq": N, "event": {...}}]}. Either
is sent in order, starting from where it last stopped. Each destination's
position is stored with the trail. So a destination that was down gets
everything when it is back, and a controller that takes over after a failover
carries on where the last one stopped. A resend after a failure can repeat an
entry but never skips one; seq tells a duplicate from a gap. A destination failing for five
minutes raises audit.shipping_failed. UDP can lose messages unnoticed; use
TCP or TLS.
Point it at storage the cluster's credentials cannot rewrite: a log server you run elsewhere, or a collector writing to an object store with Object Lock.
18. Kubernetes and virtual machines¶
Haify ships a CSI driver. Volumes are DRBD resources; a pod moving between nodes gets its storage promoted on the new one.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: haify
provisioner: haify.csi.liliang-cn.com
parameters:
pool: thin-pool
replicas: "2"
Other parameters: storageType (lvm, the default, or zfs),
resourceProfile (take pool, replicas and storage type from a profile; pool
may then be omitted), resourceLabels (key=value,...), faultDomainLabel
(default host), and allowRemoteVolumeAccess: "true", which opts a volume
into diskless attachment so a pod can run on a node that holds no replica.
Encryption. encryption: "true" creates each volume as
--encrypt does (Encryption): LUKS2 beneath DRBD, a key
made and kept on each node, LVM pools only. replicationTLS: "true" switches
the new volume's DRBD connections to TLS right after it is created, as
haify resource tls <resource> on does; the nodes must be prepared with
haify replication-tls setup, and replicas are placed only on nodes that are.
Both are recorded in the PV's volumeAttributes. A restore or clone of an
encrypted volume must use a class with encryption: "true"; any other is
refused. Nothing about the keys is stored in Kubernetes. The details are in
deploy/k8s/README.md, "Encrypted volumes".
I/O limits. readBytesPerSecond, writeBytesPerSecond (sizes such as
200Mi), readIOPS and writeIOPS cap a pod's I/O to its volume. When the
volume is published, the node plugin writes these limits for the volume's
device into the pod's cgroup (io.max). This needs cgroup v2 with the io
controller enabled for pod cgroups, and the node plugin's /sys/fs/cgroup
mount from deploy/k8s/30-node.yaml. Without them, the volume is still
published, but without limits, and the node plugin logs why.
Changing a volume in place. On Kubernetes 1.34 and later, a
VolumeAttributesClass named in a PVC's volumeAttributesClassName changes an
existing volume while it stays in use: replicas adds or removes diskful
replicas, resourceProfile moves the resource into a profile, and the four
I/O limits change (max lifts one). Running Pods get new limits within the
node plugin's --qos-interval (default 30 seconds). Shrinking never goes below
two replicas, never removes the replica where the volume is in use, and never
removes a node the PV's node affinity names: for a volume without
allowRemoteVolumeAccess, its Pods stay on the nodes it was first created
on, so extra replicas add safety but no new places to run. Details and limits
are in deploy/k8s/README.md.
Snapshots and health. A VolumeSnapshot is a snapshot of one replica's
backing volume. A VolumeGroupSnapshot snapshots several PVCs at the same
instant, with I/O suspended on all of them while it does, for an application
that spreads its data over more than one volume. When a replica of a volume
degrades or recovers, the driver posts a VolumeDegraded or VolumeRecovered
event on the PVC and on every Pod using it, so kubectl describe pod shows it.
deploy/k8s/README.md covers both, including what group snapshots need
installed in the cluster.
Off the cluster. A VolumeSnapshotClass with backupTarget: <target> also
uploads every snapshot to that backup target (Backups), as a full backup
of exactly that snapshot. A PVC restored from it is written from the backup
when the local snapshot is gone, including in another cluster whose Haify has
imported the target's backups. A StorageClass with drNode and drEndpoint
gives every volume an asynchronous replica at another site
(WAN DR). The steps for both, restoring
in another cluster and failing over to the DR site and back, are in
deploy/k8s/README.md.
The manifests are in deploy/k8s (see its README); the CSI section of
deployment-guide.md covers installation.
A database on Kubernetes with its data on a Haify volume, the counterpart of
haify app, is one MCP call: haify-mcp k8s serves
haify_k8s_app_create, haify_k8s_app_list and haify_k8s_app_delete
(mcp.md). Deleting keeps the volume claim and the
password secret unless delete_data is set, and creating the app again with
the same name and template runs it on them.
Proxmox VE has its own integration: a storage plugin (type haify) that backs VM
disks with Haify resources over the controller's REST API. It is in
deploy/proxmox, with its requirements and install steps in that README.
deploy/proxmox/bootstrap.sh, run on one PVE node, does those steps for an
existing PVE cluster: DRBD from LINBIT's repository on every node, the
controller, node registration, the pool, Self-HA, the plugin and the
storage.cfg entry.
KVM with libvirt needs no plugin: a guest's disks are Haify devices
(/dev/drbd/by-res/<resource>/0), and the qemu hook in deploy/libvirt makes
them Primary when the guest starts and handles live migration.
haify ha create --vm <guest> restarts the guest on another host when its host
fails (High availability). See
deploy/libvirt/README.md.
OpenStack uses the Cinder driver in deploy/openstack: each Cinder volume is
a Haify resource, attached to the compute host as a local block device, or as a
diskless client where the host holds no replica. The same package adds a Haify
panel and a Replication tab to Horizon. See
deploy/openstack/README.md.
19. The AI Copilot¶
haify-ai is an optional service that answers questions about the cluster in the
web UI's Copilot sidebar. It reaches the cluster through haify-mcp: every
read-only tool, plus a fixed list of day-to-day writes. Each write waits in
the chat panel until the operator approves that call, with its arguments
shown. The writes are: registering nodes and setting node labels; creating
pools, resources, profiles, NFS, iSCSI and NVMe-oF gateways, HA configs,
snapshots, snapshot schedules, backups, ZFS datasets and volumes, ZFS
snapshot clones and Kubernetes apps; adding disks, caches, volumes, replicas
and DR; adopting a resource; attaching and detaching diskless clients;
mounting; resizing volumes; setting resource options, profile options, a
resource's profile and its tiebreaker; adjusting a profile; dual-primary;
iSCSI CHAP; starting a gateway; undrain; verify; WAN repair and endpoint
changes; and testing a notification channel. Deleting, restoring, unmounting,
other role changes, evicting, draining and stopping are not available to it
at all; it proposes them and the operator runs
them from the UI. It listens on 127.0.0.1:7634 by default (HAIFY_AI_ADDR;
any non-loopback address requires a token, taken from HAIFY_AI_TOKEN,
HAIFY_TOKEN, ~/.haify/token or /etc/haify/token, in that order); the web UI proxies /ai/* to port 7634 on its own node.
Two things determine how useful it is:
- Its knowledge base. Check what it has:
GET /ai/kb/list. A near-empty one makes it fall back on the model's own memory, and for a specific question about your cluster that produces confident wrong answers. Feed it withPOST /ai/kb/ingest(a directory on the node runninghaify-ai) orPOST /ai/kb/doc(one document).GET /ai/kb/doctorchecks that retrieval over the index still returns results. - The embedder matching the index.
HAIFY_AI_EMB_DIM(default 768) must equal the width the index was built at. If you switch to an embedder of a different width, every search returns nothing and no error is reported./ai/kb/listreports the width read back from the index, so check it there.
Send the same session_id across turns and it follows a conversation; omit it
and every question starts from nothing.
20. Routine operations¶
Before a node reboot
haify node drain node1
# ... work ...
haify node undrain node1
haify resource status <each affected resource> # wait for UpToDate everywhere
Growing a volume
haify resource resize-volume db 0 200G
# then grow the filesystem on the Primary
ssh <primary> sudo resize2fs /dev/drbd<minor>
Replacing a disk
Drain the node, remove its replica, replace the disk, recreate the pool, add the replica back, and wait for the resync to finish before touching the next node.
Upgrading the controller
From a checkout, ./scripts/deploy-all.sh node1,node2,node3 builds for each
node's architecture and installs haify-controller and haify on each one,
with Self-HA taken into account. By hand:
# build for the nodes, not for the machine you build on
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 make build
scp bin/haify-controller <node>:/tmp/
ssh <node> "sudo systemctl stop haify-controller && \
sudo install -m 0755 /tmp/haify-controller /opt/haify/bin/haify-controller && \
sudo systemctl start haify-controller"
/opt/haify/bin is where the shipped unit file runs it from; copy to whatever
path your unit's ExecStart names (systemctl cat haify-controller). Set
GOARCH to the nodes' architecture (arm64 for aarch64).
With Self-HA on, do not stop or restart the controller by hand: drbd-reactor manages it, and stopping it is an unplanned failover.
Replace the binary on every standby node first, then
haify ha evict haify-meta to move the controller onto one of them, then
replace it on the node it left.
Checking that replicas really hold the same data
Every replica can say UpToDate and still differ, for example after a disk
returned the wrong block or a write never reached one copy. Only reading both
finds that:
haify resource verify db --wait 30m # compare every replica with the Primary
haify resource verify db # still running? run it again to follow it
haify resource verify db --resync # make the copies identical
DRBD records blocks that may differ in an out-of-sync bitmap. A verify adds
to the bitmap and never clears it. Marks also stay after whatever set them is
over (an earlier verify, an interrupted resync, a reconnect at equal
generation). So a peer can show most of its volume as "marked out of sync"
while its data is in fact identical to the source. That is why verify
reports two things separately: what it found itself, and what was already
marked. --resync copies the source's data over every marked block. This is
harmless when the copies are identical, and it is the only thing that clears
the marks. It copies from --node (default: the Primary). It refuses a source
that is not Primary unless another replica agrees with it, and it never
overwrites a Primary.
The controller verifies every resource on its own, one at a time, on
storage.verify_schedule (cron, default 0 3 1 * *: monthly; "" turns it
off). Marked blocks raise resource.out_of_sync; clearing them is left to you.
Resources on thin pools resync with rs-discard-granularity, so a full resync
(a new replica, a failback) keeps the target thin instead of allocating every
block. Resources created before this was a default can get it with
haify resource set-options <name> --drbd-options disk/rs-discard-granularity=65536.
Checking a cluster you have not looked at in a while
haify health-check
haify resource list
haify event list --min-severity warning
haify backup schedule list # did the last scheduled backup fail?
haify backup list # when was the newest 'completed' one?
haify channel test <each> # would you actually be told?
21. When something is wrong¶
Start here:
haify resource status <name>
haify event list --resource <name>
ssh <node> sudo drbdadm status <name>
ssh <node> sudo journalctl -u drbd-reactor -n 50
Reading the status¶
haify resource status prints one line per node, role=… disk=… repl=…;
drbdadm status on a node prints the same states as role:, disk:,
peer-disk: and replication:, plus quorum:no when the node has lost it.
| What you see | What it means |
|---|---|
role=Primary on exactly one node |
Normal. |
role=Secondary everywhere |
Nobody is serving I/O. Normal for an idle resource; a problem if something should be mounted. |
disk=UpToDate |
This replica has every byte. |
disk=Inconsistent |
Mid-resync, or never synced. Wait. |
disk=Outdated |
This copy is known stale; it will not be promoted, which is correct. |
disk=Diskless |
No local copy. Expected on a tiebreaker or a diskless client; a fault anywhere else. |
peer-disk:DUnknown (drbdadm) |
Cannot see that peer at all. Network, or the peer is down. |
repl=Established |
Healthy. |
repl=StandAlone |
The connection was dropped, usually after a split brain. It will not reconnect by itself. |
quorum:no (drbdadm) |
This replica may not serve I/O. See below. |
Common situations¶
I/O suspended on a resource, quorum:no. The replica set cannot form a
majority. Usually a two-diskful resource with no tiebreaker after losing one
node. Register a third node so a tiebreaker can be added. As an emergency
measure on the surviving diskful node only:
sudo drbdsetup resource-options <res> --quorum=1 # runtime only, not persisted
# ... once the peer is back and UpToDate ...
sudo drbdsetup resource-options <res> --quorum=majority
Change it on one node only. The peers keep majority, so a peer starting alone
still cannot be promoted, which stops this from becoming a split brain.
A resource is stuck Inconsistent after creation. A fresh DRBD device needs
one copy declared authoritative. resource create does this itself; if it was
interrupted before that step, promote once by hand with
haify resource primary <res> <node> --force, then
haify resource secondary <res> <node>.
StandAlone after a split brain. Two copies diverged. Decide which one is
authoritative (Haify will not guess), then discard the other's changes and
reconnect it. Anything written to the discarded side is lost, so look at both
before choosing.
A gateway will not start. Look at the OCF agent, not at drbd-reactor:
ssh <node> sudo drbd-reactorctl status
ssh <node> sudo journalctl -u "ocf.rs@*<resource>*" -n 50
The usual causes are a missing package (nfs-kernel-server, targetcli-fb, the
nvmet-tcp module) and a service IP already in use.
A node is unreachable but the machine is up. Haify reaches nodes over SSH from the controller node. After a node is rebuilt its host key changes and every operation fails with an empty error; clear the stale key and restart the controller.
Nothing is alerting. Check three things in order: [alert] enabled = true,
at least one channel in haify channel list that is not muted (or a webhook
under [alert]), and haify channel test on each of them.
Getting more detail¶
haify event watch # follow live
haify rbac whoami # "permission denied" that should not be
journalctl -u haify-controller -f # on the controller node
On the REST API ([server] rest_port, default 3375, with the same token):
GET /v1/logsreturns the controller's recent log lines from memory.GET /v1/auditreturns who changed what.POST /v1/diagnostics/collectruns a fixed set of named, read-only collectors on the nodes, so nobody has to log in ({"nodes": [...], "collectors": [...]}, both defaulting to all). They cover DRBD status, config and kernel state, kernel errors, the drbd-reactor, promoter and Haify journals, failed units, storage and mounts.
The web UI's Logs page shows the first two;
haify-mcp has all three (haify_log_list, haify_audit_list, haify_diagnose).
Known failure modes in the deployment guide lists the failures that are hardest to diagnose.