Files
dozzle/internal/cloud/stats_streamer.go
T

439 lines
15 KiB
Go

package cloud
import (
"cmp"
"context"
"maps"
"slices"
"sync"
"time"
"github.com/amir20/dozzle/internal/container"
pb "github.com/amir20/dozzle/proto/cloud"
"github.com/rs/zerolog/log"
)
// StatSample pairs a raw container stat with the host it came from.
// container.ContainerStat carries only an ID, and the cloud host service fans
// stats in from several local client services, so the host has to be stamped
// at the fan-in point or it is lost.
type StatSample struct {
Stat container.ContainerStat
HostID string
}
// StatsStreamHostService is the subset of the host service the stats streamer
// needs. It is type-asserted at connect time; when the running host service
// doesn't satisfy it (k8s) the streamer is skipped, exactly as
// LogStreamHostService is today.
type StatsStreamHostService interface {
ToolHostService
SubscribeStats(ctx context.Context, samples chan<- StatSample)
}
const (
// statsWindow is the aggregation window. Docker pushes stats at roughly
// 1Hz, so this folds ~30 samples per container into one entry.
statsWindow = 30 * time.Second
// statsMaxEntries caps a single batch. A host with more running containers
// than this reports a truncated batch and logs the drop — never silently.
statsMaxEntries = 2000
// statsSampleChanBuf absorbs a tick's worth of samples from a busy host
// without blocking the collector's fan-out loop.
statsSampleChanBuf = 512
// statsMaxSendFailures is how many consecutive failed sends end the
// streamer. A wedged stream should not spin forever, but one bad send must
// not cost the connection every later window either.
statsMaxSendFailures = 5
// statsMaxStaleTicks is how many windows an accumulator may survive without
// its container resolving in the metadata snapshot before it is discarded.
// One tick of grace covers a container that started mid-window; beyond that
// it is gone and holding the samples only leaks memory.
statsMaxStaleTicks = 2
)
// seriesKey identifies an accumulator. Keyed on container ID because that is
// what a raw stat carries; the ID is resolved to a name at emit time and never
// leaves this process — see the StatsBatchEntry comment in cloud.proto for why
// the wire format is keyed on name instead.
type seriesKey struct {
hostID string
containerID string
}
// wireKey is how Cloud identifies a series: host plus container name, with no
// ID (see the StatsBatchEntry comment in cloud.proto). Two accumulators can
// collapse onto one of these when a container is replaced mid-window.
type wireKey struct {
hostID string
name string
}
// statAcc folds a window's samples for one container. Gauges accumulate a sum
// (for the mean) and a running max; counters are monotonic totals, so only the
// most recent value matters.
type statAcc struct {
cpuSum, cpuMax float64
memSum, memMax float64
memUsageSum float64
samples uint32
networkRx, networkTx uint64
diskRead, diskWrite uint64
staleTicks int
}
// containerMeta is the per-window metadata snapshot needed to turn raw stats
// into a wire entry: the container's name, the core count to normalise CPU by,
// and whether the user opted the container out.
type containerMeta struct {
name string
cores float64
disabled bool
}
// statsStreamer aggregates raw container stats into fixed windows and pushes
// one StatsBatch per window to Dozzle Cloud. Unlike logStreamer it runs a
// single goroutine for every container on the instance — stats already arrive
// multiplexed on one channel, so there is nothing to parallelise.
type statsStreamer struct {
hostService StatsStreamHostService
labels container.ContainerLabels
send func(resp *pb.ToolResponse) error
acc map[seriesKey]*statAcc
// meta is the last-good container metadata snapshot. It is owned entirely
// by run()'s goroutine: refreshes happen off-loop and hand the new snapshot
// back over metaCh, so there is no shared state and no lock.
meta map[seriesKey]containerMeta
// metaCh delivers a completed async refresh. Buffered so a refresh that
// finishes after run() has returned doesn't leak its goroutine.
metaCh chan map[seriesKey]containerMeta
// refreshing guards against piling up refreshes when the host service is
// slower than the window.
refreshing bool
// window is the flush interval. A field rather than a bare const so tests
// can drive real windows through run() instead of reaching into state the
// run goroutine owns.
window time.Duration
// listWarnOnce keeps a permanently unreachable host from warning every
// window. Touched only from buildMeta's goroutine chain.
listWarnOnce sync.Once
}
func newStatsStreamer(hostService StatsStreamHostService, labels container.ContainerLabels, send func(resp *pb.ToolResponse) error) *statsStreamer {
return &statsStreamer{
hostService: hostService,
labels: labels,
send: send,
acc: make(map[seriesKey]*statAcc),
meta: make(map[seriesKey]containerMeta),
metaCh: make(chan map[seriesKey]containerMeta, 1),
window: statsWindow,
}
}
// run blocks until ctx is cancelled, folding samples into per-container
// accumulators and flushing one batch per window.
func (ss *statsStreamer) run(ctx context.Context) {
samples := make(chan StatSample, statsSampleChanBuf)
ss.hostService.SubscribeStats(ctx, samples)
// Prime the metadata map so the first window can already resolve names
// rather than spending its grace tick on a cold cache. Async, like every
// later refresh — see startMetaRefresh for why this must never block.
ss.startMetaRefresh(ctx)
ticker := time.NewTicker(ss.window)
defer ticker.Stop()
log.Debug().Dur("window", ss.window).Msg("stats streamer: started")
defer log.Debug().Msg("stats streamer: stopped")
// Consecutive failed sends. Reset by any success — this is for a stream
// that is wedged rather than merely unlucky.
failures := 0
for {
select {
case <-ctx.Done():
return
case s := <-samples:
ss.observe(s)
case m := <-ss.metaCh:
ss.meta = m
ss.refreshing = false
case now := <-ticker.C:
// Kick the next refresh before flushing so the snapshot is as fresh
// as possible for the NEXT window; this one flushes against the
// last-good snapshot.
ss.startMetaRefresh(ctx)
if err := ss.flush(now); err != nil {
// A failed send is not proof the connection is gone, and this
// used to return — one transient error and the instance
// reported no metrics again until it reconnected, saying so
// only at Debug. A genuinely dead stream cancels ctx, which is
// the case above, so the honest response here is to log and
// keep windowing.
failures++
log.Warn().Err(err).Int("consecutive", failures).Msg("stats streamer: send failed")
if failures >= statsMaxSendFailures {
log.Error().Int("consecutive", failures).Msg("stats streamer: giving up until reconnect")
return
}
continue
}
failures = 0
}
}
}
// observe folds one raw sample into its accumulator. Samples for a container
// the user disabled are dropped here so they never occupy memory.
func (ss *statsStreamer) observe(s StatSample) {
key := seriesKey{hostID: s.HostID, containerID: s.Stat.ID}
if m, ok := ss.meta[key]; ok && m.disabled {
return
}
a, ok := ss.acc[key]
if !ok {
a = &statAcc{}
ss.acc[key] = a
}
a.cpuSum += s.Stat.CPUPercent
a.cpuMax = max(a.cpuMax, s.Stat.CPUPercent)
a.memSum += s.Stat.MemoryPercent
a.memMax = max(a.memMax, s.Stat.MemoryPercent)
a.memUsageSum += s.Stat.MemoryUsage
a.samples++
// Counters are cumulative since container start — keep the newest.
a.networkRx = s.Stat.NetworkRxTotal
a.networkTx = s.Stat.NetworkTxTotal
a.diskRead = s.Stat.DiskReadTotal
a.diskWrite = s.Stat.DiskWriteTotal
}
// flush converts every resolvable accumulator into a wire entry and pushes them
// as one batch, against the last-good metadata snapshot. Emitted and abandoned
// accumulators are removed, so a container that stops simply drops out of the
// next window.
//
// Deliberately does NO I/O: it runs on the same goroutine that drains the sample
// channel, and blocking here backs up into the stats collector (see
// startMetaRefresh).
func (ss *statsStreamer) flush(now time.Time) error {
// Keyed the way the wire is keyed, not the way the accumulators are: a
// redeploy inside one window leaves the dying and the starting container
// both holding samples under the same name, and emitting them as two
// entries would put two points on the same series at the same timestamp.
merged := make(map[wireKey]*pb.StatsBatchEntry, len(ss.acc))
tsNs := now.UnixNano()
for key, a := range ss.acc {
if a.samples == 0 {
delete(ss.acc, key)
continue
}
m, ok := ss.meta[key]
if !ok {
// Container hasn't shown up in a snapshot yet (started mid-window)
// or has already gone. Hold the samples for one more window, then
// give up rather than accumulate forever.
a.staleTicks++
if a.staleTicks >= statsMaxStaleTicks {
delete(ss.acc, key)
}
continue
}
delete(ss.acc, key)
if m.disabled {
continue
}
n := float64(a.samples)
cores := m.cores
if cores <= 0 {
cores = 1
}
entry := &pb.StatsBatchEntry{
HostId: key.hostID,
ContainerName: m.name,
TimestampNs: tsNs,
// Stat.CPUPercent is per-core (100% == one full core). Normalise by
// the core count so Cloud sees overall load, matching the UI and the
// metric-alert path in internal/notification/processing.go.
CpuPercent: a.cpuSum / n / cores,
CpuPercentMax: a.cpuMax / cores,
MemoryPercent: a.memSum / n,
MemoryPercentMax: a.memMax,
MemoryUsageBytes: a.memUsageSum / n,
NetworkRxTotal: a.networkRx,
NetworkTxTotal: a.networkTx,
DiskReadTotal: a.diskRead,
DiskWriteTotal: a.diskWrite,
Samples: a.samples,
CpuCores: cores,
}
wk := wireKey{hostID: key.hostID, name: m.name}
if prev, ok := merged[wk]; ok {
mergeEntry(prev, entry)
} else {
merged[wk] = entry
}
}
if len(merged) == 0 {
return nil
}
entries := slices.Collect(maps.Values(merged))
// Sort before any truncation so a host over the cap reports a stable subset
// rather than a different random slice every window.
slices.SortFunc(entries, func(a, b *pb.StatsBatchEntry) int {
if c := cmp.Compare(a.HostId, b.HostId); c != 0 {
return c
}
return cmp.Compare(a.ContainerName, b.ContainerName)
})
if len(entries) > statsMaxEntries {
log.Warn().
Int("total", len(entries)).
Int("sent", statsMaxEntries).
Msg("stats streamer: batch exceeds entry cap, truncating")
entries = entries[:statsMaxEntries]
}
return ss.send(&pb.ToolResponse{Type: &pb.ToolResponse_StatsBatch{StatsBatch: &pb.StatsBatch{Entries: entries}}})
}
// mergeEntry folds src into dst for two accumulators that resolved to the same
// (host, container name) — a container replaced mid-window. Means are
// re-weighted by sample count; maxima take the larger.
//
// Counters take the larger rather than the sum: Docker names are unique per
// host, so the two are never live at the same time, and the old container's
// cumulative total is the one the series was already sitting at. Summing would
// emit a total neither container ever reported. The next window drops back to
// the new container's counters, which reads as a counter reset — exactly what
// a restart is, and what rate() already knows how to handle.
func mergeEntry(dst, src *pb.StatsBatchEntry) {
total := float64(dst.Samples) + float64(src.Samples)
if total == 0 {
return
}
weighted := func(a, b float64) float64 {
return (a*float64(dst.Samples) + b*float64(src.Samples)) / total
}
dst.CpuPercent = weighted(dst.CpuPercent, src.CpuPercent)
dst.MemoryPercent = weighted(dst.MemoryPercent, src.MemoryPercent)
dst.MemoryUsageBytes = weighted(dst.MemoryUsageBytes, src.MemoryUsageBytes)
dst.CpuPercentMax = max(dst.CpuPercentMax, src.CpuPercentMax)
dst.MemoryPercentMax = max(dst.MemoryPercentMax, src.MemoryPercentMax)
dst.NetworkRxTotal = max(dst.NetworkRxTotal, src.NetworkRxTotal)
dst.NetworkTxTotal = max(dst.NetworkTxTotal, src.NetworkTxTotal)
dst.DiskReadTotal = max(dst.DiskReadTotal, src.DiskReadTotal)
dst.DiskWriteTotal = max(dst.DiskWriteTotal, src.DiskWriteTotal)
dst.CpuCores = max(dst.CpuCores, src.CpuCores)
dst.Samples += src.Samples
}
// startMetaRefresh rebuilds the metadata snapshot on its own goroutine and
// hands the result back over metaCh.
//
// This MUST stay off run()'s goroutine. ListAllContainers walks every local host
// sequentially with a 5s timeout each, so one slow or unreachable peer would
// stall the sample drain for N*5s. That stall does not stay local: the stats
// collector's dispatch loop blocks on `stats <- stat` for each subscriber in
// turn (internal/docker/stats_collector.go), so once our 512-deep sample buffer
// filled, a wedged cloud consumer would starve the LIVE UI stats broadcast on
// that host too. Metrics shipping must never be able to degrade the UI.
//
// At most one refresh is in flight; if the host service is slower than the
// window we keep serving the last-good snapshot rather than queueing work.
func (ss *statsStreamer) startMetaRefresh(ctx context.Context) {
if ss.refreshing {
return
}
ss.refreshing = true
go func() {
m := ss.buildMeta()
select {
case ss.metaCh <- m:
case <-ctx.Done():
}
}()
}
// refreshMeta rebuilds the snapshot synchronously. Only for tests and callers
// that are not on the sample-draining goroutine.
func (ss *statsStreamer) refreshMeta() {
ss.meta = ss.buildMeta()
}
// buildMeta resolves every container's name, CPU divisor and opt-out state.
// Called once per window rather than per sample — it is a host round-trip, and
// there are ~30 samples per container per window.
func (ss *statsStreamer) buildMeta() map[seriesKey]containerMeta {
containers, errs := ss.hostService.ListAllContainers(ss.labels)
for _, err := range errs {
if err != nil {
// Warned once, then quiet. A host that cannot be listed drops every
// one of its containers out of the batch two windows later (see
// flush) — silence that looks exactly like idle containers, which
// is precisely the confusion this whole retry pass exists to end.
// Repeating it every 30s for a permanently unreachable peer would
// bury everything else, so the rest go to Debug.
ss.listWarnOnce.Do(func() {
log.Warn().Err(err).Msg("stats streamer: error listing containers from host; its containers will not report metrics (further errors at debug level)")
})
log.Debug().Err(err).Msg("stats streamer: error listing containers from host")
}
}
ncpuByHost := make(map[string]int)
for _, h := range ss.hostService.Hosts() {
ncpuByHost[h.ID] = h.NCPU
}
meta := make(map[seriesKey]containerMeta, len(containers))
for _, c := range containers {
// A container the user opted out of log streaming with the min_level
// label shouldn't quietly keep reporting metrics either.
_, disabled, valid := parseMinLevel(c.Labels[cloudMinLevelLabel])
if !valid {
// logStreamer already logs the invalid value loudly for this
// container; don't double-report it every 30 seconds.
disabled = false
}
cores := c.CPULimit
if cores <= 0 {
cores = float64(ncpuByHost[c.Host])
}
if cores <= 0 {
cores = 1
}
meta[seriesKey{hostID: c.Host, containerID: c.ID}] = containerMeta{
name: c.Name,
cores: cores,
disabled: disabled,
}
}
return meta
}