Post

eBPF Lab: Deploy

Bring everything up, check the rollout and smoke-test the endpoints (eBPF Lab lab).

eBPF Lab: Deploy

One command creates the cluster, gives it a network, joins it to the platform and installs four eBPF tools and the app, in an order that matters. This part shows that order, how to tell every piece is healthy, and the failures this lab has actually hit. At the end the app, Hubble and the Falcosidekick UI are open in your browser.

Bring It Up

From the lab repo:

1
2
cd ~/gitrepos/observability/ebpf-lab
make up

up is eleven targets in a fixed order:

1
2
up: cluster node-prep cilium dns storage monitoring images app tetragon falco obi ## Everything, in order (about 15 min on a first run)
	@echo; echo "app $(APP)  Hubble UI http://127.0.0.1:8092  Falcosidekick UI http://127.0.0.1:8093"
TargetWhat it doesWhy it’s here in the order
clusterCreates the PV folders under /srv/homelab/labs/ebpf-lab and the k3d cluster (skipped if it exists)Everything else needs it
node-prepMounts a shared bpffs in each node containerCilium pins its maps there
ciliumInstalls Cilium with k8sServiceHost set to the server node’s address, waits for CoreDNSUntil now the nodes are NotReady and no pod has a network
dnsCoreDNS upstreams for the homelab network, restart CoreDNSImage and rule downloads in later steps need working DNS
storageStorageClass lab-local and the two static PVsBefore any claim asks for them
monitoringlab.sh register lab-ebpf dashboards, copies the OTLP token to namespace obiArgo CD installs the monitoring pack; OBI needs the token
imagesBuilds the two app images and imports themNo registry
appApplies k8s/app/app.yaml and waits for redis, api, web 
tetragonInstalls Tetragon with the API server’s real addressHost-network pod: no ClusterIP
falcoInstalls Falco, Falcosidekick and the UIDownloads rules, so DNS must work
obiInstalls OBI, then patches in the host alias for the gatewayLast, so the app’s processes already exist

How long it takes depends mostly on what’s cached. The Makefile’s own estimate for a first run is about 15 minutes. On the run this part was checked against (2026-10-14), with every image already in the homelab’s Zot mirror, make up took about 4 minutes; experiment 7’s make rebuild (down, then up) took about 12 minutes on both of its runs (747 seconds on the first).

When it’s done:

1
app http://127.0.0.1:8091  Hubble UI http://127.0.0.1:8092  Falcosidekick UI http://127.0.0.1:8093

Verify the Rollout

make status is the preflight the screenshot script runs before every capture:

1
2
3
4
5
6
7
$ make status
node k3d-lab-ebpf-agent-0: Ready
node k3d-lab-ebpf-server-0: Ready
OK
app: ok
hubble ui: ok
falcosidekick ui: ok

The OK is cilium-dbg status --brief from inside the Cilium agent. The full status says the things this lab depends on:

1
2
3
4
5
6
7
8
9
$ kubectl --context k3d-lab-ebpf -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status
Kubernetes:              Ok         1.35 (v1.35.5+k3s1) [linux/arm64]
KubeProxyReplacement:    True   [eth0  172.30.0.136 (Direct Routing)]
Cilium:                  Ok   1.20.2 (v1.20.2-e0dc92bd)
IPAM:                    IPv4: 19/254 allocated from 10.100.0.0/24,
Routing:                 Network: Tunnel [vxlan]   Host: Legacy
Hubble:                  Ok              Current/Max Flows: 4095/4095 (100.00%), Flows/s: 21.72   Metrics: Ok
Cluster health:          2/2 reachable   (2026-10-13T21:44:07Z)   (Probe interval: 1m36.566274746s)
# … (trimmed)

KubeProxyReplacement: True, VXLAN routing and both nodes reachable are what you want. Hubble’s ring buffer is already full (4,095 flows) a few minutes after start; it keeps the most recent ones.

