Load_Balancer health checks silently fail for baremetal pool members
whose backend Logical_Switch_Port is type=external on a Logical_Switch
that has a localnet port, which is the usual layout for Neutron and the
ovn-octavia-provider baremetal driver on a provider VLAN.

A type=external LSP has no VIF of its own.  The reply to a health check
probe re-enters br-int through the switch's localnet port, so by the time
it reaches ls_in_l2_lkup MFF_LOG_INPORT carries the localnet port and the
per-backend reply lflow, which matches on the backend's inport, never
fires.  pinctrl_find_svc_monitor() is keyed on the backend port, so the
reply is dropped and the member's health is misreported.

The two supported protocols fail in opposite directions.  A TCP monitor
times out in svc_monitors_run() and goes permanently offline, taking a
healthy member out of the pool.  A UDP monitor takes the other branch of
the same timeout and goes online; the only thing that ever moves it
offline is the ICMP unreachable punted through this lflow, so a dead
member stays online forever and traffic keeps being sent to it.

Add a copy of the reply lflow per localnet port for backends that are
type=external, matching the localnet port as inport and restoring the
backend's inport before punting:

  match : inport == <localnet> && ip4.dst == <source ip> &&
          ip4.src == <backend ip> && eth.dst == <lrp mac> &&
          (tcp.src == <backend port> | icmp4.type == 3)
  action: inport = "<backend>"; handle_svc_check(inport);

Only the inport differs from the lflow that is already generated for the
backend, so UDP needs no new match shape, it reuses the icmp4.type /
icmp6.type branch that is already there for it.

Unlike 3bae90d9fe17, which rewrote the inport at ls_in_check_port_sec for
every packet carrying the backend's MAC and caused a multicast storm, the
rewrite here cannot escape a single lflow.  The match requires a unicast
eth.dst of the router port, so multicast and unknown unicast can never
reach it, and the action list ends with handle_svc_check() without a
next;, so no later table observes the assigned inport.

The new lflows are owned by the load balancer's lflow_ref.  That is safe
because they depend on port types that lsp_can_be_inc_processed() rejects,
localnet and external, so adding or removing either forces northd to
recompute.

Assisted-by: Claude Opus 5, Claude Code
Signed-off-by: JayGue Lee <[email protected]>
---
 Documentation/ref/ovn-logical-flows.7.rst |  13 +++
 northd/northd.c                           |  73 ++++++++++++--
 tests/ovn-northd.at                       | 116 ++++++++++++++++++++++
 3 files changed, 191 insertions(+), 11 deletions(-)

diff --git a/Documentation/ref/ovn-logical-flows.7.rst 
b/Documentation/ref/ovn-logical-flows.7.rst
index 1a9168ac86..8c20ed8c33 100644
--- a/Documentation/ref/ovn-logical-flows.7.rst
+++ b/Documentation/ref/ovn-logical-flows.7.rst
@@ -1438,6 +1438,19 @@ This table implements switching behavior.  It contains 
these logical flows:
   the service monitor mac defined in the ``options:svc_monitor_mac`` column of
   ``NB_Global`` table.
 
+- For each load balancer backend that is health checked from an address
+  belonging to a logical router port, a priority-110 flow matching the probe
+  reply on ``inport == B`` and applying ``handle_svc_check(inport)``, where
+  *B* is the backend's logical switch port.
+
+  A ``type=external`` port has no VIF of its own, so its replies reach the
+  switch through a localnet port and the flow above cannot match.  For such a
+  backend an additional copy of the flow is added for each localnet port *L*
+  on the switch, matching ``inport == L`` instead and applying ``inport = B;
+  handle_svc_check(inport);`` so that ``ovn-controller`` can tell which
+  service monitor the reply belongs to.  The action list ends there, so the
+  assigned ``inport`` is not visible to any later table.
+
 - A priority-100 flow that punts all IGMP/MLD packets to ``ovn-controller`` if
   multicast snooping is enabled on the logical switch.
 
diff --git a/northd/northd.c b/northd/northd.c
index 88e3ece884..55d631e8cf 100644
--- a/northd/northd.c
+++ b/northd/northd.c
@@ -9220,6 +9220,7 @@ build_lb_health_check_response_lflows(
     const struct ovn_northd_lb_vip *lb_vip_nb,
     const struct ovn_lb_datapaths *lb_dps,
     const struct ovn_datapaths *lr_datapaths,
+    const struct hmap *ls_ports,
     const struct shash *meter_groups,
     struct ds *match,
     struct ds *action)
@@ -9228,6 +9229,7 @@ build_lb_health_check_response_lflows(
      * to a real LRP, install rule that punts service check replies to the
      * controller. */
     const struct ovn_lb_backend *backend;
+    struct ds probe_match = DS_EMPTY_INITIALIZER;
     size_t j = 0;
 
     VECTOR_FOR_EACH_PTR (&lb_vip->backends, backend) {
@@ -9243,6 +9245,14 @@ build_lb_health_check_response_lflows(
             protocol = "tcp";
         }
 
+        /* A type=external backend has no VIF of its own.  Its replies reach
+         * br-int through one of the switch's localnet ports, so they need
+         * additional lflows, see below. */
+        const struct ovn_port *backend_op =
+            ovn_port_find(ls_ports, backend_nb->logical_port);
+        bool external_backend = backend_op && backend_op->nbsp &&
+                                lsp_is_external(backend_op->nbsp);
+
         size_t index;
         DYNAMIC_BITMAP_FOR_EACH_1 (index, &lb_dps->nb_lr_map) {
             struct ovn_datapath *od = sparse_array_get(&lr_datapaths->dps,
@@ -9270,32 +9280,36 @@ build_lb_health_check_response_lflows(
 
             ds_clear(match);
             ds_clear(action);
+            ds_clear(&probe_match);
 
-            /* icmp6 type 1 and icmp4 type 3 are included in the match, because
+            /* Everything but the inport is identical for every copy of the
+             * lflow, so build that part once.
+             *
+             * icmp6 type 1 and icmp4 type 3 are included in the match, because
              * the controller is using them to detect unreachable ports. */
             if (addr_is_ipv6(backend_nb->svc_mon_src_ip)) {
-                ds_put_format(match, "inport == \"%s\" && ip6.dst == %s && "
+                ds_put_format(&probe_match, "ip6.dst == %s && "
                               "ip6.src == %s && eth.dst == %s && ",
-                              backend_nb->logical_port,
                               backend_nb->svc_mon_src_ip,
                               backend->ip_str,
                               backend_nb->svc_mon_lrp->lrp_networks.ea_s);
                 if (!strcmp(protocol, "tcp")) {
-                    ds_put_format(match, "tcp.src == %s", backend->port_str);
+                    ds_put_format(&probe_match, "tcp.src == %s",
+                                  backend->port_str);
                 } else {
-                    ds_put_cstr(match, "icmp6.type == 1");
+                    ds_put_cstr(&probe_match, "icmp6.type == 1");
                 }
             } else {
-                ds_put_format(match, "inport == \"%s\" && ip4.dst == %s && "
+                ds_put_format(&probe_match, "ip4.dst == %s && "
                               "ip4.src == %s && eth.dst == %s && ",
-                              backend_nb->logical_port,
                               backend_nb->svc_mon_src_ip,
                               backend->ip_str,
                               backend_nb->svc_mon_lrp->lrp_networks.ea_s);
                 if (!strcmp(protocol, "tcp")) {
-                    ds_put_format(match, "tcp.src == %s", backend->port_str);
+                    ds_put_format(&probe_match, "tcp.src == %s",
+                                  backend->port_str);
                 } else {
-                    ds_put_cstr(match, "icmp4.type == 3");
+                    ds_put_cstr(&probe_match, "icmp4.type == 3");
                 }
             }
 
@@ -9305,11 +9319,45 @@ build_lb_health_check_response_lflows(
             const char *meter = copp_meter_get(COPP_SVC_MONITOR,
                                                peer_switch_od->nbs->copp,
                                                meter_groups);
+            ds_put_format(match, "inport == \"%s\" && %s",
+                          backend_nb->logical_port, ds_cstr(&probe_match));
             ovn_lflow_add(lflows, peer_switch_od, S_SWITCH_IN_L2_LKUP, 110,
                           ds_cstr(match), "handle_svc_check(inport);",
                           lb_dps->lflow_ref, WITH_CTRL_METER(meter));
+
+            if (!external_backend) {
+                continue;
+            }
+
+            /* The reply from a type=external backend enters br-int through a
+             * localnet port, so MFF_LOG_INPORT holds that port and the lflow
+             * above never matches.  pinctrl_find_svc_monitor() is keyed on
+             * the backend port, which means the reply is dropped and the
+             * member's health is misreported: TCP monitors time out and go
+             * permanently offline, while UDP monitors, which only ever go
+             * offline through the ICMP unreachable punted here, stay online
+             * forever.
+             *
+             * Add a copy of the lflow per localnet port that restores the
+             * backend's inport before punting.  The rewrite is confined to
+             * this lflow, the action list ends with handle_svc_check() and
+             * carries no next;, so no later table can observe it. */
+            ds_put_format(action, "inport = \"%s\"; handle_svc_check(inport);",
+                          backend_nb->logical_port);
+
+            struct ovn_port *lp;
+            VECTOR_FOR_EACH (&peer_switch_od->localnet_ports, lp) {
+                ds_clear(match);
+                ds_put_format(match, "inport == %s && %s",
+                              lp->json_key, ds_cstr(&probe_match));
+                ovn_lflow_add(lflows, peer_switch_od, S_SWITCH_IN_L2_LKUP, 110,
+                              ds_cstr(match), ds_cstr(action),
+                              lb_dps->lflow_ref, WITH_CTRL_METER(meter));
+            }
         }
     }
+
+    ds_destroy(&probe_match);
 }
 
 static void
@@ -14115,6 +14163,7 @@ build_lrouter_flows_for_lb(struct ovn_lb_datapaths 
*lb_dps,
                            const struct shash *meter_groups,
                            const struct ovn_datapaths *lr_datapaths,
                            const struct lr_stateful_table *lr_stateful_table,
+                           const struct hmap *ls_ports,
                            const struct svc_monitors_map_data *svc_mons_data,
                            struct ds *match, struct ds *action)
 {
@@ -14138,7 +14187,7 @@ build_lrouter_flows_for_lb(struct ovn_lb_datapaths 
*lb_dps,
 
         build_lb_health_check_response_lflows(
             lflows, lb, lb_vip, &lb->vips_nb[i], lb_dps, lr_datapaths,
-            meter_groups, match, action);
+            ls_ports, meter_groups, match, action);
 
         if (!build_empty_lb_event_flow(lb_vip, lb, match, action)) {
             continue;
@@ -20605,6 +20654,7 @@ build_lflows_thread(void *arg)
                                                lsi->meter_groups,
                                                lsi->lr_datapaths,
                                                lsi->lr_stateful_table,
+                                               lsi->ls_ports,
                                                &svc_mons_data,
                                                &lsi->match, &lsi->actions);
                     build_lswitch_flows_for_lb(lb_dps, lsi->lflows,
@@ -20848,7 +20898,7 @@ build_lswitch_and_lrouter_flows(
                                               lsi.lr_datapaths, &lsi.match);
             build_lrouter_flows_for_lb(lb_dps, lsi.lflows, lsi.meter_groups,
                                        lsi.lr_datapaths, lsi.lr_stateful_table,
-                                       svc_mons_data,
+                                       lsi.ls_ports, svc_mons_data,
                                        &lsi.match, &lsi.actions);
             build_lswitch_flows_for_lb(lb_dps, lsi.lflows, lsi.meter_groups,
                                        lsi.ls_datapaths,
@@ -21259,6 +21309,7 @@ lflow_handle_northd_lb_changes(struct ovsdb_idl_txn 
*ovnsb_txn,
                                    lflow_input->meter_groups,
                                    lflow_input->lr_datapaths,
                                    lflow_input->lr_stateful_table,
+                                   lflow_input->ls_ports,
                                    &svc_mons_data,
                                    &match, &actions);
         build_lswitch_flows_for_lb(lb_dps, lflows,
diff --git a/tests/ovn-northd.at b/tests/ovn-northd.at
index 6d191c1a0b..2d3f59546f 100644
--- a/tests/ovn-northd.at
+++ b/tests/ovn-northd.at
@@ -1806,6 +1806,122 @@ OVN_CLEANUP_NORTHD
 AT_CLEANUP
 ])
 
+OVN_FOR_EACH_NORTHD_NO_HV_PARALLELIZATION([
+AT_SETUP([Load balancer health check reply lflows for type=external backends])
+ovn_start
+
+# Topology:
+#
+#   lr0 --(lr0-sw0)-- sw0 (tenant LS, no localnet)
+#                      `-- vm-port      (regular VIF backend)
+#
+#   lr0 --(lr0-prov)-- prov (provider LS with a localnet port)
+#                       |-- prov-localnet (type=localnet)
+#                       |-- bm-tcp        (type=external, TCP pool member)
+#                       |-- bm-udp        (type=external, UDP pool member)
+#                       `-- prov-vm       (regular VIF backend)
+#
+# A type=external LSP has no VIF of its own, so the health check reply from
+# the baremetal member re-enters br-int through the localnet port and
+# MFF_LOG_INPORT carries the localnet port, not the backend.  Every external
+# backend therefore gets an extra copy of the reply lflow per localnet port
+# that restores the backend's inport before punting to the controller.
+# Backends that are not type=external keep a single lflow even when the
+# switch does have a localnet port.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl lrp-add lr0 lr0-sw0 00:00:00:00:01:01 10.0.0.1/24
+check ovn-nbctl lrp-add lr0 lr0-prov 00:00:00:00:02:01 10.0.50.1/24
+
+check ovn-nbctl ls-add sw0
+check ovn-nbctl --wait=sb lsp-add sw0 sw0-lr0 \
+  -- lsp-set-type sw0-lr0 router \
+  -- lsp-set-options sw0-lr0 router-port=lr0-sw0 \
+  -- lsp-set-addresses sw0-lr0 router
+check ovn-nbctl --wait=sb lsp-add sw0 vm-port \
+  -- lsp-set-addresses vm-port "00:00:00:00:01:02 10.0.0.10"
+
+check ovn-nbctl ls-add prov
+check ovn-nbctl --wait=sb lsp-add prov prov-lr0 \
+  -- lsp-set-type prov-lr0 router \
+  -- lsp-set-options prov-lr0 router-port=lr0-prov \
+  -- lsp-set-addresses prov-lr0 router
+check ovn-nbctl --wait=sb lsp-add prov prov-localnet \
+  -- lsp-set-type prov-localnet localnet \
+  -- lsp-set-options prov-localnet network_name=physnet1 \
+  -- lsp-set-addresses prov-localnet unknown
+check ovn-nbctl --wait=sb lsp-add prov bm-tcp \
+  -- lsp-set-type bm-tcp external \
+  -- lsp-set-addresses bm-tcp "00:00:00:00:02:0a 10.0.50.10"
+check ovn-nbctl --wait=sb lsp-add prov bm-udp \
+  -- lsp-set-type bm-udp external \
+  -- lsp-set-addresses bm-udp "00:00:00:00:02:0b 10.0.50.20"
+check ovn-nbctl --wait=sb lsp-add prov prov-vm \
+  -- lsp-set-addresses prov-vm "00:00:00:00:02:0c 10.0.50.30"
+
+check ovn-sbctl chassis-add hv1 geneve 127.0.0.1
+check ovn-sbctl lsp-bind vm-port hv1
+check ovn-sbctl lsp-bind bm-tcp hv1
+check ovn-sbctl lsp-bind bm-udp hv1
+check ovn-sbctl lsp-bind prov-vm hv1
+
+# TCP LB: one regular backend on sw0, one external and one regular backend
+# on the provider switch.
+check ovn-nbctl lb-add lb1 192.168.0.10:80 \
+  10.0.0.10:80,10.0.50.10:80,10.0.50.30:80 tcp
+check ovn-nbctl --wait=sb set load_balancer lb1 \
+  ip_port_mappings:10.0.0.10=vm-port:10.0.0.1
+check ovn-nbctl --wait=sb set load_balancer lb1 \
+  ip_port_mappings:10.0.50.10=bm-tcp:10.0.50.1
+check ovn-nbctl --wait=sb set load_balancer lb1 \
+  ip_port_mappings:10.0.50.30=prov-vm:10.0.50.1
+check_uuid ovn-nbctl --wait=sb -- --id=@hc create Load_Balancer_Health_Check \
+  vip="192.168.0.10\:80" -- add Load_Balancer lb1 health_check @hc
+
+# UDP LB with an external backend.  A UDP monitor is only ever moved to
+# offline by the ICMP unreachable punted through this lflow.
+check ovn-nbctl lb-add lb2 192.168.0.20:80 10.0.50.20:80 udp
+check ovn-nbctl --wait=sb set load_balancer lb2 \
+  ip_port_mappings:10.0.50.20=bm-udp:10.0.50.1
+check_uuid ovn-nbctl --wait=sb -- --id=@hc create Load_Balancer_Health_Check \
+  vip="192.168.0.20\:80" -- add Load_Balancer lb2 health_check @hc
+
+check ovn-nbctl lr-lb-add lr0 lb1
+check ovn-nbctl lr-lb-add lr0 lb2
+check ovn-nbctl ls-lb-add sw0 lb1
+check ovn-nbctl ls-lb-add prov lb1
+check ovn-nbctl --wait=sb ls-lb-add prov lb2
+
+AS_BOX([Regular VIF backend on a switch without a localnet port.])
+AT_CAPTURE_FILE([sw0_lflows])
+AT_CHECK([ovn-sbctl dump-flows sw0 | tee sw0_lflows | grep ls_in_l2_lkup dnl
+ | grep handle_svc_check | ovn_strip_lflows], [0], [dnl
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(eth.dst == 
$svc_monitor_mac && (tcp || icmp || icmp6)), action=(handle_svc_check(inport);)
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(inport == "vm-port" && 
ip4.dst == 10.0.0.1 && ip4.src == 10.0.0.10 && eth.dst == 00:00:00:00:01:01 && 
tcp.src == 80), action=(handle_svc_check(inport);)
+])
+
+AS_BOX([External backends get a localnet copy, the regular one does not.])
+AT_CAPTURE_FILE([prov_lflows])
+AT_CHECK([ovn-sbctl dump-flows prov | tee prov_lflows | grep ls_in_l2_lkup dnl
+ | grep handle_svc_check | ovn_strip_lflows], [0], [dnl
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(eth.dst == 
$svc_monitor_mac && (tcp || icmp || icmp6)), action=(handle_svc_check(inport);)
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(inport == "bm-tcp" && 
ip4.dst == 10.0.50.1 && ip4.src == 10.0.50.10 && eth.dst == 00:00:00:00:02:01 
&& tcp.src == 80), action=(handle_svc_check(inport);)
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(inport == "bm-udp" && 
ip4.dst == 10.0.50.1 && ip4.src == 10.0.50.20 && eth.dst == 00:00:00:00:02:01 
&& icmp4.type == 3), action=(handle_svc_check(inport);)
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(inport == 
"prov-localnet" && ip4.dst == 10.0.50.1 && ip4.src == 10.0.50.10 && eth.dst == 
00:00:00:00:02:01 && tcp.src == 80), action=(inport = "bm-tcp"; 
handle_svc_check(inport);)
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(inport == 
"prov-localnet" && ip4.dst == 10.0.50.1 && ip4.src == 10.0.50.20 && eth.dst == 
00:00:00:00:02:01 && icmp4.type == 3), action=(inport = "bm-udp"; 
handle_svc_check(inport);)
+  table=??(ls_in_l2_lkup      ), priority=110  , match=(inport == "prov-vm" && 
ip4.dst == 10.0.50.1 && ip4.src == 10.0.50.30 && eth.dst == 00:00:00:00:02:01 
&& tcp.src == 80), action=(handle_svc_check(inport);)
+])
+
+AS_BOX([Removing the localnet port removes the extra lflows.])
+check ovn-nbctl --wait=sb lsp-del prov-localnet
+AT_CHECK([ovn-sbctl dump-flows prov | grep ls_in_l2_lkup dnl
+ | grep 'inport = ' | wc -l | tr -d ' '], [0], [0
+])
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
 OVN_FOR_EACH_NORTHD_NO_HV([
 AT_SETUP([Load balancer VIP in NAT entries])
 AT_SKIP_IF([test $HAVE_PYTHON = no])
-- 
2.49.0

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to