stats.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. package nebula
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net"
  8. "net/http"
  9. "runtime"
  10. "strconv"
  11. "sync"
  12. "sync/atomic"
  13. "time"
  14. mp "github.com/nbrownus/go-metrics-prometheus"
  15. "github.com/prometheus/client_golang/prometheus"
  16. "github.com/prometheus/client_golang/prometheus/promhttp"
  17. "github.com/rcrowley/go-metrics"
  18. "github.com/slackhq/nebula/config"
  19. )
  20. // statsServer owns nebula's stats subsystem: the periodic metric capture
  21. // goroutine and (for prometheus) an HTTP listener. It mirrors the lifecycle
  22. // shape of dnsServer: constructor wires the reload callback, reload records
  23. // config, Start builds and runs the runtime, Stop tears it down.
  24. type statsServer struct {
  25. l *slog.Logger
  26. ctx context.Context
  27. buildVersion string
  28. configTest bool
  29. // enabled mirrors "stats configured to a real backend". Start consults
  30. // it so callers don't need to know the gating rules.
  31. enabled atomic.Bool
  32. runMu sync.Mutex
  33. runCfg *statsConfig
  34. run *statsRuntime // non-nil while a runtime is live
  35. }
  36. // statsRuntime is the live state owned by a single Start invocation. Start
  37. // stashes a pointer under runMu; Stop and Start's own exit path use pointer
  38. // equality to tell "my runtime" apart from one that replaced it after a
  39. // reload.
  40. type statsRuntime struct {
  41. cancel context.CancelFunc
  42. listener *http.Server // nil for graphite
  43. }
  44. // statsConfig is the snapshot of stats-related config that drives the runtime.
  45. // It is comparable with == so reload can detect "no change" cheaply.
  46. type statsConfig struct {
  47. typ string
  48. interval time.Duration
  49. graphite graphiteConfig
  50. prom promConfig
  51. }
  52. type graphiteConfig struct {
  53. protocol string
  54. host string
  55. // resolvedAddr is the string form of host resolved at config-load time.
  56. // Including it in the struct means a SIGHUP picks up DNS changes even
  57. // when stats.host hasn't been edited.
  58. resolvedAddr string
  59. prefix string
  60. }
  61. type promConfig struct {
  62. listen string
  63. path string
  64. namespace string
  65. subsystem string
  66. }
  67. // newStatsServerFromConfig builds a statsServer, applies the initial config,
  68. // and registers a reload callback. The reload callback is registered before
  69. // the initial config is applied so a SIGHUP can later enable, fix, or disable
  70. // stats even if the initial application failed.
  71. //
  72. // Start is safe to call unconditionally: it no-ops when stats are disabled.
  73. // The returned pointer is always non-nil, even on error.
  74. func newStatsServerFromConfig(ctx context.Context, l *slog.Logger, c *config.C, buildVersion string, configTest bool) (*statsServer, error) {
  75. s := &statsServer{
  76. l: l,
  77. ctx: ctx,
  78. buildVersion: buildVersion,
  79. configTest: configTest,
  80. }
  81. c.RegisterReloadCallback(func(c *config.C) {
  82. if err := s.reload(c, false); err != nil {
  83. s.l.Error("Failed to reload stats from config", "error", err)
  84. }
  85. })
  86. if err := s.reload(c, true); err != nil {
  87. return s, err
  88. }
  89. return s, nil
  90. }
  91. // reload records the latest config. On the initial call it only records it;
  92. // Control.Start is what launches the first runtime via statsStart. On later
  93. // calls it reconciles the running runtime with the new config:
  94. //
  95. // - newly enabled -> spawn Start
  96. // - newly disabled -> Stop the runtime
  97. // - config changed (still enabled) -> Stop the old, Start the new
  98. // - no change -> no-op
  99. func (s *statsServer) reload(c *config.C, initial bool) error {
  100. newCfg, err := loadStatsConfig(c)
  101. if err != nil {
  102. return err
  103. }
  104. enabled := newCfg.typ != "" && newCfg.typ != "none"
  105. s.runMu.Lock()
  106. sameCfg := s.runCfg != nil && *s.runCfg == newCfg
  107. s.runCfg = &newCfg
  108. running := s.run != nil
  109. s.runMu.Unlock()
  110. s.enabled.Store(enabled)
  111. if initial || sameCfg {
  112. return nil
  113. }
  114. if running {
  115. s.Stop()
  116. }
  117. if enabled && !s.configTest {
  118. go s.Start()
  119. }
  120. return nil
  121. }
  122. // Start builds the runtime from the latest config, spawns the capture loop,
  123. // and blocks until Stop is called or ctx fires. For prometheus it also serves
  124. // the HTTP listener. For graphite it blocks on the capture loop's context.
  125. // Safe to call when stats are disabled or already running (both no-op).
  126. func (s *statsServer) Start() {
  127. if !s.enabled.Load() || s.configTest {
  128. return
  129. }
  130. s.runMu.Lock()
  131. if s.ctx.Err() != nil || s.run != nil || s.runCfg == nil {
  132. s.runMu.Unlock()
  133. return
  134. }
  135. cfg := *s.runCfg
  136. captureFns, listener := s.buildRuntime(cfg)
  137. runCtx, cancel := context.WithCancel(s.ctx)
  138. rt := &statsRuntime{cancel: cancel, listener: listener}
  139. s.run = rt
  140. s.runMu.Unlock()
  141. go captureStatsLoop(runCtx, cfg.interval, captureFns)
  142. cleanExit := true
  143. if listener == nil {
  144. // Graphite: no HTTP listener to serve; block until teardown.
  145. <-runCtx.Done()
  146. } else {
  147. cleanExit = s.serveListener(listener)
  148. }
  149. // Clear our runtime only if nothing has replaced it. Stop races through
  150. // here too but leaves s.run == nil, so the pointer check skips.
  151. s.runMu.Lock()
  152. if s.run == rt {
  153. rt.cancel()
  154. s.run = nil
  155. // A listener that exited with an error (e.g., bind conflict) leaves
  156. // runCfg cached as if it were applied. Drop it so a SIGHUP with the
  157. // same config re-triggers Start once the user fixes the underlying
  158. // problem.
  159. if !cleanExit {
  160. s.runCfg = nil
  161. }
  162. }
  163. s.runMu.Unlock()
  164. }
  165. // serveListener runs ListenAndServe and ensures ctx cancellation unblocks it.
  166. // Returns true if the listener exited cleanly (Stop, ctx cancellation, or any
  167. // other http.ErrServerClosed path), false on an unexpected error.
  168. func (s *statsServer) serveListener(listener *http.Server) bool {
  169. // Per-invocation watcher: ctx cancellation triggers a listener shutdown
  170. // which in turn unblocks ListenAndServe. Closing `done` on exit keeps
  171. // the watcher from outliving this call.
  172. done := make(chan struct{})
  173. go func() {
  174. select {
  175. case <-s.ctx.Done():
  176. shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  177. defer cancel()
  178. if err := listener.Shutdown(shutdownCtx); err != nil {
  179. s.l.Warn("Failed to shut down prometheus stats listener", "error", err)
  180. }
  181. case <-done:
  182. }
  183. }()
  184. defer close(done)
  185. s.l.Info("Starting prometheus stats listener", "addr", listener.Addr)
  186. err := listener.ListenAndServe()
  187. if err == nil || errors.Is(err, http.ErrServerClosed) {
  188. return true
  189. }
  190. s.l.Error("Prometheus stats listener exited", "error", err)
  191. return false
  192. }
  193. // Stop tears down the active runtime, if any. Idempotent.
  194. func (s *statsServer) Stop() {
  195. s.runMu.Lock()
  196. rt := s.run
  197. s.run = nil
  198. s.runMu.Unlock()
  199. if rt == nil {
  200. return
  201. }
  202. rt.cancel()
  203. if rt.listener != nil {
  204. shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  205. if err := rt.listener.Shutdown(shutdownCtx); err != nil {
  206. s.l.Warn("Failed to shut down prometheus stats listener", "error", err)
  207. }
  208. cancel()
  209. }
  210. }
  211. // buildRuntime produces the capture functions and, for prometheus, an un-served
  212. // http.Server from cfg. cfg has already been validated by loadStatsConfig.
  213. func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
  214. // rcrowley/go-metrics guards these registrations with a private sync.Once,
  215. // so subsequent reloads are no-ops.
  216. metrics.RegisterDebugGCStats(metrics.DefaultRegistry)
  217. metrics.RegisterRuntimeMemStats(metrics.DefaultRegistry)
  218. captureFns := []func(){
  219. func() { metrics.CaptureDebugGCStatsOnce(metrics.DefaultRegistry) },
  220. func() { metrics.CaptureRuntimeMemStatsOnce(metrics.DefaultRegistry) },
  221. }
  222. switch cfg.typ {
  223. case "graphite":
  224. // loadStatsConfig already resolved and validated the address; re-parse
  225. // the resolved form (no DNS lookup) to get a *net.TCPAddr.
  226. addr, _ := net.ResolveTCPAddr(cfg.graphite.protocol, cfg.graphite.resolvedAddr)
  227. gcfg := graphiteConfigExport{
  228. Addr: addr,
  229. Registry: metrics.DefaultRegistry,
  230. FlushInterval: cfg.interval,
  231. DurationUnit: time.Nanosecond,
  232. Prefix: cfg.graphite.prefix,
  233. Percentiles: []float64{0.5, 0.75, 0.95, 0.99, 0.999},
  234. }
  235. captureFns = append(captureFns, func() {
  236. if err := graphiteOnce(gcfg); err != nil {
  237. s.l.Error("Graphite export failed", "error", err)
  238. }
  239. })
  240. s.l.Info("Starting graphite stats",
  241. "interval", cfg.interval,
  242. "prefix", cfg.graphite.prefix,
  243. "addr", addr,
  244. )
  245. return captureFns, nil
  246. case "prometheus":
  247. pr := prometheus.NewRegistry()
  248. pClient := mp.NewPrometheusProvider(metrics.DefaultRegistry, cfg.prom.namespace, cfg.prom.subsystem, pr, cfg.interval)
  249. captureFns = append(captureFns, func() {
  250. if err := pClient.UpdatePrometheusMetricsOnce(); err != nil {
  251. s.l.Error("Prometheus metrics update failed", "error", err)
  252. }
  253. })
  254. g := prometheus.NewGauge(prometheus.GaugeOpts{
  255. Namespace: cfg.prom.namespace,
  256. Subsystem: cfg.prom.subsystem,
  257. Name: "info",
  258. Help: "Version information for the Nebula binary",
  259. ConstLabels: prometheus.Labels{
  260. "version": s.buildVersion,
  261. "goversion": runtime.Version(),
  262. "boringcrypto": strconv.FormatBool(boringEnabled()),
  263. },
  264. })
  265. pr.MustRegister(g)
  266. g.Set(1)
  267. // promhttp.HandlerOpts.ErrorLog needs a stdlib-shaped Println logger,
  268. // so bridge our slog.Logger back to a *log.Logger that emits at Error.
  269. errLog := slog.NewLogLogger(s.l.Handler(), slog.LevelError)
  270. mux := http.NewServeMux()
  271. mux.Handle(cfg.prom.path, promhttp.HandlerFor(pr, promhttp.HandlerOpts{ErrorLog: errLog}))
  272. return captureFns, &http.Server{Addr: cfg.prom.listen, Handler: mux}
  273. }
  274. return captureFns, nil
  275. }
  276. // captureStatsLoop runs each fn on every tick of d until ctx is cancelled.
  277. func captureStatsLoop(ctx context.Context, d time.Duration, fns []func()) {
  278. t := time.NewTicker(d)
  279. defer t.Stop()
  280. for {
  281. select {
  282. case <-ctx.Done():
  283. return
  284. case <-t.C:
  285. for _, fn := range fns {
  286. fn()
  287. }
  288. }
  289. }
  290. }
  291. func loadStatsConfig(c *config.C) (statsConfig, error) {
  292. cfg := statsConfig{
  293. typ: c.GetString("stats.type", ""),
  294. }
  295. if cfg.typ == "" || cfg.typ == "none" {
  296. return cfg, nil
  297. }
  298. cfg.interval = c.GetDuration("stats.interval", 0)
  299. if cfg.interval <= 0 {
  300. return cfg, fmt.Errorf("stats.interval was an invalid duration: %s", c.GetString("stats.interval", ""))
  301. }
  302. switch cfg.typ {
  303. case "graphite":
  304. cfg.graphite.protocol = c.GetString("stats.protocol", "tcp")
  305. cfg.graphite.host = c.GetString("stats.host", "")
  306. if cfg.graphite.host == "" {
  307. return cfg, errors.New("stats.host can not be empty")
  308. }
  309. addr, err := net.ResolveTCPAddr(cfg.graphite.protocol, cfg.graphite.host)
  310. if err != nil {
  311. return cfg, fmt.Errorf("error while setting up graphite sink: %s", err)
  312. }
  313. cfg.graphite.resolvedAddr = addr.String()
  314. cfg.graphite.prefix = c.GetString("stats.prefix", "nebula")
  315. case "prometheus":
  316. cfg.prom.listen = c.GetString("stats.listen", "")
  317. if cfg.prom.listen == "" {
  318. return cfg, errors.New("stats.listen should not be empty")
  319. }
  320. cfg.prom.path = c.GetString("stats.path", "")
  321. if cfg.prom.path == "" {
  322. return cfg, errors.New("stats.path should not be empty")
  323. }
  324. cfg.prom.namespace = c.GetString("stats.namespace", "")
  325. cfg.prom.subsystem = c.GetString("stats.subsystem", "")
  326. default:
  327. return cfg, fmt.Errorf("stats.type was not understood: %s", cfg.typ)
  328. }
  329. return cfg, nil
  330. }