Every pod, with its node (trimmed to the lab’s own namespaces and the eBPF tools):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ kubectl --context k3d-lab-ebpf get pods -A -o wide
NAMESPACE     NAME                                      READY  STATUS   NODE
demo          api-5df5d59bd7-lhbhh                      1/1    Running  k3d-lab-ebpf-agent-0
demo          api-5df5d59bd7-vss5p                      1/1    Running  k3d-lab-ebpf-server-0
demo          redis-bcc8fb84b-m6nfd                     1/1    Running  k3d-lab-ebpf-server-0
demo          web-57fbb6bb9f-zcggd                      1/1    Running  k3d-lab-ebpf-server-0
falco         falco-4thlb                               2/2    Running  k3d-lab-ebpf-server-0
falco         falco-rp8wr                               2/2    Running  k3d-lab-ebpf-agent-0
falco         falco-falcosidekick-66d8cdc879-gsqc7      1/1    Running  k3d-lab-ebpf-server-0
falco         falco-falcosidekick-ui-5dddc5ddb7-995hh   1/1    Running  k3d-lab-ebpf-server-0
falco         falco-falcosidekick-ui-redis-0            1/1    Running  k3d-lab-ebpf-server-0
kube-system   cilium-5r4h7                              1/1    Running  k3d-lab-ebpf-server-0
kube-system   cilium-7lxtx                              1/1    Running  k3d-lab-ebpf-agent-0
kube-system   cilium-envoy-fjfbx                        1/1    Running  k3d-lab-ebpf-server-0
kube-system   cilium-envoy-v5qts                        1/1    Running  k3d-lab-ebpf-agent-0
kube-system   cilium-operator-58ccc79878-rlkq5          1/1    Running  k3d-lab-ebpf-agent-0
kube-system   hubble-relay-bdbfb59-l8m6q                1/1    Running  k3d-lab-ebpf-agent-0
kube-system   hubble-ui-5d45b9c77b-jgqld                2/2    Running  k3d-lab-ebpf-agent-0
kube-system   tetragon-9cj8m                            2/2    Running  k3d-lab-ebpf-server-0
kube-system   tetragon-pkwsn                            2/2    Running  k3d-lab-ebpf-agent-0
kube-system   tetragon-operator-7b4dd7b-4dcvd           1/1    Running  k3d-lab-ebpf-server-0
monitoring    agent-collector-gxzqw                     1/1    Running  k3d-lab-ebpf-server-0
monitoring    agent-collector-sg2nb                     1/1    Running  k3d-lab-ebpf-agent-0
monitoring    cluster-collector-0                       1/1    Running  k3d-lab-ebpf-server-0
obi           obi-2m59b                                 1/1    Running  k3d-lab-ebpf-server-0
obi           obi-4lshq                                 1/1    Running  k3d-lab-ebpf-agent-0

What to check:

  • The two api pods are on different nodes. The spread constraint is ScheduleAnyway, so if both land on one node, delete one and let it reschedule; experiments 1 and 4 need one per node.
  • Redis is on the server node, where its PV is. If it’s Pending, its claim couldn’t bind: check kubectl get pv,pvc -A (below).
  • Each eBPF tool has one pod per node, and Tetragon and Falco show 2/2 (the agent plus its export or rules sidecar).

The claims are bound to the static PVs:

1
2
3
4
$ kubectl --context k3d-lab-ebpf get pv
NAME                      CAPACITY  ACCESS MODES  RECLAIM POLICY  STATUS  CLAIM                                                                   STORAGECLASS
ebpf-lab-falco-ui-redis   1Gi       RWO           Retain          Bound   falco/falco-falcosidekick-ui-redis-data-falco-falcosidekick-ui-redis-0  lab-local
ebpf-lab-redis            1Gi       RWO           Retain          Bound   demo/redis-data                                                         lab-local

And the platform sees the lab:

1
2
3
4
5
6
7
$ ~/gitrepos/iac/infra-local/host/labs/lab.sh status lab-ebpf
https://172.30.0.136:6443
  cert-manager-lab-ebpf                Synced/Healthy
  prometheus-operator-crds-lab-ebpf    Synced/Healthy
  opentelemetry-operator-lab-ebpf      Synced/Healthy
  kube-state-metrics-lab-ebpf          Synced/Healthy
  monitoring-pack-lab-ebpf             Synced/Healthy

There are no iptables Service rules on the nodes, only Cilium’s own chains:

1
2
3
$ docker exec k3d-lab-ebpf-server-0 sh -c 'iptables-save | grep -c KUBE-SVC; iptables-save | grep -c CILIUM'
0
42

Memory, as Docker counts it, a few minutes after the rebuild: 3.8 GiB for the server node and 1.8 GiB for the agent.

Smoke Test

