Regional-DR build runbook (the proper way)
Stand up ODF Regional-DR (async RBD mirroring) between two air-gapped OCP clusters, from an ACM hub, using oc + GitOps only. Build in the order below. Each phase has a do-it-right note, a verify, and the pitfall it avoids. Doing Phase 3 correctly is what saves you days.
Components
| Layer | Component | Namespace |
|---|---|---|
| Network | Submariner (libreswan IPsec) | submariner-operator |
| Storage peering | ODF MirrorPeer → StorageClusterPeer | openshift-storage |
| Replication | Ceph RBD async mirror (rook-ceph-rbd-mirror) |
openshift-storage |
| Orchestration (hub) | ODR Hub Operator (Ramen) | openshift-operators |
| Orchestration (spoke) | ODR Cluster Operator (Ramen) | openshift-dr-system |
| DR metadata store | Ramen S3 = ODF NooBaa bucket | openshift-storage |
Prerequisites (fix these before you start)
- ACM hub + 2 managed ODF clusters, all in one ManagedClusterSet.
- Non-overlapping pod + service CIDRs across the two clusters. Decide at install time.
- Mirrored operators in the air-gap catalog:
submariner,odf-multicluster-orchestrator,odr-hub-operator,odr-cluster-operator. - ODF healthy on both clusters; RBD block pool present.
- Know your air-gap catalog name (here:
cs-redhat-operator-index-v4-20). You will need it.
Why non-overlapping CIDRs matter: overlap forces Submariner Globalnet, and ODF StorageClusterPeer uses raw ClusterIPs that Globalnet cannot NAT — peering never completes. Fixing this later means rebuilding a cluster. Plan it up front.
Phase 1 — Submariner
Do it right:
- Broker with
globalnetEnabled: false(CIDRs don’t overlap). - One gateway node per cluster, labeled
submariner.io/gateway=true. SubmarinerConfig:cableDriver: libreswan,airGappedDeployment: true,subscriptionConfig.source= your air-gap catalog, channelstable-0.24.
Pitfall — gateway-node pod scheduling (this one is subtle):
- Pods running on the Submariner gateway node cannot reach the remote cluster. OVN’s gateway router SNATs their egress to the node IP, which no longer matches the IPsec tunnel selector, so their packets never enter the tunnel.
- Keep DR-critical pods off the gateway node:
ocs-operator,rook-ceph-rbd-mirror, the ODF provider/client pods. - Best practice: a dedicated gateway worker with no ODF workload. On 3-node compact clusters, at least check placement (
oc get pods -n openshift-storage -o widevs the gateway node) and reschedule offenders (cordon gateway, delete pod, uncordon).
Verify:
oc get submariner -n submariner-operator ...connection statusconnectedboth ways.- Cross-cluster pod→pod from a non-gateway pod succeeds.
Phase 2 — ODF MirrorPeer (storage peering)
Do it right:
- Install
odf-multicluster-orchestratoron the hub. - Create a
MirrorPeer(type: async,schedulingIntervals: ["5m"]) referencing both StorageClusters. - The orchestrator drives StorageClusterPeer →
Peered, exchanges S3 secrets, and enables RBD pool mirroring automatically.
Pitfall — the peering gRPC to the remote provider (:50051) is issued by ocs-operator. If it sits on the gateway node, StorageClusterPeer = Failed (see Phase 1). Move it off.
Verify:
| Check | Expected |
|---|---|
oc get mirrorpeer ... .status.phase |
ExchangedSecret (or later) |
StorageClusterPeer .status.state (both clusters) |
Peered |
CephBlockPool .spec.mirroring.enabled (both) |
true |
rook-ceph-rbd-mirror pods |
Running, off the gateway node |
Phase 3 — Ramen hub operator + S3 trust (the step most builds get wrong)
Do these before creating any DRCluster, in this order.
3a. Install ODR Hub Operator
- CSV
odr-hub-operatoron the hub. It creates cmramen-hub-operator-configinopenshift-operators.
3b. Point Ramen at the air-gap catalog
- Ramen’s
drClusterOperator.catalogSourceNamedefaults toredhat-operators. - In air-gap the mirror catalog has a different name, so the auto-installed odr-cluster-operator subscription later fails
ResolutionFailed. - Two options:
- Edit cm
ramen-hub-operator-config→drClusterOperator.catalogSourceName: cs-redhat-operator-index-v4-20, then delete the ramen-hub pod to reload. - Cleaner/GitOps-able: create a
CatalogSourceliterally namedredhat-operatorsinopenshift-marketplacepointing at your mirror index, so the default just works.
- Edit cm
Config reload: Ramen reads its config only at startup.
oc rollout restartis reverted by OLM — insteadoc delete pod <ramen-hub-pod>.
3c. Make Ramen trust the NooBaa route cert — the RIGHT way
Key facts (this is why the naive fix fails):
- Ramen’s S3 client uses path-style + Go’s system cert pool. It does not use the profile
caCertificatesfield for object-store validation (that field only feeds the Velero path). - The S3 endpoint is the external NooBaa route
https://s3-openshift-storage.apps.<cluster>.<domain>, served by the cluster’s default ingress cert (issuerCN=ingress-operator@<ts>, per-cluster, self-signed — not your lab CA). - So the CA must go into Ramen’s system trust, not the profile.
Steps:
- Get each cluster’s ingress CA:
oc --kubeconfig <c> get cm default-ingress-cert -n openshift-config-managed -o jsonpath='{.data.ca-bundle\.crt}' - Add both CAs to cm
user-ca-bundle(openshift-config) — the one referenced byproxy/cluster .spec.trustedCA.- GitOps:
user-ca-bundleis Argo-managed (hub/cluster-trust/user-ca-bundle.yaml). Edit it in Git, not withoc— Argo self-heal reverts manualocedits.
- GitOps:
- CNO merges it into
openshift-trusted-cabundle, which ramen-hub mounts. - Delete the ramen-hub pod (loads system roots once at startup; won’t auto-detect new certs — documented ODF known issue).
Verify trust before going further — from the ramen-hub pod:
curl -s -o /dev/null -w '%{ssl_verify_result}\n' \
https://s3-openshift-storage.apps.<c>.<domain>/<bucket> # want 0 (trusts)
3d. S3 profiles + secrets
- Create ramen-format secrets
s3secret-<c>(keysAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) inopenshift-operators, from each cluster’s ODF S3 creds (the ODF NooBaaodrbucketsecret). - Add both profiles to
ramen-hub-operator-configs3StoreProfiles:s3ProfileName,s3Bucket,s3CompatibleEndpoint(the route),s3Region,s3SecretRef. - Keep any
caCertificatesbase64 on one line (YAML wrapping corrupts[]byte). - Delete the ramen-hub pod to reload.
Phase 4 — DRClusters + DRPolicy
Only after Phase 3 trust check returns 0.
DRCluster/wl spec.region: region-wl s3ProfileName: s3profile-wl-...
DRCluster/wl-dr spec.region: region-wl-dr s3ProfileName: s3profile-wl-dr-...
DRPolicy/dr-policy-5m drClusters: [wl, wl-dr] schedulingInterval: 5m
- Distinct regions ⇒ async / Regional-DR. (Same region = Metro/sync.)
- Creating the DRPolicy auto-installs
odr-cluster-operatoron both spokes.
Pitfall — never oc delete a DRCluster that is mid-reconcile. Its finalizer hangs it in Terminating, Ramen takes the delete code path, and validation freezes. If stuck:
oc patch drcluster <n> --type merge -p '{"metadata":{"finalizers":[]}}' # then recreate
Verify (spoke operator install can take ~10 min):
| Check | Expected |
|---|---|
oc get drcluster (both) |
Validated=True/Succeeded, phase=Available |
spoke oc get csv -n openshift-dr-system \| grep odr-cluster-operator |
Succeeded |
oc get drpolicy dr-policy-5m Validated |
True |
oc get drpolicy dr-policy-5m -o jsonpath='{.status.async.peerClasses}' |
RBD class with replicationID |
Phase 5 — Protect a workload (DRPlacementControl)
- Create a
DRPlacementControl(+Placement) referencingdr-policy-5mand the app’s PVC label selector. - Ramen then mirrors those PVCs and can fail the app over to the secondary.
GitOps ownership map
| Artifact | Location | Owner |
|---|---|---|
| DRPolicy, DRClusters, DRPlacementControls | hub/regional-dr/ |
Git / Argo |
Ingress-CA trust (user-ca-bundle) |
hub/cluster-trust/ |
Git / Argo |
Ramen s3StoreProfiles + caCertificates + catalogSourceName |
cm ramen-hub-operator-config |
Operator runtime — re-apply after ODF upgrade |
s3secret-* |
openshift-operators |
Secret — never in Git |
Upgrade caution: an ODF operator upgrade wipes the runtime Ramen config (s3 profiles, CA, catalog name). Re-apply them after every ODF upgrade. Documented ODF known issue.
Failure → cause → fix (quick reference)
| Symptom | Cause | Fix |
|---|---|---|
DRCluster s3ListFailed / send request failed |
Ramen doesn’t trust NooBaa route cert (system trust) | Add ingress CA to user-ca-bundle (GitOps), delete ramen-hub pod |
Spoke subscription ResolutionFailed |
Wrong catalog name in ramen config | Set drClusterOperator.catalogSourceName to air-gap catalog, delete ramen-hub pod |
DRCluster Validated frozen; “delete” in logs |
Stuck Terminating (finalizer) |
Clear finalizers, recreate |
StorageClusterPeer Failed |
ocs-operator on Submariner gateway node |
Move ocs-operator off gateway |
| Cross-cluster pod traffic fails one direction | Source pod on gateway node (OVN SNAT) | Keep DR pods off gateway node |
| Config edits ignored | Ramen reads config only at startup | oc delete pod <ramen-hub> (not rollout restart) |
| Everything breaks after ODF upgrade | Upgrade wiped runtime ramen config | Re-apply s3 profiles / CA / catalog |
Debugging notes
- Ramen logs only the aws-sdk wrapper (
RequestError: send request failed) even at-zap-log-level=debug— it hides the realx509cause. - Reproduce the real S3 error with
boto3(path-style + CA= OK confirms the recipe). - Set Ramen debug via the CSV
odr-hub-operator(OLM reverts plain deployment patches).