Repository navigation
RBD Mirror
rbd/mirror.Run links two clusters: it configures the pool policies and the
peer and starts rbd-mirror receivers on the destination. The service under
test works against the source (or the destination after a failover), and the
test verifies the replicated state on the other side afterwards. Failover,
split-brain and receiver loss are condition steps that the service must
survive.
Each scenario follows the same shape: the goal of the test, the fixture it builds independently of the system under test (SUT), the hand-off of connection details to the SUT, an optional condition step that reproduces an operational situation, the verification of the collaborator's state after the SUT ran, and cleanup.
Snippets run inside a test function with ctx := t.Context(). Fragments
that do not show their own Run use the fixture named in the scenario
(cluster, source, destination, link). Every snippet on this page
compiles against the current API; replace the command-line writers with your
own client in a real test.
The examples share these helpers. run executes a command in any container and
returns its exit code and combined output. waitReplicated compares the
exported bytes of the source and destination images; stop writers and create
the checkpoint first. waitTransmitPeer waits until the source pool knows the
destination as a peer it sends to (see
the pitfalls). promote retries a non-forced promotion
until the demotion has propagated.
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
"github.com/JSYoo5B/ceph-testcontainers-go/ceph"
"github.com/JSYoo5B/ceph-testcontainers-go/rbd"
rbdmirror "github.com/JSYoo5B/ceph-testcontainers-go/rbd/mirror"
"github.com/testcontainers/testcontainers-go"
tcexec "github.com/testcontainers/testcontainers-go/exec"
)
func run(ctx context.Context, ctr testcontainers.Container, cmd ...string) (int, string, error) {
code, reader, err := ctr.Exec(ctx, cmd, tcexec.Multiplexed())
if err != nil {
return 0, "", err
}
out, err := io.ReadAll(reader)
return code, string(out), err
}
func poll(ctx context.Context, t *testing.T, what string, done func() bool) {
t.Helper()
for !done() {
select {
case <-ctx.Done():
t.Fatalf("%s: %v", what, ctx.Err())
case <-time.After(2 * time.Second):
}
}
}
func waitReplicated(ctx context.Context, t *testing.T, link *rbdmirror.Mirror, sourceSpec, destinationSpec string) {
t.Helper()
want, err := link.SourceRBD(ctx, "export", "--no-progress", sourceSpec, "-")
if err != nil {
t.Fatal(err)
}
poll(ctx, t, destinationSpec+" did not match the source", func() bool {
got, err := link.DestinationRBD(ctx, "export", "--no-progress", destinationSpec, "-")
return err == nil && bytes.Equal(got, want)
})
}
func waitTransmitPeer(ctx context.Context, t *testing.T, link *rbdmirror.Mirror, pool, destinationSite string) {
t.Helper()
poll(ctx, t, "source never listed "+destinationSite+" as a transmit peer", func() bool {
out, err := link.SourceRBD(ctx, "mirror", "pool", "info", pool, "--format", "json")
if err != nil {
return false
}
var info struct {
Peers []struct {
SiteName string `json:"site_name"`
MirrorUUID string `json:"mirror_uuid"`
Direction string `json:"direction"`
} `json:"peers"`
}
if json.Unmarshal(out, &info) != nil {
return false
}
for _, p := range info.Peers {
if p.SiteName == destinationSite && p.MirrorUUID != "" &&
(p.Direction == "tx-only" || p.Direction == "rx-tx") {
return true
}
}
return false
})
}
// rbdCLI is link.SourceRBD or link.DestinationRBD of the cluster to promote.
func promote(ctx context.Context, t *testing.T, rbdCLI func(context.Context, ...string) ([]byte, error), spec string) {
t.Helper()
poll(ctx, t, "promote "+spec, func() bool {
_, err := rbdCLI(ctx, "mirror", "image", "promote", spec)
return err == nil
})
}The examples write test data with rbd bench --io-type write, which goes
through librbd like any application writer. Use your own client in a real test.
func TestSnapshotMirroring(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
newCluster := func() *ceph.Container {
cluster, err := ceph.Run(ctx, ceph.DefaultImage,
rbd.WithPools(ceph.PoolConfig{Name: "volumes"}),
)
testcontainers.CleanupContainer(t, cluster)
if err != nil {
t.Fatal(err)
}
return cluster
}
source, destination := newCluster(), newCluster()
destinationFacts, err := ceph.FactsOf(destination)
if err != nil {
t.Fatal(err)
}
link, err := rbdmirror.Run(ctx, destinationFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: source, Destination: destination, Pool: "volumes",
}))
testcontainers.CleanupContainer(t, link) // runs before the clusters' cleanup
if err != nil {
t.Fatal(err)
}
waitTransmitPeer(ctx, t, link, "volumes", "destination")
const spec = "volumes/disk"
write := func() {
if _, err := link.SourceRBD(ctx, "bench", "--io-type", "write",
"--io-size", "64K", "--io-total", "2M", "--io-pattern", "rand", spec); err != nil {
t.Fatal(err)
}
}
if _, err := link.SourceRBD(ctx, "create", "--size", "8M",
"--image-feature", "layering,exclusive-lock", spec); err != nil {
t.Fatal(err)
}
write()
// Enabling snapshot mirroring takes the first mirror snapshot.
if err := link.EnableImage(ctx, "disk"); err != nil {
t.Fatal(err)
}
if _, err := link.WaitReplayReady(ctx, "disk"); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, link, spec, spec)
// Later writes travel only with the next mirror snapshot.
write()
if _, err := link.SourceRBD(ctx, "mirror", "image", "snapshot", spec); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, link, spec, spec)
}Snapshot mirroring is asynchronous and copies the image as of each mirror
snapshot. Writes after the latest mirror snapshot are not on the destination.
Instead of explicit snapshots you can let the MGR rbd_support module take
them (rbd mirror snapshot schedule add --pool volumes 1m), but explicit
snapshots make tests deterministic.
Journal mirroring replays every write. No mirror snapshots are involved.
destinationFacts, err := ceph.FactsOf(destination)
if err != nil {
t.Fatal(err)
}
link, err := rbdmirror.Run(ctx, destinationFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: source, Destination: destination, Pool: "volumes",
Mode: rbdmirror.ModeJournal,
}))
testcontainers.CleanupContainer(t, link)
if err != nil {
t.Fatal(err)
}
// Journal mode needs exclusive-lock; EnableImage adds the journaling feature.
if _, err := link.SourceRBD(ctx, "create", "--size", "8M",
"--image-feature", "layering,exclusive-lock", "volumes/disk"); err != nil {
t.Fatal(err)
}
if err := link.EnableImage(ctx, "disk"); err != nil {
t.Fatal(err)
}
if _, err := link.WaitReplayReady(ctx, "disk"); err != nil {
t.Fatal(err)
}
write() // any librbd writer
waitReplicated(ctx, t, link, "volumes/disk", "volumes/disk")With pool scope, every image that has the journaling feature is mirrored
without EnableImage (which is rejected in pool scope):
destinationFacts, err := ceph.FactsOf(destination)
if err != nil {
t.Fatal(err)
}
link, err := rbdmirror.Run(ctx, destinationFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: source, Destination: destination, Pool: "volumes",
Scope: rbdmirror.ScopePool, // Mode defaults to journal
}))
// ...
_, err = link.SourceRBD(ctx, "create", "--size", "8M",
"--image-feature", "layering,exclusive-lock,journaling", "volumes/disk")Pool scope also enrolls existing journaled images, so quiesce their writers before creating the link.
A stopped receiver does not lose journaled writes: they wait in the source
journal and are replayed when the daemon starts again. While the daemon is
stopped, ImageStatus reports ReplayReady == false with the same image IDs,
and WaitReplayReady gives up after four minutes, or at the caller's earlier
deadline, and returns the last observation.
Planned failover moves the primary image from A to B and back without
--force. Each direction needs its own link, so each cluster runs receivers
for the other. Give each cluster one site name and keep it in both links.
// The receivers run on each link's destination, so each link uses its
// destination's image.
aFacts, err := ceph.FactsOf(a)
if err != nil {
t.Fatal(err)
}
bFacts, err := ceph.FactsOf(b)
if err != nil {
t.Fatal(err)
}
forward, err := rbdmirror.Run(ctx, bFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: a, Destination: b, Pool: "volumes",
SourceSite: "site-a", DestinationSite: "site-b",
}))
testcontainers.CleanupContainer(t, forward)
if err != nil {
t.Fatal(err)
}
reverse, err := rbdmirror.Run(ctx, aFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: b, Destination: a, Pool: "volumes",
SourceSite: "site-b", DestinationSite: "site-a",
}))
testcontainers.CleanupContainer(t, reverse)
if err != nil {
t.Fatal(err)
}
waitTransmitPeer(ctx, t, forward, "volumes", "site-b")
waitTransmitPeer(ctx, t, reverse, "volumes", "site-a")
// forward.SourceRBD and reverse.DestinationRBD run on A;
// forward.DestinationRBD and reverse.SourceRBD run on B.
const spec = "volumes/disk"
// ... create the image on A, write, forward.EnableImage(ctx, "disk") ...
waitReplicated(ctx, t, forward, spec, spec)
// Failover A -> B. Stop A's writers first.
if _, err := forward.SourceRBD(ctx, "mirror", "image", "demote", spec); err != nil {
t.Fatal(err)
}
promote(ctx, t, forward.DestinationRBD, spec) // retries until the demotion arrives
// B is primary. Its changes reach A through the reverse link.
// ... write on B ...
if _, err := reverse.SourceRBD(ctx, "mirror", "image", "snapshot", spec); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, reverse, spec, spec)
// Failback B -> A.
if _, err := reverse.SourceRBD(ctx, "mirror", "image", "demote", spec); err != nil {
t.Fatal(err)
}
promote(ctx, t, reverse.DestinationRBD, spec)
// A replicates to B again.
// ... write on A ...
if _, err := forward.SourceRBD(ctx, "mirror", "image", "snapshot", spec); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, forward, spec, spec)User snapshots taken on either side travel with the image and keep their
content through both moves. With journal mode the sequence is the same without
the mirror image snapshot calls; the promotion retries until the receiver
has consumed the demotion marker from the journal.
When the reverse link is created, A already has a tx-only peer for B, which
the forward link's daemon registered. Run turns it into rx-tx, so the
forward transmission keeps working. Using different site names for the same
cluster in the two links is rejected.
A forced promotion while the other side is still primary forks the image. Ceph reports split-brain, and you recover by choosing one side and resynchronizing the other. This discards the losing side's changes; it is not a merge.
const spec = "volumes/disk"
// The image is enabled and replicated from A (source) to B (destination).
// Fork it on purpose: stop B's receiver and force-promote B.
grace := 3 * time.Second
if err := link.Stop(ctx, &grace); err != nil { // the initial daemon
t.Fatal(err)
}
out, err := link.DestinationRBD(ctx, "mirror", "pool", "info", "volumes", "--format", "json")
if err != nil {
t.Fatal(err)
}
var info struct{ Peers []struct{ UUID string } }
if err := json.Unmarshal(out, &info); err != nil || len(info.Peers) != 1 {
t.Fatalf("expected one peer: %s %v", out, err)
}
// In snapshot mode B needs a transmit-capable peer to create its own mirror snapshots.
if _, err := link.DestinationRBD(ctx, "mirror", "pool", "peer", "set", "volumes",
info.Peers[0].UUID, "direction", "rx-tx"); err != nil {
t.Fatal(err)
}
if _, err := link.DestinationRBD(ctx, "mirror", "image", "promote", "--force", spec); err != nil {
t.Fatal(err)
}
// ... both sides are primary now; write different data on A and on B ...
// Keep A: demote B, restart its receiver and wait for the split-brain report.
if _, err := link.DestinationRBD(ctx, "mirror", "image", "demote", spec); err != nil {
t.Fatal(err)
}
if err := link.Start(ctx); err != nil {
t.Fatal(err)
}
poll(ctx, t, "split-brain report", func() bool {
out, err := link.DestinationRBD(ctx, "mirror", "image", "status", spec, "--format", "json")
var status struct{ State, Description string }
return err == nil && json.Unmarshal(out, &status) == nil &&
strings.Contains(status.State, "error") &&
strings.Contains(strings.ToLower(status.Description), "split-brain")
})
if _, err := link.DestinationRBD(ctx, "mirror", "image", "resync", spec); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, link, spec, spec)After the resync, B holds A's data and A's snapshots, B's own snapshots are gone, and the next mirror snapshot from A replicates normally. Snapshot mirroring never protects writes made after the latest checkpoint, and nothing in the module fails over automatically.
// Remove the destination's receiving peer while the receiver is stopped.
grace := 3 * time.Second
if err := link.Stop(ctx, &grace); err != nil {
t.Fatal(err)
}
if _, err := link.DestinationRBD(ctx, "mirror", "pool", "peer", "remove", "volumes", peerUUID); err != nil {
t.Fatal(err)
}
if err := link.Start(ctx); err != nil {
t.Fatal(err)
}
// The daemon runs, but new checkpoints no longer reach the destination.
// Existing destination data is kept.
if err := link.Rebootstrap(ctx); err != nil { // new peer with a new UUID
t.Fatal(err)
}
if _, err := link.DestinationRBD(ctx, "mirror", "image", "resync", "volumes/disk"); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, link, "volumes/disk", "volumes/disk")NoInitialDaemons sets up policies and the peer but starts no daemon, so you
can prepare data on the source and choose when replication begins.
destinationFacts, err := ceph.FactsOf(destination)
if err != nil {
t.Fatal(err)
}
link, err := rbdmirror.Run(ctx, destinationFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: source, Destination: destination, Pool: "volumes",
NoInitialDaemons: true,
}))
testcontainers.CleanupContainer(t, link)
if err != nil {
t.Fatal(err)
}
status, err := link.ReceiverStatus(ctx) // Ready == false, no daemons
if err != nil {
t.Fatal(err)
}
_ = status
// ... create images and write on the source; nothing replicates yet ...
if _, err := link.AddDaemon(ctx, "receiver"); err != nil {
t.Fatal(err) // a non-nil daemon returned with the error is still owned by link
}
if _, err := link.WaitReceiverReady(ctx); err != nil {
t.Fatal(err)
}
waitTransmitPeer(ctx, t, link, "volumes", "destination")
if err := link.EnableImage(ctx, "disk"); err != nil {
t.Fatal(err)
}
if _, err := link.SourceRBD(ctx, "mirror", "image", "snapshot", "volumes/disk"); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, link, "volumes/disk", "volumes/disk")The link still owns its two setup containers. Daemons() is empty and the
embedded container is nil until the first AddDaemon. A freshly configured
link differs from one whose daemons ran and were removed: the source does not
know the destination as a transmit peer until a daemon has connected, which is
why the example waits for it before enabling the image. Pool scope with
journaled images needs neither EnableImage nor a mirror snapshot. The option
controls only this link's daemons; it does not prove that no other
rbd-mirror serves the pool.
destinationFacts, err := ceph.FactsOf(destination)
if err != nil {
t.Fatal(err)
}
link, err := rbdmirror.Run(ctx, destinationFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: source, Destination: destination, Pool: "volumes",
DaemonCount: 2, // daemons "a" and "b"
}))
testcontainers.CleanupContainer(t, link)
if err != nil {
t.Fatal(err)
}
status, err := link.WaitReceiverReady(ctx)
if err != nil {
t.Fatal(err)
}
// Stop the leader and wait until the survivor leads alone.
survivor := "a"
if status.LeaderDaemonName == "a" {
survivor = "b"
}
grace := 3 * time.Second
for _, d := range link.Daemons() {
if d.DaemonName == status.LeaderDaemonName {
if err := d.Stop(ctx, &grace); err != nil {
t.Fatal(err)
}
}
}
if _, err := link.WaitReceiverReady(ctx, survivor); err != nil {
t.Fatal(err)
}Without names, the stopped daemon stays expected and the wait never succeeds.
While the stopped member is still in the native election, the wait keeps
polling until Ceph drops it. Add a replacement with AddDaemon and start a
new wait afterwards; an ongoing wait does not adopt new daemons.
One link owns the daemons and the peer for a pool. Additional namespace pairs
in the same pool are configured with native commands and observed through
BindNamespace. Different source and destination namespace names need
Ceph 20.
// The namespaces exist on both clusters (rbd.CreateNamespace on each).
destinationFacts, err := ceph.FactsOf(destination)
if err != nil {
t.Fatal(err)
}
owner, err := rbdmirror.Run(ctx, destinationFacts.Images.RBDAux, rbdmirror.WithConfig(rbdmirror.Config{
Source: source, Destination: destination, Pool: "volumes",
SourceNamespace: "app", DestinationNamespace: "app-replica",
}))
testcontainers.CleanupContainer(t, owner)
if err != nil {
t.Fatal(err)
}
// A second pair with the same site names and an explicit remote namespace.
if _, err := owner.SourceRBD(ctx, "mirror", "pool", "enable", "--site-name", "source",
"volumes/batch", "image", "--remote-namespace", "batch-replica"); err != nil {
t.Fatal(err)
}
if _, err := owner.DestinationRBD(ctx, "mirror", "pool", "enable", "--site-name", "destination",
"volumes/batch-replica", "image", "--remote-namespace", "batch"); err != nil {
t.Fatal(err)
}
view, err := owner.BindNamespace(ctx, "batch", "batch-replica")
if err != nil {
t.Fatal(err)
}
if _, err := view.WaitReceiverReady(ctx); err != nil {
t.Fatal(err)
}
_, _ = owner.SourceRBD(ctx, "create", "--size", "8M", "volumes/batch/disk")
_, _ = owner.SourceRBD(ctx, "mirror", "image", "enable", "volumes/batch/disk", "snapshot")
if _, err := view.WaitReplayReady(ctx, "disk"); err != nil {
t.Fatal(err)
}
waitReplicated(ctx, t, owner, "volumes/batch/disk", "volumes/batch-replica/disk")The two native commands are your own writes; they can leave partial
configuration if the second one fails, and nothing rolls them back.
BindNamespace only reads. Separate views can become ready at different
times; their results are not one atomic observation.
- API reference: pkg.go.dev or
go docon each package - Repository: README and guides
- Purpose and scope: API purpose and purpose tags