SD-Core with eUPF: XDP Datapath and In-Kernel Usage Metering
This guide covers the integration of the SD-Core control plane with eUPF, an eBPF/XDP user plane that classifies and forwards subscriber traffic inside the Linux kernel. It adds URR usage metering and quota enforcement end to end, so a subscriber's data allowance is measured and capped in the kernel fast path, configured from the SMF and enforced per subscriber.
Overview
A 5G Standalone core follows CUPS, Control and User Plane Separation. The SMF (Session Management Function) makes the decisions; the UPF (User Plane Function) forwards every subscriber packet between the radio (N3, GTP-U) and the internet (N6). The two communicate over the N4 interface using PFCP, the Packet Forwarding Control Protocol defined in 3GPP TS 29.244.
SD-Core ships a UPF that runs in userspace (BESS) and never actually metered subscriber data. This integration replaces that user plane with eUPF, an eBPF/XDP datapath that classifies and forwards traffic inside the kernel, before the network stack, and closes the metering gap by implementing URR (Usage Reporting Rule) accounting and quota enforcement on both sides of N4.
The practical outcome is that a subscriber's data allowance is enforced where the packets actually are. When the quota is exhausted, traffic is dropped in the kernel fast path rather than being counted after the fact or not at all.
Architecture
The deployment divides into three parts: an unchanged SD-Core control plane, the eUPF pod that replaces the BESS UPF, and the interfaces that join them.
SD-Core control plane
The control plane runs unmodified except for the SMF. NRF, UDM and AUSF provide discovery, subscriber data and authentication. The AMF terminates N2, handling NGAP registration and mobility. The SMF owns the N4 relationship and installs the forwarding rules that define each PDU session: PDR (Packet Detection Rule), FAR (Forwarding Action Rule), QER (QoS Enforcement Rule) and, with this work, URR.
Between the SMF and eUPF sits the upf-adapter, which bridges SD-Core's PFCP dialect to the one eUPF expects. The SMF must therefore run with enableUPFAdapter: true.
The eUPF pod
The pod has two halves that share state through eBPF maps:
- A Go control plane running as an ordinary userspace process. It terminates PFCP on
:8805, exposes a REST API on:8080, handles session establishment, modification and deletion, and writes the resulting rules into eBPF maps through thebpf()syscall. - An XDP data plane attached in native mode, which reads those maps on every packet.
The maps themselves (PDR, FAR, QER, URR) are the entire interface between the two halves: userspace writes, XDP reads. There is no per-packet communication with userspace, which is what keeps the datapath fast.
The pod runs with hostNetwork: true and privileged: true, which is required for attaching XDP to a host interface but has consequences covered under integration findings.
The per-packet pipeline
Each packet traverses a five-stage chain, tail-call chained through program_array.h:
1. Parse ETH / IP / UDP / GTP-U / PDU extension
2. PDR lookup uplink TEID, downlink UE IP, or SDF filter
3. QER gate, token bucket, rate limit
4. URR meter volume and packets, check quota
5. FAR fib_lookup, then XDP_TX / redirect / pass / drop
If the URR quota is exhausted, the packet is dropped in the kernel with XDP_DROP at stage 4, before any forwarding work is done.
Because this runs at the XDP hook, the packet is handled at the NIC ring with no sk_buff allocation, no netfilter traversal and no conntrack. Egress is XDP_TX on the same ifindex, so an uplink packet never enters the kernel network stack at all.
Interface map
| Interface | Protocol | Reference deployment |
|---|---|---|
| N4 | PFCP | UDP 8805, 60 s heartbeat |
| N2 | NGAP | veth 10.200.0.1 to 10.200.0.2 |
| N3 | GTP-U | UDP 2152, TEID re-allocation |
| N6 | SGi | decapsulate to n6peer, then eno1 to the network |
URR: usage metering and quota enforcement
Stock SD-Core installed no URR, so the UPF never metered anything. Closing that loop required changes on both sides of N4.
Control plane
The SMF now installs a URR on the subscriber's downlink flow at PDU session establishment, specifying the measurement method, reporting triggers, volume threshold and volume quota. The cap is configurable from smfcfg, so changing it does not require a rebuild.
Datapath
eUPF meters volume and packet counts per URR in XDP. It raises a PFCP Session Report when the threshold is crossed, drops traffic in-kernel once the quota is reached, and emits an enriched final Usage Report when the session is deleted.
Per-subscriber accounting
The SMF consumes those Usage Reports and maintains a running total per subscriber, keyed by SUPI (the SIM identity) rather than by session or IP address. On the next attach it installs quota = cap - used.
This is the detail that makes the cap meaningful: a subscriber who detaches and re-attaches, even with a new IP address, does not get a fresh allowance.
PFCP Association Release
The PFCP Association is the long-lived N4 relationship between the SMF and the UPF, distinct from the per-subscriber sessions that ride on top of it. This work adds Association Release (TS 29.244 section 6.2.8), so the relationship can be torn down cleanly: the UPF drops the peer's state and stops accepting its sessions, instead of the association lingering until a timeout expires.
It is a small addition, but it is the difference between graceful reconfiguration and waiting for heartbeats to fail, and it matters for failover.
Also implemented
- GTP-U path monitoring, for N3 reachability.
- PFD management with datapath enforcement.
- Downlink buffering, including the Downlink Data Report.
- A live dashboard that reads eUPF's REST API and shows per-subscriber usage as it moves through
OK,THRESHOLDandCAPPED.
Deployment
Prerequisites: a single-node Kubernetes (RKE2) cluster and the aether-onramp repository.
| Component | Version (reference deployment) |
|---|---|
| Kubernetes | RKE2 v1.35.3 |
| SD-Core (via aether-onramp) | NFs rel-3.0.0, AMF rel-3.1.0 |
| upf-adapter | rel-2.1.3 |
| eUPF | Helm chart 0.5.0 |
1. Deploy the SD-Core control plane
Bring up SD-Core with aether-onramp using the sd-core Helm chart, in namespace aether-5gc.
2. Use eUPF as the UPF
Skip the default BESS UPF and deploy the eUPF Helm chart instead. Ensure the SMF runs with enableUPFAdapter: true so that PFCP is bridged by the upf-adapter.
3. Build and deploy the modified SMF
docker build -t sdcore-smf:release .
docker save sdcore-smf:release | sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import -
kubectl -n aether-5gc set image deploy/smf smf=docker.io/library/sdcore-smf:release
4. Enable URR in smfcfg
configuration:
urr:
enable: true
volumeThresholdBytes: 943718400 # 900 MiB (early-warning report)
volumeQuotaBytes: 1073741824 # 1 GiB (hard cap)
Apply the change, then re-establish the PFCP association by restarting the upf-adapter first, then the smf.
5. Bring up the RAN
Start UERANSIM nr-gnb, then nr-ue, and attach a UE.
Verification
Inspect the installed URR and the live counters on eUPF:
curl http://<eUPF>:8080/api/v1/pfcp_sessions
curl http://<eUPF>:8080/api/v1/urr_map
Drive downlink traffic through the UE. Usage climbs, crosses the threshold (which raises a report), and reaches CAPPED, at which point traffic is dropped in-kernel.
Then deregister and re-attach. The new session installs the remaining quota, cap - used, confirming that the cap survives re-attachment and is not reset.
Integration findings
Three behaviours are worth knowing before reproducing this deployment.
hostNetwork breaks Service DNS
Running the pod with hostNetwork: true means status.podIP becomes the node IP, and selector-based Service resolution fails. The deployment therefore uses a selector-less Service with pinned endpoints for :8805 and :2152.
FIB lookup loops back via the physical function
bpf_fib_lookup() reads the route table of the root network namespace, which is where the XDP program is attached. Routing the UE pool therefore requires an explicit source-scoped rule: the UE pool /24 is directed to table 100 via .253.20.
No netfilter on the eUPF side
XDP_TX happens ahead of the network stack on the same ifindex, so netfilter never sees these packets. NAT and MSS clamping to 1400 consequently live in the n6peer namespace rather than on the eUPF side.
Packet journey
For one uplink packet travelling from the UE to the internet, nothing touches the wire until N6:
gNB (UERANSIM / OCUDU, N2 10.200.0.2)
-> VF0 .253.10 SR-IOV VF, N3 GTP-U 2152
-> VEB hardware L2 switch, intra-NIC hairpin
-> PF enp24s0f1 .253.1, XDP attached; fib_lookup reads THIS netns
-> XDP pipeline parse -> PDR -> QER -> URR -> FAR
-> XDP_TX verdict same ifindex, decapsulated IP
-> VF1 .253.20 SR-IOV VF, into netns n6peer
-> n6peer -> net synthetic N6 router, MASQUERADE, MSS 1400, ip_forward -> eno1
The reference host is a single-node RKE2 server with an enp24s0f1 i40e NIC, SR-IOV with four virtual functions and 40 hardware queues, and three network namespaces: root holding the physical function at .253.1, the RAN namespace on VF0 at .253.10, and n6peer on VF1 at .253.20.
Source and references
- Integration (this write-up and the deployment guide): github.com/TOSSI-Foundation/TI
- SMF (URR and per-subscriber accounting): github.com/TOSSI-Foundation/sdcore-smf, branch
ebpf-urr - eUPF (XDP URR datapath and dashboard): github.com/TOSSI-Foundation/eUPF, branch
urr
This work builds on open source: eUPF by edgecomllc and SD-Core by the OMEC project and Open Networking Foundation (Aether SD-Core), deployed with aether-onramp. Our thanks to their maintainers and communities. Standards reference: 3GPP TS 29.244 (PFCP).