The cluster runs its own resolver¶
A second CoreDNS lives in the dns namespace as a DaemonSet on hostPort 53,
udp and tcp: every node answers on 10.82.50.10x:53, so LAN hosts can use it
directly. In-cluster it is also a Service pinned to 10.96.0.53.
hostPort, not hostNetwork. The pod keeps its own network namespace, so
dnsPolicy: None with nameserver 127.0.0.1 makes it resolve through itself and
never through the node — the loop the campus resolver would otherwise close. A
hostNetwork pod would instead bind 0.0.0.0:53 in the host namespace and
contend with Talos's own host DNS resolver.
The namespace is labelled pod-security.kubernetes.io/enforce: privileged
because baseline forbids hostPort. Ingress needs no such label — its Envoy is
part of Cilium and runs in kube-system, which Talos leaves unenforced.
Adding a record by hand¶
Static records go in the hosts block of flux/services/dns/corefile.yaml, one
per line, address first:
fallthrough is not optional. Without it the plugin answers authoritatively
for everything it is asked and NXDOMAINs every name not listed — the entire
internet included. With it, only listed names are answered here and the rest
continue to the forwarders.
Reverse lookups come for free: dig -x 10.82.50.100 returns s3.gewis.nl.
A listed name is also answered for LAN clients, not just pods, since they hit the
same resolver. reload picks up a new entry without a restart, though allow ~60s
for the ConfigMap to reach every node.
s3.gewis.nl is a name that exists nowhere else — the public gewis.nl zone has
no address for it — so this creates a name rather than shadowing one. Overriding a
public name is equally possible and considerably easier to regret: it applies to
every pod and every LAN client pointed here, and nothing upstream will hint that
the answer is local.
One caveat specific to this entry: s3-01's address is not reserved. It comes
from DHCP, which is why docs/s3-01.md leaves Garage's root_domain unset and
addresses buckets path-style. 10.82.50.100 is hardcoded here and in
terraform/garage-buckets, so both rot together if the lease moves. A DHCP
reservation, as the Talos nodes already have, is what makes this safe.
Aliasing one name onto another¶
hosts only maps names to addresses. To point a name at another name — where
the target already resolves and may move — use rewrite instead:
postgres.cbc.gewis.nl is how in-cluster clients reach the Postgres NodePort.
kube.gewis.nl is a public record carrying all three node addresses, so the
alias tracks the nodes instead of pinning one, and the resolver can follow it
through its normal upstreams.
answer auto is not optional. Without it the reply carries kube.gewis.nl
as the owner name of the A records while the client asked for
postgres.cbc.gewis.nl, and stub resolvers are entitled to discard the mismatch.
answer auto rewrites the owner names back on the way out.
This is resolver-only, exactly like s3.gewis.nl: a workstation resolving
through campus DNS gets NXDOMAIN. Anything running off-cluster — tofu, for one
— has to use kube.gewis.nl directly.
DNS4EU first, Quad9 behind it¶
forward . https://86.54.11.13 https://86.54.11.213 {
tls_servername noads.joindns4.eu
next SERVFAIL REFUSED
}
forward . https://9.9.9.9 https://149.112.112.112 {
policy sequential
}
DNS4EU's Protective Resolution with Ad blocking — EU-funded, GDPR-bound, and it
null-routes ads to 0.0.0.0. The profile publishes two IPv4 addresses that answer
under the same certificate, so one tls_servername covers the pair.
Addresses, never hostnames — and that is not merely prudence. CoreDNS cannot
take a hostname upstream over DoH: parseAsHostEntry accepts hostnames for
dns:// and tls:// only, so forward . https://noads.joindns4.eu fails to
parse, with or without a resolver.
tls_servername is load-bearing: DNS4EU's certificate carries no IP SAN, so
connecting to 86.54.11.13 verifies only against the name.
Why Quad9 needs a stanza of its own. That servername is global to the
stanza, so a provider with a different name cannot sit beside DNS4EU. Going
without it requires the upstream to ship IP SANs — Quad9 does, which is why it
needs no tls_servername, while Mullvad and DNS4EU both fail curl by IP with
"no alternative certificate subject name". The construct that would have allowed
one mixed list is tls:// with per-endpoint %name, and 853 is blocked
outbound from the cluster: dial tcp 86.54.11.13:853: i/o timeout, straight
from the resolver's log. Hence two stanzas joined by next.
Know exactly what that covers. next fires only on a returned RCODE. If
DNS4EU is unreachable, forward returns SERVFAIL to the client directly and the
Quad9 stanza is never reached — a plugin error return does not continue the chain.
| DNS4EU failure | Who answers |
|---|---|
| SERVFAIL / REFUSED | Quad9, via next — measured 50–131ms |
| one address down | the other, in-stanza |
| both unreachable | nobody here; kube-system's next sends pods to campus DNS |
Quad9 filters malware, not ads, so ads are unblocked while it is carrying traffic.
policy sequential makes the order a preference rather than a spread.
Do not add failover. It looks right — retry the next upstream on SERVFAIL
rather than only on a dead socket — but when every upstream returns that RCODE
the retry loop resets its own counter at the end of the list and spins until the
5s deadline. Measured: dnssec-failed.org turns from a 164ms SERVFAIL into a
hang. next is the single-hop version and does not have this behaviour.
Campus taxes the first TLS connection, and CoreDNS caps DoH at 2s¶
The resolver logs intermittent Post "https://86.54.11.13:443/dns-query": context
deadline exceeded — roughly 15% of uncached upstream queries. Every one still
returns a correct answer via fallback, but it costs time.
It is not DNS4EU. The identical Corefile run from a workstation, against the same two addresses, handles 40 sequential uncached names and a 60-query concurrent burst with zero timeouts, p50 38ms, max 331ms — on par with Cloudflare and better than AdGuard.
It is the campus network, and it is not specific to DNS. TLS handshake time from a node, three tries per destination:
| destination | 1st | 2nd | 3rd |
|---|---|---|---|
noads.joindns4.eu |
3144ms | 81ms | 80ms |
registry.k8s.io |
3107ms | 92ms | 83ms |
github.com |
failed | 1063ms | 107ms |
dns.quad9.net |
85ms | 80ms | 83ms |
cloudflare.com |
156ms | 82ms | 96ms |
The first connection to a destination costs seconds and every one after it is
fast — the signature of a TLS-inspecting middlebox doing per-destination
first-contact analysis and caching the verdict. It taxes image pulls the same way;
registry.k8s.io is in that table for a reason.
Underneath that sits a constant CoreDNS does not expose:
read_timeout does not reach it — that knob feeds the UDP/TCP proxy. A 2s ceiling
under a ~3s first-connection tax means every cold connection is a guaranteed
timeout.
So the fix is to stop opening cold connections, which is max_idle_conns, not
expire. Go's transport already holds idle connections for 90s; the limit is how
many. From setup.go:
The plugin defaults max_idle_conns to 0, and the README calls 0 "unlimited" —
but Go reads a zero MaxIdleConnsPerHost as DefaultMaxIdleConnsPerHost, which
is 2. Two warm connections per upstream, so any concurrency beyond that opens
a cold one straight into the tax. Hence max_idle_conns 32 on both stanzas.
Measured in-cluster over ~1140 lookups:
| workload | client-visible failures | upstream timeouts |
|---|---|---|
| 300 sequential uncached | 0 | 0 — 50ms/name |
| 300 concurrent real names, cold pool | 0 / 300 | 58 |
| 300 concurrent real names, hot pool | 0 / 300 | 14 |
| 10 uncached, before the change | — | 7 (and ~700ms/name) |
Two things that table is saying. Sequentially the fix is total: 300 uncached names with zero timeouts, against ~15% before. Under concurrency a burst can still outrun the pool and pay the tax on several connections at once — but it warms, which is why the same workload drops from 58 to 14 on the second pass.
And it never costs a lookup. 0 failures in 1140, because a timed-out upstream falls to the second address, then to Quad9. The timeouts are latency, not errors. They split evenly across both DNS4EU addresses (67 vs 64), confirming it is connection setup rather than one sick endpoint. No pod restarts throughout.
What remains is unavoidable here: a genuinely new destination still pays the tax once, and the 2s cap still turns that into one retried query. If the campus middlebox ever stops being a factor — a different site, or a different uplink — this knob becomes harmless rather than load-bearing.
The escape hatch, should the tax get worse, is /etc/resolv.conf as an upstream:
the university resolvers on plain 53 are permitted and pay no TLS tax at all. It
costs the ad-blocking for whatever they answer, and it needs the pod's
dnsPolicy: None/nameserver 127.0.0.1 revisited first — as written, the pod's
own resolv.conf points at itself, so forward . /etc/resolv.conf would be a loop
and the loop plugin would refuse to start. Point it at the campus resolvers
explicitly instead.
Internal-only gewis.nl names do not resolve through it¶
gewiswg.gewis.nl is delegated out of the Cloudflare zone to gewisdc02 and
gewisdc03, which do not answer recursion from the internet, so every public
resolver SERVFAILs on it — and on time.gewis.nl, which is a CNAME into it.
A forward gewis.nl stanza pointed at those two domain controllers was tried and
removed: they are unreachable from the pod network. nslookup time.gewis.nl
131.155.70.14 from inside a pod times out, which fits the campus rule that
blocks port 53 to anything that is not a university resolver — the very rule that
put this resolver on DoH in the first place. All the stanza achieved was a pair of
timeouts before falling through to an upstream that then hung trying to recurse
into the same unreachable servers.
Nothing in the cluster needs those names today. time.gewis.nl is Talos's NTP
server, and Talos resolves it on the node, against the node's own DHCP
resolvers — never through this resolver or through kube-system CoreDNS. If a
workload ever does need the AD zone, the fix belongs in the kube-system Corefile
as forward gewis.nl /etc/resolv.conf, which reaches the campus resolvers that
are permitted, rather than here.