2023-04-10 15:36:59 +00:00
|
|
|
// Copyright (c) HashiCorp, Inc.
|
|
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
|
2022-07-12 22:40:20 +00:00
|
|
|
package nomad
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"time"
|
|
|
|
|
2023-02-08 21:20:33 +00:00
|
|
|
"github.com/armon/go-metrics"
|
2022-07-12 22:40:20 +00:00
|
|
|
"github.com/hashicorp/go-hclog"
|
2023-02-08 21:20:33 +00:00
|
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
2022-07-12 22:40:20 +00:00
|
|
|
"github.com/hashicorp/nomad/helper"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
)
|
|
|
|
|
|
|
|
type BadNodeTracker interface {
|
|
|
|
Add(string) bool
|
|
|
|
EmitStats(time.Duration, <-chan struct{})
|
|
|
|
}
|
|
|
|
|
|
|
|
// NoopBadNodeTracker is a no-op implementation of bad node tracker that is
|
|
|
|
// used when tracking is disabled.
|
|
|
|
type NoopBadNodeTracker struct{}
|
|
|
|
|
|
|
|
func (n *NoopBadNodeTracker) EmitStats(time.Duration, <-chan struct{}) {}
|
|
|
|
func (n *NoopBadNodeTracker) Add(string) bool {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// CachedBadNodeTracker keeps a record of nodes marked as bad by the plan
|
|
|
|
// applier in a LRU cache.
|
|
|
|
//
|
|
|
|
// It takes a time window and a threshold value. Plan rejections for a node
|
|
|
|
// will be registered with its timestamp. If the number of rejections within
|
|
|
|
// the time window is greater than the threshold the node is reported as bad.
|
|
|
|
//
|
|
|
|
// The tracker uses a fixed size cache that evicts old entries based on access
|
|
|
|
// frequency and recency.
|
|
|
|
type CachedBadNodeTracker struct {
|
|
|
|
logger hclog.Logger
|
2023-02-08 21:20:33 +00:00
|
|
|
cache *lru.TwoQueueCache[string, *badNodeStats]
|
2022-07-12 22:40:20 +00:00
|
|
|
limiter *rate.Limiter
|
|
|
|
window time.Duration
|
|
|
|
threshold int
|
|
|
|
}
|
|
|
|
|
|
|
|
type CachedBadNodeTrackerConfig struct {
|
|
|
|
CacheSize int
|
|
|
|
RateLimit float64
|
|
|
|
BurstSize int
|
|
|
|
Window time.Duration
|
|
|
|
Threshold int
|
|
|
|
}
|
|
|
|
|
|
|
|
func DefaultCachedBadNodeTrackerConfig() CachedBadNodeTrackerConfig {
|
|
|
|
return CachedBadNodeTrackerConfig{
|
|
|
|
CacheSize: 50,
|
|
|
|
|
|
|
|
// Limit marking 5 nodes per 30min as ineligible with an initial
|
|
|
|
// burst of 10 nodes.
|
|
|
|
RateLimit: 5.0 / (30 * 60),
|
|
|
|
BurstSize: 10,
|
|
|
|
|
|
|
|
// Consider a node as bad if it is added more than 100 times in a 5min
|
|
|
|
// window period.
|
|
|
|
Window: 5 * time.Minute,
|
|
|
|
Threshold: 100,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewCachedBadNodeTracker returns a new CachedBadNodeTracker.
|
|
|
|
func NewCachedBadNodeTracker(logger hclog.Logger, config CachedBadNodeTrackerConfig) (*CachedBadNodeTracker, error) {
|
|
|
|
log := logger.Named("bad_node_tracker").
|
|
|
|
With("window", config.Window).
|
|
|
|
With("threshold", config.Threshold)
|
|
|
|
|
2023-02-08 21:20:33 +00:00
|
|
|
cache, err := lru.New2Q[string, *badNodeStats](config.CacheSize)
|
2022-07-12 22:40:20 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to create new bad node tracker: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return &CachedBadNodeTracker{
|
|
|
|
logger: log,
|
|
|
|
cache: cache,
|
|
|
|
limiter: rate.NewLimiter(rate.Limit(config.RateLimit), config.BurstSize),
|
|
|
|
window: config.Window,
|
|
|
|
threshold: config.Threshold,
|
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add records a new rejection for a node and returns true if the number of
|
|
|
|
// rejections reaches the threshold.
|
|
|
|
//
|
|
|
|
// If it's the first time the node is added it will be included in the internal
|
|
|
|
// cache. If the cache is full the least recently updated or accessed node is
|
|
|
|
// evicted.
|
|
|
|
func (c *CachedBadNodeTracker) Add(nodeID string) bool {
|
2023-02-08 21:20:33 +00:00
|
|
|
stats, ok := c.cache.Get(nodeID)
|
2022-07-12 22:40:20 +00:00
|
|
|
if !ok {
|
2023-02-08 21:20:33 +00:00
|
|
|
stats = newBadNodeStats(nodeID, c.window)
|
|
|
|
c.cache.Add(nodeID, stats)
|
2022-07-12 22:40:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
stats.record(now)
|
|
|
|
|
|
|
|
return c.isBad(now, stats)
|
|
|
|
}
|
|
|
|
|
|
|
|
// EmitStats generates metrics for the bad nodes being currently tracked. Must
|
|
|
|
// be called in a goroutine.
|
|
|
|
func (c *CachedBadNodeTracker) EmitStats(period time.Duration, stopCh <-chan struct{}) {
|
|
|
|
timer, stop := helper.NewSafeTimer(period)
|
|
|
|
defer stop()
|
|
|
|
|
|
|
|
for {
|
|
|
|
timer.Reset(period)
|
|
|
|
|
|
|
|
select {
|
|
|
|
case <-timer.C:
|
|
|
|
c.emitStats()
|
|
|
|
case <-stopCh:
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// isBad returns true if the node has more rejections than the threshold within
|
|
|
|
// the time window.
|
|
|
|
func (c *CachedBadNodeTracker) isBad(t time.Time, stats *badNodeStats) bool {
|
|
|
|
score := stats.score(t)
|
|
|
|
logger := c.logger.With("node_id", stats.id, "score", score)
|
|
|
|
|
|
|
|
logger.Trace("checking if node is bad")
|
|
|
|
if score >= c.threshold {
|
|
|
|
// Limit the number of nodes we report as bad to avoid mass assigning
|
|
|
|
// nodes as ineligible, but do it after Get to keep the cache entry
|
|
|
|
// fresh.
|
|
|
|
if !c.limiter.Allow() {
|
|
|
|
logger.Trace("node is bad, but returning false due to rate limiting")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *CachedBadNodeTracker) emitStats() {
|
|
|
|
now := time.Now()
|
2023-02-08 21:20:33 +00:00
|
|
|
for _, nodeID := range c.cache.Keys() {
|
|
|
|
stats, _ := c.cache.Get(nodeID)
|
2022-07-12 22:40:20 +00:00
|
|
|
score := stats.score(now)
|
|
|
|
|
|
|
|
labels := []metrics.Label{
|
2023-02-08 21:20:33 +00:00
|
|
|
{Name: "node_id", Value: nodeID},
|
2022-07-12 22:40:20 +00:00
|
|
|
}
|
|
|
|
metrics.SetGaugeWithLabels([]string{"nomad", "plan", "rejection_tracker", "node_score"}, float32(score), labels)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// badNodeStats represents a node being tracked by a BadNodeTracker.
|
|
|
|
type badNodeStats struct {
|
|
|
|
id string
|
|
|
|
history []time.Time
|
|
|
|
window time.Duration
|
|
|
|
}
|
|
|
|
|
|
|
|
// newBadNodeStats returns an empty badNodeStats.
|
|
|
|
func newBadNodeStats(id string, window time.Duration) *badNodeStats {
|
|
|
|
return &badNodeStats{
|
|
|
|
id: id,
|
|
|
|
window: window,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// score returns the number of rejections within the past time window.
|
|
|
|
func (s *badNodeStats) score(t time.Time) int {
|
|
|
|
active, expired := s.countActive(t)
|
|
|
|
|
|
|
|
// Remove expired records.
|
|
|
|
if expired > 0 {
|
|
|
|
s.history = s.history[expired:]
|
|
|
|
}
|
|
|
|
|
|
|
|
return active
|
|
|
|
}
|
|
|
|
|
|
|
|
// record adds a new entry to the stats history and returns the new score.
|
|
|
|
func (s *badNodeStats) record(t time.Time) {
|
|
|
|
s.history = append(s.history, t)
|
|
|
|
}
|
|
|
|
|
|
|
|
// countActive returns the number of records that happened after the time
|
|
|
|
// window started (active) and before (expired).
|
|
|
|
func (s *badNodeStats) countActive(t time.Time) (int, int) {
|
|
|
|
windowStart := t.Add(-s.window)
|
|
|
|
|
|
|
|
// Assume all values are expired and move back from history until we find
|
|
|
|
// a record that actually happened before the window started.
|
|
|
|
expired := len(s.history)
|
|
|
|
for ; expired > 0; expired-- {
|
|
|
|
i := expired - 1
|
|
|
|
ts := s.history[i]
|
|
|
|
if ts.Before(windowStart) {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
active := len(s.history) - expired
|
|
|
|
return active, expired
|
|
|
|
}
|