The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.
Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.
Tests added for acl and resolver, both of which turned up real bugs:
- a PTR suffix matched across label boundaries, so a group scoped to
example.com. also swallowed ample.com. and notexample.com. — a client could
land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.
Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.
The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.
Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
180 lines
6.6 KiB
YAML
180 lines
6.6 KiB
YAML
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: fancy-dns
|
|
namespace: dns
|
|
labels:
|
|
app.kubernetes.io/name: fancy-dns
|
|
spec:
|
|
serviceName: fancy-dns
|
|
# A StatefulSet rather than a Deployment because each pod owns a PVC (the
|
|
# cache snapshot and the query log are per-pod state worth keeping across a
|
|
# reschedule). The flip side: the HPA does not work on a StatefulSet — the
|
|
# controller and the autoscaler fight over the replica count — so scaling
|
|
# means adding nodes and replicas here, not CPU targets.
|
|
replicas: 2
|
|
podManagementPolicy: Parallel
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: fancy-dns
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: fancy-dns
|
|
annotations:
|
|
# A config change is a SIGHUP, not a restart: the server re-reads the
|
|
# file and hot-swaps every component. This annotation is for
|
|
# stakater/reloader-class controllers; without one, `kubectl rollout
|
|
# restart` is the manual equivalent.
|
|
reloader.stakater.com/auto: "true"
|
|
spec:
|
|
serviceAccountName: fancy-dns
|
|
# No hostNetwork. The server does not set SO_REUSEPORT, so a second
|
|
# host-network pod would fail to bind :53 and crash-loop; the ports are
|
|
# served by a LoadBalancer Service instead (see 20-service.yaml).
|
|
dnsPolicy: ClusterFirst
|
|
# The app drains in-flight queries for up to 15s on SIGTERM (see the
|
|
# shutdown timeout in cmd/fancy-dns/main.go); give it room beyond that.
|
|
terminationGracePeriodSeconds: 30
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 65532
|
|
runAsGroup: 65532
|
|
fsGroup: 65532
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
topologySpreadConstraints:
|
|
- maxSkew: 1
|
|
topologyKey: kubernetes.io/hostname
|
|
whenUnsatisfiable: ScheduleAnyway
|
|
labelSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: fancy-dns
|
|
containers:
|
|
- name: fancy-dns
|
|
image: fancydns/fancy-dns:latest
|
|
imagePullPolicy: IfNotPresent
|
|
args:
|
|
- -config
|
|
- /etc/fancy-dns/config.yaml
|
|
env:
|
|
# Substituted into the config by the loader, so the admin API key
|
|
# comes from the Secret and not from the ConfigMap.
|
|
- name: FANCYDNS_API_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: fancy-dns-apikey
|
|
key: api-key
|
|
ports:
|
|
- name: dns-udp
|
|
containerPort: 53
|
|
protocol: UDP
|
|
- name: dns-tcp
|
|
containerPort: 53
|
|
protocol: TCP
|
|
- name: dot
|
|
containerPort: 853
|
|
protocol: TCP
|
|
- name: doh
|
|
containerPort: 443
|
|
protocol: TCP
|
|
- name: admin
|
|
containerPort: 8080
|
|
protocol: TCP
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop: ["ALL"]
|
|
# Only because of the low ports. The pod is not privileged and
|
|
# has no other capability left; drop the Service and the
|
|
# Capability together if you move the listeners off 53/443.
|
|
add: ["NET_BIND_SERVICE"]
|
|
# Liveness: the process is up. /api/healthz is unauthenticated on
|
|
# purpose, because a probe cannot carry an API key.
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /api/healthz
|
|
port: admin
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 20
|
|
# Readiness: the listener is actually bound, so the Service does not
|
|
# send traffic to a pod that is still binding.
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /api/healthz
|
|
port: admin
|
|
initialDelaySeconds: 3
|
|
periodSeconds: 5
|
|
failureThreshold: 2
|
|
# Startup: binding the listeners and fetching the first blocklist
|
|
# can take a while on a cold node, so give it room before the
|
|
# liveness probe starts killing it.
|
|
startupProbe:
|
|
httpGet:
|
|
path: /api/healthz
|
|
port: admin
|
|
periodSeconds: 3
|
|
failureThreshold: 30
|
|
resources:
|
|
requests:
|
|
cpu: 250m
|
|
memory: 256Mi
|
|
limits:
|
|
# No CPU limit on purpose: a DNS server that gets throttled
|
|
# turns into dropped queries, which is worse than a noisy
|
|
# neighbour. The memory limit is the one that matters, because
|
|
# the cache is sized in entries.
|
|
memory: 1Gi
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /etc/fancy-dns/config.yaml
|
|
subPath: config.yaml
|
|
readOnly: true
|
|
- name: zones
|
|
mountPath: /etc/fancy-dns/zones
|
|
readOnly: true
|
|
- name: tls
|
|
mountPath: /etc/fancy-dns/tls
|
|
readOnly: true
|
|
- name: data
|
|
mountPath: /var/lib/fancy-dns
|
|
- name: tmp
|
|
mountPath: /tmp
|
|
volumes:
|
|
- name: config
|
|
configMap:
|
|
name: fancy-dns-config
|
|
# Local zones are ordinary master files; ship them in a ConfigMap or
|
|
# manage them through the admin API (`/api/zones`), which writes into
|
|
# the data volume. Optional because the glob in the config matches
|
|
# nothing cleanly.
|
|
- name: zones
|
|
configMap:
|
|
name: fancy-dns-zones
|
|
optional: true
|
|
- name: tls
|
|
secret:
|
|
secretName: fancy-dns-tls
|
|
- name: tmp
|
|
emptyDir: {}
|
|
volumeClaimTemplates:
|
|
# Cache snapshot (cache.persist.path) and query log (qlog.path) live here,
|
|
# along with downloaded blocklists. ReadWriteOnce is per-pod: a pod keeps
|
|
# its own cache across a reschedule, which is the whole point of the
|
|
# snapshot, but it cannot be shared between replicas.
|
|
#
|
|
# Sizing: cache.size is an entry count, not bytes, so disk usage has to be
|
|
# estimated. A packed A/AAAA answer with a small authority section is
|
|
# roughly 300-600 bytes on the wire and nearer 1 kB resident, so 500k
|
|
# entries lands around 500 MB; qlog is the other consumer (see
|
|
# qlog.max_entries). 4Gi leaves room for both plus a re-fetched list.
|
|
- metadata:
|
|
name: data
|
|
spec:
|
|
accessModes: ["ReadWriteOnce"]
|
|
resources:
|
|
requests:
|
|
storage: 4Gi
|
|
# storageClassName: <your default class>
|