, ,

Cluster Networking, OVN-Kubernetes, Routes and Load Balancing (TKGI to OpenShift Series, Part 13)

On TKGI the NSX Container Plugin handed every LoadBalancer service a virtual server. OpenShift on vSphere does not. This Part wires Routes, ingress shards, MetalLB address pools and EgressIP on the cluster Part 12 installed.

TKGI to OpenShift Series · Part 13 of 26

oc get svc printed <pending> under EXTERNAL-IP for eleven minutes before I accepted that nothing was coming. On Tanzu Kubernetes Grid Integrated (TKGI) that column filled itself in, because the NSX Container Plugin (NCP) watched for services of type LoadBalancer and asked NSX-T for a virtual server. OpenShift Container Platform 4 (OCP) on vSphere ships no such watcher. The field stays empty, no event is raised, and nothing in the console tells you why.

Part 12 installed the cluster. This Part gives it a data path: the objects that carry HTTP into it, the shards that keep internal traffic away from external, the load balancer TKGI used to hand you at no effort, and the source address your database team allowlisted three years ago and has forgotten how to change.

Key takeaways: OVN-Kubernetes is already installed and is not a tunable in the way NCP was. You configure OpenShift objects, not the container network interface (CNI). Route, the OpenShift native north-south object, is what you should be creating; Kubernetes Ingress objects are accepted but generate Routes with random name suffixes that your ingress shards cannot select. Service type LoadBalancer stays pending on vSphere until MetalLB is installed, and MetalLB is the honest replacement for the NSX-T virtual servers NCP used to create. A second IngressController cannot use HostNetwork on nodes that already run the default one, so shard number two needs MetalLB underneath it. Headline command: oc -n openshift-ingress-operator get ingresscontroller default -o jsonpath='{.status.domain}’
Who this is for: you have a freshly installed OCP 4 cluster on vSphere with OVN-Kubernetes as the default network, no application traffic on it yet, and a TKGI estate still serving production through NSX-T load balancing. You read the networking assessment in Part 8 and now have to make the design real.

Preflight, What the Cluster Must Already Do

Four things have to be true before any of this is worth typing. Wildcard DNS for the apps domain resolves to the ingress virtual IP address (VIP) chosen at install. The ingress, dns and network cluster operators all report Available and not Degraded. The cluster network maximum transmission unit (MTU) is what you expect it to be. And you know which endpoint publishing strategy the default IngressController is using, because that single field decides whether you can add a second shard without extra hardware.

Everything below was run against OCP 4.18.12 with the matching oc client, MetalLB Operator from the redhat-operators catalog on the stable channel, against a source estate of TKGI 1.18 on NSX-T 4.1 with NCP. I use oc rather than kubectl throughout, and only once will I say why: oc knows about Route, Project, SecurityContextConstraints and the other OpenShift API groups, and it carries the login and project context that kubectl has no concept of. Everything oc does, it does through the same API server.

$ oc version –client Client Version: 4.18.12 Kustomize Version: v5.4.2 $ oc get clusteroperator network ingress dns NAME VERSION AVAILABLE PROGRESSING DEGRADED SINCE dns 4.18.12 True False False 42m ingress 4.18.12 True False False 38m network 4.18.12 True False False 51m $ oc get network.operator cluster -o jsonpath='{.spec.defaultNetwork.type}{"n"}{.status.clusterNetworkMTU}{"n"}’ OVNKubernetes 1400 $ oc -n openshift-ingress-operator get ingresscontroller default -o jsonpath='{.status.domain}{"n"}{.status.endpointPublishingStrategy.type}{"n"}’ apps.ocp-prod.corp.example HostNetwork

Two of those output lines matter more than they look. 1400 is the cluster network MTU on a 1500 byte host network, because Geneve encapsulation, the tunnelling protocol OVN-Kubernetes uses between nodes, takes the difference. If your vSphere port groups run 9000 byte jumbo frames and you assumed the pod network inherited them, it did not. HostNetwork is the endpoint publishing strategy the installer picks on vSphere, meaning the default router pods bind ports 80 and 443 directly on the worker nodes and a keepalived managed VIP floats between them. That is why a second shard cannot simply be created the same way.

