An interactive course · OpenStack Neutron + OVN

Reaching Out

Project Networks, Routers and Floating IPs with OVN

web-1 db-1 project network · 192.168.10.0/24 floating IP · 203.0.113.7 router r1 · the causeway the mainland · provider network a packet from the internet

You have created a network, a router and a floating IP a hundred times in the dashboard. This course shows what each of those clicks actually writes into OVN, and how a packet from your laptop finds one VM among thousands.

Sibling course: Plugged In follows how a VM boots and gets its port wired. Here we assume the VM boots fine, and study the shape of the network around it.

SCROLL TO SET SAIL ↓

01

The Island Story

The whole course in one story: an island, a causeway and a letterbox, told in five commands.

One island chain, four ideas

Every concept in this course is a spot on the map above. Four ideas, four OVN objects; keep the map in your head and the database tables will follow.

🏝️

The private island

A project network: every VM on it can chat freely, nothing can leave the shore. In OVN it is exactly one Logical_Switch.

🌉

The causeway

A router: the bridge you build towards the mainland, the provider network. In OVN it is one Logical_Router.

📮

The shared postage address

Outgoing mail gets stamped with one public return address shared by the whole island. That is SNAT, and it works one way: outward.

📬

Your beachfront letterbox

A floating IP with your name on it, so the outside world can find one specific house. In OVN it is a single dnat_and_snat rule (DNAT in, SNAT back out).

Watch the whole story happen

Press play: five commands from you, and Neutron narrates the rows each one writes. In the room too: the NB database (OVN's blueprint registry) and ovn-controller (the muscle on every node). Every later module zooms into one exchange from this conversation.

Networks are just named rows

"Create network" patches no cable and starts no process. It ends here, as one row in the NB database: a Logical_Switch whose name is simply neutron-<your-network-uuid>.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 2097-2102

CODE
    def create_network(self, context, network):        # Create a logical switch with a name equal to the Neutron network        # UUID.  This provides an easy way to refer to the logical switch        # without having to track what UUID OVN assigned to it.        lswitch_params = self._gen_network_parameters(network)        lswitch_name = utils.ovn_name(network['id'])
PLAIN ENGLISH

When you click "create network", Neutron's OVN driver lands here with your network in hand.

The comment says it all: the logical switch will be named after your network...

...so anything can find it later just by knowing the Neutron ID...

...with no lookup table of OVN-assigned IDs needed, ever.

Bundle up the network's settings (name, MTU and friends) ready for the new row.

And the name is literally neutron-<your-network-uuid>. Remember this trick; the whole course leans on it.

💡
Rows in, flows out

In the sibling course, Neutron wrote port rows and ovn-controller made them real. Routing is the same architecture with different tables: Logical_Router, NAT rules and static routes instead of ports. No agents, no namespaces on network nodes; routing is rows that become flows.

Seven steps to the open sea

The whole scenario is Neutron translating five API calls into NB rows. Each card is one step; the tag says which module unpacks it.

1
Module 2 openstack network create

A private island rises from the sea: one Logical_Switch row.

2
Module 2 openstack subnet create

Street addresses for the island: a CIDR range plus DHCP data.

3
Module 3 openstack router create

A causeway with no far end yet: a Logical_Router with nothing attached.

4
Module 3 openstack router add subnet

The causeway touches your island: a Logical_Router_Port on the switch.

5
Module 4 openstack router set --external-gateway

The causeway reaches the mainland: a default route and SNAT rules appear.

6
Module 5 openstack floating ip create + server add floating ip

Your own letterbox: one dnat_and_snat rule with your VM's address on it.

7
Module 6 A packet from the internet finds your VM

The payoff: we trace one inbound packet all the way from the mainland to the front door.

🎫
Why bother?

"My VM has no internet" and "the floating IP does not answer" are the two classic tickets. After this course you can bisect them like a boot hang: which row is missing, and which chassis should be doing the work?

Check your bearings

Three scenarios, no memorising. Steer by the island map.

A brand-new project network has no router at all, yet two VMs on the same subnet can reach each other. Why?

Working through the seven steps, at which moment could the outside world first reach your VM?

You run openstack router set r1 --external-gateway public. What does Neutron actually write?

Next stop: Your Private Island, where we watch network create and subnet create build the island row by row.

02

Your Private Island

Two clicks, one database row, and a new island rises from the sea. This is how networks and subnets are born.

Two clicks, and an island appears

In The Island Story you sailed the whole seven-step journey; this module drops anchor at steps 1 and 2, where the island itself is born.

Two dashboard clicks, and every hypervisor in the region can suddenly deliver packets for a network that did not exist ten seconds ago. Nothing was installed anywhere. What changed?

Think of it as founding a new island town, in three parts:

🏝️

The island

network create raises it: one Logical_Switch row in OVN's Northbound database, named neutron-<network-uuid>.

🗺️

The street grid

subnet create draws it: a CIDR, an allocation pool and DHCP data. House numbering for every future resident.

⛴️

The ferry terminal

Only provider networks get one: a localnet port docking the island to a real physical wharf. Project networks are reachable only by tunnel.

Step 1: raising the island

When you click Create Network, Neutron hands the request to its OVN driver, and this function runs. The heart of it is ls_add: one new row in the Northbound database, and the island exists everywhere at once.

CODE
    def create_network(self, context, network):        # Create a logical switch with a name equal to the Neutron network        # UUID.  This provides an easy way to refer to the logical switch        # without having to track what UUID OVN assigned to it.        lswitch_params = self._gen_network_parameters(network)        lswitch_name = utils.ovn_name(network['id'])        # NOTE(mjozefcz): Remove this workaround when bug        # 1869877 will be fixed.        segments = segments_db.get_network_segments(            context, network['id'])        with self._nb_idl.transaction(check_error=True) as txn:            txn.add(self._nb_idl.ls_add(lswitch_name, **lswitch_params,                                        may_exist=True))            for segment in segments:                if segment.get(segment_def.PHYSICAL_NETWORK):                    self.create_provnet_port(network['id'], segment, txn=txn)        db_rev.bump_revision(context, network, ovn_const.TYPE_NETWORKS)        self.create_metadata_port(context, network)        return network
PLAIN ENGLISH

The function Neutron runs for every new network, project and provider alike.

The naming trick, in the authors' own words: name the switch after the network UUID, so the two worlds never need a lookup table to find each other.

Pack the island's founding charter: the settings this network was created with.

Build the name: neutron- followed by the network's UUID.

A developer's sticky note about a workaround. Real code keeps its honest clutter.

Ask how this network is carried: its segments reveal whether it touches anything physical.

Open one all-or-nothing transaction against the Northbound database.

Raise the island. This single row IS the network, and may_exist=True makes it safe to run twice.

Only segments that name a physical network earn a ferry terminal. Project networks sail straight past this loop.

Stamp a revision number so the sync machinery can tell fresh data from stale.

Every island gets a concierge desk: the metadata port, built before a single VM arrives.

Done. Hand the finished network back.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py, lines 2097-2115

Two kinds of island

Every network you will ever create is one of these two. In the database, the difference between them is exactly one row.

🌊

Project network: the tunnelled island

🚇 Reachable only by Geneve tunnel: traffic between hypervisors rides inside ordinary IP packets.

🗺️ Exists on every chassis at once, because every chassis reads the same database.

🆓 Costs nothing physical: no VLAN consumed, no switch configured. Create fifty of them before lunch.

🧾 In the Northbound database: one Logical_Switch row. That is all.

⛴️

Provider network: the island with a ferry terminal

🧾 Everything the project island has, plus exactly one extra row: a localnet port.

🏷️ That port carries a VLAN tag, and its options name a physnet.

⚓ On each chassis, ovn-controller docks the ferry only if its own bridge mapping lists that physnet.

⚠️ No mapping on a chassis means no physical reach from that chassis.

Here is the ferry terminal being built, all eight lines of it.

CODE
        cmd = self._nb_idl.create_lswitch_port(            lport_name=utils.ovn_provnet_port_name(segment['id']),            lswitch_name=utils.ovn_name(network_id),            addresses=[ovn_const.UNKNOWN_ADDR],            external_ids={},            type=ovn_const.LSP_TYPE_LOCALNET,            tag=tag,            options=options)
PLAIN ENGLISH

Add one more port to the island, in the same database.

Name it after the segment, so anyone browsing the database can spot ferry terminals at a glance.

Attach it to this network's switch, found via the UUID naming trick.

Address: unknown. The ferry carries traffic for anyone, not for one fixed machine.

No Neutron bookkeeping notes attached.

The magic word. Type localnet declares: this port is a doorway to a physical network.

The VLAN tag this traffic will wear on the physical wire.

And the physnet name, which every chassis will compare against its own bridge mappings.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py, lines 2053-2060

🔎
Field note: when a provider network half works

The Logical_Switch row and its localnet port are identical for every chassis, because they all read the same database. So a provider network that works on some hypervisors and not others is almost always a bridge mapping problem on the broken ones.

Step 2: drawing the street grid

A subnet gives the island its addressing plan: the CIDR, the allocation pool, the DHCP answers. Watch how little OVN is asked to do.

CODE
    def create_subnet(self, context, subnet, network):        if subnet['enable_dhcp']:            mport_updated = False            if subnet['ip_version'] == const.IP_VERSION_4:                mport_updated = self.update_metadata_port(                    context, network, subnet=subnet)            if subnet['ip_version'] == const.IP_VERSION_6 or not mport_updated:                # NOTE(ralonsoh): if IPv4 but the metadata port has not been                # updated, the DHPC options register has not been created.                self._add_subnet_dhcp_options(subnet, network)        db_rev.bump_revision(context, subnet, ovn_const.TYPE_SUBNETS)
PLAIN ENGLISH

Step 2 begins. Notice what is missing: no switch, no tunnel, not even a new port.

If DHCP is off, OVN is barely involved at all.

Keep score: has the concierge desk been updated yet?

For IPv4, give the metadata port an address on the new street. That update creates the DHCP options along the way.

For IPv6, or when that shortcut did not fire...

...the comment explains why (typo and all: we promised you the unedited code)...

...write the DHCP_Options row directly: the answers VMs will hear when they ask for an address.

Finally, stamp the revision, exactly as create_network did.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py, lines 2502-2512

So where did the street numbers actually get reserved? Not here. Two ledgers, two jobs:

🗃️
Neutron's own database decides

The IPAM ledger and the allocation pool live here, in ordinary SQL. Every next-free-IP decision is made on this side.

📘
The OVN Northbound database receives

It stores outcomes only: addresses on ports, DHCP_Options rows. OVN never chooses an IP.

💡
Aha: the concierge desk was there all along

create_network builds the very metadata port the sibling course's metadata module runs on, before any VM exists. Each new subnet merely adds its street to that port's address list; nothing is ever rebuilt.

Watch the two clicks travel

Two commands enter through the Neutron API; everything after that is database writes. Press Next Step and notice who never gets a message.

🧑‍💻
You
🛎️
Neutron API
🗃️
Neutron DB (IPAM)
📘
OVN NB database
🖥️
ovn-controller (any chassis)
Click "Next Step" to begin
Ops checkMilestone: the island is real in both ledgers
# the island: one Logical_Switch row, named after the network UUID
$ openstack network show blue-island -c id -c status   → $NETWORK_ID | ACTIVE
$ ovn-nbctl ls-list | grep neutron-$NETWORK_ID

# the concierge desk is already there, before any VM: the metadata port
$ openstack port list --network blue-island --device-owner network:distributed

# the street grid: DHCP answers waiting for the first resident
$ ovn-nbctl --columns=cidr,options find DHCP_Options 'external_ids:"neutron:subnet_id"=$SUBNET_ID'
| cidr: 10.0.0.0/24 | options: {server_id=…, router=…, mtu=…} |

# provider island? the ferry terminal must exist, typed localnet, naming its physnet
$ ovn-nbctl lsp-get-type provnet-$SEGMENT_ID   localnet
$ ovn-nbctl lsp-get-options provnet-$SEGMENT_ID   → network_name=physnet1

# works on some hypervisors and not others? the field note says bridge mappings:
$ ovn-sbctl --columns=hostname,other_config find Chassis hostname=$BROKEN_HOST

Prove it to yourself

Three situations you could meet next week. Reason them out with the island, the ferry terminal and the two ledgers.

Your team spins up 50 project networks for a test suite. The datacentre has only about 4000 VLANs in total. Should you warn the network engineers?

A provider network gives VMs perfect connectivity on hypervisors A and B, but VMs on hypervisor C get nothing. Where do you look first?

A new VM boots and receives 10.0.0.57. Which component decided that 57 was the next free number?

The island is built; next, in The Bridge, we raise the router that connects it to other islands and to the mainland.

03

The Bridge

Your router is not a machine somewhere. It is a causeway that every hypervisor carries a copy of.

Where is my router?

Module 2 left you owning Your Private Island: a network with a subnet, isolated by design. Time to build a bridge to the other islands.

In the old ML2/OVS world a router was a Linux namespace on a network node: a real place that could be full, slow or dead. With OVN, ask where your router is and the honest answer is: everywhere.

Then · ML2/OVS router

namespace a real place on one network node
L3 agent a L3 agent process that must stay alive
choke point cross-subnet traffic detours through one box
failover HA via VRRP standbys, fingers crossed

Now · OVN router

rows one Logical_Router record in the northbound database
no process nothing to restart, nothing to die
routed locally east-west traffic never leaves the hypervisor
nothing to fail over every chassis already carries the router as flows

A different department builds bridges

Networks came from the ML2 mechanism driver. Routers come from somewhere else: a service plugin called OVNL3RouterPlugin, living in neutron/services/ovn_l3/plugin.py.

🏛️

Separate department

Routers are an add-on API, served by their own plugin, not by the core networking machinery.

🗃️

Borrowed filing cabinets

It reuses Neutron standard L3 database layer, so ownership, quotas and the API behave exactly as always.

🖊️

Same pen

All OVN writes go through the same OVNClient the ML2 driver uses. One author, one wish list.

self.scheduler = l3_ovn_scheduler.get_scheduler() line 95 of plugin.py: the gateway scheduler. Remember this one, it is the star of module 4.

Prefabricating the causeway

Think of router create as prefabricating a causeway in a yard: the whole span is built, but nothing is attached to any island yet. Here is the entire ceremony.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 1491-1504

CODE

    def create_router(self, context, router, add_external_gateway=True):
        """Create a logical router."""
        external_ids = self._gen_router_ext_ids(router)
        enabled = router.get('admin_state_up')
        lrouter_name = utils.ovn_name(router['id'])
        added_gw_ports = []
        options = {'always_learn_from_arp_request': 'false',
                   'dynamic_neigh_routers': 'true',
                   ovn_const.LR_OPTIONS_MAC_AGE_LIMIT:
                   ovn_conf.get_ovn_mac_binding_age_threshold()}
        with self._nb_idl.transaction(check_error=True) as txn:
            txn.add(self._nb_idl.lr_add(router=lrouter_name, may_exist=True,
                                        external_ids=external_ids,
                                        enabled=enabled, options=options))
            
PLAIN ENGLISH

When you ask for a router, this one function answers. Notice how little it does.

The code admits it up front: create a LOGICAL router. Written down, not built.

Prepare sticky labels tying the new row back to your Neutron router.

Note whether you created the router switched on or off.

Same naming trick as your island: the row is named neutron- plus the router UUID.

An empty list reserved for gateway ports. Ours stays empty until module 4.

Now some manners: do not memorise a neighbour from every ARP request you overhear...

...but do actively keep the neighbour entries you hold fresh...

...and forget a learned MAC address after a configurable age. Hygiene tuning, not plumbing.

Open one all-or-nothing transaction against the northbound database...

...and write a single Logical_Router row. That is the whole router. No process started, anywhere.

Landing the causeway on your island

router add subnet anchors one end of the causeway. That takes two port-ish rows: a ramp on the router, called a LRP, and the island-side switch port flipped to pair with it, wired together like a patch port.

1

Bolt a ramp (LRP) onto the router

2

Flip the island switch port to type router

3

Pair the two: traffic can now cross

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 1783-1807

CODE

        commands = [
            self._nb_idl.add_lrouter_port(
                name=lrouter_port_name,
                lrouter=lrouter,
                mac=port['mac_address'],
                networks=networks,
                may_exist=True,
                external_ids=self._gen_router_port_ext_ids(port, router['id']),
                **columns)
        ]

        if is_gw_port:
            port_net = self._plugin.get_network(
                n_context.get_admin_context(), port['network_id'])
            physnet = self._get_physnet(port_net)
            az_hints = common_utils.get_az_hints(router)
            commands.append(
                self._nb_idl.schedule_new_gateway(lrouter_port_name,
                                                  self._sb_idl,
                                                  lrouter, self._l3_plugin,
                                                  physnet, az_hints))
        commands.append(
            self._nb_idl.set_lrouter_port_in_lswitch_port(
                port['id'], lrouter_port_name, is_gw_port=is_gw_port,
                lsp_address=lsp_address))
            
PLAIN ENGLISH

Start a shopping list of database writes.

First item: the landing ramp, a Logical Router Port bolted onto the router...

...named with the usual convention and attached to your router row...

...carrying the MAC address the ramp answers on...

...and the IP networks it serves for this island.

No drama if the ramp already exists (may_exist)...

...plus sticky labels tying it back to the Neutron port, and any extra columns.

Ramp done. Now, a fork in the road.

Is this port the way OUT to the wider world? Then extra paperwork applies:

...look up the network the port sits on...

...find which physical network the outside world lives on...

...respect any availability zone preferences on the router...

...and hold a little election: schedule one chassis for gateway duty. Hold that thought.

Finally, every time, gateway or not:

...flip the island-side switch port to type router...

...and pair it with the ramp by name. Two anchored ends, one causeway.

Watch it appear everywhere at once

The cast: you, the L3 plugin, the northbound database, ovn-northd, and ovn-controller on two compute nodes. Two VMs on different subnets, both on compute-1. Click through, and keep your eye on the final step.

👤
You
🧭
OVN L3 plugin
📒
NB database
🏗️
ovn-northd
🖥️
compute-1
🖥️
compute-2
Click "Next Step" to begin
💡
Aha: distributed by default

ML2/OVS needed DVR, a whole configuration maze, to spread routing across compute nodes. In OVN, east-west distribution is not a feature you enable. It is the only mode there is.

Ops checkMilestone: causeway prefabricated, then landed on the island
# the causeway: one Logical_Router row, named after the router UUID
$ openstack router show causeway-1 -c id -c status   → $ROUTER_ID | ACTIVE
$ ovn-nbctl lr-list | grep neutron-$ROUTER_ID

# the ramps: one LRP per attached subnet, carrying the router's MAC and IP
$ ovn-nbctl lrp-list neutron-$ROUTER_ID   lrp-$PORT_ID (one per subnet)

# the island end: the switch port flipped to type router and paired by name
$ ovn-nbctl lsp-get-type $PORT_ID   router
$ ovn-nbctl lsp-get-options $PORT_ID   → router-port=lrp-$PORT_ID

# both ramps present but cross-subnet ping still fails? then it is not routing:
# check both VM ports are ACTIVE and the security groups allow the traffic

Check your bridge-building instincts

Two VMs sit on different subnets of the same router, on the same hypervisor. What path does a packet take between them?

Routing misbehaves. In ML2/OVS days you might restart the router. What is the OVN equivalent?

Architecture puzzle: why does attaching a subnet write TWO port-ish rows?

🗳️
Cliffhanger: the branch we tiptoed past

Snippet B hid a mystery: if is_gw_port: runs only when the port is the router door to the outside world, and schedule_new_gateway quietly holds an election for which chassis stands guard. Module 4, The Way Out, counts the votes: the gateway chassis, SNAT, and the way off your island.

04

The Way Out

Every island posts its mail through one elected port town. This is SNAT, the gateway chassis, and the road to the internet.

The branch we tiptoed past, resolved

Module 3 ended on a mystery: if is_gw_port:, a branch that quietly calls schedule_new_gateway. It fires the moment you run openstack router set --external-gateway ext-net, and it exists because of an awkward truth.

East-west routing lives everywhere, but everywhere cannot own one public IP. The moment your traffic heads for the internet, someone specific has to do the job.

So picture the island post office. Outgoing mail from any house is stamped with one shared return address, and all of it ships through a single designated port town.

📮
One shared return address

SNAT rewrites the source of every outbound packet to the router external IP. The houses never write their own.

One designated port town

The gateway LRP is pinned to an elected gateway chassis. All north-south traffic funnels through it.

🗳️
An election, with understudies

The scheduler ranks up to five port towns. If the port floods, the next takes over automatically.

One command, three writes

Setting the external gateway lands in _add_router_ext_gw, and the code numbers its own to-do list 1, 2, 3. Here are jobs one and three, with job two summarised in between.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 1332-1338 and 1357-1366

CODE

        # 1. Add the external gateway router port.
        admin_context = context.elevated()
        added_ports = []
        for gw_port in self._get_router_gw_ports(admin_context, router['id']):
            port = self._plugin.get_port(admin_context, gw_port['id'])
            self._create_lrouter_port(admin_context, router, port, txn=txn)
            added_ports.append(port)
⋯ lines 1339 to 1356 prepare job 2, the route for off-island mail ⋯
                txn.add(self._nb_idl.add_static_route(
                    lrouter_name, ip_prefix=gw_info.ip_prefix,
                    nexthop=gw_info.gateway_ip,
                    maintain_bfd=router_default_route_bfd_enabled,
                    **columns))

        # 3. Add necessary snat rule(s) in lrouter if snat is enabled
        if utils.is_snat_enabled(router):
            self.update_nat_rules(router['id'], enable_snat=True, txn=txn)
        return added_ports
            
PLAIN ENGLISH

The code numbers its own to-do list. Job one: hang the door.

Put on the admin hat: the provider network belongs to the operator, not to your project.

A list to remember every door we hang.

Fetch each gateway port Neutron recorded for this router (usually exactly one)...

...load its full details, MAC and IP included...

...and bolt it on with the very same ramp-builder from module 3. A gateway is just an LRP with a fancy job, written in the shared transaction.

Remember it for the caller.

(the skipped lines work out gw_info: which prefix and which gateway address the provider network offers)

Job two lands: write one static route on the router...

...matching the catch-all default prefix, meaning anywhere not local...

...and forward such packets to the nexthop, the provider network gateway.

Optionally watch that nexthop with BFD, a fast are-you-alive heartbeat.

Any extra columns, and the route is written.

Job three announces itself in its own comment.

Only if SNAT is switched on for this router (it is, by default)...

...fire the stamp machine. That is our next screen.

Done: hand back the doors we hung.

Three writes, three distinct failure modes. Quiz yourself before peeking: what breaks if each one goes missing?

🚪

1 · Gateway port

The router door onto the provider network, an LRP pinned to elected chassis. If missing: there is no door at all, and nothing north-south moves.

🧭

2 · Default route

One rule: anything not local, hand to the provider gateway. If missing: the router shrugs at off-island mail and drops it, door or no door.

📮

3 · SNAT rules

One per internal CIDR, all stamped with the router external IP. If missing: packets leave with private return addresses, so replies never come home.

One stamp for every street

Job three opens into update_nat_rules, the stamp machine. It writes one snat row for every internal street, and every row carries the same shared stamp.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 2018-2035

CODE

    def update_nat_rules(self, router_id, enable_snat, cidrs=None, txn=None):
        if enable_snat:
            idl_func = self._nb_idl.add_nat_rule_in_lrouter
        else:
            idl_func = self._nb_idl.delete_nat_rule_in_lrouter
        func = functools.partial(
            idl_func, utils.ovn_name(router_id), type='snat')

        context = n_context.get_admin_context()
        cidrs = (
            cidrs or
            self._get_snat_cidrs_for_external_router(context, router_id)
        )
        commands = [
            func(logical_ip=cidr, external_ip=router_ip)
            for router_ip in self._iter_ipv4_gw_addrs(context, router_id)
            for cidr in cidrs
        ]
            
PLAIN ENGLISH

One machine both stamps and un-stamps, depending on enable_snat.

Turning SNAT on?

Then every command will add a nat rule inside the logical router.

Turning it off?

Exactly the same shape, delete instead of add.

Pre-fill the paperwork shared by every rule:

this router, and always type snat.

Admin hat on again.

Which streets get stamped?

Either the exact list the caller handed over, or...

...every internal CIDR currently plugged into this router.

Now the mail run, one rule per pairing:

the internal street as logical_ip, the router address as the external_ip stamp...

...for each IPv4 address the router holds on the outside (usually just one)...

...crossed with every internal street.

Every street, one shared stamp.

What lands in the logical router, for a router with two subnets:

snat 192.168.10.0/24 → 203.0.113.50 street one, stamped
snat 192.168.20.0/24 → 203.0.113.50 street two, same stamp

Notice what the stamp implies: to un-stamp the replies, something must remember every conversation. That memory is connection state, and it is why this job cannot live everywhere.

Electing the port town

Because SNAT needs one owner, the gateway port is pinned to specific chassis, chosen by election. First, the electoral roll: who may even stand?

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 1644-1650

CODE

        candidates = set()
        for chassis, physnets in chassis_physnets.items():
            if (physnet and
                    physnet in physnets and
                    chassis in cms):
                candidates.add(chassis)
        candidates = list(candidates)
            
PLAIN ENGLISH

Start an empty electoral roll.

Walk every chassis in the cloud, with the physnets each one carries...

A name goes on the roll only if we know which external physnet we need...

...this chassis actually carries it, via a bridge mapping (a real cable to the outside)...

...and the chassis volunteered: cms lists every chassis that registered enable-chassis-as-gw.

Both boxes ticked? Onto the roll.

Roll closed. No ranking yet, just eligibility.

Then the count. The scheduler seats up to five winners, ranked by priority, so the port town always has understudies.

neutron/scheduler/l3_ovn_scheduler.py · lines 73-92

CODE

        if not candidates:
            LOG.warning('Gateway %s was not scheduled on any chassis, no '
                        'candidates are available', gateway_name)
            return
        chassis_count = min(
            ovn_const.MAX_GW_CHASSIS - len(existing_chassis),
            len(candidates)
        )
        # The actual binding of the gateway to a chassis via the options
        # column or gateway_chassis column in the OVN_Northbound is done
        # by the caller
        chassis = self._select_gateway_chassis(
            nb_idl, sb_idl, candidates, 1, chassis_count, target_lrouter
        )[:chassis_count]
        # priority of existing chassis is higher than candidates
        chassis = existing_chassis + chassis
        LOG.debug("Gateway %s scheduled on chassis %s",
                  gateway_name, chassis)
        return chassis
            
PLAIN ENGLISH

Nobody on the roll?

Then this exact warning lands in the log. Word for word, it is the line you grep for...

...when a router silently has no internet.

And scheduling gives up. No gateway, no way out.

How many seats to fill?

Up to MAX_GW_CHASSIS (that is 5), minus seats already taken...

...and never more than the roll can supply.

The scheduler is careful to say what it does not do:

it only picks names. The caller writes the actual Gateway_Chassis rows...

...into the northbound database.

Run the election proper. The default policy seats the least-loaded chassis first...

...(the other policy, chance, is literally a random shuffle)...

...then trim to the number of open seats.

Incumbents outrank newcomers, so...

...a reshuffle never demotes a healthy sitting gateway.

Publish the results...

...and return them ranked. Top priority carries the traffic today.

The result, written as Gateway_Chassis rows on the port:

priority 5 gw-host-3: elected, carries the traffic today
priority 4 gw-host-1: first understudy, standing by
priority 3, 2, 1 further understudies, ranked and ready
💡
Aha: the slow brain and the fast reflex

Neutron schedules: it picks names and priorities and writes the rows, once. OVN fails over: chassis watch each other with BFD and the next rank takes over in moments, no Neutron in the loop. The slow control plane makes the decision; the fast data plane handles the emergency.

Ops checkMilestone: door hung, route written, stamps inked, winner declared
# write 1, the door: the router has an external gateway
$ openstack router show causeway-1 -c external_gateway_info   → network_id + the external IP

# write 2, the route: anything not local goes to the provider gateway
$ ovn-nbctl lr-route-list neutron-$ROUTER_ID   0.0.0.0/0 203.0.113.1 dst-ip

# write 3, the stamps: one snat row per internal street, one shared stamp
$ ovn-nbctl lr-nat-list neutron-$ROUTER_ID
| snat | 203.0.113.50 | 192.168.10.0/24 |
| snat | 203.0.113.50 | 192.168.20.0/24 |

# the election result: ranked winners on the gateway port, priority 5 down to 1
$ ovn-nbctl lrp-get-gateway-chassis lrp-$GW_PORT_ID

# who is carrying it right now: BFD may already have promoted an understudy
$ ovn-sbctl --columns=chassis find Port_Binding logical_port=cr-lrp-$GW_PORT_ID

# empty election? grep the neutron log for 'was not scheduled on any chassis',
# then inspect the volunteers: enable-chassis-as-gw and the physnet mapping
$ ovn-sbctl --columns=hostname,other_config find Chassis hostname=gw-host-3
| other_config: {ovn-bridge-mappings="physnet1:br-ex", ovn-cms-options=enable-chassis-as-gw} |

Follow a packet off the island

The cast: a VM at 192.168.10.5 on compute-1, whose OVS carries the router as flows, plus the elected gateway chassis and the wider world. Between chassis, traffic rides a Geneve tunnel.

Click through both legs of the trip, and watch where the reply is forced to land.

🏠
VM 192.168.10.5
🔀
compute-1 OVS
gateway chassis
🛣️
provider network
🌍
internet
Click "Next Step" to begin

No internet? You now have an algorithm

"VMs cannot reach the internet" stops being a mystery and becomes a checklist, run top to bottom. It also answers the audit question "which node carries our internet traffic?" precisely.

1
Does the gateway port exist?

Check the router for an external gateway. No door, no way out: write number one never happened.

2
Did the election find candidates?

Grep the logs for: was not scheduled on any chassis. An empty roll means no volunteer carries the external physnet.

3
Is the elected chassis healthy?

Find the top-priority Gateway_Chassis entry and check that host and its uplink. BFD may already have promoted an understudy.

4
Are the SNAT rules present?

One snat row per internal CIDR on the logical router, all stamped with the router external IP.

VMs on all networks behind router r1 lost internet access at the same moment, yet east-west traffic is fine. What is your first suspect?

The log says: Gateway ... was not scheduled on any chassis, no candidates are available. What two properties must a chassis have to make the roll?

Architecture puzzle: routing is distributed to every chassis, so why is SNAT centralised on a gateway chassis?

What limits how many chassis back up one gateway port, and how does takeover actually happen?

🔑
Next: The Way In

SNAT sends everyone out under one shared address. Module 5, The Way In, hands the world a key to one specific VM: the floating IP.

05

The Way In

A floating IP is one database row that gives a single VM a public name, and, optionally, its own private path.

A letterbox on the beachfront

Module 4 built the way out: every VM on the island shares one outgoing stamp, SNAT at the elected gateway chassis. Fine for browsing out, useless for being found.

A floating IP is a personal, publicly listed letterbox bolted to the beachfront, forwarding both ways to one specific house.

📦

The VM believes its address is

192.168.10.5

🛰️

Your monitoring pings

203.0.113.7

Neither is lying. One database row makes both true.

📮

The shared stamp

Module 4 recap: outgoing mail is re-stamped with one island-wide return address. Nobody outside can write to you.

📬

The personal letterbox

Mail addressed to it is walked to one specific house (DNAT), and that house has its outgoing mail re-stamped with the letterbox address instead of the shared one.

🔧

Unscrewable

The letterbox can be unscrewed and moved to another house in seconds. The house never knows it exists.

Born DOWN, and that is fine

Ordering the letterbox does not install it. When you run openstack floating ip create, the OVN L3 plugin writes a ledger entry, nothing more.

neutron/services/ovn_l3/plugin.py · lines 188-196

CODE

    def create_floatingip(self, context, floatingip,
                          initial_status=n_const.FLOATINGIP_STATUS_DOWN):
        # The OVN L3 plugin creates floating IPs in down status by default,
        # whereas the L3 DB layer creates them in active status. So we keep
        # this method to create the floating IP in the DB with status down,
        # while the flavor drivers are responsible for calling the correct
        # backend to instatiate the floating IP in the data plane
        return super(OVNL3RouterPlugin, self).create_floatingip(
            context, floatingip, initial_status)
            
PLAIN ENGLISH

When a user asks for a floating IP, this one method answers...

...and its default answer is DOWN: reserved, not live.

The comment owns up: the generic database layer would mark it ACTIVE straight away...

...but OVN refuses to bluff, so the record is written switched off...

...because nothing has been plumbed yet...

...and making it real in the data plane is a separate job, done later.

(Yes, instatiate. The typo is faithfully preserved from the real file.)

Then hand the paperwork to the standard database layer...

...one ledger entry, zero packets moved.

1

Create: address reserved, status DOWN

2

Hold it as long as you like: it costs OVN nothing

3

Associate to a port: the plumbing happens, status ACTIVE

The plumbing function itself opens with a guard that proves the point. No router to hang the letterbox on, no letterbox.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 889-891

CODE

        router_id = floatingip.get('router_id')
        if not router_id:
            return
            
PLAIN ENGLISH

First question: which router does this floating IP hang off?

No router means no association yet...

...so write nothing to the northbound database and walk away. An unassociated floating IP is pure bookkeeping, which is why it is instant to attach.

The letterbox is one database row

Associate the floating IP with a VM port and here is everything OVN learns: one NAT row on the router, five keys.

🌍

203.0.113.7

external_ip

📬

dnat_and_snat

one NAT row on the router

🏠

192.168.10.5

logical_ip · logical_port

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 921-925

CODE

        columns = {'type': 'dnat_and_snat',
                   'logical_ip': floatingip['fixed_ip_address'],
                   'external_ip': floatingip['floating_ip_address'],
                   'logical_port': floatingip['port_id'],
                   'external_ids': ext_ids}
            
PLAIN ENGLISH

The row type dnat_and_snat means both directions: incoming mail gets its destination rewritten, outgoing mail gets its source re-stamped, overriding module 4 shared stamp for this one VM.

Which house the mail is walked to: the VM fixed IP.

The address painted on the letterbox: the public floating IP the world sees.

Exactly one nameplate: the specific Neutron port this letterbox forwards to.

And the usual sticky labels tying the OVN row back to the Neutron floating IP.

This is the entire public identity of your VM, and it lives on the router, not on the machine. The guest OS must never be configured with 203.0.113.7, because port security only lets the port speak as its fixed IP.

💡
Aha: one letterbox, many doors

The same function has a fixed_ip_address is None case that hands off to a different handler entirely: port forwarding. One floating IP can be a bundle of per-TCP-port forwards instead of a full letterbox.

One column, two very different paths

By default, all floating IP traffic detours through the gateway chassis. One config option, enable_distributed_floating_ip, adds a single extra column that changes the route entirely.

neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py · lines 935-937

CODE

        if ovn_conf.is_ovn_distributed_floating_ip():
            if self._nb_idl.lsp_get_up(floatingip['port_id']).execute():
                columns['external_mac'] = port_db['mac_address']
            
PLAIN ENGLISH

Only if the cloud enabled distributed floating IPs...

...and only if the VM port is actually UP right now...

...add a sixth key: the port MAC address. That single value tells the VM own chassis to answer for the floating IP itself.

With external_mac set, the hypervisor hosting the VM answers ARP for the floating IP and rewrites addresses locally. Engineers call this distributed floating IP. Watch both runs below and keep an eye on the gateway chassis.

🌍
Internet client
🛣️
Provider network
🚪
Gateway chassis
🖥️
compute-7 (VM chassis)
📦
VM
Click "Next Step" to begin run 1

Run 1 uses the Geneve tunnel between chassis; run 2 never needs it.

Unscrew the letterbox

web-1 is misbehaving, so you point the floating IP at web-2 instead. One API call, and here is everything that changes in OVN.

Before · screwed to web-1

type dnat_and_snat
external_ip 203.0.113.7
logical_ip 192.168.10.5
logical_port port of web-1

After · screwed to web-2

type dnat_and_snat · unchanged
external_ip 203.0.113.7 · unchanged, the world notices nothing
logical_ip 192.168.10.9 · swapped
logical_port port of web-2 · swapped

Two fields swap, the public address stays put. Neither guest OS was touched, because neither ever knew the letterbox existed.

row exists? Your new floating IP checklist, item 1: is there a dnat_and_snat row for this address at all?
logical_port right? Item 2: does it point at the port you think it does?
external_mac? Item 3: is it set, and should it be? That column alone decides centralised versus distributed.
Ops checkMilestone: letterbox ordered, then screwed on
# just created, not yet associated: reserved and DOWN is healthy, not broken
$ openstack floating ip show 203.0.113.7 -c status -c fixed_ip_address -c port_id
| DOWN | None | None |   # and no NAT row exists anywhere yet

# associated to web-1: the ledger flips ACTIVE once the plumbing lands
$ openstack floating ip show 203.0.113.7 -c status -c fixed_ip_address -c port_id
| ACTIVE | 192.168.10.5 | $PORT_ID |

# the letterbox itself: one dnat_and_snat row on the router
$ ovn-nbctl lr-nat-list neutron-$ROUTER_ID
| dnat_and_snat | 203.0.113.7 | 192.168.10.5 | fa:16:3e:… | $PORT_ID |

# the same row, key by key: your three-item checklist as one command
$ ovn-nbctl --columns=logical_ip,logical_port,external_mac find NAT type=dnat_and_snat external_ip=203.0.113.7

# external_mac present: distributed, the VM's own chassis answers ARP.
# external_mac empty: centralised, everything detours via the gateway chassis.
# it should match enable_distributed_floating_ip in the Neutron config

Check your letterbox instincts

A colleague wants to configure 203.0.113.7 inside the guest, so the VM knows its public address. Why is that a mistake?

Right after openstack floating ip create, the new address shows status DOWN. Is this a bug?

Debugging: a floating IP is unreachable. The dnat_and_snat row exists, external_mac is present, but the VM port is DOWN. What is the fix?

Floating IP traffic is saturating one gateway node even though the deployment intends distributed handling. Which column do you check first?

🧭
Next: follow the packet

You now own every row in the story: islands, causeways, gateways and letterboxes. Module 6, Follow the Packet, walks one packet end to end and turns the whole course into a debugging playbook.

06

Follow the Packet

Same parcel, three trips. Replay the courier log line by line, then keep the lost-parcel checklist forever.

One parcel, three trips

The Way In screwed the last letterbox to the wall: every row this scenario writes now exists. This module makes packets use them.

Time to cash in five modules of rows: three packet walks, and a checklist that turns "no internet" tickets into five-minute diagnoses.

Couriers do not debate a lost parcel. They replay the delivery log and find the first checkpoint without a scan. Our courier makes exactly three kinds of trip:

🚲

Trip 1 · Across the island

East-west: web-1 (192.168.10.5) to db-1 (10.0.20.8). Two subnets, one router, and the parcel never leaves the hypervisors.

🛳️

Trip 2 · Island to mainland

North-south out: web-1 to 8.8.8.8. Collected, stamped with the shared return address (SNAT), and shipped through one designated port town.

📬

Trip 3 · Mainland to one house

North-south in: a stranger addresses 203.0.113.7 and the parcel lands at one specific door (DNAT).

Every reachability ticket in this scenario is one of these trips. Each trip has an ordered set of checkpoints, and at each one you ask: does the row exist, and is the responsible chassis healthy?

Trip 1: across the island, no ferry

The Bridge animated this trip, so here it is as the courier files it: four log lines. The router does its work right on compute-1, as rules in the local flow table.

1

web-1 hands the parcel to its default gateway, 192.168.10.1

2

compute-1 routes it in place, using the distributed router rules

3

Geneve tunnel straight to the chassis hosting db-1

4

db-1 (10.0.20.8) signs for it

Two checkpoints cover the whole trip:

LRP × 2 The router needs a foot on both subnets: two Logical_Router_Port rows, one per street.
ACTIVE × 2 Both VM ports up. A parcel delivered to a boarded-up door is still a lost parcel.

Trip 2: island to mainland

web-1 pings 8.8.8.8. The trip starts exactly like trip 1, then hits the one stop that cannot be distributed: someone specific must stamp the mail and face the mainland.

💻
web-1
🖥️
compute-1
gw-1
🛣️
provider net
🌐
internet
Click "Next Step" to begin

The routing decision leans on the default static route; everything after it leans on the election. Four checkpoints, in the order the parcel meets them:

Gateway_Chassis Scheduled at all? This row ranks up to five chassis by priority; the top one owns the trip. Empty list, no internet.
no candidates are available The module 4 log warning. A candidate needs enable-chassis-as-gw plus the external physnet.
type='snat' Module 4 reminder: update_nat_rules writes one snat row per internal CIDR, all pointing at the router external IP. One missing row strands one subnet.
ovn-bridge-mappings Is gw-1 alive, and does it map the physnet to a real network card? A won election with a dead uplink still delivers nothing.

And remember why the reply must come back through gw-1: the connection state lives there and nowhere else.

Checkpoint one, examined: who won the election?

Neutron picks the port town the moment you attach the gateway, using a scheduler you can read in full. Here is one entire policy, five lines long:

neutron/scheduler/l3_ovn_scheduler.py · lines 148-152

CODE

    def _select_gateway_chassis(self, nb_idl, sb_idl, candidates,
                                priority_min, priority_max, target_lrouter):
        candidates = copy.deepcopy(candidates)
        random.shuffle(candidates)
        return self._reorder_by_az(nb_idl, sb_idl, candidates)
            
PLAIN ENGLISH

The chance policy. Given every chassis eligible to serve, produce the ranked winners for one router...

...along with the range of priority numbers those winners will wear.

Work on a private copy of the candidate list, so the ballot box the caller holds is untouched.

The entire election: shuffle the candidates. Genuinely.

Then reorder so the top picks spread across availability zones, and hand back the ranking.

Two policies ship in that one small file, swappable with a single config option:

🎲
Chance

Shuffle the candidates, then spread across zones. The five lines above.

⚖️
Least loaded (the default)

Count existing gateway ports per chassis and favour the least burdened, then spread across zones.

Scheduling policies are small, readable and swappable. The whole file is a ten-minute read, and now you know exactly why some chassis wears priority 5 for causeway-1.

Trip 3: mainland to one house

A stranger on the internet addresses 203.0.113.7, the floating IP. Who answers the door depends on one column you met in The Way In: external_mac.

🧑‍💻
client
🛣️
provider net
gw-1
🖥️
compute-1
💻
web-1
Click "Next Step" to begin

Whoever answers that ARP receives the traffic, so trip 3 has four checkpoints:

'type': 'dnat_and_snat' Module 5 reminder: the letterbox row itself. No row means the floating IP was never associated, and nothing on the mainland knows the address.
logical_port Pointing at the right VM port? A letterbox screwed to the wrong house fails exactly like a missing one.
columns['external_mac'] = port_db['mac_address'] Module 5 reminder: the guard writes this only when the distributed option is on and the port is up. Its presence decides who answers the ARP.
ACTIVE The VM port must be up. The rows can be perfect while the door stays boarded shut.

The lost-parcel checklist

Print this on your mental tea towel. Five symptoms, each mapped to its trip and its checkpoints, in the order you should check them.

🏘️

Neighbours unreachable, same network

Not a routing trip at all. Sibling course territory: is the port ACTIVE, and do the security groups allow the traffic?

🌉

Cross-subnet unreachable

Trip 1. Is the router attached to both subnets, two LRPs present? Are both VM ports ACTIVE?

📮

No internet from any VM behind causeway-1

Trip 2, in order: gateway port scheduled? Candidates warning in the neutron log? Elected chassis alive with its physnet uplink? snat rows present, one per CIDR?

📪

Floating IP dead

Trip 3, in order: dnat_and_snat row exists? logical_port names the right port? external_mac matches intent? VM port ACTIVE?

🐢

FIP works, but slow or asymmetric

Centralised versus distributed mismatch. Compare external_mac on the row against enable_distributed_floating_ip in the config.

💡
Aha: one loop to rule them all

Across both courses, every mechanism had the same shape: Neutron writes intent rows, OVN daemons make them real, events flow back up. Master that loop and any Neutron plus OVN feature reads the same way: load balancers, port forwarding, QoS.

Ops checkThe lost-parcel checklist, as copy-paste commands
# trip 1, island to island: both ramps present, both houses awake?
$ ovn-nbctl lrp-list neutron-$ROUTER_ID   → one lrp- per attached subnet
$ openstack port show $PORT_ID -c status   ACTIVE, on both ends

# trip 2, island to mainland: door, route, stamps, winner, in that order
$ ovn-nbctl lr-route-list neutron-$ROUTER_ID | grep 0.0.0.0/0
$ ovn-nbctl lr-nat-list neutron-$ROUTER_ID | grep snat   → one row per CIDR
$ ovn-nbctl lrp-get-gateway-chassis lrp-$GW_PORT_ID   → the ranked winners
$ grep "was not scheduled on any chassis" neutron-server.log   # empty roll?

# trip 3, mainland to one house: the letterbox row, all four checkpoints at once
$ ovn-nbctl --columns=logical_ip,logical_port,external_mac find NAT type=dnat_and_snat external_ip=203.0.113.7

# slow or asymmetric floating IP? compare external_mac above against
# enable_distributed_floating_ip in the Neutron config: they must agree

The on-call drill

Four tickets, straight off the queue. Walk the trip before you click.

Ticket: "VMs behind causeway-1 reach other subnets fine, but not the internet. Other routers are unaffected." Your first three checks, in order?

Ticket: "The FIP pings, but ssh drops under load, and all FIP traffic funnels through one node in a deployment that is supposed to be distributed." Where do you look?

You inherit a cloud and want to know which chassis carry internet traffic today. Where do you look first?

A teammate suggests: "Just reboot the network node, that always fixed routing before." Why does that rarely help with OVN?

Journey complete: the whole map, one last look

Seven steps in The Island Story. Now every step has a row you can name, inspect and hand to a teammate or an AI assistant without ambiguity.

1
Logical_Switch openstack network create

The island itself: one switch row every chassis can materialise.

2
DHCP_Options openstack subnet create

Street addresses: the CIDR plus the boot-time answers, stored as data, not as a daemon.

3
Logical_Router openstack router create

A causeway with no ends yet: one row, no process started anywhere.

4
LRP + patch twin openstack router add subnet

The causeway touches down: a Logical_Router_Port paired with its switch-side twin.

5
route + snat + Gateway_Chassis openstack router set --external-gateway

The way out: a default static route, one snat row per CIDR, and a ranked election result.

6
dnat_and_snat NAT row openstack floating ip create + server add floating ip

The letterbox: five keys on one NAT row, with external_mac as the optional sixth.

7
flows on every chassis A packet from the internet finds your VM

ovn-northd compiled every row above into flow rules. The three trips in this module rode them.

Where to explore next

Three files carried this whole course. Open them with any question, or point your AI assistant at them by name:

neutron/services/ovn_l3/plugin.py every router and floating IP API call lands here first
neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py the pen: every northbound row in this course was written here
neutron/scheduler/l3_ovn_scheduler.py both gateway election policies, readable in one sitting
🧭
The other half of the story

This course started at a network that already existed. For the boot-time story, how a VM port comes up, gets its address and is marked ACTIVE, take the sibling course on how a VM gets its network. Same loop, different tables.

Ticket closed. Next time someone says the cloud is down, you can name the trip, the row and the chassis. 🎉