Notify secondaries immediately on primary zone changes

Dynamically-updated primary zones were only reaching the secondary pods on
the hardcoded 1h SOA refresh: the operator emitted no NOTIFY, and a zone's
only apex NS is the primary itself, so default 'notify yes' reached no one.
Queries load-balanced across the serve VIP hit stale secondaries and
returned NXDOMAIN (negatively cached downstream for the 300s SOA minimum),
so records flapped for up to an hour after every update.

Add 'notify explicit' + 'also-notify' with the secondary pod IPs to primary
zone stanzas so an update NOTIFYs the secondaries for an immediate IXFR.
Applied via modzone, so existing zones pick it up on the next reconcile.
Also shorten the seed SOA refresh/retry/minimum as a fallback for missed
NOTIFYs and to shrink stale-NXDOMAIN negative caching.
This commit is contained in:
2026-07-21 00:08:00 +10:00
parent e4ed9cfdb2
commit d5e08607c1
4 changed files with 102 additions and 5 deletions
+9 -3
View File
@@ -35,13 +35,19 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error {
origin := dot(zone)
ns := "ns1." + origin
// Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP
// changed and the primary's also-notify was briefly stale) still converges
// in minutes, not the hour a 3600s refresh would impose. minimum is the
// negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
// stick in downstream resolvers for long. NOTIFY (also-notify on the
// primary) remains the fast path; these are the fallback.
content := fmt.Sprintf(`$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
3600 ; refresh
900 ; retry
300 ; refresh
60 ; retry
1209600 ; expire
300 ) ; minimum
60 ) ; minimum
@ IN NS %s
ns1 IN A %s
`, ns, origin, serial, ns, primaryIP)
+16 -2
View File
@@ -80,7 +80,15 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster))
// Primary zones replicated to secondaries (catalog members) get an
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
// them immediately rather than waiting for the SOA refresh.
var notifyTargets []string
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
}
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster), notifyTargets)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
}
@@ -135,7 +143,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
// primary zones must allow AXFR with it so secondaries can pull them.
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string) (string, error) {
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string) (string, error) {
zType := zone.Spec.Type
if zType == "" {
zType = bindv1alpha1.ZonePrimary
@@ -154,6 +162,12 @@ func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1al
// Catalog member: permit key-authenticated AXFR from secondaries.
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
}
// NOTIFY only the secondaries we know about (their apex NS is the primary
// itself, so default `notify yes` would reach no one). `notify explicit`
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs.
if len(notifyTargets) > 0 {
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", terminateInline(notifyTargets)))
}
if zone.Spec.DNSSECPolicyRef != "" {
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
}
@@ -0,0 +1,48 @@
package controller
import (
"context"
"strings"
"testing"
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
)
// A primary zone with known secondaries renders notify explicit + also-notify
// so a dynamic update NOTIFYs the secondaries immediately.
func TestBuildZoneConfigPrimaryAlsoNotify(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{
ZoneName: "main.unkin.net",
Type: bindv1alpha1.ZonePrimary,
},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", []string{"10.42.2.6", "10.42.1.5"})
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if !strings.Contains(cfg, "notify explicit") {
t.Errorf("expected notify explicit in %q", cfg)
}
if !strings.Contains(cfg, "also-notify { 10.42.2.6; 10.42.1.5; }") {
t.Errorf("expected also-notify with the secondary IPs in %q", cfg)
}
}
// With no secondaries, no also-notify is emitted (single-replica cluster).
func TestBuildZoneConfigPrimaryNoNotifyTargets(t *testing.T) {
r := &BindZoneReconciler{}
zone := &bindv1alpha1.BindZone{
Spec: bindv1alpha1.BindZoneSpec{ZoneName: "main.unkin.net", Type: bindv1alpha1.ZonePrimary},
}
cfg, err := r.buildZoneConfig(context.Background(), zone, "transfer-key", nil)
if err != nil {
t.Fatalf("buildZoneConfig: %v", err)
}
if strings.Contains(cfg, "also-notify") || strings.Contains(cfg, "notify explicit") {
t.Errorf("did not expect notify clauses with no targets: %q", cfg)
}
}
+29
View File
@@ -3,6 +3,7 @@ package controller
import (
"context"
"fmt"
"sort"
"time"
corev1 "k8s.io/api/core/v1"
@@ -126,6 +127,34 @@ func primaryTransferAddress(ctx context.Context, c client.Client, cluster *bindv
return primaryPodIP(ctx, c, cluster)
}
// secondaryPodIPs returns the pod IPs of a cluster's secondary pods (every pod
// except the ordinal-0 primary) that currently have an address. The primary
// uses this list as its zone `also-notify` set, so a change to a primary zone
// (in particular a dynamic update) triggers an immediate NOTIFY -> IXFR to the
// secondaries instead of leaving them stale until the next SOA refresh. The
// list is sorted so the rendered zone config is stable and does not churn
// modzone on every reconcile. Pod IPs change across restarts, so the caller
// relies on the zone controller's periodic requeue to refresh the set (a
// restarted secondary re-transfers the whole zone on load regardless). Returns
// nil for a single-replica cluster.
func secondaryPodIPs(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) []string {
var pods corev1.PodList
if err := c.List(ctx, &pods, client.InNamespace(cluster.Namespace), client.MatchingLabels(commonLabels(cluster.Name))); err != nil {
return nil
}
primary := primaryPodName(cluster.Name)
var ips []string
for i := range pods.Items {
p := &pods.Items[i]
if p.Name == primary || p.Status.PodIP == "" {
continue
}
ips = append(ips, p.Status.PodIP)
}
sort.Strings(ips)
return ips
}
// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials.
func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) {
var creds bind.TSIGCreds