feat(traffic): each container's network rate, and each NFS mount's bytes, so a host's traffic has a sender (#6115)
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 1m30s
CI / integration (push) Successful in 1m38s
CI / publish (push) Skipped

Docker02's outbound traffic tops out the internet connection and Steward
couldn't say who sends it: a container's counters were kept only as a
running total, and NFS traffic belongs to no container.

- Docker ingest turns each running container's cumulative network counters
  into net_rx_bps / net_tx_bps on "<host>/<container>" (net_rates), counting
  from the stored row so a restart of Steward loses nothing; a container
  new, recreated or restarted has no rate until its next snapshot.
- Agent 1.25.0 reads the bytes sent and received per NFS mount from
  mountstats (already parsed for the delays) as tx_bps / rx_bps; Steward
  stores them as nfs_tx_bps / nfs_rx_bps on "<host>:<mount>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:35:24 -04:00
co-authored by Claude Opus 5.5
parent 18032a4782
commit 613ed4e78b
8 changed files with 123 additions and 32 deletions
+45 -8
View File
@@ -30,6 +30,32 @@ def _parse_started_at(value) -> datetime | None:
return None
def net_rates(before: dict, snapshots: list) -> list:
"""(recorded_at, name, rx_bps, tx_bps) for each running container in each
snapshot, from its cumulative network counters against the last ones seen
for it: `before` is {name: (at, container_id, rx, tx)} from the stored rows,
and each snapshot moves it on. A container new since then, recreated under
the same name, or whose counters went back (restarted) has no rate until
its next snapshot (#6115)."""
last = dict(before)
out = []
for at, containers in snapshots:
for c in containers:
name, rx, tx = c.get("name"), c.get("net_rx_bytes"), c.get("net_tx_bytes")
if not name or c.get("status") != "running" or rx is None or tx is None:
continue
cid = c.get("container_id") or ""
prev = last.get(name)
last[name] = (at, cid, rx, tx)
if prev is None or prev[1] != cid or prev[2] is None or prev[3] is None:
continue
dt = (at - prev[0]).total_seconds()
if dt <= 0 or rx < prev[2] or tx < prev[3]:
continue
out.append((at, name, (rx - prev[2]) / dt, (tx - prev[3]) / dt))
return out
def _derive_events(old_state: dict, new_containers: list) -> list:
"""Diff a fresh snapshot against stored per-container state → lifecycle events.
@@ -346,7 +372,8 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
one entry per ingested sample carrying docker data (usually one; more when
the agent flushes a backlog). Every snapshot's running containers record
cpu_pct, mem_used_pct, mem_used_bytes and mem_cache_bytes into the one
metric store, at the snapshot's time; the newest snapshot drives current container state and
metric store, at the snapshot's time, and net_rx_bps / net_tx_bps from
their counters (net_rates); the newest snapshot drives current container state and
lifecycle-event derivation. `swarm` is the newest
sample's swarm object (or None off managers) — persisted when present.
`disk` is the newest sample's /system/df summary (or None on Docker-less
@@ -394,6 +421,14 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
# the one metric store (#5752): resource "<host>/<container>", so
# containers of the same name on different hosts don't collide. Recorded
# with the alert batch below, at each snapshot's own time.
# Snapshot of stored per-container state BEFORE the upsert overwrites it —
# the network counters each rate counts from, and the lifecycle-event diff.
# Skip event derivation on the host's first snapshot (empty state) so we
# don't emit a start per pre-existing container.
old_rows = (await session.execute(
select(DockerContainer).where(DockerContainer.host_id == host.id)
)).scalars().all()
batch = MetricBatch()
for recorded_at, containers in ordered:
for c in containers:
@@ -404,14 +439,16 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
if c.get(metric) is not None:
batch.add(source_module="docker", resource_name=resource,
metric_name=metric, value=c[metric], recorded_at=recorded_at)
# Bytes a second each container receives and sends, so a host's traffic
# can be put down to the container behind it (#6115).
before = {r.name: (r.scraped_at, r.container_id, r.net_rx_bytes, r.net_tx_bytes) for r in old_rows}
for recorded_at, name, rx_bps, tx_bps in net_rates(before, ordered):
resource = f"{host.name}/{name}"
batch.add(source_module="docker", resource_name=resource,
metric_name="net_rx_bps", value=round(rx_bps, 1), recorded_at=recorded_at)
batch.add(source_module="docker", resource_name=resource,
metric_name="net_tx_bps", value=round(tx_bps, 1), recorded_at=recorded_at)
await mark("metrics")
# Snapshot of stored per-container state BEFORE the upsert overwrites it —
# used to diff lifecycle events. Skip event derivation on the host's first
# snapshot (empty state) so we don't emit a start per pre-existing container.
old_rows = (await session.execute(
select(DockerContainer).where(DockerContainer.host_id == host.id)
)).scalars().all()
old_state = {
r.name: {"status": r.status, "health": r.health,
"oom_killed": r.oom_killed, "exit_code": r.exit_code,