1
2
3
4
5
6
7
8
9
10
$ curl -s -o /dev/null -w "page %{http_code}\n" 127.0.0.1:8091/
page 200
$ curl -s 127.0.0.1:8091/api/visits
{"served_by":"api-5df5d59bd7-vss5p","visits":7122}
$ curl -s 127.0.0.1:8091/api/items
[{"id":1,"name":"bpf_map"},{"id":2,"name":"kprobe"},{"id":3,"name":"xdp"}]
$ curl -s -o /dev/null -w "hubble ui %{http_code}\n" 127.0.0.1:8092/
hubble ui 200
$ curl -s -o /dev/null -w "falcosidekick ui %{http_code}\n" 127.0.0.1:8093/
falcosidekick ui 200

/api/visits returning a count proves the whole path: the NodePort, Cilium’s Service translation to web and then to an api pod, DNS for redis, and Redis on its PV. The count is high because the counter has survived every rebuild of the lab since it was first built.

Then open http://127.0.0.1:8091 and click Count a visit a few times; served_by changes between the two API pods. In http://127.0.0.1:8092, pick namespace demo and you’ll see the same calls as a service map.

Finally, the eBPF agents’ own checks:

1
2
3
4
make hubble-flows      # the last 20 flows in namespace demo
make obi-metrics       # the RED counters OBI has right now
make tetragon-events   # process events from demo in the last 2 minutes
make falco-events      # Falco alerts in the last 5 minutes

Common Failures

Most of these were hit while building or capturing this lab, and their fixes are already in the repo. The first two, the 401 and the last row are ones the setup invites but these runs didn’t meet.

SymptomCauseFix
k3d cluster create fails: port already allocatedSomething else uses 6450, 8091, 8092 or 8093Free the port, or change k3d/cluster.yaml and the card’s deploy.ports, then make rebuild
Nodes NotReady for minutesCilium not installed or not healthy yetkubectl -n kube-system get pods -l k8s-app=cilium; make cilium
After a k3d cluster start, Cilium crash-loopsDocker dropped the bpffs mounts when the node container restartedmake node-prep, then restart the Cilium pods
Pods reach the API server on its node IP but not on 10.101.0.1Socket-level load balancing at a cgroup root that pod sockets aren’t under (nested containers)socketLB.hostNamespaceOnly: true (already set)
Tetragon or OBI time out talking to the APIHost-network pods can’t use the API’s ClusterIP hereThe Makefile passes KUBERNETES_SERVICE_HOST=<server node IP>; reinstall with make tetragon / make obi
OBI runs but produces nothing for demoSame cause: without the API it has no pod metadata, so the namespace rule matches nothingAs above
Falco can’t download its rules; DNS lookups from pods time outThe node’s resolver is Docker’s, which pods can’t usemake dns
No OBI data in Grafana, OBI logs a 401obi/otlp-token missing or stalemake monitoring, then kubectl -n obi rollout restart ds/obi
Grafana has OBI metrics but panels show “No data”Short rate() windows: OBI only pushes every 60 sThe dashboard uses [3m]; for your own queries, use 2m or more
A Tetragon policy loads but never firesMatching on the resolved binary: busybox’s /usr/bin/wget is a symlink to /bin/busyboxThe lab matches execve’s first argument, the path as passed (see Part 6)
make down says the cluster is gone, but make cluster thinks it existsNodes full of eBPF programs take longer to stop than Docker’s kill timeout; k3d leaves containers behindmake down now removes leftovers itself and fails if any remain
Redis Pending after a rebuildThe PV wasn’t recreated before the app, or the node name changedmake storage, and keep the cluster name lab-ebpf

Check Your Understanding

Why must cilium run before dns and app?

Until Cilium is installed the nodes have no CNI: CoreDNS and every app pod would stay Pending or ContainerCreating because no pod can get a network. make cilium also waits for CoreDNS to be ready before the DNS settings are changed.

The cluster has no kube-proxy. What does iptables-save | grep -c KUBE-SVC returning 0 prove, and what doesn't it prove?

It proves no kube-proxy-style Service rules exist on the node. It doesn’t prove Services work; the working curl to /api/visits does, and cilium-dbg service list shows where the translation lives.

served_by keeps switching between two pod names. Who decides which pod answers?

Cilium’s eBPF program on the web pod’s network interface, when nginx connects to the api Service’s ClusterIP. It picks one of the Service’s two backends per connection, and the API opens a new connection per request, so consecutive requests can land on different pods.

This post is licensed under CC BY 4.0 by the author.