OnCallReady

Lesson 30.13 · OpenShift · 24 min read

Routes and the router

In plain words

Imagine a big hotel with one reception desk. Every guest arrives at the same front door, says a name, and reception looks up which room that name belongs to and walks them straight to the room, not through the corridor manager. If the name is not on the list, or the room is empty, reception says "sorry, not available", and the guest never reaches the room.

OpenShift's router is that reception: HAProxy (a proxy program) pods in openshift-ingress, behind a wildcard DNS name *.apps.ocp.lab (every name under apps points at the router). A Route maps a host like web-rt.apps.ocp.lab to a Service, and the router sends traffic directly to the Service's endpoint pod IPs. If nothing matches, you get the router's 503 page "Application is not available". The Route's targetPort must match an endpoint port, or there is no backend.

Why the router is the first suspect

Users type https://shop.apps.example.com and get a grey OpenShift page: "Application is not available". Your pods are Running. Between the user and the pods sits the router, and a Route that is off by one port number is enough to break everything.

What you need to know already: Services, selectors and endpoints (16.1); Ingress, an ingress controller and host/path rules (16.19); DNS names and wildcard records (8.22); reverse proxies and load balancers (9.23); HTTP status codes 502, 503, 504 and curl -s -o /dev/null -w '%{http_code}' (9.21, 9.27); oc expose from 30.6.

The words you need first

One more hop between the user and the pod

On OpenShift every cluster ships a working ingress layer: the Ingress Operator runs an IngressController called default, which is a Deployment of HAProxy router pods in openshift-ingress. DNS has a wildcard - *.apps.<cluster>.<domain>

floating IP address that moves between machines - or the nodes on bare metal). Every Route and every Ingress becomes a backend in HAProxy's config.

The IngressController's status.domain is the apps domain; dig +short (8.18) shows that any name under it resolves to the router:

# as kubeadmin
oc get ingresscontroller default -n openshift-ingress-operator -o jsonpath='{.status.domain}{"\n"}'
apps.ocp.lab
oc get pods -n openshift-ingress
NAME                              READY   STATUS    RESTARTS   AGE
router-default-7884t2w448-5pmj5   1/1     Running   0          13d
router-default-7884t2w448-bmbmh   1/1     Running   0          13d
dig +short anything-at-all.apps.ocp.lab
10.64.0.30

(simulator) The lab router answers on 10.64.0.30; you cannot exec into it.

The path of a request: client -> DNS *.apps -> router -> directly to a pod IP. The router reads the Service only to find its endpoints (the list of ready pod IP:port pairs, 16.1); traffic does not go through the Service's ClusterIP (no kube-proxy hop, 16.3). That detail explains most Route problems.

Route vs Ingress

Terms in the table: TLS termination = the place where the encrypted connection is decrypted (9.15); A/B, blue-green = sending some traffic to a new version and the rest to the old one (26.26); PEM = the text format of certificates and keys (9.17).

Ingress (networking.k8s.io)Route (route.openshift.io)
standardupstream Kubernetes, any controllerOpenShift only (older than Ingress, which it inspired)
TLS modesterminate at the controller (+ controller-specific annotations for re-encrypt/passthrough)edge, passthrough, reencrypt are first-class fields
plain HTTP when TLS is setcontroller-specificinsecureEdgeTerminationPolicy: None / Allow / Redirect
weighted backendsnot in the APIto + up to 3 alternateBackends with weights (A/B, blue-green)
hostyou must write itgenerated if empty: <route>-<project>.<apps domain>
one object =many hosts and pathsone host (+ optional path)
cert per hosta Secret referenceinline PEM in the Route, or externalCertificate (a Secret)

On OpenShift you can create Ingress objects too: the ingress-to-route controller turns each rule into a Route (owned by the Ingress, named <ingress>-<hash>), so the same router serves both. The Gateway API is also supported in recent versions. In practice most OpenShift shops use Routes directly; charts written for vanilla Kubernetes keep working through their Ingress.

Making one

oc create service clusterip web --tcp=80:8080 makes a Service whose port 80 forwards to the pods' 8080; oc expose svc/NAME then makes a Route for that Service:

$ oc create deployment web --image=docker.io/nginxinc/nginx-unprivileged:1.27 --port=8080
$ oc create service clusterip web --tcp=80:8080
$ oc expose svc/web
route.route.openshift.io/web exposed
$ oc get route web
NAME   HOST/PORT             PATH   SERVICES   PORT      TERMINATION   WILDCARD
web    web-rt.apps.ocp.lab          web        80-8080                 None
$ oc rollout status deployment/web
deployment "web" successfully rolled out
$ curl -s -o /dev/null -w '%{http_code}\n' http://web-rt.apps.ocp.lab/
200

Curl it before the pod is ready and the router answers 503 (no endpoints behind the Service yet) - that is why the rollout comes first.

Reading the columns:

The object:

$ oc get route web -o yaml
apiVersion: route.openshift.io/v1
kind: Route
metadata:
  annotations:
    openshift.io/host.generated: "true"
  labels:
    app: web
  name: web
  namespace: rt
spec:
  host: web-rt.apps.ocp.lab
  port:
    targetPort: 80-8080
  to:
    kind: Service
    name: web
    weight: 100
  wildcardPolicy: None
status:
  ingress:
  - conditions:
    - lastTransitionTime: "2026-09-22T20:00:24Z"
      status: "True"
      type: Admitted
    host: web-rt.apps.ocp.lab
    routerCanonicalHostname: router-default.apps.ocp.lab
    routerName: default
    wildcardPolicy: None

status.ingress has one entry per router that looked at the route. Admitted: True means the router serves it (a different "admission" from 30.7: here it is the router accepting the Route). weight: 100 is this backend's share of traffic; wildcardPolicy: None means the route serves exactly its host, not *.host. spec.host cannot be changed after creation (delete and recreate), and setting a custom host needs the routes/custom-host permission, which project admins and editors have.

targetPort: the trap

$ oc explain route.spec.port.targetPort
FIELD: targetPort <IntOrString>

DESCRIPTION:
    The target port on pods selected by the service this route points to. If
    this is a string, it will be looked up as a named port in the target
    endpoints port list. Required

A number means a port on the pods (the endpoint port), not the Service's port. A name means the Service port's name (which endpoints inherit). With a Service port: 80 -> targetPort: 8080:

route targetPortresult
8080matches the endpoints - works
80-8080 (the port's name)works
80no endpoint has port 80 - the router has no backend: 503
(unset)the router uses the Service's first port's endpoints - works

--name names the Route; --port sets its targetPort:

$ oc expose svc/web --name=web-bad --port=80
route.route.openshift.io/web-bad exposed
$ curl -s -o /dev/null -w '%{http_code}\n' http://web-bad-rt.apps.ocp.lab/
503
$ oc describe route web-bad
Name:                   web-bad
Namespace:              rt
...
Requested Host:         web-bad-rt.apps.ocp.lab
                           exposed on router default (host router-default.apps.ocp.lab) 3s ago
Path:                   <none>
TLS Termination:        <none>
Insecure Policy:        <none>
Endpoint Port:          80

Service:	web
Weight:		100 (100%)
Endpoints:	10.244.2.127:8080

The describe output hands you the answer if you read it: Endpoint Port: 80, while the endpoints are on :8080. oc expose --port sets exactly this field - it is not "the Service port", whatever it looks like.

The 503 page

$ curl -s http://web-bad-rt.apps.ocp.lab/ | grep -A1 '<h1>'
      <h1>Application is not available</h1>
      <p>The application is currently not serving requests at this endpoint. It may not have been started or is still starting.</p>

The page lists its own three causes, and they are the three you check, in order:

  1. The host doesn't exist - no admitted route with that host (typo, wrong project, route rejected). oc get route and its HOST/PORT column.
  2. The host exists, but doesn't have a matching path - a route with path: /api does not serve /.
  3. Route and path match, but all pods are down - really: the router has no usable endpoint. No ready pods (oc get endpoints), a Service selector that matches nothing, or a targetPort that matches no endpoint port.

Plus the TLS variants from the next lesson: an http:// request to a TLS route whose insecure policy is None, and a re-encrypt route whose backend does not speak TLS, both get the same page.

It is always HTTP 503 from the router, never from your pods - the router adds the header Pragma: no-cache; your application never saw the request. Contrast a 502/504 from the router (backend accepted the connection and failed or was too slow) and a 404 or 500 that your app itself returned (the route is fine).

Host claims and paths

Two routes cannot have the same host+path. The oldest wins, and routes from another project can never take a host a project already owns:

# the routes mission's state
oc get route web -n other
NAME   HOST/PORT            PATH   SERVICES   PORT   TERMINATION   WILDCARD
web    HostAlreadyClaimed          web        8080                 None
oc describe route web -n other | grep -A1 rejected
                           rejected by router default:  (host router-default.apps.ocp.lab)HostAlreadyClaimed (12s ago)
                             route web already exposes shop.apps.ocp.lab and is older

Path routes share a host between services (--hostname sets the host instead of the generated one, --path the URL prefix):

oc expose svc/api --hostname=shop.apps.ocp.lab --path=/api
oc expose svc/web --hostname=shop.apps.ocp.lab

/api/orders goes to api (longest path wins), everything else to web. The path is not stripped - the api pods see /api/orders.

Router annotations you will actually use

The router is tuned per Route with annotations; oc annotate route NAME key=value sets one (the same verb as kubectl annotate):

$ oc annotate route web haproxy.router.openshift.io/timeout=2m
$ oc annotate route web haproxy.router.openshift.io/disable_cookies=true
$ oc annotate route web haproxy.router.openshift.io/balance=roundrobin
$ oc annotate route web haproxy.router.openshift.io/ip_allowlist='10.0.0.0/8 10.64.0.0/24'

curl -sI fetches only the response headers (9.21) - here you can see the cookie:

curl -sI http://web-rt.apps.ocp.lab/
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 615
Set-Cookie: d4f87af586e8ff12b6606ea6df49abdd=a61a2595262d97d27c9925c2a930fbc5; path=/; HttpOnly
Cache-control: private

Weights: blue-green on one host

# a second service web-v2 behind the route
oc set route-backends web web-v1=90 web-v2=10
route.route.openshift.io/web backends updated
oc set route-backends web
NAME        KIND     TO      WEIGHT
routes/web  Service  web-v1  90 (90%)
routes/web  Service  web-v2  10 (10%)
oc get route web
NAME   HOST/PORT             PATH   SERVICES                   PORT   TERMINATION   WILDCARD
web    web-rt.apps.ocp.lab          web-v1(90%),web-v2(10%)    8080                 None

alternateBackends (up to three) with weights 0-256: the router splits connections in that ratio. The first backend you name becomes spec.to, the rest alternateBackends; backends you leave out are dropped. --adjust web-v2=+10% shifts traffic between one backend and the primary, --zero / --equal set every weight to 0 / 100, and oc set route-backends web alone prints the current split.

What you can now do

Why it helps

"Application is not available" is the error your developers will send you screenshots of, and this lesson makes it a checklist: is there an admitted route for that host, does the path match, and does the router have a usable endpoint. The last one hides the classic trap: oc expose --port=80 sets the Route's targetPort to 80, while pods listen on 8080, so oc describe route shows Endpoint Port 80 and the router has no backend.

Real enterprise apps also hit the 30-second default router timeout on slow report endpoints (a router 504) and need the haproxy.router.openshift.io/timeout annotation, plus sticky-session cookies they did not expect. HostAlreadyClaimed explains why another team cannot take your hostname. Route versus Ingress and weighted backends for blue-green are common interview topics for OpenShift roles.

Commands in this lesson

oc curl

FAQ

What is the difference between a Route and an Ingress?

Ingress is the upstream Kubernetes API, served by whichever controller you install. Route is OpenShift's own API, served by the built-in HAProxy router: one host and optional path per object, first-class edge, passthrough and reencrypt TLS, an insecure-traffic policy, weighted alternate backends, and generated hostnames. On OpenShift, Ingress objects are converted into Routes, so both work; most OpenShift teams use Routes directly.

Does traffic from the router go through the Service's ClusterIP?

No. The router uses the Service only to find its endpoints and sends connections directly to pod IPs, without the kube-proxy hop. That is why the Route's targetPort must match an endpoint port (the pod port or the Service port's name), not the Service's port. It also means router features like load balancing algorithm and sticky cookies apply per pod.

Why does my route return 503 "Application is not available"?

The router has nothing to send the request to. Check in order: there is no admitted route for that host (typo, wrong project, rejected such as HostAlreadyClaimed); the host exists but no route matches the path; or there is no usable endpoint because no pods are ready, the Service selector matches nothing, or the route's targetPort matches no endpoint port. For TLS routes, plain HTTP with insecure policy None also gives 503.

Why does my slow endpoint return 504 through the route?

The router's default server timeout is 30 seconds. A request that takes longer is cut off by HAProxy with a 504 "The server didn't respond in time", even though the application may still finish. Raise it per route with oc annotate route web haproxy.router.openshift.io/timeout=2m, or better, make long operations asynchronous. A 504 or 502 comes from the router talking to your pod; a 503 means no backend at all.

Why does the router set a cookie on my responses?

For plain HTTP, edge and reencrypt routes the router enables sticky sessions by default with a cookie, so a client keeps hitting the same pod. Stateless APIs often do not want that, because it can unbalance load; disable it with the annotation haproxy.router.openshift.io/disable_cookies=true, and choose a balancing algorithm with haproxy.router.openshift.io/balance. Passthrough routes cannot set cookies because the router never sees HTTP.

In an interview Mid

Users get the OpenShift "Application is not available" page. Walk through your troubleshooting.

That page is an HTTP 503 from the router (HAProxy) - your pods never saw the request. The router sends traffic directly to pod IPs from the Service's endpoints, so the page lists its own causes, and I check them in order:

  1. The host does not exist - oc get route: is there an admitted route with that host? Typo, wrong project, or HostAlreadyClaimed (an older route elsewhere owns it).
  2. No matching path - a route with path: /api does not serve /.
  3. No usable endpoint: oc get endpoints - no ready pods, a Service selector matching nothing, or the classic: the Route's targetPort is the pod port (or the Service port's name), not the Service port. --port=80 against endpoints on 8080 = 503; oc describe route shows "Endpoint Port: 80".
  4. TLS variants: http:// to a TLS route with insecure policy None, or a reencrypt route whose backend does not speak TLS.

Different codes, different places: a router 504 = the backend was slower than the router timeout (30 s; haproxy.router.openshift.io/timeout), 502 = the backend failed, 404/500 = your app answered.

Also asked: How do you expose an application outside the cluster on OpenShift? · What is the difference between a Route and an Ingress? · How would you use Routes for a blue-green or canary release?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.