pod_traffic row names one pod (the one the Controller was watching)
and one peer. The pod side is known at capture time. The peer side arrives
as nothing but an IP address, and turning that IP into a pod, a
host-network pod or a Service is what the Network Map, the Policy Builder
and both CLI generators depend on. This page explains where that
resolution happens, why it moved, and how to read rows written before it
moved.
Peer identity is stamped at ingest
When the Broker receives a traffic batch it resolves each row’s peer IP against its currentpod_details and svc_details tables and stores
the answer on the row itself. Once stamped, the resolution never runs
again for that row; whatever the cluster looked like at the moment the
flow was ingested is what the row says forever. Anything a client sends
in the peer_* keys is overwritten by the Broker’s own resolution.
The precedence is fixed:
Each row carries
peer_kind, peer_namespace, peer_name, peer_uid,
peer_workload_kind, peer_workload_name and peer_resolved_at. The
UID is the Kubernetes metadata.uid of the pod (or of the Service where
its stored spec carries one), so two pods that reuse the same name — a
StatefulSet replica restarting, a CronJob firing hourly — are still told
apart. peer_workload_kind/peer_workload_name name the owning
Deployment, StatefulSet, DaemonSet, Job or CronJob so the map can group
short-lived Job pods under their controller instead of drawing one node
per pod name. For a node peer, peer_name is the host-network pod
(typically a DaemonSet member), not the node.
There is deliberately no external value. An IP that matched nothing is
left null, because stamping “external” permanently would throw away the
guarded by-IP fallback for a row whose peer spec simply arrived late — a
Controller restart on that node can delay it past the late-resolve
window. Consumers therefore have exactly two cases: peer_kind set,
use it verbatim; peer_kind null, resolve by IP with the guard below and
render an ipBlock if that finds nothing.
This is the same model Cilium Hubble uses. A Hubble flow does not carry
“source IP, look it up later”; it carries a fully resolved Endpoint
for each side, captured when the flow was observed. From
api/v1/flow/flow.proto
on main, fetched 2026-09-03:
Workload is {name, kind}. kguardian’s peer_* columns map onto
namespace, pod_name, workloads and pod_uid; the identity that
Cilium computes from labels corresponds to the pod_identity and
selector labels kguardian already keeps on pod_details.
The late-resolve pass
A flow can reach the Broker before the peer pod’s spec does — the Controller on one node sees the SYN while the Controller on another node is still posting the new pod. Such a row is stored with anull peer.
A background task in the Broker wakes every PEER_LATE_RESOLVE_INTERVAL_SECS
(default 60, floor 5) and re-resolves rows with peer_kind IS NULL whose
time_stamp is younger than PEER_LATE_RESOLVE_WINDOW_SECS (default 600;
broker.peerResolution.lateResolveWindowSeconds in the chart; 0
disables the task), newest first, at most 5000 rows per pass, with one
lookup per distinct peer IP. A row that matches is stamped with
peer_resolved_at set to that moment; a row that does not is retried on
the next pass until it ages out of the window, after which it stays
null for good.
Why by-IP lookups at read time were wrong
Before this changepod_traffic stored only the peer IP. The map, the
Broker’s GET /pod/ip/{ip}, the Policy Builder and both generators
resolved IP to pod when the data was read, against pod_details as it
stood at that moment. pod_details is keyed by pod name and upserted in
place, so it holds who owns an IP now — it has no record of who owned it
last month.
Pod IPs are recycled constantly. On a cluster running hourly volsync
backups and a handful of migration Jobs, a single address had more than
50 dead former owners. A concrete case, diagnosed live on 2026-09-03:
cmangos-database(namespacegame-servers) had INGRESS rows on:3306from10.244.12.199, time-stamped 2026-05-21 and 2026-07-23.autobrr(namespacehome-system) started on 2026-08-04 and was handed10.244.12.199.- The map resolved the old rows against today’s table and drew
autobrr → cmangos-database, a connection that never happened.
A policy generated for the database would have allow-listed
autobrr’s labels.
job-name,
controller-uid) into generated policies: a long-gone Job’s IP had been
reused by whatever pod happened to be resolved, and the selector was
built from the wrong pod’s labels.
Ingest-time resolution fixes this for every row written after the
upgrade. The row records autobrr only if autobrr held the IP when the
flow was seen.
The start-time guard
A by-IP lookup still exists — it is what the ingest resolver and the late-resolve pass run, and what consumers fall back to for rows with anull peer. Everywhere it runs, it obeys one rule:
A flow is never attributed to a pod whose start time is later than the
flow’s time_stamp.
The Broker stores started_at on every pod record (status.startTime,
captured from the manifest on POST /pod/spec; null when unknown) and
applies the guard inside its own resolvers with at = row.time_stamp.
The HTTP lookup takes the same instant as a query parameter:
at, the candidates holding the IP are filtered by
started_at <= at; a dead candidate must additionally not have been
dead already at at — its record time_stamp (last seen alive, or
when it was marked dead) must be at or after the flow. Survivors are
ordered is_dead ASC, started_at DESC, time_stamp DESC; the first
wins. A pod marked dead late can still be over-permissive for the flows
in between, but the window is bounded by the reconciler interval and
the stale-alive sweep, not open-ended. Applied to the example above, the
2026-07-23 row cannot resolve to autobrr (started 2026-08-04); it
resolves to the CronJob pod that held the IP then if its row still
exists, and to nothing otherwise. A pod with a null started_at is
excluded outright. The Controller re-posts every live pod every 60 s,
so within a minute of the broker upgrade every live pod has a start
time; a null therefore means a ghost row or a pod still Pending,
neither of which can have originated a flow.
The Controller no longer posts Succeeded, Failed or deleting pods as
alive, and its reconciler marks them dead, so a completed Job cannot be
attributed later flows on its recycled IP. The Broker also keeps
pod_details honest with a stale-alive sweep:
a row still marked alive that has not been re-posted for
PEER_STALE_ALIVE_SECS (default 900; 0 disables;
broker.peerResolution.staleAliveSeconds in the chart) is marked dead,
so a pod the Controller lost track of cannot keep winning the
alive-first ordering for an IP it no longer holds.
If no candidate survives the guard the peer is unattributed: the map
renders it as an external IP node with a tooltip explaining that no live
pod matched at flow time, and every generator emits an ipBlock
(Kubernetes) or toCIDR/fromCIDR (Cilium) with a comment
# unattributed peer <ip> at <time> — never a podSelector. An
allow-list built from a stale identity is worse than one that names the
address.
The guard narrows the by-IP fallback; it does not make it exact. If two
former holders of an IP both started before the flow, history alone cannot
say which one it was, and the ordering above picks the newer start. Only
rows resolved at ingest carry a definite answer.
Pre-upgrade rows
The migration adds thepeer_* columns and leaves them null on every
existing row. There is deliberately no backfill: resolving old rows
against today’s pod_details would reproduce the exact bug this change
removes, only permanently.
A legacy row therefore looks like this on GET /pod/traffic/{name}:
GET /pod/ip/{ip}?at=<row time_stamp>, then an unattributed peer when that returns 404. Rows
younger than the late-resolve window are filled in by the Broker within
a minute or so; older ones stay this way until retention (or you)
removes them.
Pre-upgrade pod_details rows have started_at: null too, until the
Controller re-posts the pod (every live pod is re-posted when the
Controller restarts, so a rolling upgrade of the DaemonSet fills them
in). Pods that were already dead when the Broker upgraded never receive
one: the Controller only re-posts live pods, and the stored manifests
carry no status. Such a pod keeps started_at: null and is never
chosen by the guard, so a historical row whose peer was that pod renders
as unattributed rather than being guessed from the IP’s current holder.
Live pods gain started_at within a minute, and every flow recorded
after the upgrade carries its peer identity, so the gap is confined to
pre-upgrade history and closes as retention removes those rows.
Because legacy history is only as good as the guard, regenerate
policies after a fresh observation window rather than from the
pre-upgrade backlog. A few hours of post-upgrade traffic gives every row
a stored identity; the chart’s
UPGRADING notes
list the steps.
Reference
- Row fields and
peer_kindvalues: Traffic Endpoints started_atandGET /pod/ip/{ip}?at=: Pod Endpoints- Chart value:
broker.peerResolution.lateResolveWindowSeconds