What you had on TKGIOpenShift equivalentWho creates itWatch out for
NCP creating an NSX-T virtual server per LoadBalancer serviceMetalLB IPAddressPool plus L2AdvertisementYou, once, as an Operator installIn layer 2 mode the pool must sit inside the node subnet
Kubernetes Ingress object handled by NCPRoute object handled by the Ingress OperatorYou, per applicationIngress still works but generates unselectable Routes
One NSX-T load balancer per TKGI clusterIngressController shards inside one clusterYou, per traffic classDefault shard keeps serving everything unless you exclude
NSX-T SNAT giving a namespace a predictable source IPEgressIP custom resource on OVN-KubernetesYou, per namespaceSilently unassigned until a node carries the egress assignable label
NCP annotations tuning per service load balancingRoute annotations on haproxy, or a shard with its own tuningYou, per routeAnnotation names do not carry over, nothing is translated

That table is the artifact to keep from this Part. Print it, put it beside the migration wave plan from Part 6, and use it to answer the question every application team asks in week one, which is what happened to the load balancer they never had to think about.

Seconds from object created to externally reachable Median of five runs each, reference estate, one nonprod namespace, August 2026 OpenShift Route, edge 8 s MetalLB layer 2 VIP 21 s NSX-T virtual server via NCP 195 s 0100200 Lower is better. Route creation needs no southbound API call, which is the whole difference.
Publishing an application is roughly 24 times faster on Routes than it was through NCP and NSX-T.

Step by Step, Routes, Shards and Load Balancer Addresses

Step 1, expose the first migrated service

Start with the stateless storefront from wave one. Two ways exist to publish it. oc expose generates a Route with a derived hostname and no TLS. oc create route edge gives you the hostname you want and terminates TLS at the router with the cluster wildcard certificate. Use the second one in anything you intend to keep.

$ oc -n wave1-web create route edge storefront –service=storefront –port=8080 –hostname=storefront.apps.ocp-prod.corp.example route.route.openshift.io/storefront created $ oc -n wave1-web get route NAME HOST/PORT SERVICES PORT TERMINATION WILDCARD storefront storefront.apps.ocp-prod.corp.example storefront 8080 edge None $ curl -sI https://storefront.apps.ocp-prod.corp.example HTTP/1.0 503 Service Unavailable $ oc -n wave1-web get endpoints storefront NAME ENDPOINTS AGE storefront <none> 6m $ oc -n wave1-web get events –field-selector reason=FailedCreate Error creating: pods "storefront-6d9f7b4c85-" is forbidden: unable to validate against any security context constraint: [provider "restricted-v2": .spec.securityContext.runAsUser: Invalid value: 0: must be in the ranges: [1000730000, 1000739999]]

A 503 from the router is almost never a routing problem. It means haproxy has a Route, resolved a Service, and found no endpoints behind it. Here the pods never started, because the image runs as user 0 and restricted-v2, the default Security Context Constraint (SCC), refuses it. That is the admission gap from Part 7 arriving at the exact moment you thought you were doing networking. Fix the image or bind the service account to nonroot-v2, then re-check endpoints before touching anything in the router.

Step 2, decide what happens to your Ingress objects

You will arrive with a pile of them. Our reference estate had 61 Ingress objects across three TKGI clusters. Apply one unchanged to OpenShift and it works, which is exactly why teams stop thinking about it.

$ oc -n wave1-web apply -f ingress-from-tkgi.yaml ingress.networking.k8s.io/checkout created $ oc -n wave1-web get route NAME HOST/PORT SERVICES PORT TERMINATION storefront storefront.apps.ocp-prod.corp.example storefront 8080 edge checkout-xr7k2 checkout.apps.ocp-prod.corp.example checkout 8443 edge $ oc -n wave1-web get route checkout-xr7k2 -o jsonpath='{.metadata.labels}{"n"}’ {"ingress":"checkout"} # now the silent one: an Ingress that names a class OpenShift does not own $ oc -n wave1-web apply -f ingress-nginx-class.yaml ingress.networking.k8s.io/reports created $ oc -n wave1-web get route | grep reports $ oc -n wave1-web describe ingress reports | tail -3 Events: <none>

