Intro
I wanted tests to call AWS APIs without an AWS account. A job on the cluster’s GitHub runners can create a bucket, send a message to a queue, invoke a Lambda function, and throw it all away. floci is an AWS API emulator (a server that answers the AWS APIs like the real service) that runs as one container, so I put it in the k0s cluster.
S3 and SQS worked on the first try. Lambda did not. The function pod timed out while it downloaded its code from floci, but test pods with the same spec on the same node worked. The cause was a packet mark collision between two programs that both write to the same bits.
TL;DR: floci 2.4.0 runs in namespace
floci, in memory, with Lambda as one pod per execution environment, and aLimitRangegives those pods the CPU request that Kyverno requires. Two things broke. A FluxGitRepositorypinned to a commit made source-controller clone the whole floci history and run out of memory, so I vendored the chart. And hosttailscaledmasqueraded cross-node pod traffic for every Cilium identity whose low byte is0x04, because Cilium writes the identity into the bits that Tailscale uses for its forward mark0x40000/0xff0000.tailscale set --netfilter-mode=offon all eight VMs fixed it.
The figure is a card reader. Each card is a packet, and its punched holes
are the packet mark. The stencil plate is Tailscale’s mask 0xff0000: it
lets only bits 16 to 23 through. When those bits read 0x04, the hammer
stamps the card SNAT and it lands in the wrong tray. Click the figure, or
press space, to turn Tailscale’s netfilter rules off, and every card goes
through untouched. Press n to feed the next card.
The Situation
- Proxmox VE 9 on three hosts, eight Debian 13 VMs: three k0s controllers and five workers (k0s 1.36.5).
- Cilium 1.20.4 with no kube-proxy, native routing, WireGuard pod encryption, legacy host routing and iptables masquerading.
- Tailscale 1.104.5 on every VM itself, for SSH and cluster access. It ran with the default netfilter mode, so it managed its own iptables chains.
- Flux 2.10.0 owns the cluster. Every app is a folder under
kubernetes/apps/, and Kyverno 3.9.2 denies pods without CPU and memory requests.
Setup
floci has a Helm chart in its repo, charts/floci, but it is not published
to a Helm repository. The app folder holds a namespace.yaml, a
HelmRelease, a values.yaml that becomes a ConfigMap, and a LimitRange.
These are the values that matter:
image:
tag: "2.4.0"
floci:
storage:
mode: memory # a restart deletes every bucket, table and queue
lambda:
executor: kubernetes
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 1Gi }
With the kubernetes executor, floci creates one pod per Lambda execution
environment in its own namespace. A busybox init container, code-download,
fetches the function’s code zip from the floci pod over HTTP. Then the AWS
Lambda runtime image runs the handler.
The endpoint is in-cluster only, with no route from the LAN or the tailnet. floci accepts any credentials:
export AWS_ENDPOINT_URL=http://floci.floci.svc.cluster.local:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
aws s3 mb s3://smoke && aws s3 ls
Services that floci runs as Docker containers, such as RDS and ElastiCache, do not work here. The workers run containerd and have no Docker socket. A test that needs Postgres uses the cluster’s CloudNativePG instance instead.
A LimitRange for the Lambda pods
The chart’s pod template showed that floci sets only memory on the Lambda
pods: the request and the limit both equal the function’s MemorySize. The
cluster’s Kyverno policy audit-workload-hygiene runs in Deny mode and
requires a CPU request on every container, so it can reject every Lambda
pod. A LimitRange in namespace floci fills in what the pods leave out.
The API server applies it during admission, before Kyverno validates the
pod:
apiVersion: v1
kind: LimitRange
metadata:
name: default-resources
namespace: floci
spec:
limits:
- type: Container
defaultRequest: { cpu: 50m, memory: 64Mi }
default: { cpu: 500m, memory: 256Mi }
The memory defaults are for the busybox init container, which floci leaves without resources.
Issue 1: Flux Lost Every Source
Symptom
A few minutes after the first merge, every Flux GitRepository and
HelmRepository in the cluster stopped updating. source-controller restarted
16 times in about 15 minutes, and each time it reached its 256Mi limit and
failed its liveness probe.
Cause
The chart landed on floci main on 2026-11-09, after the 2.4.0 tag. So I
pinned the GitRepository to a commit:
spec:
url: https://github.com/floci-io/floci
ref:
commit: a65def00a4c9a94c8b759d5147b1d566ddd5136c
For a tag or a branch, source-controller does a shallow clone (one commit, no history). For a commit ref, it cloned the whole floci history, 113 MB, and ran out of memory. One controller serves all sources, so every app in the cluster lost its updates with it.
Fix
First I stopped the loop. Suspending only the floci objects was enough:
flux suspend kustomization floci -n flux-system
flux suspend source git floci -n floci
flux suspend helmrelease floci -n floci
# source-controller back to 98Mi, no more restarts
Then I copied charts/floci at that commit into
kubernetes/apps/floci/chart/, with floci’s MIT LICENSE and without its
ci/ and tests/ folders. The HelmRelease reads the chart from the
cluster’s own repo:
chart:
spec:
chart: ./kubernetes/apps/floci/chart
reconcileStrategy: Revision
sourceRef:
kind: GitRepository
name: flux-system
namespace: flux-system
When a floci release tag ships the chart, a GitRepository on that tag with
sparseCheckout replaces the copy. After the fix, source-controller sits at
about 147Mi.
Issue 2: The Lambda Pods Could Not Reach floci
Symptom
I created a function called smoke and invoked it. floci created a Lambda
pod on k0s-pve-01-worker-2, and its init container code-download timed
out on its wget to the floci pod IP. floci deleted the pod, created a new
one about a minute later, and failed again:
WARN [LambdaExecutorService] Failed to acquire container for function smoke:
Pod floci-lambda-smoke-45e0368a for function 'smoke' failed to start:
code-download: exited with code 1
A shell in a pod with the same labels on the same worker showed the same thing:
Connecting to 10.0.3.101:4566 (10.0.3.101:4566)
wget: download timed out
nc: 10.0.3.101 (10.0.3.101:4566): Connection timed out
Dead ends
Each of these looked like a cause and was not:
- A network policy. The namespace has none.
- The node. Test pods on three workers, including
k0s-pve-01-worker-2, reached floci. - The init container. A test pod with the same
busybox:1.36wgetin an init container and in the main container passed both. - Timing at pod start. Twelve
wgettries in a row from a new pod all passed. - Cilium’s ipcache (its table of which IP belongs to which identity).
Both nodes had the right
/32entries for the two pods. - The pod spec. A copy of the failing pod’s spec worked.
All the test pods had my own labels, and that was the clue I missed at first. Cilium gives each set of pod labels a security identity (a number that its policies use instead of IP addresses). My test pods had different identities from the Lambda pod, so they did not test the same thing.
Finding it
Hubble (Cilium’s flow log) on the floci node showed the SYN and its retries, but not from the Lambda pod:
192.168.101.41:57644 (remote-node) -> floci/floci-594b45fc68-t4f8m:4566 (ID:20596) to-endpoint FORWARDED (TCP Flags: SYN)
192.168.101.41:57644 (remote-node) -> floci/floci-594b45fc68-t4f8m:4566 (ID:20596) to-endpoint FORWARDED (TCP Flags: SYN)
The source was the node IP of the worker that ran the Lambda pod, labeled
remote-node, and no reply ever reached the pod. Something on that worker rewrote the source address. Cilium
masquerades only traffic to destinations outside 10.0.0.0/8, so the
rewrite was not Cilium’s. On that worker, the Tailscale NAT chain had
counted it:
iptables -t nat -L ts-postrouting -v -n
# Chain ts-postrouting (1 references)
# pkts bytes target prot opt in out source destination
# 7 420 MASQUERADE all -- * * 0.0.0.0/0 0.0.0.0/0 mark match 0x40000/0xff0000
Seven packets matched the Tailscale masquerade rule. The chain that sets
that mark for real Tailscale traffic, ts-forward, had counted 0.
Cause
Two programs use the same bits of skb->mark, the packet mark that the
kernel carries with each packet:
- Cilium writes the source pod’s security identity into the upper 16 bits when it hands a packet to the host network stack. With legacy host routing, cross-node pod traffic goes through the host stack and its iptables chains.
- Tailscale, in its default netfilter mode, marks packets that it
forwards from
tailscale0with0x40000under the mask0xff0000. InPOSTROUTINGit masquerades every packet with that mark to the node IP.
The function smoke had identity 2052, which is 0x0804. Shifted into
the upper 16 bits, that is mark 0x08040000. Under Tailscale’s mask, the
mark reads 0x040000, which is the Tailscale forward mark:
flowchart LR
A["Lambda pod<br/>identity 2052 = 0x0804"] --> B["Cilium writes mark<br/>0x0804 in bits 16 to 31"]
B --> C{"mark AND 0xff0000<br/>is 0x040000?"}
C -- "yes" --> D["ts-postrouting<br/>MASQUERADE to node IP"]
C -- "no" --> E["pod IP kept"]
D --> F["floci sees remote-node<br/>no reply reaches the pod"]
E --> G["code download works"]So every identity whose low byte is 0x04 (260, 516, 772, … 2052, …)
loses its cross-node pod traffic. That is one identity in 256. Traffic to a
pod on the same node does not go through the host’s iptables, so it works.
Traffic between other identities works. Pods with the same labels always get
the same identity, so the failure followed one function and looked random
from the outside.
I already met this overlap once. In Part 3,
the Cilium Gateway broke on these same VMs, and Tailscale’s 0xff0000 mark
rules looked like a perfect collision with the ingress identity. There it
was a dead end, and the real cause was src_valid_mark. Here the collision
is real.
To prove it, I gave the same code a function named hello. It got identity
8297, 0x2069, whose low byte is 0x69. The cold invoke took 18.9 s
(pod create, image pull, code download) and a warm invoke took 4.2 s,
and both returned the payload.
Fix
The VMs do not route anything for the tailnet. They advertise no subnets and
are not exit nodes, so tailscaled does not need its iptables rules at all.
I turned them off, one VM at a time:
tailscale set --netfilter-mode=off
# Warning: netfilter=off; configure iptables yourself.
iptables -t nat -S | grep -c ts-
# 0
iptables -S | grep -c ts-
# 0
tailscale status --self --peers=false | head -1
# 100.x.y.z k0s-pve-01-worker-1 ... linux -
I started with the worker that ran the failing pod, then the other four
workers, and the three controllers last. Each VM stayed on the tailnet. After
the last one, all five workers were Ready, Cilium reported 5/5 nodes
reachable, and every Flux Kustomization and HelmRelease was Ready. The VM
setup docs now use tailscale up --ssh --netfilter-mode=off, so a rebuilt VM
gets the same setting.
The setting has a cost. With netfilter off, Tailscale also removes its
rule that drops packets from 100.64.0.0/10 that do not arrive on
tailscale0, which stops other interfaces from spoofing tailnet addresses.
Nothing on the LAN uses that range, so I accepted it.
Lessons
- Pin Flux sources to tags, not commits. A commit ref makes source-controller clone the full history, and one large repo can take down every source in the cluster.
- Read what an operator puts in its pods. floci creates pods I cannot
edit. Its template showed no CPU request, and a
LimitRangefilled it in before the policy saw a single pod. - A copy of a failing pod is not the same pod. New labels give a new Cilium identity, and the identity is part of the packet.
- Read the masquerade counters. When Hubble shows
remote-nodeas the source of pod traffic,iptables -t nat -L -v -nshows which rule rewrote it. - Two programs, one mark. Cilium and Tailscale both use bits 16 to 23
of
skb->mark. On a Cilium node that runstailscaledonly for access, set--netfilter-mode=off.
References
- GitHub: floci-io/floci
- Flux: GitRepository, reference
- Flux: HelmRelease, chart template
- Kubernetes: Limit Ranges
- Kyverno: Validating policies
- Cilium: Security identities
- Cilium: Hubble
- Tailscale: tailscale set, netfilter-mode
- Tailscale: Linux netfilter modes
- GitHub: wheresryan22/anatomy
