Add the memory envelope, pprof, and a per-packet fix on the UDP path
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:
1 parent
1bdb0b081c
commit
b0e6b3bb92
11 files changed
+382
-6
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user