Skip to content

Let a replicated volume follow its container between hosts - #120

Merged
ccomb merged 7 commits into
mainfrom
69
Sep 2, 2026
Merged

Let a replicated volume follow its container between hosts#120
ccomb merged 7 commits into
mainfrom
69

Conversation

@ccomb

@ccomb ccomb commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Moving an application between two hosts was done by hand: stop it, send a last snapshot, restore that snapshot on the other host, start it there. Docker Swarm moves a service without asking, and its volume stayed behind. This is the proposal of #68 and the subject of #69, built on the replicate:<host> line rather than on a new verb, since what it does is replication with the handover automated, and "sync" is the name the README gives to the other thing.

What changes for a volume with a replicate:<host> line scheduled

  • The first container to use the volume on a host asks that host for the last snapshot of the volume to appear there, receives it, and restores it when it is the last to have appeared here and came from another host. What the volume held is kept as a snapshot first. A host that does not answer refuses the mount, and the error says how to mount without asking: pause the line.
  • The last container to stop snapshots the volume and sends that snapshot, waiting for a scheduled replication under way so that what leaves is the final state.
  • While no container uses the volume here, each scheduled round fetches what appeared on the other host, without restoring it, so that a mount receives a difference and not a whole volume within the thirty seconds Docker gives it.
  • A send refuses to bury a history this host never saw: a host that crashed with unsent writes and comes back after the application ran elsewhere keeps those writes aside, and the work done elsewhere wins.

Every replicate:<host> line already in schedule.csv changes meaning with this version, and the changelog says so.

What had to move underneath

  • The order of snapshots is read from the order BTRFS created them in, and whether one came from another host from its Received UUID, never from the date in the name: a host whose clock runs ahead can no longer pass its copy off as the most recent one. buttervolume receive follows the same rule.
  • A restore keeps what the volume held the way a snapshot is kept: nothing new when the volume is unchanged since its last snapshot, nothing at all when it is empty, and nothing done when the volume already holds the snapshot asked for. VolumeBackup is always in the answer, and a Restored field says whether the volume was replaced.
  • A snapshot is compared with the last snapshot taken of the volume here, not with a received one.
  • buttervolume replicate <host> <volume> snapshots and sends in one step, through a new /VolumeDriver.Replicate endpoint the scheduler now calls; the replication lock lives in the plugin, where the unmount needs it.
  • A volume can ask for its scheduled jobs as it is created, -o replicate:node2=1, which is how a Swarm service says once what happens to its volume on whatever host it lands on. An option nobody reads is now refused.

The README has a new section, "Move an application between hosts", and AGENTS.md says that a mount the user scheduled may replace a volume, keeping what it held.

Verified

  • ./test_local.sh: 129 passed, 11 skipped (the ones over ssh).
  • The whole suite in the plugin image, ssh included: 140 passed, nothing skipped, nothing failed.
  • Two BTRFS facts the design rests on were checked on a real filesystem before writing the code: a writable snapshot of a received subvolume clears its Received UUID, and the size of a BTRFS directory is zero exactly when it is empty.

Not verified here, and worth doing on a machine where the plugin can be replaced: two containers on one volume as seen by Docker, a mount refused as seen by Docker and Swarm, and the duration of a first mount against docker plugin enable --timeout. The plugin enabled on the machine this was written on holds volumes, and a second one would share its directories and its ssh port.

The design was reviewed before implementation by a separate session, which found the four problems the plan now records: dates versus creation order, "never written" versus "empty", one Mount per container, and the option read after the "volume already exists" return. One more surfaced while writing the tests: fetching ahead of the mount made the send barrier pass by mere presence of the name, so the barrier reads the BTRFS lineage instead.

Supersedes #69, whose four side fixes were merged earlier as #110, #111, #112 and #113.

The date in a snapshot's name is the clock of whichever host took it, so
two hosts whose clocks disagree disagree on which snapshot is the most
recent. The filesystem knows: it hands out subvolume ids in creation
order, and marks with a Received UUID the subvolumes that arrived
through btrfs receive. This reads both, for one subvolume or for a whole
directory, and also as one shell command a remote host can be asked to
run over ssh, so that both sides describe a directory the same way.

