~/dataplane-visualizer

hubble observe --follow

seeing the dataplane

The main comparison ends at L7 (flow observability) and L8 (network policy) — the two things eBPF can do that iptables can't. This is the deep dive behind them. Because the same in-kernel programs that route a packet also identify and judge it, you can watch enforcement happen, live, by identity. Every capture below is real, from the V2 cluster.

1 · Identity — the number behind every flow

Cilium never reasons about pod IPs. It assigns every sender and receiver a numeric IDENTITY derived from labels, and collapses “who is this?” into a single integer the datapath checks in O(1). There are two ranges.

Reserved (1–255) — fixed, well-known, the same on every Cilium cluster. For things that aren’t normal pods: the host, the outside world, other nodes, the apiserver.

A reserved identity — world

cilium-dbg identity get 2
ID   LABELS
2    reserved:world

Identity 2 is reserved:world — 'any address outside the cluster that Cilium doesn’t manage,' i.e. the internet. No pod, no labels — just a fixed constant.

The reserved range

cilium-dbg identity list # 1–255
ID         LABELS
1          reserved:host
2          reserved:world
3          reserved:unmanaged
4          reserved:health
5          reserved:init
6          reserved:remote-node
7          reserved:kube-apiserver
8          reserved:ingress
9          reserved:world-ipv4
10         reserved:world-ipv6

These never change. host, world, remote-node, kube-apiserver, and so on — the vocabulary Cilium uses for non-workload traffic.

Allocated (256–65535) — minted dynamically, one per unique label set, cluster-local. These are your workloads. Same labels → same identity; delete the pod and the number is garbage-collected and reused.

An allocated identity — a pod

cilium-dbg identity get 34708
ID      LABELS
34708   k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=netflow-test
        k8s:io.cilium.k8s.policy.cluster=default
        k8s:io.cilium.k8s.policy.serviceaccount=default
        k8s:io.kubernetes.pod.namespace=netflow-test
        k8s:role=other

Identity 34708 IS the client-blocked pod — computed from its labels, role=other among them. Enforcement is identity-to-identity, so it survives pods being recreated with new IPs.

2 · Reading a flow — verdicts

Every Hubble line names a source and destination by identity, a verdict, a direction, and TCP flags. Here the two sides of the same policy: allowed vs denied.

ALLOWED — role=frontend gets in

hubble observe --to-label app=nginx --follow
client-allowed:34376 (ID:63760) -> nginx:80 (ID:18565) policy-verdict:L3-Only INGRESS ALLOWED (TCP Flags: SYN)
client-allowed:34376 (ID:63760) -> nginx:80 (ID:18565) to-endpoint FORWARDED (TCP Flags: ACK)

client-allowed (identity 63760) reaches nginx (18565): 'policy-verdict: L3-Only INGRESS ALLOWED', the handshake completes (SYN then FORWARDED ACK). An explicit allow, matched by identity.

DENIED — role=other bounces

hubble observe --namespace netflow-test --verdict \ DROPPED --follow
client-blocked:40520 (ID:34708) <> nginx:80 (ID:18565) Policy denied DROPPED (TCP Flags: SYN)
client-blocked:40520 (ID:34708) <> nginx:80 (ID:18565) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)

client-blocked (identity 34708) is refused: 'Policy denied DROPPED' / 'INGRESS DENIED' — and it happens at the very first SYN, so the handshake never begins. Same destination, different source identity, opposite outcome.

The only difference between these two is the role label → a different identity → an allow entry, or not. No IPs anywhere in the decision.

3 · The internet at your door (ingress)

nginx has a public LoadBalancer IP, so the open internet finds it. This flow was generated by curling that IP from outside the cluster — it shows up as reserved:world and the ingress policy refuses it, at the eBPF layer, before it reaches the pod.

world → nginx, denied

curl http://34.72.78.182 # from outside the cluster
162.195.118.115:63031 (world) <> nginx:80 (ID:18565) Policy denied DROPPED (TCP Flags: SYN)
162.195.118.115:63031 (world) <> nginx:80 (ID:18565) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)

A real external IP (162.195.118.115) → nginx, collapsed to identity 2 (world) and dropped. Your public Service is effectively private to role=frontend — many source IPs, one identity, one policy decision.

4 · The mirror — egress

A second policy governs the other direction: an egress rule that lets the egress-demo pod reach only nginx. Everything else outbound is dropped on the way out — and world shows up again, this time as the denied destination.

pod → world, denied on egress

hubble observe --from-pod netflow-test/egress-demo \ --verdict DROPPED --follow
egress-demo:51920 (ID:58472) <> 1.1.1.1:80 (world) Policy denied DROPPED (TCP Flags: SYN)

egress-demo (identity 58472) → 1.1.1.1, which resolves to identity 2 (world): 'Policy denied DROPPED', enforced at the pod’s own node before the packet leaves. Same identity, same mechanism — direction flipped.

Ingress guards who gets in; egress guards where you can go. Same identity model, same eBPF allowlist, opposite enforcement point — and world (identity 2) sits on both ends of the story.

back to the layer-by-layer comparison