Services, DNS steering, and taints
The three surfaces Bloodraven actually moves when it promotes: a label selector, an A record, and a node taint. What each one reaches, and where each one stops.
By the end of this topic you can
- Choose which of the four Service kinds an application should use for writes, for reads, and never
- Follow a
DNSEndpointCR out to an external-dns record and say what TTL costs you - Explain what the
db-readonlyNoExecute taint evicts and why that is part of failover
playground has failed over. pdx is writable, iad is fenced, you have the exact count of transactions the
promotion cost you — and the counter application is still reading from iad, the demoted site, while any
write it attempts comes back ERROR 1290, with nothing paging anyone. The next topic answers why that
connection survived. This one answers the other half: what Bloodraven actually moves when it promotes
a site, and where each mechanism stops.
There are three surfaces — Services, one DNS object, one node taint.
Four Services, eight objects
Bloodraven reconciles four kinds of Service per group — two per-site, two shared — so the object
count is 2 × len(sites) + 2 — for three-site playground (iad, pdx, reader), eight objects.
| Service | Selector | Who may use it |
|---|---|---|
mysql-playground-<site> | name + instance + site (plus healthy=yes on a read-only site) | site-pinned tooling, debugging |
mysql-playground-<site>-internal | name + instance + site | sidecar and peer traffic only |
mysql-playground-primary | instance + shipstream.io/role=primary | writes |
mysql-playground-replicas | instance + role=replica + healthy=yes | reads |
Applications get two of those four: -primary for writes, -replicas for reads (objective 1). The
internal per-site Service is never yours. It publishes the sidecar port alongside MySQL, sets
publishNotReadyAddresses: true so peers reach a pod that is not serving yet, and carries the
canonical replication source host name. Point an application at it and you have opted out of every
guarantee below.
Count the labels on the two shared Services; the counts are the lesson. -primary is two:
app.kubernetes.io/instance=playground and shipstream.io/role=primary. There is no healthy key on it,
and publishNotReadyAddresses is false. -replicas is three: instance, role=replica,
and shipstream.io/healthy=yes. The operator stamps role and healthy onto the pods on each
reconcile; the Services never change, only the labels underneath them.
For a role: read-only reader site, that healthy=yes stamp is expensive. Five conjuncts must all
hold: source convergence is converged; replicating is true; secondsBehindSource is non-nil; the
reported source host canonically matches the active site’s internal per-site Service — a direct
source, so a replication chain does not count; and the lag is within
EffectiveReadOnlyMaxLagSeconds(). That last one has no default: nil inherits maxLagSeconds
(300 shipped, 30 in the playground); an explicit 0 demands zero reported lag. Four out of five is
healthy=no, and the reader leaves -replicas without a word.
Two consequences fall out of the selectors.
A fenced pod matches neither. An in-place restore and a planned failover both stamp role="fenced"
on the primary’s pod. "fenced" is neither "primary" nor "replica", so the pod drops out of both
shared Services at once while its own per-site Service still selects it. That is what fencing looks
like at the Service layer: unreachable for application writes and reads, still reachable for you.
Invalid authority sheds every endpoint. When authority is invalid or incomplete — no confirmed writable active site — the operator deliberately leaves every site non-primary and every reader non-serving. Both shared Services keep existing and select nothing. Someone will file this as a bug: shedding endpoints is the design choice. A refused connection is a failure your retry logic can see. A successful read of yesterday’s data is not.
One DNS object
Outside the cluster, Bloodraven writes one object: a DNSEndpoint on
externaldns.k8s.io/v1alpha1, named bloodraven-<group>, always an A record, with recordTTL
taken from spec.dns.ttl (default 60; the playground overrides it to 10).
kubectl -n bloodraven-playground get dnsendpoint bloodraven-playground -o yamlapiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: bloodraven-playground
spec:
endpoints:
- dnsName: playground-db.example.local # spec.dns.hostname
recordType: A
recordTTL: 10 # spec.dns.ttl (default 60)
targets:
- 10.96.100.20 # CHANGED: was 10.96.100.10The write model surprises people. There is no create/update split. The write is a single idempotent
server-side apply with FieldOwner("bloodraven") and forced ownership — one call creates the object,
corrects a hand-edited target, and reclaims a field someone else took. Around it,
reconcileDNS runs on every poll: it re-derives the desired target from live topology, reads the
live record back, and applies only on a real divergence. Nothing is memoized to replay later. So an
apply that a webhook or an RBAC rule rejects needs no human — it heals on a later poll, it survives an
operator restart, and it can never publish a superseded target at a site that has since gone read-only
(objective 2).
v1alpha1 is still external-dns’s current group version, and an approved proposal targets v1beta1
with no date attached — plan for the move, do not wait for it.
Then the boundary. Writing the record is where Bloodraven’s authority ends: the operator cannot
accelerate DNS propagation. That is external-dns’s job, then your resolver’s, then your client’s
cache, and your TTL is the floor on how long a stale answer survives. A stuck external-dns is a write
outage that begins after the operator logged that it finished. Chaos scenario 38 demonstrates that:
deny the operator write verbs on dnsendpoints, kill the primary, and the CR promotes correctly while
the DNS target stays stale — a perfect failover and an unreachable database at the same instant.
The taint
shipstream.io/db-readonly-playground=true:NoExecuteRecall from Unit 2: the taint is a pure function of a per-site state transition,
applied earlier in the same poll than any cross-site action. It is not a step in the failover
sequence, and role: read-only sites are never tainted at all — a reader is already read-only, so
there is nothing to demote.
NoExecute is upstream Kubernetes behaviour, and stronger than people expect. Pods that do
not tolerate the taint are evicted immediately. Pods that tolerate it with a tolerationSeconds
stay bound for exactly that long, after which the node lifecycle controller evicts them. That is why
this belongs to failover (objective 3): an application pod pinned to the demoted site is removed
rather than left pointing at a read-only MySQL, and whatever replaces it starts with a fresh
connection pool. Chaos scenario 21 verifies the chain — the old primary’s node taint evicts
a non-tolerating canary while a canary tolerating the same taint stays Running.
Where this leaves you
You can now name every surface Bloodraven moves on a promotion — pod labels behind four Services, one
DNSEndpoint, one node taint — and where each stops: at the endpoint list, at external-dns’s front
door, at the node boundary. Which makes the counter application far more interesting than it was. Its pod was never on iad’s node,
so nothing evicted it. It never re-resolved a hostname, so the TTL never applied. And it never asked
-primary for an endpoint at all, because it already had one. It is holding an open socket to a MySQL
that has been read-only since the promotion. Every mechanism in this topic fired correctly, and every
one of them missed — because all three act on routing, and nothing here routes a connection that has
already been established.
Flashcards
How many Service objects does Bloodraven create for a failover group, and what is the formula?
2 × len(sites) + 2 — two per-site kinds plus two shared kinds. Three-site playground therefore has eight Service objects.
mysql-playground-iad-internal — what is this Service for?
Sidecar and peer traffic only. It carries the sidecar port beside MySQL, publishes not-ready addresses, and is the canonical replication source host. Applications never use it.
Which labels are in the mysql-<group>-primary selector?
Two: app.kubernetes.io/instance=<group> and shipstream.io/role=primary. No healthy key, and publishNotReadyAddresses is false.
Which labels are in the mysql-<group>-replicas selector?
Three: app.kubernetes.io/instance=<group>, shipstream.io/role=replica, and shipstream.io/healthy=yes.
What stamps a pod with role="fenced"?
A full-instance in-place restore, or a planned failover once it enters Draining — both strip the primary role from the source pod.
Name the five conjuncts a role: read-only site must satisfy to be labelled healthy=yes.
Source convergence converged; replicating true; non-nil secondsBehindSource; reported source host canonically equal to the active site's internal per-site Service (a direct source); and lag within EffectiveReadOnlyMaxLagSeconds().
What do the shared Services do when authority is invalid or incomplete?
They shed every endpoint — every site is left non-primary and every reader non-serving, so clients get a connection failure instead of a stale read.
apiVersion and kind of the object Bloodraven writes for DNS steering?
externaldns.k8s.io/v1alpha1, kind DNSEndpoint. An approved upstream proposal targets v1beta1, with no date attached.
What is the DNSEndpoint object named, and what record type does it always carry?
bloodraven-<group> — so bloodraven-playground — and always an A record.
Default value of spec.dns.ttl?
60 seconds. The playground overrides it to 10.
Give the full taint Bloodraven applies to a demoted site's nodes in group playground.
shipstream.io/db-readonly-playground=true:NoExecute — prefix shipstream.io/db-readonly-, group suffix, constant value true, effect NoExecute.
Which site role is never tainted?
read-only. A reader is already read-only, so there is no demotion to enforce; the tainter returns early for it (and for an empty taint selector).
Quiz
Show answer
Answer: Reconciliation job → mysql-playground-primary; BI job → mysql-playground-replicas
-primary selects on role=primary and follows the promotion; -replicas selects on role=replica plus healthy=yes and drops a lagging or non-converged reader automatically. Option 2 pins writes to whichever site is active today — the per-site Service does not follow a promotion, so the job writes to a read-only MySQL the moment playground fails over. Option 3 sends reads at the internal per-site Service, which exists for sidecar and peer traffic and applies no health gate at all. Option 4 is the common over-caution: it works, but it puts every reporting query on the write path and throws away the reader tier you are paying for. (objective 1)
-primary selects on role=primary and follows the promotion; -replicas selects on role=replica plus healthy=yes and drops a lagging or non-converged reader automatically. Option 2 pins writes to whichever site is active today — the per-site Service does not follow a promotion, so the job writes to a read-only MySQL the moment playground fails over. Option 3 sends reads at the internal per-site Service, which exists for sidecar and peer traffic and applies no health gate at all. Option 4 is the common over-caution: it works, but it puts every reporting query on the write path and throws away the reader tier you are paying for. (objective 1)
Show answer
Answer:
Neither shared Service reaches it. mysql-playground-primary requires role=primary and mysql-playground-replicas requires role=replica plus healthy=yes; "fenced" matches neither, so the pod drops out of both at once. Its own per-site Service, mysql-playground-<site>, selects on name/instance/site and does not look at role, so that one still reaches it — which is how you keep an operator path to a fenced instance.
A full-credit answer shows: A strong answer names both shared Services and why the label misses each selector, and says the per-site Service still selects the pod because role is not in its selector. Credit answers that note this is what fencing looks like at the Service layer. Do not credit an answer claiming the pod is unreachable from everywhere, or that fencing works by deleting the Service.
Fencing at the Service layer is a label change, not an object deletion. Because the two shared selectors name mutually exclusive role values, one label edit removes the pod from application writes and application reads in a single step, while the per-site Service keeps it reachable for diagnosis. (objective 1)
Sample answer
Neither shared Service reaches it. mysql-playground-primary requires role=primary and mysql-playground-replicas requires role=replica plus healthy=yes; "fenced" matches neither, so the pod drops out of both at once. Its own per-site Service, mysql-playground-<site>, selects on name/instance/site and does not look at role, so that one still reaches it — which is how you keep an operator path to a fenced instance.
A full-credit answer shows
A strong answer names both shared Services and why the label misses each selector, and says the per-site Service still selects the pod because role is not in its selector. Credit answers that note this is what fencing looks like at the Service layer. Do not credit an answer claiming the pod is unreachable from everywhere, or that fencing works by deleting the Service.
Fencing at the Service layer is a label change, not an object deletion. Because the two shared selectors name mutually exclusive role values, one label edit removes the pod from application writes and application reads in a single step, while the per-site Service keeps it reachable for diagnosis. (objective 1)
Show answer
Answer: The lag conjunct fails: readOnlyMaxLagSeconds is unset, so the effective reader threshold inherits maxLagSeconds = 30, and 45 > 30 leaves the pod labelled healthy=no
All five reader conjuncts must hold; four of five is healthy=no, and here the fifth — lag within EffectiveReadOnlyMaxLagSeconds() — is the one that fails, because a nil readOnlyMaxLagSeconds inherits maxLagSeconds. Option 1 inverts the design: serving reads is exactly what a read-only site is for. Option 3 is a real half-truth worth unlearning — maxLagSeconds does drive the ReplicationLagging condition, but it is also what the reader threshold inherits, so it reaches the endpoint list too. Option 4 confuses the failover cooldown, which gates promotion, with endpoint membership, which is recomputed every reconcile. (objective 1)
All five reader conjuncts must hold; four of five is healthy=no, and here the fifth — lag within EffectiveReadOnlyMaxLagSeconds() — is the one that fails, because a nil readOnlyMaxLagSeconds inherits maxLagSeconds. Option 1 inverts the design: serving reads is exactly what a read-only site is for. Option 3 is a real half-truth worth unlearning — maxLagSeconds does drive the ReplicationLagging condition, but it is also what the reader threshold inherits, so it reaches the endpoint list too. Option 4 confuses the failover cooldown, which gates promotion, with endpoint membership, which is recomputed every reconcile. (objective 1)
Show answer
Answer: False
The reverse is true: nobody has to do anything. reconcileDNS runs on every poll, re-derives the desired target from live topology rather than replaying a memoized one, and the write itself is one idempotent server-side apply with forced ownership — no create/update split. So the record heals on a later poll, with no second failover and no MySQL mutation, and the heal survives an operator restart because nothing needed to be remembered. Chaos scenario 38 is exactly this experiment. What manual action cannot fix is the other half: the operator cannot accelerate DNS propagation once the record is written. (objective 2)
The reverse is true: nobody has to do anything. reconcileDNS runs on every poll, re-derives the desired target from live topology rather than replaying a memoized one, and the write itself is one idempotent server-side apply with forced ownership — no create/update split. So the record heals on a later poll, with no second failover and no MySQL mutation, and the heal survives an operator restart because nothing needed to be remembered. Chaos scenario 38 is exactly this experiment. What manual action cannot fix is the other half: the operator cannot accelerate DNS propagation once the record is written. (objective 2)
Show answer
Answer: A pod with no matching toleration is evicted immediately; a pod tolerating the taint with tolerationSeconds: 60 stays bound for 60 s and is then evicted by the node lifecycle controller
That is upstream NoExecute semantics, and chaos scenario 21 verifies it against a real promotion: the non-tolerating canary is evicted while a canary tolerating the same taint stays Running. Option 1 describes NoSchedule, the effect people assume; NoExecute reaches pods that are already running, which is the entire point of using it for a demotion. Option 2 forgets that a toleration on the key is exactly what keeps a pod bound — scenario 21's tolerating canary stays put. Option 4 is the most dangerous distractor: a PDB protects only against voluntary evictions, and a taint-driven eviction is not one. (objective 3)
That is upstream NoExecute semantics, and chaos scenario 21 verifies it against a real promotion: the non-tolerating canary is evicted while a canary tolerating the same taint stays Running. Option 1 describes NoSchedule, the effect people assume; NoExecute reaches pods that are already running, which is the entire point of using it for a demotion. Option 2 forgets that a toleration on the key is exactly what keeps a pod bound — scenario 21's tolerating canary stays put. Option 4 is the most dangerous distractor: a PDB protects only against voluntary evictions, and a taint-driven eviction is not one. (objective 3)