The output of btrfs subvolume show is now read field by field instead
of by line number, since newer versions print more lines than the
parser assumed, and the list of snapshots nobody used is no longer
returned.
A receive asked the other host for the names it keeps and took the one
whose date was the highest. That date is the clock of whichever host
took the snapshot, so a host whose clock ran ahead passed its copy off
as the most recent one, and the README could only advise keeping the
clocks in agreement. The host is now asked to describe its snapshots
the way btrfs numbers them, in the order they were created or received
there, and the last one is fetched. The parent of the incremental
transfer is read the same way: the last earlier one both sides hold.
A restore snapshotted the volume before replacing it, whatever the
volume held: a volume unchanged since its last snapshot left a second,
identical one behind, and an empty volume left an empty one. The copy
now goes through the rule a snapshot goes through, and names the
previous snapshot when nothing changed since it. A volume that already
holds the snapshot asked for is left alone, so asking twice does the
same as asking once, which is what a mount that restores will need.

The snapshot of a volume is also compared with the last snapshot taken
of it here, in creation order, rather than with whichever snapshot of
that volume carries the latest date. A snapshot received from another
host carries the date its host wrote, and was never taken of this
volume: comparing with it made a host at rest snapshot its stale copy
again the minute a fresher one arrived.
Two hosts can end up with two histories of the same volume: one
crashes with writes it never sent, the application moves to the other,
which sends its work to the replication host, then the first one comes
back. Its next send would carry a snapshot dated after everything over
there, and every host reading that directory would take its stale copy
for the most recent one. Before sending, the remote host is now asked
what the last snapshot of the volume to appear there is, and a send is
refused when this host neither holds it nor holds the trace of having
exchanged it, with the error saying what to receive first. A volume at
rest sends nothing, so it still asks nothing.
A scheduled replication was three calls the scheduler made in turn, a
snapshot, a send and a cleanup when the send failed, and the set of
replications under way lived in the scheduler. The unmount of a volume
is about to replicate too, from inside the plugin, and has to wait for
a scheduled replication of the same volume before sending the final
state, where the scheduled round skips its turn. So the three calls
become one function in the plugin, with the lock beside it, reachable
through a new endpoint that the scheduler calls and that gives the
command line buttervolume replicate: what moving an application by hand
needed as two commands.
Moving an application between two hosts was done by hand: stop it,
send a last snapshot, restore that snapshot on the other host, start it
there. Docker Swarm moves a service without asking, and its volume
stayed behind. On a volume with a replication scheduled, the first
mount now asks the replication host for the last snapshot of the
volume to appear there, receives it and restores it when it is the
last to have appeared here and came from another host, keeping what
the volume held; the last unmount snapshots the volume and sends it.

The last snapshot is read from the order BTRFS created them in, so a
host whose clock runs ahead cannot pass its copy off as the newest, and
a snapshot taken here since anything was received means the volume's
own history goes on: a container restarting on the same host keeps
what it wrote. A host that does not answer refuses the mount, since
mounting on a silence is how a stale copy buries the work done
elsewhere, and pausing the line is the way to mount without asking.

Docker calls the plugin once per container, so the mounts are counted,
and every scheduled round of a volume no container uses here fetches
what appeared on the other host, because Docker gives a mount thirty
seconds and a whole volume does not fit in them.
A Docker Swarm service lands on whatever host has room, and the volume
is created there on the spot: nobody can write its replication in the
schedule of that host ahead of time. An option named after an action,
replicate:node2=1, now writes that line in the schedule when the volume
is created, and when it already exists, since a service deployed again
onto a host that kept the volume has to find its replication scheduled
all the same. A line already there is left alone, paused or not.

An option that is neither copyonwrite, compression nor an action is
refused where it was ignored: an option nobody read would leave a
replication unscheduled and nothing said, and the volume is not created
before its options are read, so a bad one leaves nothing behind.
@ccomb
ccomb merged commit 19ddfe5 into main Sep 2, 2026
3 checks passed
@ccomb
ccomb deleted the 69 branch September 2, 2026 11:23
@ccomb ccomb mentioned this pull request Sep 2, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant