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

  1. Choose which of the four Service kinds an application should use for writes, for reads, and never
  2. Follow a DNSEndpoint CR out to an external-dns record and say what TTL costs you
  3. Explain what the db-readonly NoExecute 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.

ServiceSelectorWho may use it
mysql-playground-<site>name + instance + site (plus healthy=yes on a read-only site)site-pinned tooling, debugging
mysql-playground-<site>-internalname + instance + sitesidecar and peer traffic only
mysql-playground-primaryinstance + shipstream.io/role=primarywrites
mysql-playground-replicasinstance + role=replica + healthy=yesreads

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).

Try itThe DNS object after the promotion
kubectl -n bloodraven-playground get dnsendpoint bloodraven-playground -o yaml
apiVersion: 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.10
Recorded output. Run reveals what is already on the page — nothing executes, and no cluster is contacted.

The 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

AnatomyThe taint Bloodraven applies to a demoted site's nodes
key prefixshipstream.io/db-readonly-
Fixed constant TaintKeyPrefix. Namespaced to ShipStream so it cannot collide with your own taints.
group suffixplayground
The failover group name. Two groups on one node taint independently — this is why the key is per-group.
value=true
Constant TaintValue. It never varies, so a toleration on the key alone with operator Exists is the normal way to tolerate it.
effect:NoExecute
Not NoSchedule. This one reaches pods that are already running on the node.

Recall 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.

1 / 12

Quiz

Question 1 of 5

A nightly reconciliation job writes heavily to playground. A BI reporting job only reads from it. Which Services should each bind to?

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)

Question 2 of 5

A planned failover has stamped role="fenced" on the source site's MySQL pod. Which Services still reach that pod, and which do not?

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)

Question 3 of 5

The reader site in playground reports replicating: true, source convergence converged, and secondsBehindSource: 45. The group sets replication.maxLagSeconds: 30 and does not set readOnlyMaxLagSeconds. Why is reader absent from mysql-playground-replicas?

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)

Question 4 of 5

An RBAC rule denied the operator's DNSEndpoint apply during the promotion of playground. Once the rule is fixed, an operator has to re-run the failover or re-apply the record by hand to get DNS pointing at the new primary.

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)

Question 5 of 5

Bloodraven taints iad's nodes with shipstream.io/db-readonly-playground=true:NoExecute after promoting pdx. What happens to workloads already running on those nodes?

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)

Erase saved progress?

This erases all quiz scores, reading progress, project checklists, and your name on the certificate. It cannot be undone, and it affects only this course in this browser.