Look at the generated name. checkout-xr7k2 carries a random suffix that changes if the Ingress is recreated, and the only label on it is one the Operator wrote. Anything that selects routes by name breaks on the next apply, and ingress sharding by route label cannot see it at all, because you cannot add your own labels to an object you did not create. Then look at the last two commands. An Ingress naming an ingressClassName that OpenShift does not own produces no Route, no warning and no event. It is simply ignored, and someone finds out during the cutover window.

Against the usual advice: most migration guides tell you to keep Ingress objects because they are portable and OpenShift accepts them. Convert them to Route instead. Portability you never exercise is worth less than a labelled, named object your shards can select and your pipeline can update. Convert during wave one while the count is small, not during wave three when it is 61.

Step 3, install MetalLB before you build the second shard

Order matters here and most walkthroughs get it backwards. Because the default IngressController already holds ports 80 and 443 on every worker through HostNetwork, a second shard needs an address of its own, and on vSphere that address has to come from somewhere. MetalLB is that somewhere. Install the Operator into metallb-system from OperatorHub, then create the three objects below.

apiVersion: metallb.io/v1beta1 kind: MetalLB metadata: name: metallb namespace: metallb-system — apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: ingress-pool namespace: metallb-system spec: addresses: – 10.42.16.240-10.42.16.249 autoAssign: true — apiVersion: metallb.io/v1beta1 kind: L2Advertisement metadata: name: ingress-l2 namespace: metallb-system spec: ipAddressPools: – ingress-pool

Layer 2 mode answers ARP for the pool addresses from whichever node currently owns them, which gives failover but not load spreading. Border Gateway Protocol (BGP) mode spreads sessions across nodes and needs a peering session with your top of rack switches, plus a network team willing to grant an autonomous system number. Start on layer 2. Move to BGP when a measurement, not an opinion, says one node is the bottleneck.

Step 4, create the internal shard and exclude it from the default

One shard per traffic class is the pattern that survives. In the reference estate that means two: the default one serving customer facing routes on the apps wildcard, and an internal one serving back office routes on a separate wildcard that only resolves inside the corporate network. The second object below is not optional, and skipping it is the mistake in the field note further down.

apiVersion: operator.openshift.io/v1 kind: IngressController metadata: name: internal namespace: openshift-ingress-operator spec: domain: int.ocp-prod.corp.example replicas: 2 routeSelector: matchLabels: shard: internal endpointPublishingStrategy: type: LoadBalancerService loadBalancer: scope: External # exclude the shard from the default controller, or both will serve it $ oc -n openshift-ingress-operator patch ingresscontroller default –type=merge -p ‘{"spec":{"routeSelector":{"matchExpressions":[{"key":"shard","operator":"NotIn","values":["internal"]}]}}}’ ingresscontroller.operator.openshift.io/default patched $ oc -n openshift-ingress get svc router-internal NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) router-internal LoadBalancer 172.30.44.12 10.42.16.240 80:31234/TCP,443:30987/TCP

Had you set endpointPublishingStrategy to HostNetwork on that shard, copying what the default controller does, the router pods would never have scheduled. Worth seeing the real message once, because it reads like a capacity problem and is not.

$ oc -n openshift-ingress get pods -l ingresscontroller.operator.openshift.io/deployment-ingresscontroller=internal NAME READY STATUS RESTARTS AGE router-internal-5b8c9f7d4-2xk9v 0/1 Pending 0 3m $ oc -n openshift-ingress describe pod router-internal-5b8c9f7d4-2xk9v | grep -A2 Events Events: Warning FailedScheduling 0/6 nodes are available: 3 node(s) did not have free ports for the requested pod ports, 3 node(s) had untolerated taint {node-role.kubernetes.io/master: }.

Step 5, give the namespace a predictable source address

This is the step nobody plans for and everybody needs. On TKGI, NSX-T applied source network address translation (SNAT) per namespace, so an application talking to an Oracle database left the cluster wearing one predictable address, and a firewall rule was written against it years ago. Move the workload to OpenShift without an EgressIP and it leaves wearing the node address, which changes whenever the pod reschedules, and the database connection is refused by a rule nobody remembers writing.

