mirror of
https://github.com/coredns/coredns.git
synced 2026-08-20 23:08:28 -04:00
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>
This commit is contained in:
@@ -31,6 +31,7 @@ var Directives = []string{
|
|||||||
"ready",
|
"ready",
|
||||||
"health",
|
"health",
|
||||||
"pprof",
|
"pprof",
|
||||||
|
"shed",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"errors",
|
"errors",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import (
|
|||||||
_ "github.com/coredns/coredns/plugin/root"
|
_ "github.com/coredns/coredns/plugin/root"
|
||||||
_ "github.com/coredns/coredns/plugin/route53"
|
_ "github.com/coredns/coredns/plugin/route53"
|
||||||
_ "github.com/coredns/coredns/plugin/secondary"
|
_ "github.com/coredns/coredns/plugin/secondary"
|
||||||
|
_ "github.com/coredns/coredns/plugin/shed"
|
||||||
_ "github.com/coredns/coredns/plugin/sign"
|
_ "github.com/coredns/coredns/plugin/sign"
|
||||||
_ "github.com/coredns/coredns/plugin/template"
|
_ "github.com/coredns/coredns/plugin/template"
|
||||||
_ "github.com/coredns/coredns/plugin/timeouts"
|
_ "github.com/coredns/coredns/plugin/timeouts"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ trace:trace
|
|||||||
ready:ready
|
ready:ready
|
||||||
health:health
|
health:health
|
||||||
pprof:pprof
|
pprof:pprof
|
||||||
|
shed:shed
|
||||||
prometheus:metrics
|
prometheus:metrics
|
||||||
errors:errors
|
errors:errors
|
||||||
log:log
|
log:log
|
||||||
|
|||||||
79
plugin/shed/README.md
Normal file
79
plugin/shed/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# shed
|
||||||
|
|
||||||
|
## Name
|
||||||
|
|
||||||
|
*shed* - serializes UDP response writes per listener socket and sheds load when the socket cannot keep up.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
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 (holders plus waiters) per file
|
||||||
|
descriptor and terminates the process with
|
||||||
|
|
||||||
|
~~~ txt
|
||||||
|
panic: too many concurrent operations on a single file or socket (max 1048575)
|
||||||
|
~~~
|
||||||
|
|
||||||
|
when that is exceeded. CoreDNS serves UDP with one goroutine per query, all writing back through
|
||||||
|
the shared packet connection, so when queries arrive faster than the socket's serialized writes
|
||||||
|
drain, every excess in-flight query parks its goroutine in that wait queue and nothing bounds the
|
||||||
|
pile. Observed in production: ~2.8M goroutines and 60GiB RSS before the panic.
|
||||||
|
|
||||||
|
The *shed* plugin makes that panic structurally unreachable, per UDP listener socket:
|
||||||
|
|
||||||
|
* **Single writer** - responses are not written by the handler goroutine. The packed response is
|
||||||
|
pushed onto a bounded per-socket stack (fixed depth 1024) and one writer goroutine per socket
|
||||||
|
performs the wire writes, so the file descriptor never sees more than one writer. The stack
|
||||||
|
evicts the oldest entry when full and the writer pops the newest first, so under overload the
|
||||||
|
socket's residual capacity always goes to the freshest response. The depth is a fixed burst
|
||||||
|
budget (roughly 12-16ms of a typical socket's drain rate), not a tunable.
|
||||||
|
* **Coupled shedding** - while a socket's stack is full, arriving queries on that socket are
|
||||||
|
dropped before any plugin runs; work admitted then would only produce a response destined for
|
||||||
|
eviction. There is no configuration: the stack's fullness is the signal.
|
||||||
|
|
||||||
|
Drops are silent - no response is written, so the client's resolver retries against another
|
||||||
|
server, the standard load-shedding contract for UDP DNS. Every drop is counted.
|
||||||
|
|
||||||
|
The plugin only acts on UDP; TCP queries pass through untouched. It can only be used in plain DNS
|
||||||
|
server blocks (not *tls*, *grpc*, *https* or *quic*), which is enforced at startup. It should be
|
||||||
|
listed before (above) the *prometheus* plugin in the plugin chain, so that shed drops are never
|
||||||
|
counted as handled requests by the *prometheus* plugin - which is where this plugin sits by
|
||||||
|
default.
|
||||||
|
|
||||||
|
When several server blocks share a listener, any block with *shed* installs the write discipline
|
||||||
|
for every write on that socket, while the pre-chain shedding only runs in blocks that carry the
|
||||||
|
directive - keep it uniform across blocks sharing a listener. The discipline covers every response
|
||||||
|
written through `WriteMsg`, which is how every plugin responds; a plugin writing raw bytes with
|
||||||
|
`ResponseWriter.Write` would bypass it.
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
~~~ txt
|
||||||
|
shed
|
||||||
|
~~~
|
||||||
|
|
||||||
|
The plugin takes no arguments.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
If monitoring is enabled (via the *prometheus* plugin) then the following metric is exported:
|
||||||
|
|
||||||
|
* `coredns_shed_dropped_total{server, reason}` - counter of dropped queries and responses. The
|
||||||
|
`reason` label is `query` for queries dropped before the plugin chain because the socket's
|
||||||
|
stack was full, and `response` for responses dropped at the write boundary (evicted by a newer
|
||||||
|
response, failed to reach the wire, or arriving during shutdown).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Protect the UDP listener while forwarding:
|
||||||
|
|
||||||
|
~~~ corefile
|
||||||
|
. {
|
||||||
|
shed
|
||||||
|
forward . 8.8.8.8
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
The fdMutex limit is enforced in `GOROOT/src/internal/poll/fd_mutex.go`.
|
||||||
273
plugin/shed/fdmutex_test.go
Normal file
273
plugin/shed/fdmutex_test.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
// Evidence tests for the fdMutex overflow panic described in README.md.
|
||||||
|
// One flood harness, two write disciplines:
|
||||||
|
//
|
||||||
|
// - TestFdMutexPanicOneSlowWrite (SHED_FLOOD_TEST=1): one held write plus
|
||||||
|
// >2^20 queued raw writers deterministically panic a subprocess.
|
||||||
|
// - TestFdMutexPanicUDPFlood (SHED_FLOOD_TEST=1): the same panic with
|
||||||
|
// nothing held — raw writers simply outpace the serialized drain.
|
||||||
|
// - TestSingleWriterNoPanicSameLoad: the same responders through the
|
||||||
|
// plugin's stack and single writer complete with every response written
|
||||||
|
// or counted dropped. Runs at 50k responders by default (including
|
||||||
|
// -race in CI); at the full 1.5M under SHED_FLOOD_TEST=1.
|
||||||
|
//
|
||||||
|
// The panic tests re-exec the test binary (the panic is a process death),
|
||||||
|
// cost ~1.5M goroutines / a few GiB / seconds, and assert on the runtime's
|
||||||
|
// message — env-gated so no automated or casual run pays that, or breaks if
|
||||||
|
// a future Go release rewords the panic.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overflowMsg must match GOROOT/src/internal/poll/fd_mutex.go.
|
||||||
|
const overflowMsg = "too many concurrent operations on a single file or socket (max 1048575)"
|
||||||
|
|
||||||
|
const (
|
||||||
|
childEnv = "COREDNS_SHED_FDMUTEX_CHILD" // "flood", "held", absent = normal run
|
||||||
|
floodEnv = "SHED_FLOOD_TEST" // set to run the panic tests and the full-size survival test
|
||||||
|
|
||||||
|
// fdMutex fields are 20-bit: the 1,048,576th concurrent op panics.
|
||||||
|
fdMutexLimit = 1 << 20
|
||||||
|
|
||||||
|
// floodWriters is comfortably above the limit, so the flood mode still
|
||||||
|
// crosses it after subtracting whatever the drain completes while
|
||||||
|
// spawning. ciWriters exercises the same code paths at a size every
|
||||||
|
// test run can afford.
|
||||||
|
floodWriters = 1_500_000
|
||||||
|
ciWriters = 50_000
|
||||||
|
nSpawners = 16
|
||||||
|
|
||||||
|
// Flood mode uses near-max UDP payloads so each serialized sendto is
|
||||||
|
// expensive — a stand-in for a response datapath slower than the
|
||||||
|
// arrival rate.
|
||||||
|
floodPayload = 63 * 1024
|
||||||
|
|
||||||
|
childTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
switch os.Getenv(childEnv) {
|
||||||
|
case "flood":
|
||||||
|
childFlood(false)
|
||||||
|
case "held":
|
||||||
|
childFlood(true)
|
||||||
|
default:
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
// The parent asserts on the exit status; this line is log-only.
|
||||||
|
fmt.Println("CHILD-SURVIVED-WITHOUT-PANIC")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnResponders spawns n goroutines, each calling respond once — a raw
|
||||||
|
// socket write in the panic modes, a stack push in the survival mode.
|
||||||
|
func spawnResponders(n int, respond func()) (started, completed *atomic.Int64) {
|
||||||
|
started, completed = new(atomic.Int64), new(atomic.Int64)
|
||||||
|
var spawn sync.WaitGroup
|
||||||
|
for range nSpawners {
|
||||||
|
spawn.Go(func() {
|
||||||
|
for range n / nSpawners {
|
||||||
|
started.Add(1)
|
||||||
|
go func() {
|
||||||
|
respond()
|
||||||
|
completed.Add(1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
spawn.Wait()
|
||||||
|
return started, completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// childFlood is the crash payload: pile >2^20 concurrent raw writes onto one
|
||||||
|
// UDP socket. With held=true, one in-progress write is first parked via
|
||||||
|
// SyscallConn so the pile-up is deterministic; with held=false the writers
|
||||||
|
// race a genuine serialized drain.
|
||||||
|
func childFlood(held bool) {
|
||||||
|
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("child: listen:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("child: sink listen:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := sink.LocalAddr().(*net.UDPAddr)
|
||||||
|
|
||||||
|
payload := make([]byte, floodPayload)
|
||||||
|
if held {
|
||||||
|
payload = payload[:64] // writes only queue as waiters; size is irrelevant
|
||||||
|
// Park one write in progress: the callback holds the fd's write
|
||||||
|
// lock exactly as a write blocked in the kernel would. Everything
|
||||||
|
// arriving behind it becomes an fdMutex waiter.
|
||||||
|
rc, err := conn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("child: syscallconn:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holding := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
rc.Write(func(uintptr) bool {
|
||||||
|
close(holding)
|
||||||
|
select {} // hold the write lock for the life of the process
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
<-holding
|
||||||
|
fmt.Println("child: one slow write in progress (fd write lock held)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("child: spawning %d concurrent UDP writers on one socket (limit %d)\n",
|
||||||
|
floodWriters, fdMutexLimit-1)
|
||||||
|
started, completed := spawnResponders(floodWriters, func() {
|
||||||
|
conn.WriteToUDP(payload, dst) //nolint:errcheck // the pile, not the result, is the point
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the panic is going to happen it already has (it fires inside a
|
||||||
|
// writer's WriteToUDP). Give the drain a moment, then report survival.
|
||||||
|
deadline := time.Now().Add(childTimeout)
|
||||||
|
for completed.Load() < started.Load() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCrashChild re-execs this test binary in the given child mode and
|
||||||
|
// returns its combined output. The child is expected to die.
|
||||||
|
func runCrashChild(t *testing.T, mode string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||||
|
cmd.Env = append(os.Environ(), childEnv+"="+mode, "GOTRACEBACK=single")
|
||||||
|
start := time.Now()
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
t.Logf("child (%s) ran %v, err=%v", mode, time.Since(start).Round(time.Millisecond), err)
|
||||||
|
s := string(out)
|
||||||
|
// Panic output ends with a goroutine stack; keep the log readable.
|
||||||
|
if i := strings.Index(s, "goroutine "); i > 0 {
|
||||||
|
t.Logf("child output:\n%s[stack trace elided]", s[:i])
|
||||||
|
} else {
|
||||||
|
t.Logf("child output:\n%s", s)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("child process survived — expected fdMutex overflow panic")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipUnlessFloodTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv(floodEnv) == "" {
|
||||||
|
t.Skipf("panic reproduction (~%d goroutines, a few GiB); set %s=1 to run", floodWriters, floodEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFdMutexPanicUDPFlood: >2^20 genuinely concurrent writes on one UDP
|
||||||
|
// socket kill the process. Nothing is held or mocked — the writers simply
|
||||||
|
// arrive faster than the fd's serialized writes drain, which is the
|
||||||
|
// production storm condition.
|
||||||
|
func TestFdMutexPanicUDPFlood(t *testing.T) {
|
||||||
|
skipUnlessFloodTest(t)
|
||||||
|
out := runCrashChild(t, "flood")
|
||||||
|
if !strings.Contains(out, overflowMsg) {
|
||||||
|
t.Fatalf("child died without the fdMutex overflow panic; want %q", overflowMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFdMutexPanicOneSlowWrite: deterministic variant — a single slow
|
||||||
|
// in-progress write plus >2^20 queued writers overflow the fdMutex waiter
|
||||||
|
// counter. No timing or throughput assumptions.
|
||||||
|
func TestFdMutexPanicOneSlowWrite(t *testing.T) {
|
||||||
|
skipUnlessFloodTest(t)
|
||||||
|
out := runCrashChild(t, "held")
|
||||||
|
if !strings.Contains(out, overflowMsg) {
|
||||||
|
t.Fatalf("child died without the fdMutex overflow panic; want %q", overflowMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingUDPWriter is the dns.Writer handed to the plugin's stack: the raw
|
||||||
|
// wire write, counted on success (a failed write is counted by the plugin
|
||||||
|
// as a drop).
|
||||||
|
type countingUDPWriter struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
dst *net.UDPAddr
|
||||||
|
written atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *countingUDPWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := w.conn.WriteToUDP(p, w.dst)
|
||||||
|
if err == nil {
|
||||||
|
w.written.Add(1)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleWriterNoPanicSameLoad drives the flood harness through the
|
||||||
|
// plugin's actual respStack and writer goroutine. Only that one goroutine
|
||||||
|
// ever touches the fd, so the fdMutex overflow is structurally unreachable,
|
||||||
|
// and every response is accounted for as written or dropped.
|
||||||
|
func TestSingleWriterNoPanicSameLoad(t *testing.T) {
|
||||||
|
n := ciWriters
|
||||||
|
if os.Getenv(floodEnv) != "" {
|
||||||
|
n = floodWriters
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sink.Close()
|
||||||
|
|
||||||
|
dropped := droppedTotal.WithLabelValues(t.Name(), "response")
|
||||||
|
droppedBefore := testutil.ToFloat64(dropped) // the child accumulates across -count>1 runs
|
||||||
|
rs := newRespStack(stackDepth, dropped)
|
||||||
|
writerDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(writerDone)
|
||||||
|
rs.writerLoop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
w := &countingUDPWriter{conn: conn, dst: sink.LocalAddr().(*net.UDPAddr)}
|
||||||
|
payload := make([]byte, 64)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
started, completed := spawnResponders(n, func() {
|
||||||
|
// The responder's entire write path: what the decorator installs.
|
||||||
|
(&stackWriter{stack: rs, inner: w}).Write(payload) //nolint:errcheck // always reports success
|
||||||
|
})
|
||||||
|
deadline := time.Now().Add(childTimeout)
|
||||||
|
for completed.Load() < started.Load() {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("only %d/%d responders completed", completed.Load(), started.Load())
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
rs.close()
|
||||||
|
<-writerDone
|
||||||
|
|
||||||
|
written := w.written.Load()
|
||||||
|
droppedN := int64(testutil.ToFloat64(dropped) - droppedBefore)
|
||||||
|
if spawned := started.Load(); written+droppedN != spawned {
|
||||||
|
t.Fatalf("accounting: written=%d + dropped=%d != %d responders", written, droppedN, spawned)
|
||||||
|
}
|
||||||
|
t.Logf("%d concurrent responders completed in %v with ONE fd writer: %d responses written, %d evicted (counted drops), no panic",
|
||||||
|
started.Load(), elapsed.Round(time.Millisecond), written, droppedN)
|
||||||
|
}
|
||||||
15
plugin/shed/metrics.go
Normal file
15
plugin/shed/metrics.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var droppedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: plugin.Namespace,
|
||||||
|
Subsystem: "shed",
|
||||||
|
Name: "dropped_total",
|
||||||
|
Help: "Counter of queries and responses dropped, per server, by reason.",
|
||||||
|
}, []string{"server", "reason"})
|
||||||
68
plugin/shed/setup.go
Normal file
68
plugin/shed/setup.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/coredns/caddy"
|
||||||
|
"github.com/coredns/coredns/core/dnsserver"
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
clog "github.com/coredns/coredns/plugin/pkg/log"
|
||||||
|
pkgparse "github.com/coredns/coredns/plugin/pkg/parse"
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pluginName = "shed"
|
||||||
|
|
||||||
|
var log = clog.NewWithPlugin(pluginName)
|
||||||
|
|
||||||
|
// stackDepth is a burst budget, not a knob: ~12-16ms at a typical socket's
|
||||||
|
// serialized drain rate; a larger value would only hold staler responses.
|
||||||
|
const stackDepth = 1024
|
||||||
|
|
||||||
|
func init() { plugin.Register(pluginName, setup) }
|
||||||
|
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
s, err := parse(c)
|
||||||
|
if err != nil {
|
||||||
|
return plugin.Error(pluginName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hook only exists on the plain-DNS UDP path; on any other transport
|
||||||
|
// shed would silently protect nothing, so refuse at parse time. Every
|
||||||
|
// block key is checked: caddy propagates the plugin list to all keys of
|
||||||
|
// a server block, not just the one setup runs for.
|
||||||
|
for _, key := range c.ServerBlockKeys {
|
||||||
|
if tr, _ := pkgparse.Transport(key); tr != transport.DNS {
|
||||||
|
return plugin.Error(pluginName, c.Errf("only plain DNS server blocks are supported; %q uses transport %q", key, tr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := dnsserver.GetConfig(c)
|
||||||
|
cfg.UDPDecorateWriterFunc = s.decorateWriterFactory
|
||||||
|
|
||||||
|
// On a graceful reload the retiring instance must stop its writer
|
||||||
|
// goroutines; shutdown removes only this instance's sockets.
|
||||||
|
c.OnShutdown(s.shutdown)
|
||||||
|
|
||||||
|
cfg.AddPlugin(func(next plugin.Handler) plugin.Handler {
|
||||||
|
s.Next = next
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(c *caddy.Controller) (*Shed, error) {
|
||||||
|
s := &Shed{}
|
||||||
|
i := 0
|
||||||
|
for c.Next() {
|
||||||
|
if i > 0 {
|
||||||
|
return nil, plugin.ErrOnce
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
if len(c.RemainingArgs()) != 0 {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
if c.NextBlock() {
|
||||||
|
return nil, c.Errf("shed takes no options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
53
plugin/shed/setup_test.go
Normal file
53
plugin/shed/setup_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coredns/caddy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
shouldErr bool
|
||||||
|
}{
|
||||||
|
{"shed", false},
|
||||||
|
{"shed extra", true},
|
||||||
|
{"shed {\n depth 10\n}", true},
|
||||||
|
{"shed\nshed", true},
|
||||||
|
}
|
||||||
|
for i, tc := range tests {
|
||||||
|
c := caddy.NewTestController("dns", tc.input)
|
||||||
|
err := setup(c)
|
||||||
|
if tc.shouldErr && err == nil {
|
||||||
|
t.Errorf("Test %d: expected error for input %q", i, tc.input)
|
||||||
|
}
|
||||||
|
if !tc.shouldErr && err != nil {
|
||||||
|
t.Errorf("Test %d: unexpected error for input %q: %s", i, tc.input, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupRejectsNonDNSTransport(t *testing.T) {
|
||||||
|
for _, key := range []string{"tls://.:853", "grpc://.:443", "https://.:443", "quic://.:853"} {
|
||||||
|
c := caddy.NewTestController("dns", "shed")
|
||||||
|
c.ServerBlockKeys = []string{key}
|
||||||
|
err := setup(c)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for server block key %q", key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "plain DNS") {
|
||||||
|
t.Errorf("error for %q = %q, want it to mention plain DNS", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupAcceptsPlainDNSKeys(t *testing.T) {
|
||||||
|
c := caddy.NewTestController("dns", "shed")
|
||||||
|
c.ServerBlockKeys = []string{"example.org.:53", "dns://.:53"}
|
||||||
|
if err := setup(c); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
plugin/shed/shed.go
Normal file
116
plugin/shed/shed.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// 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 }
|
||||||
172
plugin/shed/shed_test.go
Normal file
172
plugin/shed/shed_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/core/dnsserver"
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
|
"github.com/coredns/coredns/plugin/pkg/dnstest"
|
||||||
|
"github.com/coredns/coredns/plugin/test"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newShed constructs a Shed whose package-level registry entries (and
|
||||||
|
// writer goroutines) are removed after the test.
|
||||||
|
func newShed(t *testing.T, next plugin.Handler) *Shed {
|
||||||
|
t.Helper()
|
||||||
|
s := &Shed{Next: next}
|
||||||
|
t.Cleanup(func() { _ = s.shutdown() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func msg() *dns.Msg {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion("example.org.", dns.TypeA)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// packedReply is what miekg/dns's WriteMsg hands the decorated writer.
|
||||||
|
func packedReply(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(msg())
|
||||||
|
data, err := m.Pack()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func ctxFor(srv *dnsserver.Server) context.Context {
|
||||||
|
return context.WithValue(context.Background(), dnsserver.Key{}, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// answering is a Next handler that writes a response.
|
||||||
|
func answering() plugin.Handler {
|
||||||
|
return plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
if err := w.WriteMsg(m); err != nil {
|
||||||
|
return dns.RcodeServerFailure, err
|
||||||
|
}
|
||||||
|
return dns.RcodeSuccess, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingWriter parks the writer goroutine in a raw Write until release is
|
||||||
|
// closed.
|
||||||
|
type blockingWriter struct {
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blockingWriter) Write(p []byte) (int, error) {
|
||||||
|
b.entered <- struct{}{}
|
||||||
|
<-b.release
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillStack parks srv's writer goroutine and fills the stack through the
|
||||||
|
// production decorator; cleanup releases the writer.
|
||||||
|
func fillStack(t *testing.T, s *Shed, srv *dnsserver.Server) *socketState {
|
||||||
|
t.Helper()
|
||||||
|
dec := s.decorateWriterFactory(srv)
|
||||||
|
v, ok := registry.Load(srv)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("decorator factory must register the socket's state")
|
||||||
|
}
|
||||||
|
st := v.(*socketState)
|
||||||
|
bw := &blockingWriter{
|
||||||
|
entered: make(chan struct{}, stackDepth+2),
|
||||||
|
release: make(chan struct{}),
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { close(bw.release) })
|
||||||
|
data := packedReply(t)
|
||||||
|
// First push is popped by the writer, which parks in the raw Write.
|
||||||
|
if _, err := dec(bw).Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-bw.entered
|
||||||
|
for !st.stack.full() {
|
||||||
|
if _, err := dec(bw).Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoServerInContextFailsOpen(t *testing.T) {
|
||||||
|
s := newShed(t, answering())
|
||||||
|
rec := dnstest.NewRecorder(&test.ResponseWriter{})
|
||||||
|
if _, err := s.ServeDNS(context.Background(), rec, msg()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Msg == nil {
|
||||||
|
t.Fatal("expected a response without a dnsserver in the context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnregisteredSocketFailsOpen(t *testing.T) {
|
||||||
|
s := newShed(t, answering())
|
||||||
|
rec := dnstest.NewRecorder(&test.ResponseWriter{})
|
||||||
|
// The server carried by the context was never registered by the
|
||||||
|
// decorator factory (e.g. a straggler after a reload swept it).
|
||||||
|
if _, err := s.ServeDNS(ctxFor(&dnsserver.Server{}), rec, msg()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Msg == nil {
|
||||||
|
t.Fatal("expected a response for an unregistered socket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPPassesThrough(t *testing.T) {
|
||||||
|
s := newShed(t, answering())
|
||||||
|
srv := &dnsserver.Server{}
|
||||||
|
fillStack(t, s, srv)
|
||||||
|
rec := dnstest.NewRecorder(&test.ResponseWriter{TCP: true})
|
||||||
|
// Even with the socket's stack full, TCP is never shed.
|
||||||
|
if _, err := s.ServeDNS(ctxFor(srv), rec, msg()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Msg == nil {
|
||||||
|
t.Fatal("expected a response over TCP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoupledShedWhenStackFull(t *testing.T) {
|
||||||
|
s := newShed(t, answering())
|
||||||
|
srv := &dnsserver.Server{}
|
||||||
|
st := fillStack(t, s, srv)
|
||||||
|
|
||||||
|
before := testutil.ToFloat64(st.droppedQuery)
|
||||||
|
rec := dnstest.NewRecorder(&test.ResponseWriter{})
|
||||||
|
rcode, err := s.ServeDNS(ctxFor(srv), rec, msg())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rcode != dns.RcodeSuccess {
|
||||||
|
t.Errorf("rcode = %d, want RcodeSuccess (silent drop)", rcode)
|
||||||
|
}
|
||||||
|
if rec.Msg != nil {
|
||||||
|
t.Error("a shed query must not be answered")
|
||||||
|
}
|
||||||
|
if got := testutil.ToFloat64(st.droppedQuery) - before; got != 1 {
|
||||||
|
t.Errorf("dropped_total{reason=%q} increment = %v, want 1", "query", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassesThroughWhenNotFull(t *testing.T) {
|
||||||
|
s := newShed(t, answering())
|
||||||
|
srv := &dnsserver.Server{}
|
||||||
|
s.decorateWriterFactory(srv)
|
||||||
|
rec := dnstest.NewRecorder(&test.ResponseWriter{})
|
||||||
|
if _, err := s.ServeDNS(ctxFor(srv), rec, msg()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Msg == nil {
|
||||||
|
t.Fatal("expected a response while the stack has room")
|
||||||
|
}
|
||||||
|
}
|
||||||
176
plugin/shed/stack.go
Normal file
176
plugin/shed/stack.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/core/dnsserver"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decorateWriterFactory is installed as Config.UDPDecorateWriterFunc by
|
||||||
|
// setup. dnsserver's ServePacket calls it once per UDP listener socket,
|
||||||
|
// before the socket serves its first packet, so the socket's state and
|
||||||
|
// writer goroutine exist before ServeDNS ever looks them up. miekg/dns then
|
||||||
|
// applies the returned dns.DecorateWriter once per packet, wrapping the
|
||||||
|
// response writer.
|
||||||
|
func (s *Shed) decorateWriterFactory(srv *dnsserver.Server) dns.DecorateWriter {
|
||||||
|
st := s.mintState(srv)
|
||||||
|
return func(w dns.Writer) dns.Writer {
|
||||||
|
return &stackWriter{stack: st.stack, inner: w}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stackWriter is the per-packet transport wrapper. miekg/dns runs every
|
||||||
|
// message transform (including TSIG) before handing Write the packed bytes,
|
||||||
|
// so the deferred operation is precisely the serialized syscall.
|
||||||
|
type stackWriter struct {
|
||||||
|
stack *respStack
|
||||||
|
inner dns.Writer // the raw response writer; its Write is the terminal syscall
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write pushes the packed bytes and reports success: from here on "written"
|
||||||
|
// means "queued for the socket's writer goroutine". The pushed slice is
|
||||||
|
// exclusively owned — miekg/dns packs each response into a fresh allocation.
|
||||||
|
func (sw *stackWriter) Write(data []byte) (int, error) {
|
||||||
|
if sw.stack.push(pendingResp{w: sw.inner, data: data}) {
|
||||||
|
sw.stack.dropped.Inc()
|
||||||
|
}
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingResp is one captured response awaiting the socket's writer
|
||||||
|
// goroutine: the packed bytes and the raw writer that puts them on the wire.
|
||||||
|
type pendingResp struct {
|
||||||
|
w dns.Writer
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// respStack is a per-socket bounded ring of pending responses with a single
|
||||||
|
// insertion cursor and no head index: entries occupy the size slots before
|
||||||
|
// next (mod depth), so when the ring is full the slot at next holds the
|
||||||
|
// oldest entry and pushing over it is the eviction.
|
||||||
|
type respStack struct {
|
||||||
|
dropped prometheus.Counter // responses evicted, write-failed, or pushed after close
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []pendingResp // ring; len(buf) is the fixed depth
|
||||||
|
next int // index of the next push
|
||||||
|
size int // occupied slots
|
||||||
|
closed bool // set by close; pushes are rejected from then on
|
||||||
|
|
||||||
|
n atomic.Int64 // size mirror for the lock-free full() check
|
||||||
|
notify chan struct{} // cap 1: writer wake-up
|
||||||
|
stop chan struct{} // closed on shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRespStack(depth int, dropped prometheus.Counter) *respStack {
|
||||||
|
return &respStack{
|
||||||
|
dropped: dropped,
|
||||||
|
buf: make([]pendingResp, depth),
|
||||||
|
notify: make(chan struct{}, 1),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push adds p as the newest entry, evicting the oldest when full. Never
|
||||||
|
// blocks. Reports whether a response was dropped as a result: the evicted
|
||||||
|
// oldest, or — on a closed stack — p itself.
|
||||||
|
func (rs *respStack) push(p pendingResp) (dropped bool) {
|
||||||
|
rs.mu.Lock()
|
||||||
|
switch {
|
||||||
|
case rs.closed:
|
||||||
|
rs.mu.Unlock()
|
||||||
|
return true
|
||||||
|
case rs.size == len(rs.buf):
|
||||||
|
dropped = true // the slot at next holds the oldest entry
|
||||||
|
default:
|
||||||
|
rs.size++
|
||||||
|
}
|
||||||
|
rs.buf[rs.next] = p
|
||||||
|
rs.next = (rs.next + 1) % len(rs.buf)
|
||||||
|
rs.n.Store(int64(rs.size))
|
||||||
|
rs.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case rs.notify <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop removes and returns the newest entry.
|
||||||
|
func (rs *respStack) pop() (pendingResp, bool) {
|
||||||
|
rs.mu.Lock()
|
||||||
|
if rs.size == 0 {
|
||||||
|
rs.mu.Unlock()
|
||||||
|
return pendingResp{}, false
|
||||||
|
}
|
||||||
|
rs.next = (rs.next - 1 + len(rs.buf)) % len(rs.buf)
|
||||||
|
p := rs.buf[rs.next]
|
||||||
|
rs.buf[rs.next] = pendingResp{} // release the response bytes
|
||||||
|
rs.size--
|
||||||
|
rs.n.Store(int64(rs.size))
|
||||||
|
rs.mu.Unlock()
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// full is the lock-free view used by the coupled-shed predicate.
|
||||||
|
func (rs *respStack) full() bool { return rs.n.Load() >= int64(len(rs.buf)) }
|
||||||
|
|
||||||
|
// close stops the writer goroutine — it drains whatever is stacked, then
|
||||||
|
// exits — and rejects any straggler pushes.
|
||||||
|
func (rs *respStack) close() {
|
||||||
|
rs.mu.Lock()
|
||||||
|
rs.closed = true
|
||||||
|
rs.mu.Unlock()
|
||||||
|
close(rs.stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitNonempty blocks until the stack has work, or reports false once the
|
||||||
|
// stack is closed and empty. The re-check on the stop arm matters: the
|
||||||
|
// select may pick stop over a pending notify, but entries accepted before
|
||||||
|
// the close must still be served — closed guarantees no new pushes, so the
|
||||||
|
// drain terminates.
|
||||||
|
func (rs *respStack) waitNonempty() bool {
|
||||||
|
if rs.n.Load() > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-rs.notify:
|
||||||
|
return true
|
||||||
|
case <-rs.stop:
|
||||||
|
return rs.n.Load() > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerLoop is the socket's single writer: it waits for pending responses,
|
||||||
|
// pops the one that is newest at write time, and writes it to the wire.
|
||||||
|
func (rs *respStack) writerLoop() {
|
||||||
|
for {
|
||||||
|
if !rs.waitNonempty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p, ok := rs.pop(); ok {
|
||||||
|
rs.write(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write performs the deferred raw write; a response that fails to reach the
|
||||||
|
// wire is a counted drop. A writer panic must not kill the process — that is
|
||||||
|
// the failure class this plugin removes — so it is recovered, like
|
||||||
|
// dnsserver does for synchronous writes.
|
||||||
|
func (rs *respStack) write(p pendingResp) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
rs.dropped.Inc()
|
||||||
|
log.Errorf("Recovered panic in shed writer: %v", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err := p.w.Write(p.data); err != nil {
|
||||||
|
rs.dropped.Inc()
|
||||||
|
log.Debugf("Deferred response write failed: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
plugin/shed/stack_test.go
Normal file
164
plugin/shed/stack_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package shed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/core/dnsserver"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chanWriter hands each written payload to a channel — the race-safe way to
|
||||||
|
// observe the writer goroutine's deferred writes.
|
||||||
|
type chanWriter struct {
|
||||||
|
got chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *chanWriter) Write(p []byte) (int, error) {
|
||||||
|
w.got <- p
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStackEvictsOldestPopsNewest(t *testing.T) {
|
||||||
|
rs := newRespStack(3, droppedTotal.WithLabelValues(t.Name(), "response")) // no writer goroutine: pure data structure test
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
dropped := rs.push(pendingResp{data: []byte{byte(i)}})
|
||||||
|
if want := i > 3; dropped != want {
|
||||||
|
t.Errorf("push %d: dropped = %v, want %v", i, dropped, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rs.full() {
|
||||||
|
t.Error("expected full stack after overfilling")
|
||||||
|
}
|
||||||
|
// 1 and 2 were evicted; the survivors pop newest-first.
|
||||||
|
for _, want := range []byte{5, 4, 3} {
|
||||||
|
p, ok := rs.pop()
|
||||||
|
if !ok || p.data[0] != want {
|
||||||
|
t.Fatalf("pop = %v, %v; want entry %d", p.data, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := rs.pop(); ok {
|
||||||
|
t.Error("expected empty stack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStackCloseRejectsPushDrainsRest(t *testing.T) {
|
||||||
|
rs := newRespStack(4, droppedTotal.WithLabelValues(t.Name(), "response"))
|
||||||
|
|
||||||
|
// A stale notify token on an empty open stack wakes the writer, which
|
||||||
|
// must tolerate the failed pop (writerLoop's pop-ok check).
|
||||||
|
rs.push(pendingResp{data: []byte{9}})
|
||||||
|
rs.pop() // pop directly, leaving the push's token buffered
|
||||||
|
if !rs.waitNonempty() {
|
||||||
|
t.Error("a stale token should report as work")
|
||||||
|
}
|
||||||
|
if _, ok := rs.pop(); ok {
|
||||||
|
t.Error("pop should find nothing behind a stale token")
|
||||||
|
}
|
||||||
|
|
||||||
|
rs.push(pendingResp{data: []byte{1}})
|
||||||
|
rs.close()
|
||||||
|
if !rs.push(pendingResp{data: []byte{2}}) {
|
||||||
|
t.Error("push on closed stack should report a drop")
|
||||||
|
}
|
||||||
|
// Entries accepted before the close must still be served.
|
||||||
|
if !rs.waitNonempty() {
|
||||||
|
t.Fatal("waitNonempty should report the pre-close entry")
|
||||||
|
}
|
||||||
|
if p, ok := rs.pop(); !ok || p.data[0] != 1 {
|
||||||
|
t.Fatalf("pop = %v, %v; want pre-close entry", p, ok)
|
||||||
|
}
|
||||||
|
// The pre-close push's token may still be buffered; drain it so the
|
||||||
|
// final wait deterministically takes the stop arm.
|
||||||
|
select {
|
||||||
|
case <-rs.notify:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if rs.waitNonempty() {
|
||||||
|
t.Error("waitNonempty should report false once closed and drained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecoratorCapturesWriteAndWriterWrites(t *testing.T) {
|
||||||
|
s := newShed(t, nil)
|
||||||
|
srv := &dnsserver.Server{}
|
||||||
|
dec := s.decorateWriterFactory(srv)
|
||||||
|
if _, ok := registry.Load(srv); !ok {
|
||||||
|
t.Fatal("factory should pre-register the socket's state")
|
||||||
|
}
|
||||||
|
|
||||||
|
cw := &chanWriter{got: make(chan []byte, 1)}
|
||||||
|
data := packedReply(t)
|
||||||
|
w := dec(cw)
|
||||||
|
if _, ok := w.(*stackWriter); !ok {
|
||||||
|
t.Fatalf("decorator returned %T, want *stackWriter", w)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-cw.got:
|
||||||
|
m := new(dns.Msg)
|
||||||
|
if err := m.Unpack(got); err != nil {
|
||||||
|
t.Fatalf("writer goroutine wrote unparseable bytes: %s", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("writer goroutine never performed the deferred write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// panicWriter panics on its first Write, then counts.
|
||||||
|
type panicWriter struct {
|
||||||
|
writes atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *panicWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.writes.Add(1) == 1 {
|
||||||
|
panic("writer exploded")
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterPanicRecovered(t *testing.T) {
|
||||||
|
s := newShed(t, nil)
|
||||||
|
srv := &dnsserver.Server{}
|
||||||
|
dec := s.decorateWriterFactory(srv)
|
||||||
|
|
||||||
|
pw := &panicWriter{}
|
||||||
|
data := packedReply(t)
|
||||||
|
if _, err := dec(pw).Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := dec(pw).Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The writer goroutine must survive the first write's panic and still
|
||||||
|
// perform the second.
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for pw.writes.Load() < 2 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("writer performed %d writes, want 2 (goroutine died on panic?)", pw.writes.Load())
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShutdownIsInstanceScoped(t *testing.T) {
|
||||||
|
old := newShed(t, nil)
|
||||||
|
cur := newShed(t, nil)
|
||||||
|
oldSrv, newSrv := &dnsserver.Server{}, &dnsserver.Server{}
|
||||||
|
old.decorateWriterFactory(oldSrv)
|
||||||
|
cur.decorateWriterFactory(newSrv)
|
||||||
|
|
||||||
|
if err := old.shutdown(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := registry.Load(oldSrv); ok {
|
||||||
|
t.Error("old instance's entry should be swept")
|
||||||
|
}
|
||||||
|
if _, ok := registry.Load(newSrv); !ok {
|
||||||
|
t.Error("new instance's entry must survive the old instance's shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
47
test/shed_test.go
Normal file
47
test/shed_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coredns/coredns/plugin/metrics"
|
||||||
|
"github.com/coredns/coredns/plugin/test"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShed checks that with the shed plugin installed a query is answered
|
||||||
|
// over both UDP (through the plugin's deferred single-writer path) and
|
||||||
|
// TCP (which shed passes through), and that its drop counter is exported.
|
||||||
|
func TestShed(t *testing.T) {
|
||||||
|
corefile := `.:0 {
|
||||||
|
shed
|
||||||
|
prometheus localhost:0
|
||||||
|
whoami
|
||||||
|
}`
|
||||||
|
|
||||||
|
i, udp, tcp, err := CoreDNSServerAndPorts(corefile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not get CoreDNS serving instance: %s", err)
|
||||||
|
}
|
||||||
|
defer i.Stop()
|
||||||
|
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion("whoami.example.org.", dns.TypeA)
|
||||||
|
|
||||||
|
if r, err := dns.Exchange(m, udp); err != nil || r.Rcode != dns.RcodeSuccess {
|
||||||
|
t.Fatalf("Expected UDP reply, got %v: %v", r, err)
|
||||||
|
}
|
||||||
|
c := &dns.Client{Net: "tcp"}
|
||||||
|
if r, _, err := c.Exchange(m, tcp); err != nil || r.Rcode != dns.RcodeSuccess {
|
||||||
|
t.Fatalf("Expected TCP reply, got %v: %v", r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := test.Scrape("http://" + metrics.ListenAddr + "/metrics")
|
||||||
|
got, labels := test.MetricValue("coredns_shed_dropped_total", data)
|
||||||
|
if got != "0" {
|
||||||
|
t.Errorf("Expected coredns_shed_dropped_total 0, but got %s", got)
|
||||||
|
}
|
||||||
|
if labels["reason"] == "" {
|
||||||
|
t.Errorf("Expected coredns_shed_dropped_total to carry a reason label, got %v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user