Reported symptoms
Four tickets are open against four different services, filed by three different teams over eleven days.
- Ingest. Object uploads to in-cluster storage succeed for small payloads and fail for large ones. The client sees a connection reset, not a timeout. The storage team says their service is healthy and produced a dashboard to prove it.
- Database. A StatefulSet replica has never finished its initial sync. Every attempt stalls and restarts, and the offset it reaches is different each time. This was filed as a database bug and escalated to the vendor.
- Monitoring. Two Prometheus targets time out on every
scrape. Every other target in the same namespace, scraped by
the same Prometheus, returns in under 200 ms. The two failing
targets are the ones with the largest
/metricsresponses, which nobody noticed because nobody was comparing response sizes. - DNS. A few external names fail to resolve, perhaps one attempt in five. Every in-cluster name resolves instantly and always. This was filed against CoreDNS.
Between them the teams have ruled out a great deal. The application code is unchanged. The ingress path is clean: nothing reproduces from a laptop. Node CPU, memory and interface error counters are flat. No NetworkPolicy has been added. The cluster was built three weeks ago and passed its post-build smoke test.
Two observations sit in the tickets without comment. The first is that when the two Pods involved happen to be scheduled on the same node, everything works. The second is that the failures are about payload size rather than about time - they are perfectly reproducible at one size and perfectly clean one byte below it.
Both of those are the answer, and neither was read as evidence.
Evidence provided
$ kubectl exec -n ingest deploy/uploader -- ip link show eth04: eth0@if217: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP
link/ether 9a:1c:2f:44:0b:6e brd ff:ff:ff:ff:ff:ffIllustrative output
$ kubectl get installation default -o jsonpath='{.spec.calicoNetwork.mtu}'1450Illustrative output
$ kubectl debug node/worker-07 -it --image=nicolaka/netshoot -- ip link show eth02: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1460 qdisc mq state UP
link/ether 42:01:0a:80:00:07 brd ff:ff:ff:ff:ff:ffIllustrative output
$ kubectl exec -n ingest deploy/uploader -- ping -c 1 -M do -s 1382 10.244.3.14PING 10.244.3.14 (10.244.3.14) 1382(1410) bytes of data.
1390 bytes from 10.244.3.14: icmp_seq=1 ttl=63 time=0.71 msIllustrative output
$ kubectl exec -n ingest deploy/uploader -- ping -c 3 -M do -s 1383 10.244.3.14PING 10.244.3.14 (10.244.3.14) 1383(1411) bytes of data.
--- 10.244.3.14 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2043msIllustrative output
$ kubectl exec -n ingest deploy/uploader -- ping -c 1 -M do -s 1422 10.244.1.9PING 10.244.1.9 (10.244.1.9) 1422(1450) bytes of data.
1430 bytes from 10.244.1.9: icmp_seq=1 ttl=64 time=0.09 msIllustrative output
Work the evidence before reading on
Three numbers are on the table and they do not add up. Write them down before continuing.
- The Pod says 1450. The CNI says 1450. Those two agreeing means the CNI is doing exactly what it was told, so the configuration is not corrupt - it is wrong on purpose.
- The node says 1460. Nobody in the incident had looked at this number, because everybody knew it was 1500.
- The measured cross-node limit is a 1410-byte packet. The same-node limit is 1450.
The question to answer before reading on: what is added to a Pod’s packet on its way out of one node and taken off again on its way into another, that is not added when both Pods are on the same node - and how big is it?
If you can name the number, you have the incident.
Root cause
1. The overlay costs about fifty bytes and the arithmetic was done against the wrong underlay
This cluster runs Calico with VXLAN encapsulation. Every Pod-to-Pod packet that crosses a node boundary is wrapped: a VXLAN header, a UDP header, an outer IP header and an outer Ethernet header, roughly 50 bytes in total. Traffic between two Pods on the same node is routed locally and is never wrapped, which is why the same-node sweep runs clean to the full Pod MTU.
The rule the course states is a subtraction:
Pod eth0 MTU = underlay MTU - overlay overhead
Calico’s default Pod MTU of 1450 is that subtraction performed against an underlay of 1500. It is the right answer to a question this cluster was never asked.
| Quantity | Assumed at build time | Actual |
|---|---|---|
| Underlay MTU | 1500 | 1460 |
| VXLAN overhead | 50 | 50 |
| Correct Pod MTU | 1450 | 1410 |
| Configured Pod MTU | 1450 | 1450 |
| Largest encapsulated frame | 1500 | 1500 |
| Largest frame the underlay carries | 1500 | 1460 |
The last two rows are the fault. A Pod emits up to 1450 bytes because its interface permits it, the node adds 50, and the result does not fit down a 1460-byte link.
2. The provider default is 1460, and that is documented
The GCP VPC default MTU for VM instances is 1460, not 1500. It is
an explicit setting, and a cluster built on default networking
inherits it silently. Nothing in the Kubernetes stack notices:
the CNI does not read the node’s NIC and adjust, kubectl never
mentions it, and kubectl get events stays empty because no
Kubernetes object is unhealthy. The only place the truth is
written down is ip link show on the node, which is the one
command an incident about an application does not prompt anybody
to run.
3. Path MTU discovery is not the safety net people expect
The natural objection is that TCP should discover the smaller path and reduce its segment size. It does not, for a reason worth being precise about.
Path MTU discovery works when a router on the path refuses an
oversize packet and returns an ICMP Fragmentation Needed
message to the sender, which then sends smaller packets. Both
halves of that are shaky here. The sender is a Pod whose
interface advertises 1450, so its stack has no local reason to
send less. The refusal, if it happens at all, happens to the
encapsulated frame, one layer below the Pod, on a path the Pod
has no visibility into. Whether an ICMP notice ever finds its way
back to the Pod depends on the kernel’s encapsulation behaviour
and on whether anything between the nodes drops ICMP - and the
course’s guidance on this is not to rely on it. Treat PMTUD as a
mechanism that can save you, never as one that will.
Resolution
- Confirm the underlay measurement independently before changing anything. Run
ping -M do -s 1432between two nodes: 1432 plus 8 bytes of ICMP and 20 bytes of IP is exactly 1460. It must succeed, and 1433 must fail. If it does not behave that way, the underlay is not 1460 and the rest of this arithmetic is wrong. - Compute the target Pod MTU explicitly and write it in the change record: underlay 1460 minus VXLAN overhead 50 gives 1410. Do not carry the number forward from some other cluster; carry the subtraction itself.
- Patch the Calico Installation:
kubectl patch installation default --type=merge -p '{"spec":{"calicoNetwork":{"mtu":1410}}}'. Read the resource back and confirm the value took. - Understand what the patch did not do. A Calico MTU change applies to Pods created afterwards. Every Pod already running keeps 1450 and keeps failing. Verify this directly: exec into an old Pod and a freshly created one and compare
ip link show eth0. - Plan the recreation as its own change, not as a footnote. Every workload in the cluster needs a rolling restart before the fix is complete, which is a cluster-wide rollout with the usual budget, ordering and PodDisruptionBudget consequences. Start with the four workloads in the incident so the tickets can be closed, then schedule the rest.
- If the rollout cannot happen tonight, hold deliberately. Name an owner, name an end time, and record the interim mitigation - capping the application write size below the threshold works and is honest, provided nobody mistakes it for the fix.
- Record the underlay MTU, the overhead and the resulting Pod MTU in the cluster bootstrap document, next to each other, as the subtraction. A bare
mtu: 1410in a manifest is unfalsifiable in a year.
Verification
- A newly created Pod reports
mtu 1410fromip link show eth0, and a Pod that predates the change still reports 1450. Both halves matter: the second is how you measure how far the rollout has got. - The packet sweep is unchanged, and that is the expected result.
ping -M do -s 1382still succeeds cross-node and 1383 still fails, because the underlay was never the thing that moved. Anyone who expects this boundary to shift has misunderstood the fix, and checking it is how that misunderstanding surfaces. - The upload that failed now succeeds at the size that used to break it, repeated ten times rather than once. This is the check that can fail, and it is the only one the ingest team will accept.
- The database replica completes its initial sync end to end and stays caught up for an hour.
- Both Prometheus targets scrape green for a full hour with no timeouts. Their
/metricsresponses are the largest in the namespace, so they are the most sensitive canary in the cluster for this fault returning. - The intermittent external DNS failures stop. Sample the failing names a few hundred times rather than a few times, because a fault that appeared one attempt in five needs volume to be ruled out.
- Repeat the cross-node sweep between at least four different node pairs, not one. A cluster with a heterogeneous node pool can have more than one underlay MTU, and a single pair proves nothing about the others.
Prevention
- Measure the underlay before you configure the CNI, and treat the provider default as unknown until measured. The default is 1460 on GCP, 1500 on AWS and Azure standard networking, and something smaller again behind a VPN or an IPSec tunnel.
- Write the subtraction, not the answer.
underlay 1460 - VXLAN 50 = 1410in the bootstrap document survives a CNI upgrade that changes a default;mtu: 1410alone does not. - Add a bootstrap check that compares Pod MTU plus overlay overhead against node MTU and fails when the sum exceeds it. It is three commands and it catches the entire class of fault before any traffic exists to break.
- Make the smoke test send a full-size payload across a node boundary. A smoke test that only moves small objects passes on a cluster where nothing large can move at all, which is exactly what happened here.
- Read “small works, large fails” as a size correlation and therefore as an MTU signature. Before a bug is filed against an application, someone should have asked whether the failure tracks payload size, and that question costs one command.
- Be suspicious of any failure that vanishes when two Pods share a node. Same-node traffic skips the overlay, so a same-node control is a free test that separates “the network path” from “everything else” in one step.