← All ArticlesAmal Zacharia
October 9, 2026·12 min read

Clean Counters Don't Prove There's No Loss: How a DHCP Pool Broke Production

Every 90 seconds or so, one of our TCP connections went quiet for 30 seconds. No errors. No drops. Every counter on every machine said the network was fine.

The connection was a long-lived gRPC stream between a node agent and a control plane, both in production, both on a private network at a hosted provider. When it stalled, the agent reconnected, and our retry logic hid most of the damage. That is also why it took days to notice how bad it was.

I spent two of those days convinced the provider’s network was dropping packets. I had a support ticket drafted. It was wrong.

This post walks through how the bug looked, why the wrong theory was so convincing, and the 15-minute test that found the real cause. If you have ever stared at clean counters and a broken connection, this one is for you.

New to this? What is a “stall” and why would TCP go quiet?

TCP promises reliable delivery. If a segment is lost, the sender retransmits it, waiting longer each time (exponential backoff). So from the application’s point of view, loss does not look like an error. It looks like silence: the data you expect never shows up, then the retries run out, and the socket finally times out.

That is why “the connection hangs for 30 seconds, then dies” is a classic symptom of packets going missing somewhere. It is also the symptom of several other things, which is the point of this post.

The symptom

A soak test against the production address showed the same pattern repeatedly:

The only path that failed was the one crossing the private network between two specific hosts. I’ll call them the control node (runs the services) and the compute host (runs the agent).

On the failing path, a capture inside the server’s container showed the server sending the same reply nine times, with the gaps doubling each time (0.2 s, 0.2 s, 0.4 s, 0.8 s, up to 13 seconds). That is TCP backoff. The client never acknowledged the reply, so the reply was getting lost somewhere after it left the server.

The wrong theory: the provider’s network drops frames

I matched packets segment by segment between captures taken on both hosts. For traffic from the compute host to the control node, every segment arrived: 286 sent, 286 received. For the other direction:

Direction Sent Received Missing
Compute host to control node 286 286 0
Control node to compute host 322 210 112

The 112 missing segments clustered into groups that lined up with the client’s stall timestamps. Seven of nine groups were exactly nine copies of one reply segment, which is exactly what the backoff pattern predicts.

So the picture was: packets leave the control node and never arrive at the compute host. And only in one direction. That looks like a fabric problem. It looks like someone else’s problem.

What made the theory stick was that nothing contradicted it:

I read “no counter shows a drop” as “the loss is somewhere counters cannot see.” That was the mistake. There was a second reading that fit the evidence equally well, and I never tested it: the packets were not lost. They were delivered to somewhere else.

New to this? Why would a packet vanish without any counter noticing?

Counters on a network card count things that go wrong on that card: a frame arrived corrupted, the receive queue overflowed, a transmit failed. A frame that leaves the sender perfectly and is received perfectly by a different machine breaks none of those rules. The sender did its job. The wrong receiver did its job. Only the intended receiver is missing a packet, and nobody counts that.

Think of a courier who delivers every parcel flawlessly, to the wrong house.

The investigation: shrink the test

Before sending the ticket, I wanted a cleaner reproduction. The production path had a lot of moving parts: Kubernetes, a load balancer, a CNI doing connection tracking, a routed hop. Any of them could be the culprit, and each one made my captures harder to read.

So I removed all of them. The new test was two plain programs, an echo server and an echo client, running directly between the two private addresses of the hosts. No containers, no load balancer, nothing but a socket and a wire.

It still failed. In 15 minutes: 10 stalls, and 112 of 186 segments from the control node to the compute host missing, while 0 of 245 going the other way. So Kubernetes and the CNI were innocent, and the bug lived at or below the IP layer.

Then I did one more thing: I swapped the roles and ran traffic in both directions at the same time, with ping as well. This was the experiment that mattered, because it separates “which direction is physically broken” from “which side is the client”.

The results were confusing in a useful way:

If the fabric were dropping frames on one direction of the cable, the loss would follow the cable. It did not. It followed a particular IP address. Whenever traffic was sent to the compute host’s address 10.20.0.2, something went wrong. And the replies to the compute host’s pings, being addressed to that same address, were the ones that got lost.

The reveal: look at the destination MAC

I ran the capture one more time, and this time I looked at the Ethernet header, not just the IP header: tcpdump -e, which prints the destination MAC address of every frame.

The control node had sent frames addressed to 10.20.0.2. Here is where they actually went:

Destination MAC Frames
02:00:00:aa:aa:01 (the compute host’s real NIC) 5,915
fa:16:3e:00:00:d6 (someone else) 3,567

The compute host received exactly 5,915 frames, no more, no fewer. Every one of the other 3,567 had been delivered, correctly and without error, to a device I did not know about.

Then I went back to a capture from two days earlier that included ARP traffic, and searched it for that second MAC. It was there. 24 ARP replies saying 10.20.0.2 is-at fa:16:3e:00:00:d6. The evidence had been sitting in my own captures since the first day. I had filtered ARP out of most of them, and in the one that included it, I never searched for other MACs.

New to this? IP vs. MAC, and what ARP does

An IP address says which machine you want to talk to. A MAC address says which network card on the local network should physically receive the frame. To send to 10.20.0.2, your machine has to find out which MAC currently owns that IP.

It does this with ARP: it broadcasts “who has 10.20.0.2?”, and the owner replies “I do, my MAC is X”. The answer is cached in a neighbor table (ip neigh), and every frame to that IP is then addressed to that MAC.

