Skip to content

Commit 5e62690

Browse files
devantlerclaude
andauthored
fix(external-dns): harden single-replica writer against node failure (#2128)
Coroot flags external-dns:Deployment as "single instance - not resilient to node failure". external-dns v0.21.0 (chart 1.21.1) has NO leader election — the multi-replica proposal is status `not-planned` and the `--enable-leader-election` flag does not exist in the binary — so a 2nd ACTIVE replica is unsafe: both write to Cloudflare under one txtOwnerId, causing duplicate writes / rate-limit churn / records fighting. The validate-replica-floor policy exempts external-dns for this reason. Instead of a hazardous duplicate-writes 2-replica config, minimise the node-failure blast radius of the deliberately-single writer: - priorityClassName: system-cluster-critical so the replacement pod reschedules ahead of normal workloads on node loss (matches its peers CoreDNS and the hcloud-csi controller), shrinking the DNS-drift window. - drain-safe PodDisruptionBudget (maxUnavailable: 1, the #1880 pattern; chart ships no PDB template) so a node drain cycles the single pod cleanly instead of stalling. - document the TXT-registry ownership fence that makes a reschedule safe and the upgrade path to true active/standby HA once external-dns ships leader election. Validated with `kubectl kustomize k8s/providers/hetzner/infrastructure/external-dns`. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 84c95a0 commit 5e62690

3 files changed

Lines changed: 58 additions & 0 deletions

File tree

k8s/providers/hetzner/infrastructure/external-dns/helm-release.yaml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ spec:
2222
sources:
2323
- gateway-httproute
2424
policy: sync
25+
# TXT-registry ownership is what makes this single writer safe to reschedule:
26+
# every managed record gets a paired "_externaldns." TXT carrying this
27+
# txtOwnerId, so a pod that restarts on another node (node failure / drain)
28+
# recognises the records it already owns and reconciles idempotently instead
29+
# of re-creating or fighting them. This ownership fence is also why a second
30+
# ACTIVE replica is unsafe (see priorityClassName note) — two instances would
31+
# share one owner id and issue duplicate Cloudflare writes.
2532
registry: txt
2633
txtOwnerId: "${domain}"
2734
txtPrefix: "_externaldns."
@@ -36,6 +43,36 @@ spec:
3643
secretKeyRef:
3744
name: external-dns-cloudflare
3845
key: api-token
46+
# Node-failure resilience for a DELIBERATELY single-replica writer.
47+
#
48+
# external-dns has NO leader election at the pinned app version (chart 1.21.1
49+
# = external-dns v0.21.0). The "support multiple replicas with leader
50+
# election" proposal (docs/proposal/001-leader-election.md) is status
51+
# `not-planned` and the `--enable-leader-election` flag does not exist in the
52+
# controller binary — the only concurrency control is `--once` (run-once).
53+
# So running >1 ACTIVE replica is unsafe: every replica would write to
54+
# Cloudflare under the same txtOwnerId, causing duplicate API calls,
55+
# rate-limit churn, and records fighting each other. The validate-replica-floor
56+
# policy exempts external-dns by name for exactly this reason.
57+
#
58+
# Therefore we do NOT run 2 always-active replicas. We instead minimise the
59+
# node-failure blast radius of the single writer:
60+
# * priorityClassName: system-cluster-critical — external-dns programs the
61+
# cluster's public DNS (its peer infrastructure CoreDNS and the hcloud-csi
62+
# controller use the same class), so on a node loss the scheduler places
63+
# the replacement pod ahead of normal workloads, shrinking the window in
64+
# which DNS records drift from desired state.
65+
# * a drain-safe PDB (pod-disruption-budget.yaml, maxUnavailable: 1) so a
66+
# node drain cycles the single pod cleanly instead of being a surprise.
67+
# * TXT-registry ownership (above) so the rescheduled pod reclaims its
68+
# records idempotently.
69+
#
70+
# UPGRADE PATH to true active/standby HA: once external-dns ships leader
71+
# election (the `--enable-leader-election` flag, currently `not-planned`),
72+
# switch to replicas: 2 with leader election enabled (only the leader writes),
73+
# a topologySpreadConstraints across nodes, and drop this single-replica note.
74+
# Track the upstream proposal before bumping the replica count.
75+
priorityClassName: system-cluster-critical
3976
deploymentStrategy:
4077
type: Recreate
4178
serviceMonitor:

k8s/providers/hetzner/infrastructure/external-dns/kustomization.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ resources:
77
- helm-release.yaml
88
- helm-repository.yaml
99
- networkpolicy.yaml
10+
- pod-disruption-budget.yaml
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
apiVersion: policy/v1
2+
kind: PodDisruptionBudget
3+
metadata:
4+
name: external-dns
5+
namespace: external-dns
6+
spec:
7+
# maxUnavailable: 1 is the platform-wide drain-safe PDB pattern (issue #1880):
8+
# it never deadlocks a node drain regardless of replica count. external-dns is
9+
# a DELIBERATELY single-replica writer (no leader election at the pinned app
10+
# version v0.21.0 — see helm-release.yaml; a 2nd ACTIVE replica would issue
11+
# duplicate Cloudflare writes under one txtOwnerId), so this PDB does not add
12+
# standby redundancy. Its job is to let a node drain evict and reschedule the
13+
# single pod cleanly (the high priorityClassName makes that reschedule fast)
14+
# rather than the drain stalling on an un-budgeted pod. Convert to a true HA
15+
# budget once external-dns ships leader election and this runs replicas: 2.
16+
maxUnavailable: 1
17+
selector:
18+
matchLabels:
19+
app.kubernetes.io/name: external-dns
20+
app.kubernetes.io/instance: external-dns

0 commit comments

Comments
 (0)