Debugging Packet Drops at Scale with Cilium and eBPF

Chasing phantom tail latencies across our container fleet forced us to abandon legacy iptables rules and look directly inside the Linux kernel network stack with eBPF.

VW
Viery Wahyu•Author
•6 min read

Phantom network latency is the ultimate test of an infrastructure architect's sanity. Everything on your application monitoring dashboard claims the system is healthy, with database query execution sitting under two milliseconds, memory utilization flatlining comfortably, and CPU cores barely breaking a sweat. Yet every few minutes during peak market activity, internal gRPC calls between our high-frequency ingestion workers and our downstream state engines stall for full one-second intervals before succeeding on a retry. When we started investigating those exact tail latency spikes across our production cluster, we quickly realized that our packets were vanishing silently inside the Linux kernel network stack, and our traditional debugging tools were completely blind to the crime scene.

Why Legacy iptables and tcpdump Fail in High-Churn Clusters

For years, the standard playbook for diagnosing dropped connections on Linux meant logging into a suspect host, grepping through massive iptables rule chains, and running tcpdump against a virtual ethernet interface. That workflow falls apart completely when you operate a dense container environment where hundreds of ephemeral workloads spin up, scale out, and recycle IP addresses every hour. In a traditional Kubernetes setup powered by kube-proxy, every new service endpoint appends more sequential netfilter rules to the host kernel. By the time a single network packet travels from a source container through a virtual bridge to a destination pod, the kernel has to evaluate thousands of linear iptables rules, turning packet routing into a subtle CPU bottleneck that drops packets silently whenever connection tracking tables fill up.

Attempting to catch those intermittent drops with tcpdump under heavy production load actually makes the outage worse. Capturing raw frames requires copying packet buffers from kernel space into user space, which introduces severe context-switching overhead right when the network interface card is already struggling to drain its ring buffers. Even when you manage to capture a multi-gigabyte PCAP file without degrading live traffic, correlating raw ephemeral IP addresses back to specific microservice identities and container namespaces after the fact feels like piecing together shredded paper in the dark.

Illuminating the Kernel Path With Cilium and Hubble

Our breakthrough came when we ripped out the legacy kube-proxy netfilter implementation and migrated our cluster networking layer to Cilium powered by eBPF. Instead of forcing every packet through thousands of sequential iptables chains, Cilium attaches compiled bytecode programs directly to socket operations and traffic control hooks inside the Linux kernel. Routing decisions happen in constant time via high-speed eBPF hash maps, completely bypassing redundant layers of the host network stack when two containers communicate on the same node or across an overlay tunnel.

More importantly for our debugging mission, eBPF transformed our network from an opaque black box into a live, identity aware telemetry stream through Hubble. Rather than guessing which virtual interface was dropping frames, we queried the Hubble relay directly from our terminal to stream real-time kernel drop events enriched with cryptographic service identities, pod labels, and exact Linux kernel drop reason codes.

sh
1# Streaming real-time kernel packet drops with service identity context
2hubble observe --verdict DROPPED --from-label "app=ingestion-worker" --output jsonpb

Within thirty seconds of running that observation stream during a traffic burst, the culprit appeared right on our screen. Hubble was not reporting policy denials from a misconfigured firewall; instead, it was flagging CT_MAP_INSERT_FAILED and INVALID_SOURCE_IP verdicts originating deep inside the connection tracking subsystem. Our high-throughput telemetry workers were opening and tearing down thousands of short-lived TCP connections per second to an external liquidity feed, exhausting the default eBPF connection tracking map capacity before existing entries in the TIME_WAIT state could expire and be garbage collected.

Pinpointing Socket Buffer Drops With Custom bpftrace Probes

While Hubble gave us the high-level verdict across the cluster, we wanted to verify exactly where the kernel itself was freeing the socket buffers during peak congestion so we could tune both our sysctl parameters and our Cilium map sizes with mathematical certainty. Because eBPF allows safe, non-intrusive instrumentation of live production kernels, we ran a lightweight bpftrace one-liner attached to the skb:kfree_skb kernel tracepoint. Unlike tcpdump, which copies entire packet payloads to user space, bpftrace aggregates drop reasons and kernel function stack traces directly inside an in-kernel map and only prints the summary counters when you terminate the probe.

c
1#!/usr/bin/env bpftrace
2// Counting exact kernel functions and drop reasons freeing network socket buffers
3tracepoint:skb:kfree_skb
4{
5 @[ksym(args->location), args->protocol] = count();
6}
7
8interval:s:10
9{
10 print(@);
11 clear(@);
12}

Inspecting the symbol locations emitted by kfree_skb confirmed that packets were being discarded right at the connection tracking insertion boundary and occasionally inside tcp_v4_rcv when the socket backlog queue overflowed during micro-bursts. Armed with concrete kernel symbol evidence rather than guesswork, we applied a two-part architectural fix. At the application layer, we refactored our Go and Node.js worker pools to enforce persistent HTTP/2 multiplexing and strict TCP keep-alive pooling so connections were reused continuously instead of churning ephemeral ports. At the infrastructure layer, we scaled Cilium's dynamic connection tracking map ratio and deployed an explicit, identity-based CiliumNetworkPolicy that replaced coarse IP filtering with zero-trust L7 visibility.

yaml
1apiVersion: "cilium.io/v2"
2kind: CiliumNetworkPolicy
3metadata:
4 name: ingestion-to-state-engine-policy
5 namespace: production
6spec:
7 endpointSelector:
8 matchLabels:
9 app: state-engine
10 ingress:
11 - fromEndpoints:
12 - matchLabels:
13 app: ingestion-worker
14 toPorts:
15 - ports:
16 - port: "9090"
17 protocol: TCP
18 rules:
19 l7proto: grpc

Enforcing security and routing policies through eBPF maps tied to workload labels rather than volatile IP addresses eliminated the last remnants of routing jitter across our fleet. Even when our autoscaler spins up fifty new worker pods in ten seconds, Cilium updates the in kernel identity map instantaneously without rewriting massive firewall tables or stalling active sockets.

Treating the Kernel as an Observable Application Layer

Debugging packet drops at scale taught us that treating the operating system kernel as an untouchable boundary is a luxury modern distributed systems can no longer afford. When your architecture processes thousands of concurrent requests per second across containerized microservices, a single exhausted kernel map or an unoptimized netfilter chain will masquerade as an application bug for weeks. By embracing Cilium and eBPF observability, we stopped blindly restarting pods or over-provisioning compute nodes whenever tail latency spiked. Having the ability to ask the Linux kernel exactly why it dropped a packet and getting an answer mapped directly to a service name in milliseconds turns your most frustrating infrastructure outages into deterministic engineering problems you can solve before customers ever notice.

VW

Written by

Viery Wahyu

📍 Bali, Indonesia

Software Engineer

Software engineer based in Bali with over a decade of experience building fast web applications

The Journal

View all →