-
Notifications
You must be signed in to change notification settings - Fork 80
Expand file tree
/
Copy pathloadbalance.go
More file actions
283 lines (259 loc) · 9.62 KB
/
Copy pathloadbalance.go
File metadata and controls
283 lines (259 loc) · 9.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
package rdns
import (
"errors"
"expvar"
"math/rand"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
)
const (
defaultLoadBalanceInitialRTT = 100 * time.Millisecond
defaultLoadBalanceMinimumRTTSample = time.Microsecond
defaultLoadBalanceEMAAlpha = 0.1
// Two transient failures in a row are unlikely to be coincidental; apply the
// penalty only then to avoid suppressing a resolver that had a single slow response.
loadBalancePenaltyThreshold = 2
// Fraction of picks made uniformly at random instead of by weight. Exploration
// keeps every resolver's stats fresh, guarantees a minimum traffic share so that
// penalized or slow resolvers can recover (and be re-measured) rather than being
// starved indefinitely, and avoids cold-start lock-in onto whichever resolver
// happened to be probed first.
defaultLoadBalanceExploration = 0.05
// Floor for the EMA used when computing weights. Without it a resolver that
// responds in microseconds would get a weight thousands of times the neutral
// weight, starving slower or unprobed resolvers. Clamping to 1ms caps the
// maximum weight at 100 (vs. the neutral 1.0).
defaultLoadBalanceMinimumWeightRTT = time.Millisecond
)
// LoadBalance is a resolver group that prefers resolvers with lower average
// response times and penalizes resolvers that fail.
type LoadBalance struct {
id string
resolvers []Resolver
mu sync.RWMutex
stats []loadBalanceStats
opt LoadBalanceOptions
metrics *failRouterMetrics
// Per-resolver current rttEMA in microseconds, published under
// routedns.router.<id>.rtt keyed by resolver String().
rttVars []*expvar.Float
randFloat func() float64
}
type loadBalanceStats struct {
rttEMA float64 // exponential moving average in microseconds
count int
consecFails atomic.Int32 // reset to 0 on success; never read under mu
// emaInflated is set by updateOnFailure whenever it raised the EMA (via the
// failure penalty or an upward timeout sample) and cleared on success. It
// tells updateOnSuccess whether the current EMA is an artefact of failures
// that should be re-seeded immediately, rather than inferring that from the
// failure-streak length (which also fires when nothing inflated the EMA).
// Guarded by mu.
emaInflated bool
}
// LoadBalanceOptions contain settings for the load-balancing resolver group.
type LoadBalanceOptions struct {
// Duration recorded as the RTT penalty for persistently failing resolvers
// (after loadBalancePenaltyThreshold consecutive failures). 0 disables the
// penalty; without it fast-failing resolvers are still prevented from
// gaining weight via the upward-only EMA update on failure.
FailurePenalty time.Duration
// Determines if a SERVFAIL returned by a resolver should be considered an
// error response and trigger a failover.
ServfailError bool
// Determines if an empty response returned by a resolver should be considered an
// error response and trigger a failover.
EmptyError bool
}
var _ Resolver = &LoadBalance{}
// NewLoadBalance returns a new instance of a load-balancing resolver group.
func NewLoadBalance(id string, opt LoadBalanceOptions, resolvers ...Resolver) *LoadBalance {
rtt := getVarMap("router", id, "rtt")
rttVars := make([]*expvar.Float, len(resolvers))
for i, resolver := range resolvers {
rttVars[i] = new(expvar.Float)
rtt.Set(resolver.String(), rttVars[i])
}
return &LoadBalance{
id: id,
resolvers: resolvers,
stats: make([]loadBalanceStats, len(resolvers)),
opt: opt,
metrics: newFailRouterMetrics(id, len(resolvers)),
rttVars: rttVars,
randFloat: rand.Float64,
}
}
// Resolve a DNS query using a weighted random resolver selection. Resolvers with
// lower average response times receive more traffic. Failed resolvers are
// penalized and the request is retried with another resolver.
func (r *LoadBalance) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) {
log := logger(r.id, q, ci)
var buf [8]int
var remaining []int
if len(r.resolvers) <= len(buf) {
remaining = buf[:len(r.resolvers)]
} else {
remaining = make([]int, len(r.resolvers))
}
for i := range r.resolvers {
remaining[i] = i
}
var (
a *dns.Msg
err error
)
for len(remaining) > 0 {
pos := r.pick(remaining)
idx := remaining[pos]
resolver := r.resolvers[idx]
r.metrics.route.Add(resolver.String(), 1)
log.With("resolver", resolver.String()).Debug("forwarding query to resolver")
start := time.Now()
a, err = resolver.Resolve(q.Copy(), ci)
elapsed := time.Since(start)
if err == nil && r.isSuccessResponse(a) {
r.updateOnSuccess(idx, elapsed)
return a, nil
}
log.With("resolver", resolver.String()).Debug("resolver returned failure",
"error", err)
r.metrics.failure.Add(resolver.String(), 1)
// Count every failure as a failover to stay consistent with FailRotate
// (which increments on every failure, including the last/only resolver).
// This keeps routedns.router.<id>.failover comparable across groups and
// preserves the signal for a completely broken single-resolver group.
r.metrics.failover.Add(1)
penalized := r.updateOnFailure(idx, elapsed)
if penalized {
Log.Debug("penalizing resolver",
"id", r.id,
"resolver", resolver.String(),
"penalty", r.opt.FailurePenalty,
)
}
remaining[pos] = remaining[len(remaining)-1]
remaining = remaining[:len(remaining)-1]
}
if err == nil && a == nil {
err = errors.New("no active resolvers left")
}
return a, err
}
func (r *LoadBalance) String() string {
return r.id
}
// pick selects an index into remaining using weights derived from the inverse
// EMA response time. Resolvers without history use a neutral weight. With
// probability defaultLoadBalanceExploration a uniform random resolver is picked
// instead, ensuring every resolver keeps receiving some traffic.
func (r *LoadBalance) pick(remaining []int) int {
// ε-greedy exploration: occasionally pick uniformly at random regardless of
// weight so penalized/slow resolvers get re-measured and can recover.
if r.randFloat() < defaultLoadBalanceExploration {
return int(r.randFloat() * float64(len(remaining)))
}
var buf [8]float64
var weights []float64
if len(remaining) <= len(buf) {
weights = buf[:len(remaining)]
} else {
weights = make([]float64, len(remaining))
}
minWeightRTT := float64(defaultLoadBalanceMinimumWeightRTT.Microseconds())
r.mu.RLock()
var total float64
for i, idx := range remaining {
weight := 1.0
s := &r.stats[idx]
if s.count > 0 && s.rttEMA > 0 {
// Floor the effective RTT at weighting time only; rttEMA itself is
// left unchanged so recovery and metrics still see the true value.
weight = float64(defaultLoadBalanceInitialRTT.Microseconds()) / max(s.rttEMA, minWeightRTT)
}
weights[i] = weight
total += weight
}
r.mu.RUnlock()
selected := r.randFloat() * total
for i, weight := range weights {
selected -= weight
if selected <= 0 {
return i
}
}
return len(remaining) - 1
}
// updateOnSuccess records a successful response time and clears the failure streak.
func (r *LoadBalance) updateOnSuccess(idx int, d time.Duration) {
if d < defaultLoadBalanceMinimumRTTSample {
d = defaultLoadBalanceMinimumRTTSample
}
us := float64(d.Microseconds())
r.stats[idx].consecFails.Store(0)
r.mu.Lock()
s := &r.stats[idx]
// Re-seed directly to the measured RTT on the first sample, or when prior
// failures inflated the EMA (the penalty or an upward timeout sample). In
// the inflated case, blending with alpha would take ~20+ successes to decay
// back, during which the resolver gets little traffic. Gating on the
// recorded inflation (rather than the failure-streak length) avoids wiping
// an established average when nothing actually raised the EMA.
if s.count == 0 || s.emaInflated {
s.rttEMA = us
} else {
s.rttEMA = defaultLoadBalanceEMAAlpha*us + (1-defaultLoadBalanceEMAAlpha)*s.rttEMA
}
s.emaInflated = false
ema := s.rttEMA
s.count++
r.mu.Unlock()
r.rttVars[idx].Set(ema)
}
// updateOnFailure records a failed response time and returns true if the
// failure-penalty was applied (consecutive failure threshold reached).
//
// Without a penalty, only allow the EMA to move upward on failure. This
// prevents fast-failing resolvers (e.g. connection refused, returning in
// microseconds) from appearing artificially fast and attracting more traffic.
func (r *LoadBalance) updateOnFailure(idx int, elapsed time.Duration) bool {
fails := r.stats[idx].consecFails.Add(1)
penalize := r.opt.FailurePenalty > 0 && fails >= loadBalancePenaltyThreshold
d := elapsed
if penalize {
d = r.opt.FailurePenalty
}
if d < defaultLoadBalanceMinimumRTTSample {
d = defaultLoadBalanceMinimumRTTSample
}
us := float64(d.Microseconds())
r.mu.Lock()
s := &r.stats[idx]
if s.count == 0 {
// Seed with at least the baseline RTT so a fast first failure doesn't
// give this resolver a large weight advantage over unprobed resolvers.
s.rttEMA = max(us, float64(defaultLoadBalanceInitialRTT.Microseconds()))
// The seed is derived from a failure, not a real measurement; mark it
// inflated so the first success re-seeds to the measured RTT.
s.emaInflated = true
} else {
newEMA := defaultLoadBalanceEMAAlpha*us + (1-defaultLoadBalanceEMAAlpha)*s.rttEMA
if penalize || newEMA > s.rttEMA {
// Record that this update raised the EMA above the level established
// by successes, so the next success re-seeds instead of slowly
// decaying an inflated value.
s.rttEMA = newEMA
s.emaInflated = true
}
}
ema := s.rttEMA
s.count++
r.mu.Unlock()
r.rttVars[idx].Set(ema)
return penalize
}
func (r *LoadBalance) isSuccessResponse(a *dns.Msg) bool {
return isSuccessResponse(a, r.opt.ServfailError, r.opt.EmptyError)
}