Beeward is an eBPF agent for Kubernetes nodes that watches over NFS/EFS I/O and wards off abusive traffic. It has two independent modules:
- NFS/EFS I/O monitoring — per-pod NFS throughput, IOPS and latency, traced in the kernel and exported as Prometheus metrics.
- XDP rate limiter — per-IP SYN rate limiting, concurrent connection limits and automatic blacklisting, enforced at the NIC before packets reach the network stack. Useful against SYN floods and connection-exhaustion attacks such as Slowloris.
Both modules run in a single DaemonSet and share one Prometheus endpoint. A failure in one module does not stop the other.
A shared NFS volume (Amazon EFS, Filestore, Azure Files, self-hosted NFS) is a common way to give many pods the same storage, especially across clouds. When it slows down or runs out of throughput, existing metrics can't say which pod is responsible: cloud providers report per file system, node_exporter reports per mount (and mounts of the same export share one client cache), and cAdvisor only covers block devices.
Beeward traces NFS reads and writes in the kernel and attributes them to the pod that issued them, so a noisy neighbor on a shared volume shows up directly in Prometheus.
# Deploy the prebuilt image (ghcr.io/ca110us/beeward, linux/amd64)
kubectl apply -f https://raw.githubusercontent.com/ca110us/beeward/main/deploy/daemonset.yaml
# Check metrics
kubectl -n beeward port-forward daemonset/beeward 9100:9100
curl -s http://localhost:9100/metrics | grep -E '^(nfs|xdp)_'The XDP rate limiter is off by default. To enable it or change settings, download deploy/daemonset.yaml, edit the env vars (see Configuration) and apply it.
To run on a subset of nodes, label them and uncomment the nodeSelector in deploy/daemonset.yaml:
kubectl label node <node-name> beeward=enabledAll configuration is via environment variables.
| Variable | Default | Description |
|---|---|---|
NODE_NAME |
unknown |
Node name used as the node label (set from spec.nodeName) |
METRICS_PORT |
9100 |
Prometheus metrics port |
MONITOR_INTERVAL |
60s |
How often NFS stats are collected |
| Variable | Default | Description |
|---|---|---|
XDP_ENABLED |
false |
Set to true to enable the module |
XDP_TARGET_PORTS |
443,80 |
Destination ports to protect |
XDP_SYN_RATE_LIMIT |
100 |
Max SYN packets per second per source IP (token bucket rate and capacity) |
XDP_CONN_LIMIT |
200 |
Max concurrent TCP connections per source IP |
XDP_BLACKLIST_DURATION |
600 |
Seconds a source IP stays blocked after exceeding a limit |
XDP_CONN_IDLE_TIMEOUT |
300 |
Seconds without packets from the client before a connection stops counting toward the limit |
XDP_WHITELIST_CIDRS |
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 |
Source CIDRs that bypass all checks |
XDP_PIN_PATH |
/sys/fs/bpf/xdp_ratelimit |
Where BPF maps are pinned for inspection with bpftool |
Note: With
hostNetworkthe program attaches to the host's main interface, so traffic is seen after the cloud load balancer. If your ingress is exposed through a NodePort Service, setXDP_TARGET_PORTSto the NodePorts, not the load balancer's external ports. Make sure the whitelist covers your pod, service and node CIDRs so in-cluster traffic is never limited.
- Kernel hooks — kprobes/kretprobes on
nfs_file_read,nfs_file_splice_read(sendfile/splice, newer kernels) andnfs_file_writerecord bytes, operation counts and latency per cgroup (container). Latency is paired per thread, so concurrent calls don't interfere. Probes attach as soon as thenfsmodule is loaded, even if that happens after Beeward starts. - Pod resolution — each new cgroup is mapped to a pod UID once, by reading
/proc/<pid>/cgroupfor a PID recently seen in it, falling back to a cgroup inode search under/sys/fs/cgroupif that process has already exited. - Aggregation — per-cgroup growth is added to per-pod totals, so metrics only ever increase: short-lived processes exiting or containers restarting never make a counter go backwards, and a reused PID can't attribute I/O to the wrong pod.
- Cleanup — when a pod's cgroup disappears its map entries and metrics are removed. Cgroups idle for an hour are evicted from the BPF map (their totals are kept), which bounds map usage.
No Kubernetes API access is required.
All labeled with pod_uid and node.
| Metric | Description |
|---|---|
nfs_read_bytes_total / nfs_write_bytes_total |
Cumulative bytes read / written |
nfs_read_ops_total / nfs_write_ops_total |
Cumulative read / write operations |
nfs_read_io_time_seconds_total / nfs_write_io_time_seconds_total |
Cumulative time spent in I/O |
nfs_read_latency_max_seconds / nfs_write_latency_max_seconds |
Max latency observed |
Example queries:
# Read throughput (bytes/sec)
rate(nfs_read_bytes_total{node="$node"}[5m])
# Read IOPS
rate(nfs_read_ops_total{node="$node"}[5m])
# Read busy percentage
rate(nfs_read_io_time_seconds_total{node="$node"}[5m]) * 100
# Average read latency (seconds)
rate(nfs_read_io_time_seconds_total{node="$node"}[5m]) / rate(nfs_read_ops_total{node="$node"}[5m])
For every IPv4 TCP packet to a protected port:
- Whitelist — source IPs matching
XDP_WHITELIST_CIDRS(LPM trie) pass immediately. - Blacklist — blacklisted source IPs are dropped until their entry expires.
- SYN rate — each SYN consumes a token from a per-IP token bucket refilled at
XDP_SYN_RATE_LIMITper second. An empty bucket blacklists the IP. - Connection limit — each connection is tracked by its 4-tuple, so it is counted exactly once: retransmitted SYNs don't count again, and the first FIN/RST from either side (client FIN/RST in XDP, server FIN/RST in a TC egress program) counts it down. Connections that end without a visible FIN/RST, such as a vanished client, stop counting after
XDP_CONN_IDLE_TIMEOUT. A new connection beyondXDP_CONN_LIMITblacklists the IP.
The program tries native XDP first and falls back to generic (SKB) mode if the driver does not support it. The main interface is detected from the default route in /proc/net/route. Non-TCP, non-IPv4 and unprotected-port traffic is always passed.
| Metric | Labels | Description |
|---|---|---|
xdp_ratelimit_packets |
action (pass, drop_syn_rate, drop_conn_limit, drop_blacklist) |
Cumulative packets by verdict |
xdp_ratelimit_blacklist_size |
IPs currently blacklisted | |
xdp_ratelimit_blacklist_adds |
Cumulative blacklist additions | |
xdp_ratelimit_whitelist_hits |
Cumulative whitelist hits | |
xdp_ratelimit_whitelist_size |
Number of whitelist CIDRs | |
xdp_ratelimit_map_entries |
map |
Current entries per BPF map |
xdp_ratelimit_config |
param |
Active configuration values |
# On the node (or in the pod)
bpftool map dump pinned /sys/fs/bpf/xdp_ratelimit/blacklist_map- Linux 5.10 or newer, cgroup v2. CI tests 5.10, 5.15, 6.1, 6.6, 6.12, 6.18 and 7.0
- Server-initiated closes are seen immediately on Linux 6.6+ (TC egress via TCX); on older kernels a server close is counted when the client answers with its own FIN, or after
XDP_CONN_IDLE_TIMEOUTif it never does - NFS client functions present in the kernel (
grep nfs_file /proc/kallsyms) for the NFS module - Pod privileges:
privileged: true,hostPID: true,hostNetwork: true - Host mounts:
/sys/kernel/debug(kprobes) and/sys/fs/bpf(map pinning)
make docker-build # build image
make docker-push # build and push
make IMAGE_NAME=my/repo IMAGE_TAG=v1 docker-push
make deploy # apply deploy/daemonset.yaml (update its image first)Compilation happens inside the container, so this also works on macOS. The Dockerfile installs clang/llvm/libbpf, runs go generate (bpf2go) and builds a static binary on a busybox base image.
No NFS metrics
- Check NFS mounts exist on the node:
mount | grep nfs - Generate some I/O:
kubectl exec -it <pod> -- dd if=/dev/zero of=/mnt/nfs/test bs=1M count=10 - Check logs:
kubectl -n beeward logs -l app=beeward --tail=100
eBPF programs fail to attach
- Check the kernel version:
uname -r - Check the NFS symbols exist:
grep nfs_file /proc/kallsyms
High memory usage
The NFS stats map holds up to 10240 cgroups and the latency maps up to 65536 in-flight calls. Adjust MAX_CGROUPS / MAX_INFLIGHT in c/nfs_trace.bpf.c and rebuild.
GPL-2.0 (required for eBPF programs using GPL-only helpers).