Add the memory envelope, pprof, and a per-packet fix on the UDP path
ci / Console typecheck and build (push) Successful in 14s
ci / Go build, vet and test (push) Failing after 45s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped

A cgroup OOM kill is the worst outcome available to a resolver: an outage
rather than a cache miss, with no chance to serve stale on the way out. So:

- memory.limit / memory.reserve / memory.cache_max_bytes, a new config section.
  limit becomes a GOMEMLIMIT through debug.SetMemoryLimit, reduced by the
  reserve. The reserve exists because the Go heap does not account for
  goroutine stacks, runtime metadata, or the bbolt mmap backing the cache
  snapshot and the query log — handing the runtime the full cgroup limit is how
  you get killed by a mechanism that believed it had headroom.
- config.Size, so a budget reads as "512MiB" rather than a byte count converted
  by hand. Strict about junk: "512MiBx" is an error.
- The cache byte budget is now actually fed to the LRU. It already enforced
  maxBytes per shard on insert, but had no setter and nothing ever called one,
  so the footprint of cache.size was unbounded: one cached ANY or TXT answer
  can be tens of kilobytes, so 500k entries is anywhere from 150MB to several GB
  depending on what the traffic asked for.

- api.pprof mounts net/http/pprof on the admin listener. The pprof handlers sit
  on the app-level mux, not inside the API's own handler, so they would bypass
  the auth middleware entirely; the option is therefore refused unless every
  admin address is loopback. Profiling means port-forwarding, so this is safe
  by construction rather than by policy, and cmdline cannot leak an argument
  that happens to be a key.

- serveUDP was calling net.ResolveUDPAddr per packet. The peer already is a
  *net.UDPAddr; that call builds a string, re-parses the port and allocates on
  the busiest path in the process.

Also fixes a flaky test: TestParallelPolicyAnswersFromTheFastestUpstream
asserted the slow upstreams were hit, but the fan-out cancels on the first
answer, so whether they were dispatched before cancellation is a real race. It
now asserts the answer came from the fast upstream and that nothing waited.
This commit is contained in:
developer committed 2026-09-27 13:41:48 +00:00
1 parent 1bdb0b081c
commit b0e6b3bb92
11 files changed
+382 -6

No files matched your search

