Intro
I rebuilt my homelab Kubernetes cluster from scratch and wanted three simple things on top of it: Cilium L2 announcements, a LoadBalancer IP pool, and a Gateway API entry point for the APIs I plan to deploy later.
The LoadBalancer IP showed up, ARP resolved, and the Gateway reported Programmed: True. Yet every HTTP request from outside the cluster just hung.
The root cause turned out to be an interaction between three components that are each fine on their own: Cilium’s transparent proxy, Tailscale’s rp_filter workaround, and the Proxmox bridge firewall. This post covers the setup, the symptoms, the dead ends, and the one sysctl that fixed it.
TL;DR:
tailscaledsetsnet.ipv4.conf.all.src_valid_mark=1. With that set, the kernel drops the reply from the backend pod to Cilium’s Envoy as a martian packet. Setnet.ipv4.conf.eth0.accept_local=1on the nodes. Also, don’t use Cilium DSR mode behind the Proxmox VM firewall.
Update:
eth0alone is not enough. When the Gateway’s backend pod runs on the same node as the Envoy handling the request, the reply arrives on the pod’slxc*interface and is dropped as martian there too (found later with Headlamp:martian source 10.0.0.95 from 10.0.0.171, on dev lxc...). Setnet.ipv4.conf.all.accept_local=1as well. The fix section below includes it.
The Situation
Hardware and platform
- 3 Proxmox VE 9 nodes (
pve-01,pve-02,pve-03), VMs bridged onvmbr0with the Proxmox firewall enabled on each NIC. - 8 VMs built from a Debian 13 (trixie) cloud image with cloud-init:
- 3 k0s controllers, one per Proxmox node, with a keepalived VIP
192.168.101.151for the API. - 5 workers: 2 on
pve-01, 2 onpve-02, 1 onpve-03.
- 3 k0s controllers, one per Proxmox node, with a keepalived VIP
- k0s v1.36.4 installed with k0sctl, with
kube-proxydisabled and a custom CNI. - Tailscale on every VM for SSH access (Tailscale SSH), with
--accept-routesoff.
Cilium configuration
- Cilium 1.20.2, native routing with
autoDirectNodeRoutes, pod CIDRs10.0.x.0/24. kubeProxyReplacement: true.l2announcements.enabled: trueandgatewayAPI.enabled: true.- Gateway API CRDs v1.6.1. I matched the version to
sigs.k8s.io/gateway-apiin Cilium’sgo.modinstead of taking the latest release.
The objects I added:
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
name: lab-ip-pool
spec:
blocks:
- start: "192.168.101.152"
stop: "192.168.101.168"
---
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: l2-announcements
spec:
interfaces:
- ^eth[0-9]+
externalIPs: true
loadBalancerIPs: true
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: lab-gateway
namespace: gateway
spec:
gatewayClassName: cilium
infrastructure:
annotations:
io.cilium/lb-ipam-ips: "192.168.101.152"
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
CiliumLoadBalancerIPPool is served as cilium.io/v2 now, and v2alpha1 is deprecated. CiliumL2AnnouncementPolicy is still only v2alpha1.
Issue 0: The Small Ones
Before the real problem, a few simpler mistakes got in the way.
The IP pool overlapped live addresses. My old pool was .150-.170. It contained the k0s API VIP (.151) and some other device on the LAN (.169). Cilium would have happily handed the API VIP to a LoadBalancer Service. A quick ping sweep plus the neighbor table on a Proxmox host found both:
for i in $(seq 150 170); do ping -c1 -W1 192.168.101.$i >/dev/null 2>&1 & done; wait
ip neigh show dev vmbr0
arping wasn’t installed, so the first sweep silently reported “nothing in use”, even for the VIP. Always sanity check a scan against an address you know is taken.
Another service took the Gateway’s IP. With ingressController.enabled: true, the cilium-ingress LoadBalancer Service grabbed .152 first, and the Gateway sat at <pending>:
The IP '192.168.101.152' is already allocated to another service
Fix: pin the ingress Service in the Helm values.
ingressController:
enabled: true
service:
annotations:
io.cilium/lb-ipam-ips: "192.168.101.153"
Issue 1: DSR Replies Dropped by the Proxmox Bridge
Symptom
ARP for .152 resolved to a worker’s MAC, so L2 announcements were working. But HTTP to .152 timed out from outside the cluster. A test NodePort Service showed the pattern:
| Node | NodePort result |
|---|---|
| Node running the backend pod | 200 |
| Every other node | timeout |
Pod-to-pod traffic across nodes, and even across Proxmox hosts, worked fine.
Cause
My old values had loadBalancer.mode: dsr. With Direct Server Return, the node that receives the request forwards it to the backend node, and the backend node replies directly to the client. The reply takes a different path from the request.
Each VM NIC has the Proxmox firewall enabled, so the host bridges it through a fwbr* bridge with net.bridge.bridge-nf-call-iptables=1. Host conntrack sees a SYN-ACK for a connection whose SYN it never saw, marks it INVALID, and the PVE firewall drops it.
Fix
Go back to the default SNAT mode by removing loadBalancer.mode from the values. After that, every NodePort answered from every client.
Issue 2: The Gateway Accepts, But Never Answers
This was the real one.
Symptom
Now Envoy accepted the TCP connection, but requests hung. Envoy’s admin socket showed the upstream to the backend pod failing:
A=/var/run/cilium/envoy/sockets/admin.sock
curl -s --unix-socket $A http://localhost/clusters | grep cx_connect_fail
# gateway/cilium-gateway-lab-gateway/nettest:echo:80::10.0.3.3:8080::cx_connect_fail::4
The upstream socket itself looked interesting:
ss -tneo state syn-sent | grep 8080
# 10.0.2.87:39741 10.0.3.3:8080 ... fwmark:0x80b00
10.0.2.87is the node’sreserved:ingressendpoint IP. Envoy uses it as a transparent source address for upstream connections.0x80b00is Cilium’s proxy-egress magic mark (0xb00) with the security identity in the upper bits. Identity8isreserved:ingress.
Dead ends
This is where I lost most of the time. Each of these was plausible, and each was wrong:
- Tailscale ip rules colliding with Cilium marks. Tailscale installs
fwmark 0x80000/0xff0000policy rules, and the ingress identity produces exactly0x80000in those bits. A perfect-looking collision. But stoppingtailscaled(which removes the rules;tailscale downdoes not) didn’t fix it. - XDP acceleration (
loadBalancer.acceleration: best-effort). Disabled it, no change. endpointRoutes. Disabled it, no change.- Interface naming. Previous attempts in this homelab had issues with interface name patterns, but the VMs use
eth0, and Cilium reportedDevices: eth0 (Direct Routing). - Restarting the operator, agents, and Envoy. No change. (It did surface a separate gotcha, covered below.)
Packet captures were also misleading early on. At one point the SYN seemed to vanish between netfilter and the wire. That turned out to be stale config, not the real failure.
Finding it: ask the kernel why it dropped the packet
What finally worked was skipping theories and asking the kernel directly. bpftrace on the kfree_skb tracepoint, filtered to the backend IP (10.0.3.3 is 0x0303000a in network byte order):
tracepoint:skb:kfree_skb {
$skb = (struct sk_buff *)args->skbaddr;
$ip = (struct iphdr *)($skb->head + $skb->network_header);
if ($ip->daddr == 0x0303000a || $ip->saddr == 0x0303000a) {
printf("drop reason=%d loc=%s src=%s dst=%s dev=%s mark=0x%x\n",
args->reason, ksym(args->location), ntop($ip->saddr), ntop($ip->daddr),
$skb->dev->name, $skb->mark);
}
}
Output:
drop reason=2 loc=ip_rcv_finish_core.isra.0 src=10.0.3.3 dst=10.0.2.87 dev=eth0 mark=0x200
So the SYN went out fine. The pod’s SYN-ACK came back on eth0 and the kernel dropped it in the input route lookup. Kernel 6.12 reports this as reason 2 (NOT_SPECIFIED). More detailed route-input drop reasons only arrived in 6.13. Turning on martian logging made it explicit:
sysctl -w net.ipv4.conf.all.log_martians=1
dmesg
# IPv4: martian source 10.0.2.87 from 10.0.3.3, on dev eth0
Cause
Here is the chain:
- The SYN-ACK matches Cilium’s
CILIUM_PRE_manglerule-m socket --transparent, which marks it0x200. - Mark
0x200sends it throughip rule fwmark 0x200/0xf00 lookup 2004, and table 2004 is simplylocal default dev lo. This is how TPROXY delivers it to Envoy’s socket. - The kernel then validates the source address. With
net.ipv4.conf.all.src_valid_mark=1, source validation uses the packet’s mark. With mark0x200,10.0.3.3resolves through table 2004 to a local route. - A packet arriving on
eth0whose source looks local is a martian. Dropped. That holds even withrp_filter=0, because a local source is rejected unlessaccept_localis set.
So who set src_valid_mark=1? Not Cilium. The controllers, which don’t run Cilium at all, had it too. The tailscaled journal answers it:
router: enabling connmark-based rp_filter workaround
Tailscale enables this on every start so its own marked traffic passes reverse-path checks. Setting the sysctl back to 0 works until tailscaled restarts, then it comes right back.
Fix
Leave Tailscale’s workaround alone and allow local-looking sources on the node interface:
cat <<'EOF' > /etc/sysctl.d/90-cilium-tailscale.conf
# Cilium TPROXY replies are martian when tailscaled sets all.src_valid_mark=1.
# Replies arrive on eth0 (backend on another node) or a pod lxc* interface
# (backend on the same node), so allow it on all interfaces.
net.ipv4.conf.all.accept_local = 1
net.ipv4.conf.eth0.accept_local = 1
EOF
sysctl -p /etc/sysctl.d/90-cilium-tailscale.conf
To prove it, I killed Envoy’s pooled upstream sockets before each request, so an old connection couldn’t hide the result:
ss -K dst 10.0.3.3 dport = 8080
src_valid_mark | accept_local | Result |
|---|---|---|
| 1 | 0 | timeout |
| 1 | 1 | 200 |
I also added the file to the cloud-init vendor snippet, so new VMs get it from the start:
write_files:
- path: /etc/sysctl.d/90-cilium-tailscale.conf
content: |
net.ipv4.conf.all.accept_local = 1
net.ipv4.conf.eth0.accept_local = 1
After that, http://192.168.101.152 returned 200 from every Proxmox host and from VMs outside the cluster.
Blockers Along the Way
None of these were the bug, but each one cost time.
My Mac couldn’t reach the LAN. On my laptop, Apple-signed binaries (/usr/bin/ssh, /usr/bin/nc, /usr/bin/curl) reached 192.168.101.0/24, but everything else (k0sctl, kubectl, Homebrew nc) failed with no route to host. Tailscale addresses worked for every binary. I never found the root cause, but I worked around it twice:
k0sctl: a
bastionon each host, pointing at a Proxmox node through its Tailscale address.kubectl: the Tailscale Kubernetes operator with the API server proxy in auth mode (
apiServerProxyConfig.mode: "true"). A tailnet grant maps my identity tosystem:masters, so the kubeconfig has no token at all:{ "src": ["group:admin"], "dst": ["tag:k8s-operator"], "app": { "tailscale.com/cap/kubernetes": [ {"impersonate": {"groups": ["system:masters"]}} ] } }
k0sctl 0.33 waits for nodes to be Ready. With a custom CNI, nodes are never Ready before Cilium is installed, so the first apply timed out and rolled back. Use k0sctl apply --no-wait, then install the CNI.
A Helm release stuck in pending-install. An interrupted cilium install --wait left the release half done. The fix was to delete the release Secret and run helm upgrade --install again. Helm adopts the existing objects because they carry its ownership annotations.
Server-side apply doesn’t prune fields owned by someone else. Helm 4 applies server-side. The first install came from the cilium CLI, which owned some ConfigMap keys (enable-bpf-masquerade, bpf-lb-sock-hostns-only). Removing those options from the values did not remove the keys. I had to recreate cilium-config from Helm once. Watch for this when you move a release between tools.
Envoy keeps its old proxy port after an agent restart. After restarting the Cilium agents, the Gateway’s proxy port moved (14244 to 15016). The standalone cilium-envoy pods lost their xDS stream and never opened the new listener. Requests hung until I restarted them:
kubectl -n kube-system rollout restart ds/cilium-envoy
Lessons
- Let the kernel tell you why it dropped a packet.
kfree_skbwithbpftrace, thenlog_martians, found in minutes what hours of theorizing didn’t. - “It looks like a collision” is a hypothesis, not a finding. The Tailscale mark overlap looked perfect, and it wasn’t the cause.
- Check where a sysctl comes from before you override it.
src_valid_markwasn’t from Debian or Cilium. A daemon reapplied it on every start, so the fix had to be a setting that daemon doesn’t touch. - Asymmetric datapaths and stateful bridges don’t mix. DSR is great on bare metal. Behind a hypervisor firewall that tracks connections, use SNAT.
- Test with fresh connections. Envoy’s connection pool hid the result of a sysctl change once. Kill the pooled sockets before you conclude anything.
Series
This post is part of Homelab Rebuild:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway (this post)
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers