k6 extension for BGP benchmarking
xk6-bgp drives real BGP sessions against a target BGP daemon (FRR, GoBGP, RustyBGP, …). Establish sessions, advertise and withdraw prefixes, and measure how fast the daemon delivers UPDATEs end-to-end — all from a k6 script.
A minimal UPDATE delivery scenario: one Peer advertises prefixes,
another Peer (which the DUT reflects to) waits for them and emits
bgp_prefix_received_duration.
import bgp from 'k6/x/bgp';
export const options = { vus: 1, iterations: 1 };
export default function () {
const sender = new bgp.Peer({
localAs: 65001,
peerAs: 65000,
routerId: '10.0.0.1',
target: __ENV.TARGET,
families: ['ipv4-unicast'],
tags: { peer: 'sender' },
});
const receiver = new bgp.Peer({
localAs: 65002,
peerAs: 65000,
routerId: '10.0.0.2',
target: __ENV.TARGET,
families: ['ipv4-unicast'],
tags: { peer: 'receiver' },
});
receiver.open();
sender.open();
const routes = [];
for (let i = 0; i < 1000; i++) routes.push(`10.99.${i >> 8}.${i & 0xff}/32`);
const adv = sender.advertise({
family: 'ipv4-unicast',
nextHop: '10.0.0.1',
localAs: 65001,
routes,
});
try {
const res = receiver.waitForPrefixes({
prefixes: routes,
timeout: '10s',
sentAtMonoNs: adv.sentAtMonoNs,
});
console.log(`received: matched=${res.matched}`);
} catch (e) {
console.error(`waitForPrefixes: ${e}`);
}
receiver.close();
sender.close();
}./k6 run -e TARGET=10.0.0.99:179 examples/ipv4_unicast.jsThe examples directory contains scripts demonstrating various scenarios:
smoke.js— minimal one-peer advertise/withdraw smoke testipv4_unicast.js— IPv4-unicast UPDATE delivery between two Peersipv6_unicast.js— IPv6-unicast variantipv4_addpath.js— RFC 7911 ADD-PATH delivery of multiple paths per prefixmup.js—ipv4-mupadvertise of all four MUP route typessrv6_l3vpn.js—l3vpn-ipv4advertise with RT + SRv6 L3 Service TLV (End.DT4 SID)route_refresh.js— RFC 2918 ROUTE-REFRESH with RFC 7313 EoRR-demarcated replay measurementthroughput.js— single-peer advertise throughput sweep overCOUNTprefixesmulti_peer.js— many-peer benchmarksession_up.js—OPEN → Establishedscaling under many concurrent peers
For local smoke without a real BGP daemon, see
cmd/fakebgpd — a minimal reflector bundled for
end-to-end tests.
Each Peer represents one BGP session over TCP/179. A typical flow:
peer.open()— TCP-connect, send OPEN, exchange capabilities, reachEstablished. Returns{ sessionUpUs }with theOpenSent → Establishedduration in microseconds, the same value pushed to thebgp_session_upmetric.peer.advertise({...})/peer.withdraw({...})— send MP_REACH / MP_UNREACH UPDATEs (auto-chunked to fitBGP_MAX_MESSAGE_LENGTH, or the RFC 8654 ceiling when both sides negotiated Extended Messages).peer.waitForPrefixes({...})— block until the expected prefixes arrive on this Peer or the timeout fires (throws on timeout).peer.close()— send Cease NOTIFICATION and tear the session down.
A Peer is single-use. Calling open() again after close() throws
"Peer is single-use; construct a new bgp.Peer to reconnect".
Construct a new bgp.Peer instance per iteration when you need a
fresh session.
Negotiated by default in OPEN:
- MP-BGP for the declared
families - Extended Messages (RFC 8654)
- Route Refresh (RFC 2918)
- Graceful Restart with N-bit (RFC 4724 + RFC 8538)
- 4-octet AS (RFC 6793)
Opt-in via capabilities:
- Enhanced Route Refresh (RFC 7313) —
enhancedRouteRefresh: true. When both sides advertise it, inbound BoRR/EoRR demarcations are recognized andpeer.waitForRouteRefreshEndcan measure a refresh replay end-to-end (seedocs/route_refresh.md). - ADD-PATH (RFC 7911) —
addPath: { '<family>': 'receive' | 'send' | 'both' }per family. Each direction takes effect only when the peer advertised the matching opposite direction. With receive negotiated, observed-set keys become"<prefix>:<pathId>"for that family (seewaitForPrefixes); with send negotiated, routes may carry apathId(routes without one go out as Path Identifier0), and apathIdon a session that did not negotiate send is an error.
When a peer does not advertise the 4-octet AS capability, xk6-bgp
follows RFC 6793 § 4.2.2:
AS_PATH is sent with 2-octet AS numbers (a non-mappable local AS
becomes AS_TRANS with the real value in AS4_PATH), and received
2-octet AS_PATHs are decoded accordingly.
The JS API is synchronous: each Peer method blocks the calling VU until the underlying I/O completes. BGP benchmark scripts run sequentially per VU (open → advertise → wait → close), so this matches the natural shape of every example here. k6 runs each VU on its own goroutine, so blocking in one VU does not block others.
| Field | Type | Default | Description |
|---|---|---|---|
localAs |
number | — | Local AS number (required) |
peerAs |
number | — | Remote AS number (required; 0 accepts any AS) |
routerId |
string | — | Router-ID in dotted-quad form (required) |
target |
string | — | host:port of the BGP speaker (required) |
families |
string[] | — | AFI/SAFI list, e.g. ['ipv4-unicast', 'ipv6-unicast'] (required) |
localAddress |
string | unset | Source IP for the outbound TCP connection; used by throughput.js / multi_peer.js to drive many sessions from distinct loopback aliases |
timers |
object | defaults | { holdtime, openTimeout } as k6 duration strings. The keepalive interval is not separately configurable — it is derived from the negotiated HoldTime as HoldTime / 3 per RFC 4271 § 10 |
capabilities |
object | see note | Per-capability overrides: { extendedMessage, routeRefresh, enhancedRouteRefresh, gracefulRestart, addPath }. extendedMessage, routeRefresh, and gracefulRestart default on; enhancedRouteRefresh and addPath default off. addPath maps family strings to 'receive' / 'send' / 'both' (see Capabilities) |
tags |
object | unset | Key-value pairs added to every metric this Peer emits. tags.peer becomes the peer label |
| Method | Returns | Description |
|---|---|---|
peer.open() |
{ sessionUpUs } |
TCP-connect and run the OPEN/KEEPALIVE handshake; resolves on Established |
peer.advertise(opts) |
{ count, sentAtWallNs, sentAtMonoNs } |
Send MP_REACH UPDATEs |
peer.withdraw(opts) |
{ count, sentAtWallNs, sentAtMonoNs } |
Send MP_UNREACH UPDATEs |
peer.waitForPrefixes(opts) |
{ matched, missing, firstSeenWallNs, firstSeenMonoNs, lastSeenWallNs, lastSeenMonoNs } |
Block until all opts.prefixes are observed; throws on timeout |
peer.routeRefresh(opts) |
{ sentAtWallNs, sentAtMonoNs } |
Send a ROUTE-REFRESH request (RFC 2918) for opts.family; errors unless the peer advertised the Route Refresh capability — see docs/route_refresh.md |
peer.waitForRouteRefreshEnd(opts) |
{ eorrWallNs, eorrMonoNs } |
Block until the peer's EoRR demarcation (RFC 7313) for opts.family; requires enhancedRouteRefresh negotiated. opts.sentAtMonoNs (typically routeRefresh.sentAtMonoNs) anchors bgp_route_refresh_duration and filters EoRRs from earlier refresh cycles; also takes opts.timeout |
peer.stats() |
{ updates, advertised, withdrawn, uniquePrefixes, firstUpdateWallNs, firstUpdateMonoNs, lastUpdateWallNs, lastUpdateMonoNs, routeRefreshReceived, borrReceived, eorrReceived } |
Snapshot of cumulative receive-side counters; cheap, does not block |
peer.close() |
— | Send Cease NOTIFICATION and close the session |
| Property | Type | Description |
|---|---|---|
peer.state |
string | Current FSM state: Idle, Active, OpenSent, OpenConfirm, or Established |
| Field | Type | Description |
|---|---|---|
family |
string | AFI/SAFI declared in peer.families (required) |
nextHop |
string | IPv4 or IPv6 next-hop (required for advertise) |
localAs |
number | AS_PATH origin AS (required for advertise) |
routes |
string[] | object[] | Prefix strings (['10.0.0.0/24']) or family-specific descriptor objects — see Supported AFI/SAFI (required). Any object form additionally accepts pathId (RFC 7911 Path Identifier; requires ADD-PATH send negotiated for the family) |
origin |
number | ORIGIN attribute: 0 IGP, 1 EGP, 2 INCOMPLETE (advertise only, default 0) |
med |
number | MULTI_EXIT_DISC (advertise only) |
localPref |
number | LOCAL_PREF for iBGP (advertise only) |
extCommunities |
string[] | EXTENDED_COMMUNITIES entries (RFC 4360). Each string may carry an optional type prefix (rt: / soo: / encap: / routermac:); a bare value defaults to Route-Target. encap:vxlan etc. emit the Encapsulation EC (RFC 9012); routermac:<MAC> emits the EVPN Router's MAC EC (RFC 9135 § 9) |
srv6L3Service |
object | SRv6 L3 Service TLV (RFC 9252); see docs/srv6_l3vpn.md |
pmsiTunnel |
object | PMSI Tunnel attribute (RFC 6514). Shape: { tunnel: 'ingress-repl' | <num>, label, endpoint, isLeafInfoRequired? }. For EVPN Type 3 with VXLAN, set tunnel: 'ingress-repl', label: <VNI>, endpoint: <egress PE IP> per RFC 8365 § 5.1.3 |
useMpReach |
boolean | Force IPv4-unicast through MP_REACH_NLRI instead of the UPDATE NLRI field |
useExtendedMessages |
boolean | Chunk UPDATEs up to the RFC 8654 65535-byte limit. The peer must have advertised capability 6 — advertise/withdraw returns an error otherwise (see Capabilities) |
updateRate |
number | Cap the per-Peer UPDATE send rate at this many messages per second (0 = unlimited) |
| Field | Type | Description |
|---|---|---|
prefixes |
(string | object)[] | Expected route set: prefix strings or family-specific descriptor objects (same shape as advertise.routes — see Supported AFI/SAFI) (required). With ADD-PATH receive negotiated for the family, keys carry the Path Identifier: append :<pathId> to a prefix string ('10.0.0.0/24:1') or set pathId on a descriptor object |
timeout |
string | number | k6 duration string or seconds; throws if not met before this |
sentAtMonoNs |
number | Filter observations that predate this mono-ns timestamp, and anchor the bgp_prefix_received_duration sample (typically advertise.sentAtMonoNs) |
bgp.barrier(name, count) is a process-wide barrier shared across
VUs. Call .arrive() before timing-sensitive sections (e.g. wait for
all VUs to reach Established before any of them advertises) so that
the benchmark measures the steady-state throughput rather than ramp-up
artifacts. Barriers are single-use — pick a fresh name per
rendezvous if a script needs to barrier multiple times.
.arrive(timeout) takes an optional timeout (k6 duration string or
seconds number) and throws when it elapses. Always pass one in scripts
where a VU can fail before its arrive() (a failed open(),
typically) — without a timeout the remaining VUs block until the
scenario's maxDuration. A timed-out arrival still counts toward
count, so one aborted VU does not wedge the rest a second time.
| Family string | SAFI | Route descriptor | Reference | Doc | Example |
|---|---|---|---|---|---|
ipv4-unicast |
1 | prefix string or { prefix } |
RFC 4271 | — | examples/ipv4_unicast.js |
ipv6-unicast |
1 | prefix string or { prefix } |
RFC 4760 | — | examples/ipv6_unicast.js |
ipv4-mup / ipv6-mup |
85 | { type, rd, ... } |
draft-mpmz-bess-mup-safi | docs/mup.md |
examples/mup.js |
l3vpn-ipv4 / l3vpn-ipv6 |
128 | { rd, prefix } (+ srv6L3Service on advertise) |
RFC 4364, RFC 9252 | docs/srv6_l3vpn.md |
examples/srv6_l3vpn.js |
l2vpn-evpn |
70 | { type: 'mac-ip' | 'imet' | 'ip-prefix', rd, ... } |
RFC 7432, RFC 9136 | docs/evpn.md |
examples/evpn.js |
| Name | Type | Unit | Description |
|---|---|---|---|
bgp_session_up |
Trend | µs | OpenSent → Established per Peer |
bgp_prefix_received_duration |
Trend | µs | sentAtMonoNs → receive timestamp of the last expected prefix |
bgp_prefix_sent |
Counter | routes | Cumulative NLRIs sent |
bgp_prefix_received |
Counter | routes | Cumulative NLRIs received |
bgp_route_refresh_duration |
Trend | µs | ROUTE-REFRESH write → EoRR read (RFC 7313); see docs/route_refresh.md |
The Trend metrics carry microsecond samples. They don't end in
_us (k6 convention is to document the unit in the metric table
rather than embed it in the name). BGP delivery latencies are
typically sub-millisecond, so storing them as ms would round many
samples to 0.
Default tags: plane=control, peer=<tags.peer from JS, if set>. You
can attach additional tags via the tags option on the Peer
constructor.
Under k6's Prometheus remote-write output, names are prefixed with
k6_ and Counters get a _total suffix appended, so the
metrics above show up as k6_bgp_session_up_*,
k6_bgp_prefix_received_duration_*, k6_bgp_route_refresh_duration_*,
k6_bgp_prefix_sent_total, k6_bgp_prefix_received_total in
Prometheus.
The xk6 build tool builds a k6 binary that includes the xk6-bgp extension:
go install go.k6.io/xk6/cmd/xk6@latest
xk6 build --with github.com/higebu/xk6-bgp@v0.1.0The minimum Go toolchain is the one k6 itself requires (currently
Go 1.25, dictated by go.k6.io/k6 v1.7). xk6 will tell you if
your local toolchain is too old.
To track the development branch instead of a release, replace
@v0.1.0 with @latest (master HEAD) or a specific commit hash.
master may be broken at any time. The version reported by
bgp.version is read from the module's embedded build info; override
it at build time via GOFLAGS:
GOFLAGS='-ldflags=-X github.com/higebu/xk6-bgp.Version=v0.1.0-local' \
xk6 build --with github.com/higebu/xk6-bgp@latestIssues and pull requests are welcome. Commit messages follow
Conventional Commits — see
CLAUDE.md. The commitlint GitHub Action enforces
the format on PR commits. Run the local lint with:
golangci-lint run ./...Licensed under the Apache License, Version 2.0.