ARP trusts whoever answers. If two devices answer for the same IP, the cache keeps whichever reply arrived last, and traffic starts going to one or the other.

The root cause: a DHCP port on an address someone else was using

The second MAC belonged to a Neutron DHCP port. In OpenStack-based private networks, each DHCP-enabled subnet gets a port that serves DHCP and DNS for the subnet, and that port takes an IP address from the subnet’s allocation pool.

Here is the chain of events:

  1. The subnet had DHCP enabled, with an allocation pool starting at 10.20.0.2.
  2. The compute host had 10.20.0.2 configured as a static address in its OS, outside anything DHCP knew about.
  3. When the subnet was created, Neutron created its DHCP port and assigned it the first free address in the pool: 10.20.0.2.

Neither side knew about the other. Nothing refused the assignment. For almost twelve days, two devices on one Layer 2 network answered ARP for the same IP.

TWO DEVICES, ONE IP
Control node
10.10.0.2
sends to 10.20.0.2
→
who has .2?
Private L2 network
→
Compute host (static)
10.20.0.2
MAC 02:00:00:aa:aa:01
runs the app
→
Neutron DHCP port
10.20.0.2 (auto-assigned)
MAC fa:16:3e:00:00:d6
does not run the app
Both devices reply to the ARP request. The sender keeps whichever reply arrives last.

Why it was intermittent

The control node’s ARP cache for 10.20.0.2 held whichever reply arrived most recently. While it pointed at the compute host, everything worked. When it flipped to the DHCP port, every segment the control node sent to 10.20.0.2 went to a device that does not run our application. That device either dropped the traffic or answered with a TCP reset. (It did answer pings, which is why one direction of the ping test looked healthy.)

Then the cache refreshed, the other device’s reply won, and the connection came back.

WHY THE STALLS CAME AND WENT
CONTROL NODE ARP ENTRY FOR 10.20.0.2 POINTS TO
Compute host
DHCP port
Compute host
DHCP port
Compute host
WHAT THE APPLICATION SEES
Healthy
30 s stall
Healthy
30 s stall
Healthy
time →
Counters stay clean the whole time. The sender sends fine and the wrong device receives fine. Only the intended host is missing data.

I checked this against the capture from the first day. ARP replies from the DHCP port’s MAC appeared at 06:18:57, 06:27:54 and 06:30:13. Three of the stall bursts began at 06:18:58, 06:27:56 and 06:30:08. I only compared those three, not all 24, and I did not measure the exact flipping mechanism, so I would call this strong evidence rather than proof. But the fix test (below) does not depend on it.

It also explains the “clean counters” puzzle completely:

The fix, and a like-for-like test

With the system owner’s approval, I made two changes, and nothing else:

  1. Moved the DHCP port from 10.20.0.2 to 10.20.0.10.
  2. Changed the subnet’s allocation pool to start at 10.20.0.10, so DHCP can never hand out .2 again.

I saved snapshots of the subnet and the port before changing them. Then I re-ran the exact same 15-minute test:

Check Before After
TCP client timeouts or resets 10 and 152 0 and 0
Ping control node to compute host, loss 0% 0% (4500 of 4500)
Ping compute host to control node, loss 36.9% 0% (4500 of 4500)
Frames sent to 10.20.0.2 that reached the compute host’s real MAC 5,915 of 9,482 9,277 of 9,277
ARP replies from the second MAC repeated none

Fifteen minutes is short. Before the fix, a stall appeared every 60 to 90 seconds, so the test covered at least ten expected events and none happened. That is a good sign, not a long-term confirmation, so I kept watching production for the next day or two.

Quick detection: three commands

If you suspect a duplicate IP on a network you can reach, these take under a minute:

# More than one MAC in the replies means a duplicate
sudo arping -I eth0 -c 5 10.20.0.2

# Note the MAC, then watch whether it changes
ip neigh show 10.20.0.2

# Look for two different "is-at" MACs for one IP
sudo tcpdump -ni eth0 -e arp and host 10.20.0.2

And in OpenStack, find out which port holds the address:

openstack port list --network <network-id> \
  -c id -c mac_address -c fixed_ips -c device_owner

What I’d tell a colleague

1. Clean counters do not prove loss, and they do not disprove it either. A frame sent to the wrong device leaves no error anywhere. Before you decide packets are being dropped, check where they are being addressed.

2. Capture at both ends, and include ARP. The cause was visible on day one in a capture I did not read carefully. Link-layer addresses would have told me immediately.

3. Reduce the test before you escalate to a vendor. Two echo programs, run in both directions at once, were faster and more conclusive than days of work at the Kubernetes level. They also kept me from sending a support ticket that blamed someone else for my own subnet.

4. A static address inside a DHCP pool is a latent fault. If a network uses both, keep an inventory of the static addresses and reserve each one or exclude it from the pools. We had a second subnet with the exact same exposure, which I only found because I went looking after the fix.

5. Keep your infrastructure code and your live state in sync. The correct pool had been written in Terraform but never applied. If the next apply had reverted the live fix, the bug would have come back with no warning.

A hard-to-find bug is rarely hard because of its complexity. This one was a single wrong address. It was hard because every instrument I trusted was measuring something that was working correctly, and none measured the thing that was wrong. Next time something goes missing, I will ask where it went before I ask where it was lost.

More articles
All articles →
Currently
Remote-first.
contact@crza.dev
Share
X / Twitter