Skip to content

Commit 07c7f28

Browse files
kradalbyclaude
andcommitted
integration: load br_netfilter so pod ClusterIP DNS works on arm64
The arm64 CI diagnostics showed CoreDNS healthy and the kube-dns Service backed by a ready endpoint, yet the operator pod's lookups to the kube-dns ClusterIP timed out. That is the signature of bridge netfilter being off: with br_netfilter unloaded, bridged pod-to-pod traffic skips the kube-proxy iptables DNAT rules, so every ClusterIP — kube-dns included — is unreachable from pods, even though the service and its endpoints are perfectly healthy. The amd64 dev box does not hit this because Docker loads br_netfilter for its bridge networks. Bind the host kernel modules into the k3s container and, before k3s programs kube-proxy, load br_netfilter and turn on the bridge-nf-call-iptables and ip_forward sysctls. Both are no-ops where the module is already present, so the amd64 path (which passes locally) is unaffected. Extend the diagnostics with the loaded modules, the bridge/forward sysctls, and the kube-dns NAT rules, so a ClusterIP-unreachable failure shows whether the fault is the module, the sysctls, or a missing kube-proxy rule rather than leaving it to inference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f61bc26 commit 07c7f28

1 file changed

Lines changed: 45 additions & 1 deletion

File tree

integration/k3sic/k3sic.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ const (
9494
// kubectlBin is the in-container kubectl the k3s image ships on PATH.
9595
kubectlBin = "kubectl"
9696

97+
// shellBin is the in-container shell used for compound commands.
98+
shellBin = "/bin/sh"
99+
97100
// tailscaleNamespace is where the operator and its proxies are installed.
98101
tailscaleNamespace = "tailscale"
99102

@@ -245,6 +248,10 @@ func New(
245248

246249
k.container = container
247250

251+
// Make ClusterIP DNAT work before k3s programs kube-proxy rules; without it
252+
// in-cluster DNS times out on hosts where br_netfilter is not preloaded.
253+
k.ensureBridgeNetfilter()
254+
248255
// Drop the Headscale CA cert(s) into the trust store so helm and the
249256
// operator trust the test server's TLS certificate.
250257
for i, cert := range k.caCerts {
@@ -276,6 +283,35 @@ func withK3sHostConfig(config *docker.HostConfig) {
276283
"/run": "",
277284
"/var/run": "",
278285
}
286+
287+
// Bind the host kernel modules read-only so the container can load
288+
// br_netfilter (see ensureBridgeNetfilter). Without bridge netfilter,
289+
// kube-proxy's ClusterIP DNAT rules do not apply to bridged pod-to-pod
290+
// traffic, so kube-dns (and every other Service) is unreachable from pods —
291+
// the in-cluster DNS timeout seen on the arm64 CI runner. The container
292+
// shares the host kernel, so the modules match.
293+
config.Binds = append(config.Binds, "/lib/modules:/lib/modules:ro")
294+
}
295+
296+
// ensureBridgeNetfilter loads br_netfilter and enables the sysctls that make
297+
// kube-proxy's ClusterIP DNAT apply to bridged pod-to-pod traffic. On a host
298+
// where the module is already loaded (e.g. the amd64 dev box, where Docker
299+
// loads it for bridge networks) these are no-ops; on the arm64 CI runner the
300+
// module is absent and pods cannot reach any Service IP — kube-dns included —
301+
// so in-cluster DNS times out. Best-effort: k3s also loads the module, and a
302+
// genuinely missing module surfaces in DumpDiagnostics rather than here.
303+
func (k *K3sInContainer) ensureBridgeNetfilter() {
304+
for _, cmd := range []string{
305+
"modprobe br_netfilter || true",
306+
"sysctl -w net.bridge.bridge-nf-call-iptables=1 || true",
307+
"sysctl -w net.bridge.bridge-nf-call-ip6tables=1 || true",
308+
"sysctl -w net.ipv4.ip_forward=1 || true",
309+
} {
310+
out, stderr, err := k.Execute([]string{shellBin, "-c", cmd})
311+
if err != nil {
312+
log.Printf("[k3s] %q failed: %v (stdout: %s, stderr: %s)", cmd, err, out, stderr)
313+
}
314+
}
279315
}
280316

281317
// Hostname returns the hostname of the [K3sInContainer].
@@ -643,6 +679,14 @@ func (k *K3sInContainer) DumpDiagnostics() {
643679
{kubectlBin, "-n", kubeSystemNamespace, "logs", "-l", "k8s-app=kube-dns", "--tail=100"},
644680
{kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns", "-o", "wide"},
645681
{kubectlBin, "-n", kubeSystemNamespace, "get", "configmap", "coredns", "coredns-custom", "-o", "yaml"},
682+
// Host network state behind a ClusterIP-unreachable DNS timeout: whether
683+
// br_netfilter is loaded and the call-iptables/forward sysctls are on, and
684+
// whether kube-proxy actually programmed the kube-dns DNAT rule. If CoreDNS
685+
// is healthy (above) but these are missing, the fault is the Service DNAT
686+
// path, not DNS.
687+
{shellBin, "-c", "lsmod | grep -E 'br_netfilter|nf_conntrack' || echo 'br_netfilter NOT loaded'"},
688+
{shellBin, "-c", "sysctl net.bridge.bridge-nf-call-iptables net.ipv4.ip_forward 2>&1 || true"},
689+
{shellBin, "-c", "iptables-save -t nat 2>/dev/null | grep -iE 'KUBE-SERVICES|kube-dns|10.43.0.10' | head -40 || echo 'no kube-dns nat rules'"},
646690
} {
647691
out, stderr, err := k.Execute(c)
648692
label := strings.Join(c, " ")
@@ -715,7 +759,7 @@ func (k *K3sInContainer) Shutdown() error {
715759
// k3s spawns containerd and a tree of child processes inside this
716760
// container; the bundled k3s-killall.sh tears them down. Best-effort: the
717761
// Purge below removes the container regardless.
718-
_, _, err = k.Execute([]string{"/bin/sh", "-c", "k3s-killall.sh || true"})
762+
_, _, err = k.Execute([]string{shellBin, "-c", "k3s-killall.sh || true"})
719763
if err != nil {
720764
log.Printf("running k3s-killall in %s: %s", k.hostname, err)
721765
}

0 commit comments

Comments
 (0)