← Back to list

Building a BGP Monitoring Protocol (BMP) Lab: From Router to Dashboard

A hands-on guide to building a complete BMP pipeline with Huawei NE40e, GoBMP, Kafka, PostgreSQL, and Grafana seeing what your routing…

Lahcen Khouchane · 2026-03-01 14:19 · 0 claps · 11.9 min read
#bgp #bmp #kafka #grafana #docker
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🎬 · Film & Television

Building a BGP Monitoring Protocol (BMP) Lab: From Router to Dashboard

A hands-on guide to building a complete BMP pipeline with Huawei NE40e, GoBMP, Kafka, PostgreSQL, and Grafana seeing what your routing policy is hiding.

Why BMP? The Problem It Solves

If you’ve ever managed a BGP-heavy network, you know the pain. A customer calls: “I’m advertising 15.15.15.15/32 to you, but my traffic isn’t flowing.” Your first move? SSH into the router, run display bgp routing-table peer x.x.x.x accepted-routes, then not-accepted-routes, then check the policy, then check the next router… repeat across 60 POPs. That doesn’t scale.

BGP Monitoring Protocol (RFC 7854) solves this by having the router continuously stream its BGP state peer sessions, route announcements, and statistics — to an external collector over TCP. No polling. No CLI scraping. No SNMP traps that miss half the events.

The real power is pre-policy monitoring. BMP can show you every route a peer sends before your inbound filters act on it. This means you can see what got rejected and why, without logging into the router. For an ISP with hundreds of customers, this turns a 20-minute troubleshooting session into a dashboard lookup.

This post walks through building a complete BMP pipeline in a lab from router configuration to Grafana dashboards showing real filtered prefixes.

Lab Topology

Four Huawei NE40e routers running in eNSP. All in AS 65001, using IS-IS as the IGP. The central router (9.9.9.9) acts as a route reflector and runs BMP, streaming data to a GoBMP collector on the management network.

The key detail: R2 advertises 15.15.15.15/32, but an inbound route-policy on NE40E blocks it. With BMP in pre-policy mode, we can see that rejected prefix in the monitoring data even though the router’s RIB never accepts it.

The Data Pipeline

NE40E ──BMP/TCP──▶ GoBMP ──JSON──▶ Kafka ──consume──▶ Python ──SQL──▶ PostgreSQL ──query──▶ Grafana

Each component has a specific job:

GoBMP receives raw BMP messages from the router and parses them into JSON. It’s a lightweight Go binary that understands the BMP wire format and publishes structured messages to Kafka topics, one topic per message type.

Kafka acts as a message buffer between GoBMP and the database. This matters because when a BGP session resets, the router dumps its entire routing table over BMP in seconds potentially thousands of messages. Kafka absorbs that burst while the consumer drains at a pace PostgreSQL can handle. For this lab with a handful of routes, Kafka isn’t strictly necessary, but it teaches the architecture pattern used at production scale.

Python Consumer bridges Kafka and PostgreSQL. It reads JSON messages from Kafka topics, extracts the relevant fields, and writes SQL INSERTs to the appropriate tables. This is the translator layer where we handle GoBMP’s JSON quirks like inconsistent field names between message types.

PostgreSQL provides permanent, queryable storage. Unlike Kafka (which retains messages temporarily), PostgreSQL lets us run time-range queries, aggregations, and JOINs that power the Grafana dashboards.

Grafana visualizes the data peer status timelines, route counts, and most importantly, the pre-policy statistics showing received vs. accepted vs. rejected prefix counts.

Router Configurations

NE40E BMP Router and Route Reflector

This is the central router that runs BMP. It peers with all three routers, reflects routes between them, and streams BMP data to the GoBMP collector.

sysname NE40E

isis 1
 is-level level-2
 cost-style wide
 network-entity 49.0001.0000.0000.0009.00
 is-name NE40E

interface Ethernet1/0/0
 ip address 192.168.1.77 255.255.255.0
 isis enable 1

interface Ethernet1/0/1
 description TO-R1
 ip address 10.0.1.1 255.255.255.252
 isis enable 1

interface Ethernet1/0/2
 description TO-R2
 ip address 10.0.2.1 255.255.255.252
 isis enable 1

interface Ethernet1/0/3
 description TO-R3
 ip address 10.0.3.1 255.255.255.252
 isis enable 1

interface LoopBack0
 ip address 9.9.9.9 255.255.255.255
 isis enable 1

The route-policy that blocks 15.15.15.15/32 from R2:

ip ip-prefix BLOCK-15 index 10 deny 15.15.15.15 32
ip ip-prefix BLOCK-15 index 20 permit 0.0.0.0 0 less-equal 32

route-policy FILTER-PEER2 permit node 10
 if-match ip-prefix BLOCK-15

BGP configuration with route reflection and per-peer keep-all-routes:

bgp 65001
 router-id 9.9.9.9
 group RR internal
 peer 1.1.1.1 as-number 65001
 peer 1.1.1.1 group RR
 peer 1.1.1.1 connect-interface LoopBack0
 peer 2.2.2.2 as-number 65001
 peer 2.2.2.2 group RR
 peer 2.2.2.2 connect-interface LoopBack0
 peer 3.3.3.3 as-number 65001
 peer 3.3.3.3 group RR
 peer 3.3.3.3 connect-interface LoopBack0

 ipv4-family unicast
  undo synchronization
  peer RR enable
  peer RR reflect-client
  peer 1.1.1.1 enable
  peer 1.1.1.1 group RR
  peer 1.1.1.1 keep-all-routes
  peer 2.2.2.2 enable
  peer 2.2.2.2 group RR
  peer 2.2.2.2 route-policy FILTER-PEER2 import
  peer 2.2.2.2 keep-all-routes
  peer 3.3.3.3 enable
  peer 3.3.3.3 group RR
  peer 3.3.3.3 keep-all-routes

The keep-all-routes command is critical. Without it, the router discards rejected routes from memory immediately, so BMP would have nothing to report for pre-policy. With it, rejected routes are kept in a separate Adj-RIB-In table that BMP can read.

BMP Configuration

bmp
 statistics-timer 15
 route-mode pre-policy
 session 192.168.1.180 alias GOBMP-VM
  tcp connect port 11019
  monitor address-family all disable
  connect-interface Ethernet1/0/0
  monitor ipv4-family unicast peer 1.1.1.1
  monitor ipv4-family unicast peer 2.2.2.2
  monitor ipv4-family unicast peer 3.3.3.3

A few things to note:

route-mode pre-policy tells BMP to send routes as received, before inbound policy filtering. On this NE40e version, it's a global setting you can't mix pre-policy and post-policy per peer. You get one or the other.

monitor address-family all disable followed by specific monitor statements is a deny-all-then-permit approach. Without the disable, BMP would stream data for every peer in every address family. We only want IPv4 unicast from our three peers.

statistics-timer 15 sends per-peer stats every 15 seconds prefix counts, rejected counts, and other counters.

R1 Config

sysname R1

isis 1
 is-level level-2
 cost-style wide
 network-entity 49.0001.0000.0000.0001.00

interface Ethernet0/0/0
 ip address 10.0.1.2 255.255.255.252
 isis enable 1

interface LoopBack0
 ip address 1.1.1.1 255.255.255.255
 isis enable 1

interface LoopBack11
 ip address 11.11.11.11 255.255.255.255

interface LoopBack12
 ip address 12.12.12.12 255.255.255.255

bgp 65001
 router-id 1.1.1.1
 peer 9.9.9.9 as-number 65001
 peer 9.9.9.9 connect-interface LoopBack0
 ipv4-family unicast
  undo synchronization
  network 11.11.11.11 255.255.255.255
  network 12.12.12.12 255.255.255.255
  peer 9.9.9.9 enable

R2 Config

sysname R2

isis 1
 is-level level-2
 cost-style wide
 network-entity 49.0001.0000.0000.0002.00

interface Ethernet0/0/0
 ip address 10.0.2.2 255.255.255.252
 isis enable 1

interface LoopBack0
 ip address 2.2.2.2 255.255.255.255
 isis enable 1

interface LoopBack15
 ip address 15.15.15.15 255.255.255.255

interface LoopBack22
 ip address 22.22.22.22 255.255.255.255

bgp 65001
 router-id 2.2.2.2
 peer 9.9.9.9 as-number 65001
 peer 9.9.9.9 connect-interface LoopBack0
 ipv4-family unicast
  undo synchronization
  network 15.15.15.15 255.255.255.255
  network 22.22.22.22 255.255.255.255
  peer 9.9.9.9 enable

R2 advertises both 22.22.22.22/32 and 15.15.15.15/32. NE40E’s inbound policy accepts the first and rejects the second.

R3 Config

sysname R3

isis 1
 is-level level-2
 cost-style wide
 network-entity 49.0001.0000.0000.0003.00

interface Ethernet0/0/0
 ip address 10.0.3.2 255.255.255.252
 isis enable 1

interface LoopBack0
 ip address 3.3.3.3 255.255.255.255
 isis enable 1

interface LoopBack33
 ip address 33.33.33.33 255.255.255.255

interface LoopBack10
 ip address 192.168.1.1 255.255.255.255

interface LoopBack11
 ip address 192.168.2.1 255.255.255.255

bgp 65001
 router-id 3.3.3.3
 peer 9.9.9.9 as-number 65001
 peer 9.9.9.9 connect-interface LoopBack0
 ipv4-family unicast
  undo synchronization
  network 33.33.33.33 255.255.255.255
  network 192.168.1.1 255.255.255.255
  network 192.168.2.1 255.255.255.255
  peer 9.9.9.9 enable

Verifying the Setup

Before looking at BMP data, let’s confirm everything is working on the NE40E.

IS-IS adjacencies:

<NE40E>dis isis peer

  System Id     Interface          Circuit Id        State HoldTime Type     PRI
--------------------------------------------------------------------------------
R1              Eth1/0/1           R1.01              Up   9s       L2       64
R2              Eth1/0/2           R2.01              Up   8s       L2       64
R3              Eth1/0/3           R3.01              Up   8s       L2       64

BGP peers :

<NE40E>dis bgp peer

 BGP local router ID : 9.9.9.9
 Local AS number : 65001
 Total number of peers : 3     Peers in established state : 3

  Peer        V    AS   MsgRcvd  MsgSent  OutQ  Up/Down       State PrefRcv
  1.1.1.1     4  65001     82      117     0 01:18:47 Established      2
  2.2.2.2     4  65001     62      102     0 01:00:01 Established      2
  3.3.3.3     4  65001    214      315     0 03:28:04 Established      3

BGP routing table

<NE40E>dis bgp routing-table

 Total Number of Routes: 7
      Network            NextHop        MED    LocPrf  PrefVal Path/Ogn

 *>i  11.11.11.11/32     1.1.1.1         0      100       0    i
 *>i  12.12.12.12/32     1.1.1.1         0      100       0    i
   i  15.15.15.15/32     2.2.2.2         0      100       0    i
 *>i  22.22.22.22/32     2.2.2.2         0      100       0    i
   i  33.33.33.33/32     3.3.3.3         0      100       0    i
   i  192.168.1.1/32     3.3.3.3         0      100       0    i
   i  192.168.2.1/32     3.3.3.3         0      100       0    i

See 15.15.15.15/32? It has i but no *> — the route is known (because of keep-all-routes) but not best, not valid, not installed. The route-policy rejected it. Without keep-all-routes, this line wouldn't appear at all.

BMP session

<NE40E>dis bmp session 192.168.1.180 alias GOBMP-VM verbose

  BMP session 192.168.1.180, port 11019
    Current state: Up, Age: 01h45m22s
    Route Mode: pre-policy
    Statistics timeout: 15(s)
  Bmp monitor 3 Peer(s):
  BGP ipv4-family unicast :
    1.1.1.1
    2.2.2.2
    3.3.3.3

Docker Stack

The collector infrastructure runs on a Linux VM (192.168.1.180) reachable from the NE40E’s management interface.

docker-compose.yml

services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    container_name: zookeeper
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
    ports:
      - "2181:2181"

  kafka:
    image: confluentinc/cp-kafka:7.5.0
    container_name: kafka
    depends_on:
      - zookeeper
    ports:
      - "9092:9092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
    healthcheck:
      test: kafka-topics --bootstrap-server localhost:9092 --list
      interval: 10s
      timeout: 5s
      retries: 5

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: kafka-ui
    depends_on:
      - kafka
    ports:
      - "8080:8080"
    environment:
      KAFKA_CLUSTERS_0_NAME: bmp-lab
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
      KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181

  gobmp:
    image: sbezverk/gobmp:latest
    container_name: gobmp
    depends_on:
      kafka:
        condition: service_healthy
    ports:
      - "11019:11019"
      - "56767:56767"
    command: >
      --source-port=11019
      --kafka-server=kafka:29092
      --v=5
    restart: unless-stopped

  postgres:
    image: postgres:16
    container_name: postgres
    environment:
      POSTGRES_DB: bmpdata
      POSTGRES_USER: bmpuser
      POSTGRES_PASSWORD: bmppass123
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    depends_on:
      - postgres
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
    volumes:
      - grafana-storage:/var/lib/grafana

volumes:
  pgdata:
  grafana-storage:

Start everything:

docker compose up -d
docker compose ps
NAME        IMAGE                             COMMAND                  SERVICE     CREATED       STATUS                 PORTS
gobmp       sbezverk/gobmp:latest             "/gobmp --source-por…"   gobmp       3 hours ago   Up 3 hours             0.0.0.0:11019->11019/tcp, [::]:11019->11019/tcp, 0.0.0.0:56767->56767/tcp, [::]:56767->56767/tcp
grafana     grafana/grafana:latest            "/run.sh"                grafana     3 hours ago   Up 3 hours             0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp
kafka       confluentinc/cp-kafka:7.5.0       "/etc/confluent/dock…"   kafka       3 hours ago   Up 3 hours (healthy)   0.0.0.0:9092->9092/tcp, [::]:9092->9092/tcp
kafka-ui    provectuslabs/kafka-ui:latest     "/bin/sh -c 'java --…"   kafka-ui    3 hours ago   Up 3 hours             0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
postgres    postgres:16                       "docker-entrypoint.s…"   postgres    3 hours ago   Up 3 hours             0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp
zookeeper   confluentinc/cp-zookeeper:7.5.0   "/etc/confluent/dock…"   zookeeper   3 hours ago   Up 3 hours             2888/tcp, 0.0.0.0:2181->2181/tcp, [::]:2181->2181/tcp, 3888/tcp

Understanding GoBMP’s Output

GoBMP publishes to separate Kafka topics per message type. The three that matter for this lab:

| Kafka Topic                      | Content                                   |
| -------------------------------- | ----------------------------------------- |
| `gobmp.parsed.peer`              | Peer Up/Down events                       |
| `gobmp.parsed.unicast_prefix_v4` | IPv4 route announcements and withdrawals  |
| `gobmp.parsed.statistics`        | Per-peer stats reports (every 15 seconds) |

You can inspect the raw messages using Kafka UI at http://localhost:8080, or from the command line:

docker exec kafka kafka-console-consumer \
  --bootstrap-server localhost:9092 \
  --topic gobmp.parsed.statistics \
  --from-beginning --max-messages 3

Peer Up Messages

When a BGP session establishes, GoBMP publishes to gobmp.parsed.peer:

{
    "action": "add",
    "router_ip": "9.9.9.9",
    "remote_ip": "2.2.2.2",
    "remote_asn": 65001,
    "local_asn": 65001,
    "local_port": 179,
    "remote_port": 61650,
    "remote_holddown": 180,
    "is_prepolicy": false,
    "is_adj_rib_in_post_policy": false
}

Peer Down Messages

When a session drops, the same topic carries a "down" action with a reason code:

{
    "action": "down",
    "router_ip": "9.9.9.9",
    "remote_ip": "3.3.3.3",
    "bmp_reason": 3
}

The bmp_reason codes from RFC 7854: 1 = local closed, 2 = local sent NOTIFICATION, 3 = remote sent NOTIFICATION, 4 = remote closed without NOTIFICATION.

Route Announcements

Actual prefixes appear on gobmp.parsed.unicast_prefix_v4:

{
    "action": "add",
    "router_ip": "9.9.9.9",
    "peer_ip": "2.2.2.2",
    "peer_asn": 65001,
    "prefix": "15.15.15.15",
    "prefix_len": 32,
    "nexthop": "2.2.2.2",
    "is_adj_rib_in_post_policy": false,
    "base_attrs": {
        "origin": "igp",
        "local_pref": 100
    }
}

Notice that 15.15.15.15/32 appears here even though it’s blocked by the route-policy. This is pre-policy BMP in action the router reports what the peer sent before the filter runs.

Statistics Reports

Every 15 seconds, gobmp.parsed.statistics delivers per-peer counters:

{
    "router_ip": "9.9.9.9",
    "remote_ip": "2.2.2.2",
    "remote_asn": 65001,
    "ads_rib_in": 2,
    "local_rib": 1,
    "prefixes_rejected_inbound": 1
}

This is the proof: ads_rib_in: 2 (R2 sent 2 prefixes), local_rib: 1 (only 1 made it to the routing table), prefixes_rejected_inbound: 1 (1 was blocked by the inbound policy). The math checks out: 2 − 1 = 1.

Database Schema

The init-db.sql file creates three tables:

CREATE TABLE IF NOT EXISTS bmp_peers (
    id SERIAL PRIMARY KEY,
    router_ip VARCHAR(45) NOT NULL,
    peer_ip VARCHAR(45) NOT NULL,
    peer_asn BIGINT,
    is_up BOOLEAN DEFAULT false,
    bmp_reason INT,
    remote_port INT,
    timestamp TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS bmp_routes (
    id SERIAL PRIMARY KEY,
    router_ip VARCHAR(45) NOT NULL,
    peer_ip VARCHAR(45) NOT NULL,
    peer_asn BIGINT,
    prefix VARCHAR(50) NOT NULL,
    prefix_len INT,
    nexthop VARCHAR(45),
    origin VARCHAR(10),
    local_pref INT,
    policy_type VARCHAR(10) NOT NULL,
    action VARCHAR(10) NOT NULL,
    timestamp TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS bmp_stats (
    id SERIAL PRIMARY KEY,
    router_ip VARCHAR(45) NOT NULL,
    peer_ip VARCHAR(45) NOT NULL,
    peer_asn BIGINT,
    ads_rib_in INT DEFAULT 0,
    local_rib INT DEFAULT 0,
    prefixes_rejected INT DEFAULT 0,
    duplicate_prefix INT DEFAULT 0,
    timestamp TIMESTAMPTZ DEFAULT NOW()
);

Python Consumer

The consumer reads from Kafka, detects message type by topic name, extracts fields, and writes to PostgreSQL.

import json
import psycopg2
from kafka import KafkaConsumer
from datetime import datetime
import os

KAFKA_BROKER = os.environ.get('KAFKA_BROKER', 'localhost:9092')
DB_CONFIG = {
    'host': os.environ.get('DB_HOST', 'localhost'),
    'database': os.environ.get('DB_NAME', 'bmpdata'),
    'user': os.environ.get('DB_USER', 'bmpuser'),
    'password': os.environ.get('DB_PASS', 'bmppass123')
}

TOPICS = [
    'gobmp.parsed.peer',
    'gobmp.parsed.unicast_prefix_v4',
    'gobmp.parsed.unicast_prefix_v6',
    'gobmp.parsed.statistics'
]

def get_db_connection():
    conn = psycopg2.connect(**DB_CONFIG)
    conn.autocommit = True
    return conn

def process_peer_message(data, cursor):
    action = data.get('action', '')
    is_up = (action == 'add')
    cursor.execute("""
        INSERT INTO bmp_peers
        (router_ip, peer_ip, peer_asn, is_up, bmp_reason, remote_port, timestamp)
        VALUES (%s, %s, %s, %s, %s, %s, %s)
    """, (
        data.get('router_ip', ''),
        data.get('remote_ip', ''),       # NOTE: remote_ip for peer messages
        data.get('remote_asn', 0),
        is_up,
        data.get('bmp_reason'),
        data.get('remote_port'),
        data.get('timestamp', datetime.utcnow().isoformat())
    ))
    status = "UP" if is_up else f"DOWN (reason: {data.get('bmp_reason', '?')})"
    print(f"  [PEER] {data.get('remote_ip')} -> {status}")

def process_route_message(data, cursor):
    if data.get('is_eor', False):
        return
    if not data.get('prefix'):
        return

    if data.get('is_adj_rib_in_post_policy', False):
        policy_type = 'post'
    else:
        policy_type = 'pre'

    base_attrs = data.get('base_attrs', {})
    cursor.execute("""
        INSERT INTO bmp_routes
        (router_ip, peer_ip, peer_asn, prefix, prefix_len,
         nexthop, origin, local_pref, policy_type, action, timestamp)
        VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
    """, (
        data.get('router_ip', ''),
        data.get('peer_ip', ''),         # NOTE: peer_ip for route messages
        data.get('peer_asn', 0),
        data.get('prefix', ''),
        data.get('prefix_len', 0),
        data.get('nexthop', ''),
        base_attrs.get('origin', ''),
        base_attrs.get('local_pref'),
        policy_type,
        data.get('action', 'add'),
        data.get('timestamp', datetime.utcnow().isoformat())
    ))
    print(f"  [ROUTE] {data.get('peer_ip')} -> {data.get('prefix')}/{data.get('prefix_len')} "
          f"[{policy_type}] nexthop={data.get('nexthop')}")

def process_stats_message(data, cursor):
    cursor.execute("""
        INSERT INTO bmp_stats
        (router_ip, peer_ip, peer_asn, ads_rib_in, local_rib,
         prefixes_rejected, duplicate_prefix, timestamp)
        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
    """, (
        data.get('router_ip', ''),
        data.get('remote_ip', ''),       # NOTE: remote_ip for stats messages
        data.get('remote_asn', 0),
        data.get('ads_rib_in', 0),
        data.get('local_rib', 0),
        data.get('prefixes_rejected_inbound', 0),
        data.get('duplicate_prefix', 0),
        data.get('timestamp', datetime.utcnow().isoformat())
    ))
    rejected = data.get('prefixes_rejected_inbound', 0)
    reject_str = f" REJECTED={rejected}" if rejected > 0 else ""
    print(f"  [STATS] {data.get('remote_ip')} -> "
          f"rib_in={data.get('ads_rib_in', 0)} "
          f"local_rib={data.get('local_rib', 0)}{reject_str}")

def main():
    print("BMP Kafka Consumer — NE40e + GoBMP Lab")
    consumer = KafkaConsumer(
        *TOPICS,
        bootstrap_servers=KAFKA_BROKER,
        auto_offset_reset='earliest',
        group_id='bmp-lab-consumer',
    )
    conn = get_db_connection()
    cursor = conn.cursor()

    for msg in consumer:
        try:
            data = json.loads(msg.value.decode('utf-8'))

            if msg.topic == 'gobmp.parsed.peer':
                process_peer_message(data, cursor)
            elif msg.topic in ('gobmp.parsed.unicast_prefix_v4',
                               'gobmp.parsed.unicast_prefix_v6'):
                process_route_message(data, cursor)
            elif msg.topic == 'gobmp.parsed.statistics':
                process_stats_message(data, cursor)

        except Exception as e:
            print(f"[ERROR] {e}")

    cursor.close()
    conn.close()

if __name__ == '__main__':
    main()

Install dependencies and run:

pip install kafka-python-ng psycopg2-binary
python consumer.py

Grafana Dashboards

Connect Grafana (http://localhost:3000, admin/admin) to PostgreSQL: Connections → Data sources → PostgreSQL → Host: postgres:5432, Database: bmpdata, User: bmpuser, Password: bmppass123, TLS disabled.

Peer Status Table

A table showing every peer state change with timestamps, reason codes, and TCP port numbers. When a peer flaps, you see the DOWN event (with bmp_reason) followed by the UP event with a new remote_portconfirming it's a new TCP session, not a keep-alive recovery.

SELECT
    timestamp,
    peer_ip,
    peer_asn,
    CASE WHEN is_up THEN 'UP'
         ELSE 'DOWN (reason ' || COALESCE(bmp_reason::text, '?') || ')'
    END AS status,
    remote_port
FROM bmp_peers
ORDER BY timestamp DESC
LIMIT 50;

Routes Per Peer

A bar chart showing how many route announcements BMP captured per peer. R2 (2.2.2.2) leads with 6 because it has 2 prefixes that got re-announced across multiple BMP session resets.

SELECT
    peer_ip,
    COUNT(*) AS route_count
FROM bmp_routes
WHERE action = 'add'
GROUP BY peer_ip
ORDER BY route_count DESC;

All Peers Stats Comparison

This is the dashboard that demonstrates BMP’s core value. Three groups of bars, one per peer:

1.1.1.1: Received 2, Accepted 2, Rejected 0 — no filtering, everything passes 2.2.2.2: Received 2, Accepted 1, Rejected 1–15.15.15.15/32 is blocked 3.3.3.3: Received 3, Accepted 3, Rejected 0 — no filtering

That red bar on peer 2.2.2.2 is the entire point of this lab. Without BMP pre-policy monitoring, you’d have to SSH into the router and run display bgp routing-table peer 2.2.2.2 not-accepted-routes to discover that a prefix is being rejected. With BMP, it shows up on a dashboard automatically, in real time, across every peer on every router.

SELECT
    peer_ip,
    MAX(ads_rib_in) AS "Received",
    MAX(local_rib) AS "Accepted",
    MAX(prefixes_rejected) AS "Rejected"
FROM bmp_stats
WHERE timestamp > NOW() - INTERVAL '5 minutes'
GROUP BY peer_ip
ORDER BY peer_ip;

What We Learned

BMP’s value is in what your router hides from you. That red “Rejected: 1” bar on peer 2.2.2.2 told us in a glance what would have taken CLI access and per-peer commands to find manually. Scale that to hundreds of peers and you see why ISPs invest in this.

Build the full pipeline, even for 7 routes. GoBMP → Kafka → Python → PostgreSQL → Grafana taught us how each component connects and fails. Learning that pattern in a lab is cheaper than learning it with 900,000 prefixes in production.


메타데이터
post_id
3ec6a8b779d3
slug
building-a-bgp-monitoring-protocol-bmp-lab-from-router-to-dashboard-3ec6a8b779d3
url
https://medium.com/@netopsautomation/building-a-bgp-monitoring-protocol-bmp-lab-from-router-to-dashboard-3ec6a8b779d3
canonical_url
https://medium.com/@netopsautomation/building-a-bgp-monitoring-protocol-bmp-lab-from-router-to-dashboard-3ec6a8b779d3
author_url
https://medium.com/@netopsautomation
status
ok
fetched_at
2026-06-10 15:53:41