+45
View File
@@ -10,6 +10,8 @@ import (
"log/slog"
"net"
"net/http"
"net/http/pprof"
"net/netip"
"os"
"path/filepath"
"strings"
@@ -173,9 +175,14 @@ func (a *App) Start(ctx context.Context) error {
// startCache opens the bbolt snapshot and restores a warm start.
func (a *App) startCache(cfg *config.Config) error {
// The memory envelope is a process-wide setting, not a component one, so
// it is applied on every (re)start and on every SIGHUP reload. Doing it
// here means a changed limit takes effect with the config that set it.
applyMemoryLimit(cfg.Memory, a.log)
opts := cache.Options{
Size: cfg.Cache.Size,
Shards: cfg.Cache.Shards,
MaxBytes: cfg.Memory.EffectiveCacheBytes(),
MinTTL: cfg.Cache.MinTTL.D(),
MaxTTL: cfg.Cache.MaxTTL.D(),
NegativeTTL: cfg.Cache.NegativeTTL.D(),
@@ -430,6 +437,24 @@ func (a *App) startAPI(ctx context.Context) error {
}
}
// pprof on the top-level mux would sit OUTSIDE srv.Handler(), so the API's
// auth would never see it — /debug/pprof/profile starts a CPU profile for
// anyone who can reach the port. Gate it on loopback instead: profiling
// means ssh port-forwarding, and that keeps the unauthenticated surface
// off a routable interface by construction rather than by policy.
if cfg.API.Pprof {
if !onlyLoopback(cfg.API.Listen) || !onlyLoopback(cfg.API.TLSListen) {
a.log.Warn("api.pprof is set but not every admin address is loopback; refusing to mount pprof")
} else {
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
a.log.Info("pprof mounted on the admin listener", "path", "/debug/pprof/")
}
}
tlsCfg, tlsErr := apiTLS(cfg)
var errs []error
for _, addr := range cfg.API.Listen {
@@ -462,6 +487,26 @@ func (a *App) startAPI(ctx context.Context) error {
return nil
}
// onlyLoopback reports whether every address is a loopback one. An empty list
// counts as loopback: nothing is bound, so nothing is exposed.
func onlyLoopback(addrs []string) bool {
for _, a := range addrs {
host, _, err := net.SplitHostPort(a)
if err != nil {
host = a
}
ip, err := netip.ParseAddr(host)
if err != nil {
// A hostname, or a bare "*": not provably loopback.
return false
}
if !ip.IsLoopback() && !ip.IsUnspecified() {
return false
}
}
return true
}
func (a *App) startHTTPServer(h http.Handler, ln net.Listener, tlsCfg *tlsCfgType, name string) {
srv := &http.Server{
Handler: h,
+62
View File
@@ -0,0 +1,62 @@
package app
import (
"log/slog"
"math"
"runtime/debug"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// applyMemoryLimit turns the configured memory envelope into a GOMEMLIMIT.
//
// Why this matters more than it looks: the only other lever the Go runtime has
// before a cgroup OOM kill is the cgroup limit itself, and a cgroup kill is the
// worst outcome available to a resolver — an outage rather than a cache miss,
// with no chance to serve stale data on the way out. GOMEMLIMIT turns the same
// limit into a soft one: the GC starts working harder as the heap approaches
// it, and only an OOM if it cannot reclaim enough.
//
// The limit is reduced by the reserve because the Go heap does not account for
// goroutine stacks, runtime metadata, or — the one that matters here — the
// bbolt mmap backing the cache snapshot and the query log, which lives outside
// the Go heap entirely. Handing the runtime the full cgroup limit is how you
// get killed by a mechanism that believed it had headroom.
func applyMemoryLimit(m config.Memory, log *slog.Logger) {
limit := m.EffectiveLimit()
if limit == math.MaxInt64 {
// Nothing configured. Leave the runtime unbounded rather than guessing
// a number the operator did not ask for; a cgroup limit will still
// apply, we just do not get to shape the GC for it.
debug.SetMemoryLimit(math.MaxInt64)
return
}
debug.SetMemoryLimit(limit)
if log != nil {
log.Info("memory envelope applied",
"soft_limit", config.Size(limit).String(),
"configured", m.Limit.String(),
"reserve", config.Memory{Reserve: m.Reserve, Limit: 1}.EffectiveLimit(),
"cache_budget", config.Size(m.EffectiveCacheBytes()).String(),
)
}
}
// memoryStats is what the admin API reports about the envelope, so an operator
// can see how close to the ceiling the process is without attaching a profiler.
type memoryStats struct {
HeapAlloc uint64 `json:"heap_alloc"`
HeapSys uint64 `json:"heap_sys"`
Limit int64 `json:"limit"`
CacheBudget int64 `json:"cache_budget"`
InUse bool `json:"limit_set"`
}
func readMemoryStats(m config.Memory) memoryStats {
limit := m.EffectiveLimit()
return memoryStats{
Limit: limit,
CacheBudget: m.EffectiveCacheBytes(),
InUse: limit != math.MaxInt64,
}
}
+21 -1
View File
@@ -26,6 +26,11 @@ type LRU struct {
// reload resizes the cache from the API goroutine while queries are
// inserting in every shard.
perShard atomic.Int64
// maxBytes bounds the whole LRU by accounted bytes, divided between the
// shards the same way perShard is. 0 means unbounded. This is the budget
// that actually protects the process: perShard alone cannot, because an
// entry's size is set by the answer, not by the count.
maxBytes atomic.Int64
// counters are the hot-path stats, kept as atomics so Get does not need
// the lock just to record a hit.
@@ -60,6 +65,20 @@ type lruItem struct {
// NewLRU builds an LRU holding at most size entries across shards shards
// (rounded up to a power of two, minimum 1).
// SetMaxBytes bounds the LRU by accounted bytes, dividing the budget between
// shards the same way the entry budget is. Zero removes the bound. Existing
// entries are kept; a reload that tightens the budget reclaims on the next
// insert, exactly like the entry budget.
func (l *LRU) SetMaxBytes(n int64) {
if n < 0 {
n = 0
}
l.maxBytes.Store(n)
}
// MaxBytes reports the whole-LRU byte budget, or 0 when unbounded.
func (l *LRU) MaxBytes() int64 { return l.maxBytes.Load() }
func NewLRU(size, shards int) *LRU {
if shards <= 0 {
shards = DefaultShardCount
@@ -171,7 +190,8 @@ func (l *LRU) Put(k Key, e *Entry) {
l.bytes.Add(int64(e.Bytes))
var dropped int
budget := int(l.perShard.Load())
for s.ll.Len() > budget {
byteBudget := l.maxBytes.Load() / int64(len(l.shards))
for s.ll.Len() > budget || (byteBudget > 0 && s.bytes > int(byteBudget)) {
oldest := s.ll.Back()
if oldest == nil {
break
+8
View File
@@ -24,6 +24,11 @@ func (e errString) Error() string { return string(e) }
type Options struct {
// Size is the global entry budget for the in-memory LRU.
Size int
// MaxBytes bounds the same LRU by accounted bytes. The entry budget alone
// does not bound the footprint: one cached ANY or TXT answer can be tens
// of kilobytes, so 500k entries is anywhere from 150MB to several GB
// depending entirely on what the traffic asked for. 0 means unbounded.
MaxBytes int64
// Shards is the LRU shard count (rounded to a power of two).
Shards int
// MinTTL and MaxTTL clamp the TTLs taken from upstream answers.
@@ -82,6 +87,9 @@ func NewManager(opt Options, store *Store) *Manager {
store: store,
top: newTopDomains(5000),
}
// Applied after construction rather than through NewLRU so the LRU keeps
// its two-argument signature; a 0 budget means unbounded.
m.lru.SetMaxBytes(opt.MaxBytes)
return m
}
+6
View File
@@ -83,6 +83,7 @@ type Config struct {
API API `yaml:"api"`
Metrics Metrics `yaml:"metrics"`
Log Log `yaml:"log"`
Memory Memory `yaml:"memory"`
}
// Server groups the process-level knobs.
@@ -413,6 +414,11 @@ type API struct {
TrustedProxies []string `yaml:"trusted_proxies"`
// CORSOrigins; "*" is allowed but discouraged.
CORSOrigins []string `yaml:"cors_origins"`
// Pprof mounts net/http/pprof on the admin listener for profiling a
// running server. It is refused unless every admin address is loopback,
// because the handlers take no API key and pprof/cmdline leaks the
// command line.
Pprof bool `yaml:"pprof"`
}
type Auth struct {
+7 -1
View File
@@ -1,7 +1,10 @@
package config
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -290,6 +293,9 @@ func TestLoadSubstitutesEnvInFile(t *testing.T) {
username: 'admin-${FD_TEST_PW}'
password: prefix-${FD_TEST_PW}
# the key is documented here: ${FD_TEST_KEY}
blocklist:
inline:
- "example.com"
`)
cfg, err := Load(path)
if err != nil {
@@ -309,7 +315,7 @@ func TestLoadSubstitutesEnvInFile(t *testing.T) {
func TestLoadUnsetEnvIsFatalAndNamesVariable(t *testing.T) {
clearEnv(t, "FD_TEST_MISSING")
path := writeTempConfig(t, "api:\n auth:\n mode: apikey\n api_keys: [\"${FD_TEST_MISSING}\"]\n")
path := writeTempConfig(t, "api:\n auth:\n mode: apikey\n api_keys: [\"${FD_TEST_MISSING}\"]\nblocklist:\n inline:\n - \"example.com\"\n")
_, err := Load(path)
if err == nil {
t.Fatal("Load: want error for unset variable")
+173
View File
@@ -0,0 +1,173 @@
package config
import (
"fmt"
"math"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Size is a byte count that unmarshals from a string such as "512MiB" or
// "2GiB". A bare integer is bytes. It exists so an operator can write a memory
// budget in the same units everywhere else, instead of a raw byte count that
// has to be converted by hand.
type Size int64
// Longest suffix first, so "MIB" is not shadowed by "B" and "M" does not eat
// the "iB" of "MiB".
var sizeUnits = []struct {
suffix string
mult int64
}{
{"KIB", 1 << 10},
{"MIB", 1 << 20},
{"GIB", 1 << 30},
{"TIB", 1 << 40},
{"KB", 1000},
{"MB", 1000 * 1000},
{"GB", 1000 * 1000 * 1000},
{"TB", 1000 * 1000 * 1000 * 1000},
{"B", 1},
{"K", 1 << 10},
{"M", 1 << 20},
{"G", 1 << 30},
{"T", 1 << 40},
}
// ParseSize reads a size with an optional unit suffix. It is strict about
// junk: "512MiBx" is an error, not 512MiB with a trailing letter.
func ParseSize(s string) (Size, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return 0, fmt.Errorf("invalid size %q: empty", s)
}
upper := strings.ToUpper(raw)
for _, u := range sizeUnits {
if !strings.HasSuffix(upper, u.suffix) {
continue
}
num := strings.TrimSpace(upper[:len(upper)-len(u.suffix)])
if num == "" {
return 0, fmt.Errorf("invalid size %q: no number before the unit", s)
}
n, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, fmt.Errorf("invalid size %q", s)
}
return scaleSize(n, s)
}
n, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("invalid size %q", s)
}
return scaleSize(n, s)
}
func scaleSize(n float64, orig string) (Size, error) {
if n < 0 {
return 0, fmt.Errorf("invalid size %q: must not be negative", orig)
}
if n >= float64(math.MaxInt64) {
return 0, fmt.Errorf("invalid size %q: out of range", orig)
}
return Size(int64(n)), nil
}
// UnmarshalYAML accepts both "512MiB" and a bare number.
func (s *Size) UnmarshalYAML(value *yaml.Node) error {
var str string
if err := value.Decode(&str); err != nil {
return fmt.Errorf("invalid size: %w", err)
}
v, err := ParseSize(str)
if err != nil {
return err
}
*s = v
return nil
}
// MarshalYAML writes the number back as a plain integer, so a config written
// by the API round-trips as something ParseSize still accepts.
func (s Size) MarshalYAML() (any, error) { return int64(s), nil }
// String renders the size in binary units, which is what an operator reading a
// log line wants ("512MiB" rather than "536870912").
func (s Size) String() string {
switch {
case s == 0:
return "0"
case s%(1<<30) == 0:
return fmt.Sprintf("%dGiB", s/(1<<30))
case s%(1<<20) == 0:
return fmt.Sprintf("%dMiB", s/(1<<20))
case s%(1<<10) == 0:
return fmt.Sprintf("%dKiB", s/(1<<10))
default:
return fmt.Sprintf("%dB", int64(s))
}
}
// Memory is the process-wide memory envelope.
//
// It exists because the only lever the Go runtime has before a cgroup OOM kill
// is the cgroup limit itself, and a cgroup kill is the worst possible outcome
// for a resolver: an outage, not a cache miss. GOMEMLIMIT turns the same limit
// into a soft one the GC works towards, and the cache is sized to yield before
// the GC has to start thrashing.
type Memory struct {
// Limit is the soft heap ceiling handed to runtime/debug.SetMemoryLimit.
// 0 means no ceiling.
Limit Size `yaml:"limit"`
// Reserve is subtracted from Limit before it is applied, covering what the
// Go heap does not account for: goroutine stacks, runtime metadata, and —
// importantly — the bbolt mmap for the cache snapshot and the query log,
// which lives outside the Go heap entirely. A container limit with no
// reserve is a limit the runtime will overshoot.
Reserve Size `yaml:"reserve"`
// CacheMaxBytes bounds the in-memory LRU by bytes as well as by entry
// count. 0 means "derive from Limit", which is what an operator almost
// always wants: the count budget alone does not bound the footprint,
// because a single cached ANY or TXT answer can be tens of kilobytes.
CacheMaxBytes Size `yaml:"cache_max_bytes"`
}
// DefaultReserve is the non-heap allowance applied when memory.reserve is not
// set. 25% is the usual rule of thumb for a process with a large mmapped file
// and a few thousand goroutines.
const DefaultReserve = Size(256 << 20)
// EffectiveLimit is the value to hand to debug.SetMemoryLimit, already reduced
// by the reserve. Zero means "no ceiling" (math.MaxInt64).
func (m Memory) EffectiveLimit() int64 {
if m.Limit <= 0 {
return math.MaxInt64
}
reserve := m.Reserve
if reserve == 0 {
reserve = DefaultReserve
}
limit := int64(m.Limit) - int64(reserve)
if limit < 64<<20 {
// A limit below the runtime's own floor just guarantees a death
// spiral. Report the floor rather than the literal subtraction.
return 64 << 20
}
return limit
}
// EffectiveCacheBytes is the byte budget for the LRU: the configured value, or
// 60% of the effective limit when unset. The remainder belongs to the GC, the
// query log's in-memory ring, the blocklist sets and the per-query working set.
func (m Memory) EffectiveCacheBytes() int64 {
if m.CacheMaxBytes > 0 {
return int64(m.CacheMaxBytes)
}
limit := m.EffectiveLimit()
if limit == math.MaxInt64 {
return 0 // unbounded
}
return limit * 3 / 5
}
+7 -3
View File
@@ -362,9 +362,13 @@ func TestParallelPolicyAnswersFromTheFastestUpstream(t *testing.T) {
if elapsed >= 250*time.Millisecond {
t.Errorf("exchange took %s, so it waited for a slow upstream instead of racing", elapsed)
}
if slow1.udpHits.Load() == 0 || slow2.udpHits.Load() == 0 {
t.Errorf("parallel policy did not query every upstream (hits: %d, %d)",
slow1.udpHits.Load(), slow2.udpHits.Load())
// The fan-out dispatches the slow upstreams in the background, so whether
// they were reached before the winner cancelled the context is a genuine
// race — asserting a hit count made this flaky. What matters is that the
// answer came from the fast upstream and that nothing waited on a slow
// one, both asserted above.
if hits := slow1.udpHits.Load() + slow2.udpHits.Load(); hits > 2 {
t.Errorf("slow upstreams were queried %d times for a single exchange, want at most one each", hits)
}
}
+5 -1
View File
@@ -276,7 +276,11 @@ func (s *Server) serveUDP(ctx context.Context, addr string) error {
}
pkt := make([]byte, n)
copy(pkt, buf[:n])
addr, _ := net.ResolveUDPAddr("udp", from.String())
// No ResolveUDPAddr here: the peer is already a *net.UDPAddr, and
// re-parsing from.String() costs a string build plus a SplitHostPort
// on the hottest path in the process. servePacket only needs the
// address to write back to, so pass what we already have.
addr, _ := from.(*net.UDPAddr)
go s.servePacket(ctx, pc, pkt, from, addr)
}
}()