networking kubernetes iptables wireguard

A VPN sidecar that cut two nodes off the internet

Summary

While debugging a VPN container that refused to connect, I ran it with hostNetwork: true to rule out Kubernetes pod networking as the cause. The container has a built-in kill switch: firewall rules that block all traffic except the VPN tunnel. With host networking, those rules were applied to the physical node instead of the pod. The VPN then failed to connect, the rules stayed behind, and the node lost all internet access. Every other pod on it went down with it. This happened on two nodes in the same evening. The lesson for me was about blast radius: giving a container access to the host is a design decision, not a config flag.

Context

I wanted my torrent client to route all its traffic through a commercial VPN. The usual way to do this in Kubernetes is a sidecar: a second container in the same pod that runs gluetun, which sets up a WireGuard tunnel. Because containers in a pod share one network namespace, the application's traffic goes through the tunnel without the application knowing anything about it.

Gluetun also comes with a kill switch. On startup it rewrites the iptables rules of the network namespace it lives in. The default policy on outbound traffic becomes DROP, with exceptions only for localhost, established connections, the local subnet and the VPN endpoint itself. That is exactly what you want from a VPN container: if the tunnel drops, the application must not leak traffic out the real interface. Inside a pod's own namespace the only thing those rules can affect is the pod.

The problem was that the tunnel would not come up. Gluetun's logs showed the same error on every attempt:

RTNETLINK answers: No such device

That is a kernel routing error, and it appeared while gluetun tried to add an IPv6 route for the tunnel. I spent most of an evening on it. I checked MTU, IPv6 routing on the node, kernel versus userspace WireGuard, and whether my ISP or router was blocking the endpoint. None of it fixed anything.

Symptoms

The first outage did not come from Kubernetes at all. To rule out pod networking, I brought the same WireGuard config up directly on k3s-worker-01 with wg-quick. It failed with the identical error. That was actually useful, because it proved the problem was not Kubernetes. But the interface it created on the way to failing was never cleaned up. A wg0 interface with routes attached stayed on the node and hijacked its traffic, and the node lost internet.

The second outage came an hour or two later and is the one this writeup is really about. Still trying to find the connection failure, I deployed the gluetun pod with hostNetwork: true on k3s-worker-02, so the pod would use the node's network namespace directly instead of its own. Shortly after, that node stopped answering as well. DNS resolution timed out, then pings to public addresses showed 100% packet loss. Image pulls on the node failed. Every pod scheduled there was effectively offline.

$ ping -c 3 1.1.1.1
3 packets transmitted, 0 received, 100% packet loss

Investigation

The first outage was easy once I looked at the interfaces. ip a showed the leftover wg0, and wg-quick down wg0 brought the node back. Manually created interfaces do not clean themselves up, which I knew in theory and now know in practice.

The second one I initially read as a DNS problem, because DNS was the first thing to fail. systemd-resolved was running and processing requests, it just could not reach its upstream servers. Pinging 1.1.1.1 directly, with no DNS involved, also failed. So the node had lost connectivity below the DNS layer. Same symptom as the wg0 incident, but this time ip a showed no stray interface.

The timing pointed at gluetun. The node had gone dark right after the hostNetwork pod started its connection attempts, and I knew what gluetun does on startup. If its kill-switch rules had been written into the node's iptables instead of a pod's, then a failed VPN connection would leave the node in exactly the state the kill switch is designed to produce: everything blocked except the tunnel, and no tunnel. Checking the firewall confirmed it:

$ sudo iptables -L -n -v

Chain OUTPUT (policy DROP)
 target  prot  in   out   source      destination
 ACCEPT  all   *    lo    0.0.0.0/0   0.0.0.0/0
 ACCEPT  all   *    *     0.0.0.0/0   0.0.0.0/0   state RELATED,ESTABLISHED
 ACCEPT  all   *    eno1  0.0.0.0/0   192.168.x.0/24
 ACCEPT  udp   *    eno1  0.0.0.0/0   <vpn endpoint>   udp dpt:51820

A default DROP on the node's outbound chain, and the only external destination allowed was the VPN server on port 51820, which the node could not establish a tunnel to. These were the kill switch rules, applied to the wrong namespace.

I scaled the deployment to zero and expected the rules to disappear with the container. They did not. Nothing runs gluetun's teardown when a pod is killed, so the rules it had written into the host stayed there after the container that wrote them was gone.

Root cause

hostNetwork: true removes the namespace boundary that normally keeps a container's network changes contained. Gluetun's kill switch locks down whatever namespace it runs in, and with that flag the namespace was the physical node. When the VPN failed to connect, the node was left with the lockdown and without the tunnel. Kubernetes has no concept of undoing what a container did to the host, so deleting the pod restored nothing. The connection failure itself was a separate problem. The outage came from how I chose to debug it.

Fix

The immediate repair was a manual firewall reset on the node: set the default policies back to ACCEPT and flush the rules. That was safe here only because k3s and Flannel regenerate their own iptables rules automatically. Connectivity came back right away. I verified plain reachability and a registry pull before touching anything else.

The structural fix was to stop using hostNetwork for this workload at all. The VPN sidecar now runs in the pod's own network namespace, where a misfiring kill switch can only take down the one pod that wanted the VPN in the first place. That was the original design. I only reached for hostNetwork as a diagnostic step, and the lesson is that a diagnostic step with node-level side effects needs the same care as a production change.

The connection failure that started all of this was eventually solved by switching VPN providers. The same WireGuard credentials that failed inside every container mode worked fine on a bare Linux host and on my laptop. A different provider's config, using gluetun's native provider mode, connected on the first try. I never conclusively found out what in the original provider's config did not work inside the container environment.

What I'd do differently

Never test a privileged network change on a node that is running other workloads. Both outages came from experiments, a manual wg-quick and a hostNetwork pod, done on live worker nodes because that was convenient. Draining the node first, or testing on a throwaway VM, would have cost five minutes and prevented both.

Read what a container does to its host before giving it host access. I knew gluetun had a kill switch and I knew what hostNetwork meant. I did not put the two together until a node was dark. Any container that needs NET_ADMIN or hostNetwork can break the node, and I should treat it that way.

When a node loses connectivity after network experiments, check for leftovers first: ip a for interfaces, iptables -L for rules. In both incidents the cause was state that outlived the process that created it, and both were visible within a minute once I looked in the right place.