NetworkPolicy runs after DNAT: the hairpin nobody tests
The spec declares the ordering undefined, which is why the same manifest can allow traffic in staging and drop it in production
The policy was the careful kind. Egress allowed to 0.0.0.0/0 on 443, with the RFC1918 ranges carved out, so pods could reach the internet but not wander around the private network. It passed review. It worked in staging for three weeks.
In production it dropped a call that had nothing to do with private networks: a pod talking to the company's own API over its public hostname.
The manifest was identical in both places. What differed was where the packet got rewritten.
The bit the manifest doesn't tell you
A NetworkPolicy is written against addresses. Packets get matched against addresses. In between sits the part nobody diagrams: kube-proxy, or your CNI's replacement for it, rewriting the destination.
Pod sends to 203.0.113.10:443, the load balancer address for api.example.com. That address resolves back into the cluster, so the packet never leaves. A Service picks it up and DNATs the destination to a backend pod, 10.244.3.17:8443.
Now the policy gets evaluated. So what address does it see?
If enforcement happens before DNAT, it sees 203.0.113.10 and the rule matches 0.0.0.0/0. Allowed.
If enforcement happens after DNAT, it sees 10.244.3.17, which lands squarely in the 10.0.0.0/8 block you carved out. Dropped.
Same manifest. Same intent. Opposite outcome, decided by a mechanism the manifest can't see.
The spec says it's undefined
This is where it stops being a CNI bug and starts being a design gap. From the Kubernetes NetworkPolicy documentation:
> Cluster ingress and egress mechanisms often require rewriting the source or destination IP of packets. In cases where this happens, it is not defined whether this happens before or after NetworkPolicy processing, and the behavior may be different for different combinations of network plugin, cloud provider, Service implementation, etc.
And more directly:
> Connections from pods to Service IPs that get rewritten to cluster-external IPs may or may not be subject to ipBlock-based policies.
"May or may not" is doing a lot of work in a security primitive. Nobody's going to fix this in a patch release, because there is nothing to fix. Two implementations can both be conformant and disagree.
GKE takes the honest route and refuses the question: in Dataplane V2 you can't put a Pod or Service IP in ipBlock.cidr at all. The API rejects it rather than accepting a rule whose meaning depends on packet-rewrite ordering.
Why ipBlock is the fragile selector
Every other NetworkPolicy selector matches on something Kubernetes owns. podSelector matches labels. namespaceSelector matches labels. Those are stable facts in etcd, and no datapath component rewrites them mid-flight.
ipBlock matches an IP address, which is the one field in the packet that half your infrastructure exists to change. kube-proxy rewrites it. The CNI rewrites it. The cloud load balancer rewrites it. Your egress gateway rewrites it. The rule's written against the one value that's explicitly in motion.
Which gives a rule of thumb worth more than any specific workaround: use label selectors for anything inside the cluster, and treat `ipBlock` as a tool for addresses that live outside the cluster. If the target has a Pod or a Service in front of it, select the Pod.
The carve-out policy above should have been two rules. Allow egress to the API by podSelector plus namespaceSelector, since it is in the cluster. Allow egress to 0.0.0.0/0 on 443 with private ranges excluded for everything that really is outside. Once the in-cluster case is handled by labels, the DNAT ordering stops mattering, because no rewritten address is ever matched against a CIDR.
Where the dev/prod split comes from
The reason this passes staging is that hairpin routing is not universal.
In a small cluster, a pod reaching for the external hostname of a Service in the same cluster often gets hairpinned by the node: the packet turns around at the local datapath and gets DNATed there. In a production setup with a real cloud load balancer, the same request can leave the node, reach the LB, and come back with a rewrite performed somewhere else entirely.
Two paths, two rewrite points, one policy. The environment where you test the policy is the environment where its behaviour is defined, and only there.
Testing that catches it looks like this: from a pod in the restricted namespace, curl the target the way the application actually addresses it. Not the Service DNS name if the app uses the public hostname. Not the public hostname if the app uses the Service. The address the app uses is the only one whose rewrite path you're testing.
kubectl -n restricted run probe --rm -it --image=curlimages/curl --restart=Never -- \
curl -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' --max-time 5 https://api.example.com%{remote_ip} is the useful part. It tells you which address curl actually connected to, which tells you whether you're on the hairpin path or the external one before you start reasoning about policy.
The limits worth knowing
Knowing your CNI's enforcement point helps, but it isn't a general answer. Cilium enforces in eBPF at the socket and tc layers, Calico in iptables or eBPF depending on the mode, and both move that boundary between versions. Anything you learn today is a fact about the version you're on, not about the API.
There's one thing the spec does guarantee, and it surprises people the other way: traffic to and from the node a pod runs on is always allowed, whatever your rules say. A policy that looks like it blocks the node isn't blocking the node.
And the honest summary of the whole area: NetworkPolicy is a good tool for expressing which workloads may talk to which workloads. It's a poor tool for expressing which IP ranges a workload may reach, because IP ranges aren't what it's built on. When you find yourself reaching for ipBlock to describe something inside your own cluster, that's usually the signal to go back to labels.


