Files
coredns/plugin/shed/shed.go
rpb-ant 76056dd2e5 plugin/shed: add UDP overload protection plugin (#8312)
* plugin/shed: add UDP overload protection plugin

UDP responses written back through one listener socket serialize on the
Go runtime's internal fdMutex, which allows at most 2^20-1 concurrent
operations per file descriptor and panics the process when exceeded.
CoreDNS serves UDP with one goroutine per query, all writing through the
shared packet connection, so a sustained overload parks every excess
in-flight query in that wait queue until the process dies with
"too many concurrent operations on a single file or socket". Observed
in production: ~2.8M goroutines and 60GiB RSS before the panic.

The shed plugin makes the panic structurally unreachable. It installs,
via Config.UDPDecorateWriterFunc, a per-socket bounded evict-oldest
stack drained newest-first by a single writer goroutine, so the fd
never sees more than one writer and residual capacity under overload
always goes to the freshest response. While a socket's stack is full,
arriving queries are dropped before any plugin runs. Drops are silent
(the client's resolver retries elsewhere) and counted in
coredns_shed_dropped_total{server, reason}.

plugin/shed/fdmutex_test.go demonstrates the failure and the fix with
one shared flood harness. Two subprocess tests reproduce the exact
runtime panic without the plugin's write discipline - one deterministic
(a held write plus >2^20 queued writers), one with nothing held or
mocked; both exercise the Go runtime rather than the plugin, so they
are gated behind SHED_FLOOD_TEST=1. The counterfactual - the same load
through the plugin's stack, completing with every response accounted
for as written or dropped - runs in every test invocation, including
-race, at 50k responders, and at the full 1.5M with SHED_FLOOD_TEST=1:

    SHED_FLOOD_TEST=1 go test ./plugin/shed/

Signed-off-by: Ryan Brewster <rpb@anthropic.com>

* test: add shed e2e test

Query a shed-enabled server over UDP (the plugin's deferred
single-writer path) and TCP (which shed passes through), and check
that coredns_shed_dropped_total is exported with its reason label.

No-Verification-Needed: test-only change
Signed-off-by: Ryan Brewster <rpb@anthropic.com>

---------

Signed-off-by: Ryan Brewster <rpb@anthropic.com>
2026-07-27 12:13:25 +03:00

117 lines
4.0 KiB
Go

// Package shed bounds concurrent UDP response writes per listener socket so
// that an overload storm degrades into counted drops instead of a goroutine
// pile-up and the Go runtime's fdMutex overflow panic. See README.md for the
// failure mode and fdmutex_test.go for its reproduction.
//
// Setup installs dnsserver's Config.UDPDecorateWriterFunc: the decorated
// Write pushes the packed response onto a bounded evict-oldest stack and one
// writer goroutine per socket pops newest-first onto the wire, so the fd
// never sees more than one writer. While a socket's stack is full, ServeDNS
// drops arrivals before any chain work. Every drop is counted.
package shed
import (
"context"
"sync"
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
"github.com/prometheus/client_golang/prometheus"
)
// Shed implements the plugin.Handler interface.
type Shed struct {
Next plugin.Handler
}
// registry maps a listener socket's *dnsserver.Server to its per-socket
// state. It is package level: several server blocks can share a listener,
// and every block's ServeDNS must see that socket's one stack.
var registry sync.Map // *dnsserver.Server -> *socketState
// socketState is one listener socket's registry record. owner scopes
// shutdown to this instance's entries on reload.
type socketState struct {
owner *Shed
stack *respStack
droppedQuery prometheus.Counter
}
// lookupState returns the per-socket state for this request's socket, or nil
// — and on nil ServeDNS fails open. A miss happens when the request carries
// no *dnsserver.Server in its context (tests, non-dnsserver entry points),
// or for a handler still finishing on a server a reload already removed;
// neither admits unbounded new work.
func (s *Shed) lookupState(ctx context.Context) *socketState {
srv := ctx.Value(dnsserver.Key{})
if srv == nil {
return nil
}
if v, ok := registry.Load(srv); ok {
return v.(*socketState)
}
return nil
}
// mintState creates one listener socket's state and starts its writer
// goroutine. Idempotent; the loaded path only happens in tests.
func (s *Shed) mintState(srv *dnsserver.Server) *socketState {
st := &socketState{
owner: s,
stack: newRespStack(stackDepth, droppedTotal.WithLabelValues(srv.Address(), "response")),
droppedQuery: droppedTotal.WithLabelValues(srv.Address(), "query"),
}
if v, loaded := registry.LoadOrStore(srv, st); loaded {
return v.(*socketState)
}
go st.stack.writerLoop()
return st
}
// shutdown removes this instance's registry entries and stops their writer
// goroutines. A push after removal is rejected by the closed stack and
// counted as a dropped response.
func (s *Shed) shutdown() error {
registry.Range(func(k, v any) bool {
if st := v.(*socketState); st.owner == s {
// LoadAndDelete makes close-once structural even if sweeps race.
if _, loaded := registry.LoadAndDelete(k); loaded {
st.stack.close()
}
}
return true
})
return nil
}
// ServeDNS implements the plugin.Handler interface.
func (s *Shed) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
// The failure mechanism is UDP-specific: connectionless writes racing
// one fdMutex. TCP must never be starved by UDP-storm shedding.
state := request.Request{W: w, Req: r}
if state.Proto() != "udp" {
return plugin.NextOrFailure(s.Name(), s.Next, ctx, w, r)
}
st := s.lookupState(ctx)
if st == nil {
return plugin.NextOrFailure(s.Name(), s.Next, ctx, w, r) // fail open — see lookupState
}
// Coupled shed. full() is a racy read by design: a load-shedding
// heuristic, not an invariant. Silent drop: nothing is written, and
// RcodeSuccess satisfies plugin.ClientWrite so dnsserver writes nothing
// either.
if st.stack.full() {
st.droppedQuery.Inc()
return dns.RcodeSuccess, nil
}
return plugin.NextOrFailure(s.Name(), s.Next, ctx, w, r)
}
// Name implements the plugin.Handler interface.
func (s *Shed) Name() string { return pluginName }