$ oc get egressip NAME EGRESSIPS ASSIGNED NODE ASSIGNED EGRESSIPS wave1-egress 10.42.16.201 # no assigned node, and no event explaining it. label the nodes that may carry one $ oc label node worker-01 worker-02 worker-03 k8s.ovn.org/egress-assignable="" node/worker-01 labeled node/worker-02 labeled node/worker-03 labeled $ oc get egressip NAME EGRESSIPS ASSIGNED NODE ASSIGNED EGRESSIPS wave1-egress 10.42.16.201 worker-02 10.42.16.201

The EgressIP object itself selects namespaces by label and lists addresses that must be routable in the node subnet but outside any DHCP scope and outside the MetalLB pool. Overlap those ranges and you get a duplicate address on the wire, which vSphere will report as a MAC flap and your network team will report as your fault.

apiVersion: k8s.ovn.org/v1 kind: EgressIP metadata: name: wave1-egress spec: egressIPs: – 10.42.16.201 namespaceSelector: matchLabels: kubernetes.io/metadata.name: wave1-web
flowchart LR
  C1[External client] --> D1[Wildcard DNS apps domain]
  D1 --> V1[Ingress VIP on keepalived]
  V1 --> R1[Default router shard]
  C2[Internal client] --> D2[Wildcard DNS int domain]
  D2 --> V2[MetalLB layer 2 address]
  V2 --> R2[Internal router shard]
  R1 --> S[Service ClusterIP]
  R2 --> S
  S --> P[Pod on Geneve overlay]
Two wildcards, two addresses, two router shards, one Service. Route labels decide which shard admits which hostname.

Verification and Rollback

Four checks prove the whole chain, and each of them fails loudly rather than quietly, which is the point of choosing them.

$ oc get route –all-namespaces -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,ADMITTED:.status.ingress[*].conditions[0].status,BY:.status.ingress[*].routerName NS NAME ADMITTED BY wave1-web storefront True default back-office reports True internal $ curl -sI https://storefront.apps.ocp-prod.corp.example | head -1 HTTP/2 200 $ oc -n openshift-ingress get svc router-internal -o jsonpath='{.status.loadBalancer.ingress[0].ip}{"n"}’ 10.42.16.240 $ oc -n wave1-web run nettest –image=registry.access.redhat.com/ubi9/ubi-minimal –rm -it –restart=Never — curl -s https://ifconfig.me 10.42.16.201

A clean result looks like this. Every Route shows ADMITTED True against exactly one routerName, never two. External hostnames answer 200 with the wildcard certificate your browser already trusts. router-internal has an address from the MetalLB pool rather than pending. Outbound traffic from the migrated namespace presents the EgressIP, not a node address. And oc get clusteroperator ingress still shows Degraded False, because a shard that cannot schedule will degrade the whole ingress operator, not just itself.

Rollback is unusually cheap at this stage, which is the argument for doing all of it before any production DNS changes. Remove the routeSelector from the default IngressController and it re-admits every route within seconds, so a broken shard never means a dark application. Delete the shard IngressController and its Service and router pods go with it. For MetalLB, delete the L2Advertisement first and the IPAddressPool second, otherwise addresses stay advertised on the wire for a few seconds longer than you expect. Remove an EgressIP by deleting the custom resource; traffic reverts to node source addresses immediately, so re-open the firewall rule before you do it, not after. Keep the TKGI NSX-T virtual servers live and serving throughout. Nothing in this Part requires them to be switched off, and Part 20 handles the actual DNS cutover.

Networking Failures and Remediation

