Runbooks

Every runbook starts with the same incident header. Fill it in before making changes.
| Field | Value |
|---|---|
| Severity | Set from paging policy |
| Expected impact | Read/write outage, degraded failover, backup risk, or no user impact |
| Data loss risk | None, possible async lag loss, or unknown |
| Prerequisites | kubectl, namespace access, MySQL credentials if SQL is needed |
| Required permissions | Read CRs/Events/logs; write CR annotations only when runbook says so |
| Estimated time | Record expected and actual time |
| Rollback path | Document before action |
| Verification command | Always include active site, DNS, and app write check when relevant |
If spec.dragonfly.enabled=true, include status.dragonfly.phase, active Dragonfly Service endpoints, and a Redis-compatible cache/session read/write check in the same incident record.
Planned failover
Use Planned Failover for controlled maintenance. Do not use during split-brain or when the target replica is unhealthy.
kubectl annotate mysqlfailovergroup orders -n orders \
bloodraven.shipstream.io/planned-failover=pdx --overwrite
kubectl get mysqlfailovergroup orders -n orders -o wide
Every PrimaryCandidate and DisasterRecovery site must be up and answering: a planned failover waits (admission.waitReason: PeerUnreadable) and then ends NotStarted with AdmissionTimeout while one is down. Bring the site back first (a site cannot be removed from spec.sites; to drop one, see Replacing a site).
Wait for the run to finish, then check its outcome, not only the phase:
kubectl get mysqlfailovergroup orders -n orders \
-o jsonpath='{.status.plannedFailover.phase}{" "}{.status.plannedFailover.result.outcome}{" "}{.status.plannedFailover.result.reason}{" "}{.status.plannedFailover.result.warnings}{"\n"}'
kubectl bloodraven promote orders pdx --wait does the same and also reports a run the operator ended with the bloodraven.shipstream.io/planned-failover-terminal annotation while status writes were failing (status may still read Promoting); kubectl bloodraven status orders prints Ended by terminal marker: <outcome>:<reason> for it. Treat the marker's outcome like result.outcome below.
Verify DNS and application writes before starting maintenance on the old active site.
If Dragonfly is enabled, also verify status.plannedFailover.dragonfly.sessions (Preserved or Lost) and confirm the active Dragonfly Service points at the new site.
kubectl get mysqlfailovergroup orders -n orders \
-o jsonpath='{.status.plannedFailover.dragonfly}{"\n"}'
kubectl get endpoints orders-dragonfly -n orders
If the run did not succeed, result.outcome decides what to do:
| Outcome | Action |
|---|---|
NotStarted, RolledBack | Nothing was written to the target and the source is writable. Fix result.reason and re-annotate. If result.warnings includes TargetReplicationNotRestored (with SourceReplaced, say), the target was left without a running channel: restore it by hand or reclone the target first. If it includes TargetSuperReadOnlyNotRestored, first run SET GLOBAL super_read_only = ON on the target and confirm SELECT @@super_read_only reads 1: until then privileged sessions can write to it beside the source. |
SourceLeftFenced | Follow Planned failover left the source fenced. |
PromotedUnconfirmed | Follow Promoted but unconfirmed. |
ManualRecoveryRequired | Follow Planned failover automation hold. |
Planned failover left the source fenced
Use when status.plannedFailover.result.outcome is SourceLeftFenced. Nothing was written to the target; the source is read-only.
- Read the reason, the warnings and the message:
kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.status.plannedFailover.result}{"\n"}{.status.plannedFailover.message}{"\n"}{.status.lastFailover.target}{"\n"}' - Check every site's state:
SELECT @@GLOBAL.read_only, @@GLOBAL.super_read_only, @@GLOBAL.gtid_executed;. - If another site is writable, that site is the primary. Do not unfence the source; old-primary recovery repoints it as a replica.
- If no site is writable, decide by the healing table:
SourceLost, or a peer unreachable: emergency failover decides. Restore connectivity.- An attributable fence (no
FenceNotAttributablewarning; reasons such asStatusStalled,ManagerUnavailableor a rollback that could not confirm the unfence), every site reachable, andstatus.lastFailover.targetempty or the source: the topology manager re-asserts the source on its own withinfailoverCooldown(WARNre-asserting fenced promoted primary). Wait for it. FenceNotAttributable(also on a run that sent its fence and then ended because authority moved),LegacyRunRefused,UnknownSite(the source or target was removed fromspec.sitesmid-run, so nothing was named),AuthorityChangedbefore the fence was applied, orstatus.lastFailover.targetnaming another site: manual. AFenceNotAttributableorLegacyRunRefusedmessage ends with this advice (check which site is writable; if none is, unfence the site with the most complete GTID set by hand (confirm no other site is writable first); re-annotate only once a site is writable). Pick the site with the most complete GTID set, confirm no other site is writable, and unfence it (SET GLOBAL super_read_only=OFF; SET GLOBAL read_only=OFF;). Do not re-annotate a planned failover instead: with no writable site, admission waits (AuthorityUnsettled) and endsNotStarted{AdmissionTimeout}after 2 minutes, and writes stay refused all along. Request a planned failover only after one site is writable.
- Verify
status.activeSite, DNS and an application write.
While the run's own attributable fence has not healed (the source is not yet observed writable and no failover was recorded since the run started), a new planned-failover request is refused with a PlannedFailoverRejected Warning (... left source "<site>" fenced and the fence has not healed yet; ...) and status.plannedFailover keeps the fenced run: let the re-assert finish, or unfence by hand, then re-annotate.
Planned failover automation hold
Use when the AutomaticFailoverHeld condition is True (status.plannedFailoverHold is set), you see PlannedFailoverManualRecoveryRequired or PlannedFailoverHoldPersisted Events, or a bootstrap, reclone or keyring rotation is refused with automatic failover is held after planned failover <runID>; resolve the split brain, then annotate bloodraven.shipstream.io/acknowledge-planned-failover-hold=<runID> (a rotation adds ; retrying after the acknowledgement). A planned failover may have written to its target and ended ManualRecoveryRequired. Emergency failover, split-brain resolution, the re-assert, ordered updates, rotations, recovery and DNS reconciliation are held for the group until you acknowledge the hold. Dragonfly is held as well: a failed Dragonfly master is not replaced, replicas are not re-attached, and Dragonfly rollouts and snapshot upgrades do not promote, so a Dragonfly failure during the hold is an outage of the cache until you acknowledge. Writes continue on whichever site is writable, and backups, backup schedules, verification and keyring work keep running.
| Field | Value |
|---|---|
| Severity | High: automatic failover is off for this group |
| Data loss risk | Unknown until the sites are compared; the hold exists so that automation does not discard writes |
| Required permissions | SQL on every site; annotate the MysqlFailoverGroup |
| Rollback path | None needed: acknowledging only resumes automation |
- Read the held run and the sites it names:
kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.status.plannedFailoverHold}{"\n"}{.status.plannedFailover.runID}{" "}{.status.plannedFailover.result}{"\n"}{.metadata.annotations.bloodraven\.shipstream\.io/planned-failover-terminal}{"\n"}'status.plannedFailoverHoldnames the run (runID), itsreason,sourceSite,targetSiteand theambiguousSites: the target, the source, and every site the failing step found writable or could not read. A hold rebuilt from the terminal marker by a process that did not see the run lists every site inspec.sites. When status writes had stalled, a terminal-marker annotation{"schemaVersion":1,"runID":"<runID>","outcome":"ManualRecoveryRequired","reason":"<reason>"}(optionally with awarningsarray) carries the same hold instead. - On every site read
@@GLOBAL.read_only,@@GLOBAL.super_read_only,@@GLOBAL.gtid_executedandSHOW REPLICA STATUS. Compare the source and the target withGTID_SUBTRACTboth ways. - Resolve the split brain by hand. Decide which site is the primary from the data, not from history: the site holding the transactions you must keep. Fence every other site (
SET GLOBAL super_read_only=ON;). If both sites took writes, preserve the losing site's extra transactions (mysqlbinlog --include-gtids=<extra set>) before anything reclones it. - Make sure exactly one
PrimaryCandidateis writable and DNS points at it. The topology manager does not move DNS while the hold stands. - Acknowledge the hold with the run's id. The plugin reads it from the group:
kubectl bloodraven ack-planned-failover-hold orders -n orders # or, by hand: kubectl annotate mysqlfailovergroup orders -n orders \ bloodraven.shipstream.io/acknowledge-planned-failover-hold=<runID> - Verify:
status.plannedFailoverHolddisappears, the condition turnsFalsewith reasonPlannedFailoverHoldAcknowledged, the annotation is removed by the operator, the Normal EventPlannedFailoverHoldAcknowledgedis emitted, and the log showsplanned failover: hold acknowledgedthenplanned failover: claim releasedwithwhy=ack. Automation then resumes from the state you left: old-primary recovery repoints the fenced site, or reports divergence for you to reclone.
An acknowledgement naming another run is ignored (PlannedFailoverHoldAcknowledgmentIgnored); correct the value. One that finds no standing hold, or the claim held for another run, is ignored the same way and removed; nothing is resumed. A True condition without status.plannedFailoverHold (a hand-edited status, or a CRD rolled back across the planned-failover claim release, which prunes the hold record but keeps the condition) names no run and cannot be acknowledged: after resolving the split brain, remove the condition by hand (kubectl patch mysqlfailovergroup orders -n orders --subresource=status --type=json -p '[{"op":"remove","path":"/status/conditions/<index>"}]'). The operator then releases the hold on its next pass, without a restart: the log shows planned failover: released an automation hold no durable record backs and planned failover: claim released with why=orphanHold. Until then such a refusal reads automatic failover is held by a True AutomaticFailoverHeld condition that names no run; resolve the split brain, then remove the condition. Do not acknowledge before step 4: the next poll would act on whatever state the sites are in.
Dragonfly degraded
Use when status.dragonfly.phase=Degraded, bloodraven_dragonfly_site_up==0, Dragonfly promotion failed, status.dragonfly.replTakeoverSupported=false, or you see DragonflySessionsLost / DragonflyReplTakeoverUnsupported. MySQL is still authoritative; this runbook protects cache/session continuity.
- Confirm MySQL
status.activeSiteand application writes are healthy. - Inspect
status.dragonfly.replTakeoverSupportedandreplTakeoverProbeMessage. A false value means the running image does not advertiseREPLTAKEOVER— pinv1.38.0+(or a private build that has the command) and roll the StatefulSets. - Inspect
status.dragonfly.sites[]for sites whoseroleisUnreachableorStaleMaster, and for syncing or link-down sites. - Check the active Dragonfly Service endpoints and pod role/traffic labels.
- Restart or reschedule only the affected Dragonfly pod if the stale site has not accepted writes.
- If a stale master may have accepted writes, keep it out of the active Service and decide whether cache/session data can be discarded.
kubectl get mysqlfailovergroup orders -n orders \
-o jsonpath='{.status.dragonfly}{"\n"}'
kubectl get pod -n orders -l app.kubernetes.io/name=dragonfly --show-labels
kubectl get endpoints orders-dragonfly -n orders
Emergency manual promotion
Use only when automation cannot complete and you have confirmed the old primary cannot accept writes.
- Preserve Events and logs.
- Confirm old primary is unreachable or fenced.
- Confirm candidate has the best GTID/replication position available.
- Promote using your approved MySQL operational procedure.
- Update or unblock Bloodraven reconciliation only after state is consistent.
Verification:
kubectl get mysqlfailovergroup orders -n orders -o yaml
dig orders.az.example.com
Split-brain recovery
Use when more than one site can accept writes.
- Stop application writes if possible.
- Identify all writable sites.
- Choose the authoritative site using business ownership and GTID evidence.
- Fence losing sites by ensuring
super_read_only=ONand blocking application traffic. - Preserve divergent data for manual reconciliation.
- Reclone losing sites only after data owners approve.
Do not skip GTIDs to make replication look green.
Divergent old primary recovery
Use when the old primary contains transactions absent from the promoted site.
- Export evidence from the old primary.
- Ask the data owner whether divergent rows must be recovered manually.
- Keep the old primary fenced.
- Reclone from the active primary when approved.
- Verify replication returns to healthy.
kubectl describe mysqlfailovergroup orders -n orders
kubectl get events -n orders --sort-by=.lastTimestamp | grep -i divergent
Divergent replica
Use when Degraded=True with reason DivergentTransactions names a site with
divergenceDetectedBy=ReplicaAudit, or an ordered update or planned failover
refuses a divergent target. The replica is usually replicating normally; its
extra transactions are history the primary never had.
- Read the set:
kubectl get mysqlfailovergroup orders -n orders -o jsonpath='{.status.sites[?(@.name=="<site>")].divergentGtid}'. - Decode and keep the errant events on the replica:
mysqlbinlog --include-gtids='<divergentGtid>' <binlog files> > errant.sql. - Ask the data owner whether the transactions must be kept.
- Discard them with
kubectl bloodraven reclone orders <site> -n orders, or keep them by replaying the decoded transactions on the primary. To accept the replica's divergence instead, commit an empty transaction per GTID on the primary (SET GTID_NEXT='<uuid>:<n>'; BEGIN; COMMIT;, thenSET GTID_NEXT='AUTOMATIC';). That makes the GTID sets agree without moving rows: the replica keeps rows the primary lacks, becomes promotable again once the audit clears, and a later promotion makes those rows authoritative with noDivergentPromotionrecord. Use it only when the transactions' effects are absent, harmless, or already reconciled. - Within about 30s the audit logs
divergence cleared: replica holds no transactions the primary lacks,Degradedclears, and a stalled ordered update resumes on its own.
See Divergent replicas.
PVC loss recovery
Use when a MySQL data PVC is deleted or corrupted.
- Confirm whether the affected site is active.
- If active, stop application writes and follow emergency recovery policy.
- If standby, reclone from the active primary.
- If both sites are lost, restore from backup.
See Backup And Restore for restore workflows.
Total site loss
Use when all nodes/storage/network for a site are gone.
- Confirm surviving site is writable and healthy.
- Confirm DNS points to the surviving site.
- Disable app scheduling to the lost site.
- Rebuild site infrastructure.
- Rejoin via clone or restore.
Expected impact is bounded by failover detection, relay-log drain, DNS TTL, and application reconnect behavior.
Keyring not sealed
Use when bloodraven_keyring_phase{phase="sealed"} is 0 for a site, or
status.conditions[type=EncryptionAtRestReady] is False.
A site that is not sealed is running with a writable keyring. Its data is still encrypted, but the operator cannot yet prove the key is durably escrowed.
- Find out what it is waiting for —
messagecarries the reason:kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{range .status.encryptionAtRest.sites[*]}{.name}{"\t"}{.phase}{"\t"}{.message}{"\n"}{end}' UnsealedwithunsealReason: CloneorBootstrapis normal and transient. Wait for the clone to finish.Unsealedwith "waiting for the sidecar to escrow" — check the sidecar can reach the operator:kubectl exec -n orders deploy/mysql-orders-iad -c sidecar -- \ wget -qO- localhost:8080/keyring/statuslastErroron that payload names the failure. Common causes: the operator's auxiliary Service is unreachable, or the escrow token Secret was deleted (the operator re-mints it on the next reconcile, but the pod needs to roll to pick it up).Escrowedthat never becomesSealed— the pod has not rolled onto the sealed rendering. Check for a stuck ordered update (status.updatePhase).Failed— read the message and follow Keyring escrow lost if it names a missing or corrupt Secret.
Full background: Data-at-rest encryption.
Keyring escrow lost
Use on a KeyringEscrowMissing or KeyringEscrowCorrupt Event.
- Confirm the pod is still running:
kubectl get pod -n orders -l bloodraven.shipstream.io/site=iad - If it is running, recreate the exact missing Secret from the keyring
still mounted in the running pod. Do not force an unseal or rollout:
that would destroy the only surviving in-memory copy before it was
durable again.
set -o pipefail # The site's MySQL pod, selected as its Deployment selects it (Dragonfly # and backup pods share the site label). Expect exactly one name. POD=$(kubectl get pod -n orders \ -l app.kubernetes.io/name=mysql,app.kubernetes.io/instance=orders,bloodraven.shipstream.io/site=iad \ --field-selector=status.phase=Running -o jsonpath='{.items[*].metadata.name}') [[ "$POD" != *" "* && -n "$POD" ]] || { echo "expected one MySQL pod, got: '$POD'" >&2; exit 1; } PHASE=$(kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.status.encryptionAtRest.sites[?(@.name=="iad")].phase}|{.status.encryptionAtRest.sites[?(@.name=="iad")].unsealReason}') # Only a sealed site whose escrow was lost: a rotation, bootstrap or # clone in progress owns its Secret. [[ "$PHASE" == "Failed|" || "$PHASE" == "Sealed|" ]] || { echo "site is $PHASE; not an escrow loss" >&2; exit 1; } SECRET=$(kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.status.encryptionAtRest.sites[?(@.name=="iad")].keyringSecret}') VERSION=$(kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.status.encryptionAtRest.sites[?(@.name=="iad")].keyringVersion}') DIGEST=$(kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.status.encryptionAtRest.sites[?(@.name=="iad")].keyringDigest}') KEYRING_DIR=$(kubectl get mysqlfailovergroup orders -n orders \ -o jsonpath='{.spec.encryptionAtRest.keyring.dataFileDir}') KEYRING_DIR=${KEYRING_DIR:-/run/mysql-keyring} FG_UID=$(kubectl get mysqlfailovergroup orders -n orders -o jsonpath='{.metadata.uid}') if kubectl get secret "$SECRET" -n orders -o name >/dev/null 2>&1; then echo "Secret $SECRET exists (KeyringEscrowCorrupt?): not touching it; see below" >&2 exit 1 fi KEYRING_FILE=$(mktemp) kubectl exec -n orders "$POD" -c mysql -- cat "$KEYRING_DIR/keyring" >"$KEYRING_FILE" || exit 1 ACTUAL_DIGEST="sha256:$(sha256sum "$KEYRING_FILE" | cut -d' ' -f1)" # macOS: shasum -a 256 if [[ "$ACTUAL_DIGEST" != "$DIGEST" ]]; then echo "the running keyring does not match status; nothing was written" >&2 exit 1 fi kubectl create secret generic "$SECRET" -n orders --from-file=keyring="$KEYRING_FILE" || exit 1 rm -f "$KEYRING_FILE" kubectl label secret "$SECRET" -n orders \ app.kubernetes.io/name=mysql-keyring \ app.kubernetes.io/managed-by=bloodraven \ bloodraven.shipstream.io/failover-group=orders bloodraven.shipstream.io/site=iad \ bloodraven.shipstream.io/keyring-version="$VERSION" kubectl annotate secret "$SECRET" -n orders bloodraven.shipstream.io/keyring-digest="$DIGEST" kubectl patch secret "$SECRET" -n orders --type=merge -p="{ \"metadata\":{\"ownerReferences\":[{ \"apiVersion\":\"shipstream.io/v1alpha1\",\"kind\":\"MysqlFailoverGroup\", \"name\":\"orders\",\"uid\":\"${FG_UID}\",\"controller\":true,\"blockOwnerDeletion\":true }]},\"immutable\":true }"
Do not set the phase back toSealedby hand. The operator does it once the Secret hashes to the recorded digest and MySQL confirms it is running that keyring (EventKeyringRecovered); a hand-written status could overwrite a rotation started meanwhile. Verify that the site returns toSealedwithout restarting the pod.
The doctor helper.agents/skills/bloodraven-doctor/scripts/keyring-escrow-recover.sh -n orders orders iaddoes the same and also stops if the site's state changes while it runs.
The block checks the running keyring against the recorded digest before it writes anything and never deletes a Secret. OnKeyringEscrowCorruptthe Secret still exists, so it refuses. Replacing it is a deliberate, separate step: back the existing Secret up (kubectl get secret "$SECRET" -n orders -o yaml > escrow-backup.yaml), delete exactly that Secret, and run the block again; it re-verifies the running keyring first. - If the pod is already gone, that site's data is unrecoverable.
Delete the unreadable PVC, then re-clone it from a healthy peer. The
confirmation token is mandatory because this permanently discards the
target site's local data:
kubectl delete pvc mysql-orders-iad-data -n orders kubectl bloodraven reclone orders iad --cold -n orders # or by hand, with the group's uid: kubectl annotate mysqlfailovergroup orders -n orders \ bloodraven.shipstream.io/reclone-site="iad:confirm=orders:uid=$(kubectl get mfg orders -n orders -o jsonpath='{.metadata.uid}')" - If every site lost its escrow, restore from backup. Backup artifacts are logical dumps protected by a separate key, so they do not depend on any site's keyring.
Network partition diagnosis
Use Network Partitions. Prioritize preventing two writable sites.
kubectl get mysqlfailovergroup orders -n orders -o yaml
kubectl get endpoints,networkpolicy -n orders
kubectl get events -n orders --sort-by=.lastTimestamp
Failed backup
- Keep the last successful backup.
- Inspect
MysqlBackupstatus and Job logs. - Fix credentials, storage, or MySQL source health.
- Trigger a manual backup.
- Verify the new backup.
kubectl get mysqlbackup -n orders
kubectl describe mysqlbackup -n orders <backup-name>
Failed restore
- Confirm this is a recovery environment or approved destructive action.
- Inspect restore Job logs.
- Validate source path and decryption Secret.
- Recreate the recovery failover group if bootstrap partially initialized MySQL.
Operator unavailable
Use Operator Availability. Sidecar self-fencing is part of the safety model; do not disable it during diagnosis.
kubectl rollout status deployment/bloodraven -n bloodraven
kubectl logs -n bloodraven deploy/bloodraven
kubectl get lease -A | grep bloodraven
DNS failover stuck
- Confirm
status.activeSitechanged. - Inspect
DNSEndpointand external-dns logs. - Confirm provider record and TTL.
- If applications still write old site, fence the old site and restart affected clients.
kubectl get dnsendpoint -A | grep orders
kubectl logs -n external-dns deploy/external-dns
dig orders.az.example.com
kubectl bloodraven plugin
kubectl-bloodraven is the operator's day-2 sidekick: a single static binary that wraps the annotation grammars and on-demand CR shapes the operator already understands. It is the recommended way to drive a planned failover, kick off an ad-hoc backup or verification, or peek at the current health of a MysqlFailoverGroup without reaching for raw kubectl annotate / kubectl apply incantations.
Troubleshooting