What you seeActual causeFix
503 Application is not available from the routerService has no endpoints, usually pods blocked by restricted-v2oc get endpoints first; fix the image or bind nonroot-v2 before touching ingress
EXTERNAL-IP stuck at pending on a LoadBalancer serviceNo load balancer provider on vSphere, and no error is raisedInstall MetalLB, create an IPAddressPool and an L2Advertisement
Router pods Pending with no free ports for the requested pod portsSecond shard set to HostNetwork while the default already binds 80 and 443Switch the shard to LoadBalancerService, or pin it to separate infra nodes
One Route admitted by two routerName valuesDefault IngressController still matches the sharded labelPatch the default with a NotIn matchExpression for the shard label
MetalLB address assigned but nothing answers ARP for itPool sits outside the node subnet, which layer 2 mode requiresMove the pool into the node subnet or switch that pool to BGP mode
EgressIP created but ASSIGNED NODE stays blankNo node carries the k8s.ovn.org/egress-assignable labelLabel at least two workers so the address survives a node reboot
An applied Ingress produces no Route and no eventingressClassName names a controller OpenShift does not ownDrop the field or convert the object to a Route with your own labels

Field Note from a Wildcard Certificate That Followed the Wrong Router

Saturday, 02:10. We had built the internal shard exactly as above, minus one command. I created the internal IngressController with its routeSelector and never patched the default controller to exclude the same label, because the documentation example only ever shows the new object and my reading was that a labelled route belongs to whoever claims it. It does not work that way. The default controller keeps serving every route it is not explicitly told to ignore, so six back office applications were admitted by both routers at once.

Nothing failed. That was the problem. Both routers answered, the internal wildcard DNS record happened to resolve to an address that reached the default router first, and every request landed on a router holding the apps wildcard certificate rather than the int one. Browsers threw a certificate name mismatch, curl threw an SSL error, and the back office team logged a P2 saying the platform had broken TLS. It took 40 minutes to stop looking at certificates and run the one command that shows routerName per route, at which point two entries in the BY column made the cause obvious. One patch, and the second entry disappeared inside 20 seconds.

Questions from the network review

Can we keep NSX-T doing the load balancing and point it at OpenShift. Yes, as an external load balancer in front of the ingress VIP, and plenty of estates run exactly that during transition. What you cannot do is have NSX-T create per service virtual servers automatically, because that was NCP, and NCP has no OpenShift equivalent.

Do we need MetalLB if everything is HTTP. No. Routes handle HTTP and HTTPS through the router shards and MetalLB adds nothing there. You need it for a second shard, and for non HTTP TCP or UDP services such as message brokers and database front ends that used to sit behind an NSX-T virtual server.

What replaces the NSX-T distributed firewall rules between namespaces. NetworkPolicy and AdminNetworkPolicy on OVN-Kubernetes, which is Part 16 and deserves its own runbook rather than a paragraph here. If the landing place you are weighing is VMware vSphere Kubernetes Service rather than OpenShift, the NSX-T posture carries over far more directly, and the TKGI to VKS guide covers that path.

Two Shards, One MetalLB Pool, and Route Objects Everywhere

My recommendation for a TKGI estate landing on OpenShift is narrow on purpose. Build exactly two IngressController shards, external and internal, and resist the pull to recreate one shard per old TKGI cluster; that habit is what Part 2 argued against and it costs you router pods and certificates for no isolation you actually enforce. Install MetalLB once, in layer 2 mode, with a single pool sized for the shard plus a handful of non HTTP services, and leave BGP for the day a measurement demands it. Convert every Ingress object to a Route as it migrates, with your own name and your own shard label, so the object your pipeline updates is the object the router reads.

On Monday, run the route admission check across all namespaces on whatever cluster you have, even an empty one, and confirm the BY column holds exactly one routerName per route. It takes ten seconds and it is the check I did not have on that Saturday. Part 14 takes this networked cluster and turns to projects, quotas, MachineSets and node scaling, where the TKGI plan model finally gets replaced by something you declare rather than order.

TKGI to OpenShift Series · Part 13 of 26
« Previous: Part 12  |  Guide  |  Next: Part 14 »

References

Red Hat, OVN-Kubernetes network plugin, OpenShift Container Platform 4.19

Red Hat, MetalLB Operator, Networking Operators, OpenShift Container Platform 4.18

Red Hat, Ingress sharding in OpenShift Container Platform, Networking

Red Hat, Configuring an egress IP address, OVN-Kubernetes network plugin, OpenShift Container Platform 4.19

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading