Kubernetes · OpenShift

OpenShift Networking & Storage

Routes and the four TLS modes, OVN-Kubernetes and the MTU fault everybody misdiagnoses, NetworkPolicy isolation that silently blinds monitoring, and the CSI chain where each stage fails in a different log.

26 min read Level: core → advanced OpenShift 02 / 03
The model

HOW A REQUEST REACHES A POD

Eight layers between the browser and the container, each able to drop the packet for its own reason.

CLIENTBrowser / API clientDNS*.apps.<cluster>.<domain>EDGELoad balancer / VIPINGRESSRouter pods (HAProxy)Route objectTLS terminationSERVICEClusterIPEndpointSliceOVERLAYOVN-KubernetesGeneve tunnelNetworkPolicy / ACLNODEbr-int (OVS)veth pairPODContainer netnseth0

The router is the only layer that returns a useful status code. Everything below it fails by dropping, which is why the triage table later works upwards from the pod rather than down from the client.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat is OVN-Kubernetes doing on each node?
OVN-KUBERNETES, ONE NODEPod created (CNIADD)NetworkPolicypod veth + IPflows in br-intCONTROLovnkube-masterwatches pods and policynorthbound DBlogical intentsouthbound DBphysical flowsON THE NODEovnkube-nodeprograms the bridgebr-int (OVS)every pod veth lands hereOpenFlow rulesthe actual dataplanePOLICYNetworkPolicy → ACLsdefault allow until the first policyEgressIP / EgressFirewallOpenShift extrasgeneve tunnelsnode to node
NetworkPolicy is translated to OVS ACLs, and the translation is where the default flips: a namespace with no policy allows everything, and the first policy selecting a pod denies everything else to it.
ConnectionHow does outside traffic reach a pod, and how does a pod reach its disk?
external clientDNS → *.apps wildcardHAProxy router pod:443 TLSRoute → Serviceedge / passthroughEndpointSlice → pod IPbr-int flowsgeneve if cross-nodepod :8080PVC → CSI mountattach, then mount
The Route is OpenShift's own object and terminates TLS in the router by default, which is why a passthrough Route behaves so differently: the pod must then serve TLS itself.
Core

NETWORKING

Routes, the overlay, service endpoints and the policy model.

A Route is OpenShift's ingress object. It predates the Kubernetes Ingress API, and OpenShift supports both — an Ingress is silently converted into a Route by the ingress operator, which is worth knowing because the object you debug is the Route even when the object you created was an Ingress.

What a Route gives you that plain Ingress does not: per-route TLS policy, weighted backends for canary splits, and configuration knobs (timeouts, balance algorithm, rate limits) as annotations that the router actually honours.

The four TLS modes, and which one you want

ModeRouter doesPod receivesUse when
edgeTerminates TLSPlain HTTPDefault. Traffic inside the cluster is on the overlay.
passthroughForwards bytes, does not decryptTLSThe app must see the client cert (mTLS), or does its own TLS.
reencryptTerminates, then opens a new TLS connectionTLSYou need the router's cert externally and encryption internally.
nonePlain HTTP end to endHTTPEssentially never in production.

passthrough cannot do path-based routing. The router never decrypts, so it cannot read the path — it routes on SNI alone. A passthrough Route with a path: set is a configuration that silently does not do what it says.

what the router actually did with your route
$ oc get route app -o jsonpath='{.status.ingress[*].conditions[*]}' | jq
{ "type": "Admitted", "status": "False",
"reason": "HostAlreadyClaimed",
"message": "route app already exposes app.apps.ocp.example.com and is older" }
Admitted=False is the single most useful field on a Route. A route that is not
admitted returns 503 from the router and looks exactly like a broken backend.
$ oc -n openshift-ingress rsh deploy/router-default cat haproxy.config | grep -A6 app

OVN-Kubernetes is the default CNI on modern OpenShift. Each node gets a slice of the cluster CIDR; pods get an address from their node's slice; traffic between nodes is encapsulated in Geneve over the node network.

Three consequences that matter operationally:

  • MTU is not negotiable. Geneve adds ~100 bytes of header. If the node network MTU is 1500, the pod MTU must be ~1400. Get this wrong and small packets work perfectly while large ones vanish — which presents as "TLS handshakes fine, large responses hang", the single most misdiagnosed cluster networking fault there is.
  • NetworkPolicy is enforced in OVS flows, not iptables. iptables -L on the node tells you nothing about why a pod was blocked.
  • The cluster CIDR cannot be changed after install. Sizing it too small is permanent; the node subnet size caps pods per node.
the MTU check that resolves the 'large responses hang' incident
$ oc get network.operator cluster -o jsonpath='{.spec.defaultNetwork.ovnKubernetesConfig.mtu}'
1400
$ oc debug node/worker-01 -- chroot /host ip link show br-ex | grep mtu
3: br-ex: <BROADCAST,MULTICAST,UP> mtu 1500
1500 - 100 (Geneve) = 1400. Correct here. If the underlay is 1450, it is not.
prove it from inside a pod — DF set, so it fails rather than fragmenting:
$ oc rsh deploy/app ping -M do -s 1372 10.128.4.9
PING 10.128.4.9 1372(1400) bytes of data.
1380 bytes from 10.128.4.9: icmp_seq=1 ttl=64 time=0.31 ms
$ oc rsh deploy/app ping -M do -s 1400 10.128.4.9
ping: local error: message too long, mtu=1400 <- the ceiling, confirmed

A Service is a stable name and VIP; the real destination list is the EndpointSlice. An endpoint appears there only when the pod is Ready. So the single most common "the service returns 503" cause is not networking at all — it is a readiness probe failing, which silently empties the slice.

Cluster DNS is CoreDNS, and names resolve as <service>.<namespace>.svc.cluster.local. Short names work inside the same namespace through the search path — which is also why a pod resolving db in the wrong namespace gets a confusing answer rather than an error.

service returning 503 — three commands, in this order
$ oc get endpointslice -l kubernetes.io/service-name=api
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
api-x7k2n IPv4 8080 <unset> 4d
empty ENDPOINTS = no Ready pod. This is a probe problem, not a network problem.
$ oc get pods -l app=api -o wide
NAME READY STATUS RESTARTS
api-7d9f-x2 0/1 Running 0 <- Running but not Ready
$ oc describe pod api-7d9f-x2 | grep -A3 Readiness
Readiness probe failed: HTTP probe failed with statuscode: 500

An empty cluster allows every pod to reach every other pod, in every namespace. NetworkPolicy is additive allow-listing with an unusual trigger: a pod is unrestricted until at least one policy selects it, and from that moment only traffic explicitly allowed by some policy reaches it.

That produces the classic self-inflicted outage. You add one policy allowing frontend → backend. The backend is now isolated, so its egress to the database — never mentioned in the policy — is unaffected (egress is a separate policyType) but the monitoring scrape from the openshift-monitoring namespace is now blocked, and your dashboards go blank without a single error.

a default-deny baseline that does not break the platform
1. deny everything into this namespace
kind: NetworkPolicy
spec: { podSelector: {}, policyTypes: [Ingress] }
2. re-allow same-namespace traffic
spec: { podSelector: {}, ingress: [{ from: [{ podSelector: {} }] }] }
3. re-allow the router, or every Route in this namespace 503s
from: [{ namespaceSelector: { matchLabels:
{ policy-group.network.openshift.io/ingress: "" } } }]
4. re-allow monitoring, or the namespace disappears from Prometheus
from: [{ namespaceSelector: { matchLabels:
{ network.openshift.io/policy-group: monitoring } } }]
Test the policy against the platform, not just your app

Router, monitoring, and the DNS namespace all need explicit allowances once a namespace is isolated. The failure is silent in each case: a 503 from the router, a gap in Prometheus, and a DNS timeout that looks like a slow dependency.

Advanced

STORAGE

Binding modes, access modes, the CSI chain, and the two settings you cannot change after creation.

WORKLOADPodStatefulSet volumeClaimTemplateCLAIMPersistentVolumeClaimPOLICYStorageClassreclaimPolicyvolumeBindingModeallowVolumeExpansionCONTROLexternal-provisionerexternal-attacherexternal-resizerDRIVERCSI driver (controller)CSI driver (node)VOLUMEPersistentVolumeBACKENDCeph / NetApp / EBS / vSphere

A PVC that never binds is one of these arrows not completing. Which arrow tells you which log to open.

A StorageClass is the policy: which provisioner, what parameters, what happens to the volume when the claim is deleted, and — critically — when the volume gets created.

volumeBindingModeVolume is createdFailure mode
ImmediateAs soon as the PVC existsVolume lands in AZ-a, pod is later scheduled to AZ-b, pod is Pending forever
WaitForFirstConsumerOnce a pod using it is scheduledPVC sits Pending until a pod appears — which looks broken but is correct

On any cluster whose nodes span failure domains, WaitForFirstConsumer is the right default and Immediate is a latent scheduling bug.

Two other fields decide whether you can recover later, and neither can be changed on an existing volume: reclaimPolicy (Delete destroys the backend volume when the PVC goes; Retain keeps it) and allowVolumeExpansion (false means you can never grow it without a migration).

the Pending PVC decision tree
$ oc get pvc data-0
NAME STATUS VOLUME CAPACITY STORAGECLASS AGE
data-0 Pending gp3-csi 11m
$ oc describe pvc data-0 | tail -5
Normal WaitForFirstConsumer waiting for first consumer to be created
^ correct and healthy. The pod has not been scheduled yet — look at the POD.
Warning ProvisioningFailed failed to provision volume: rpc error:
code = ResourceExhausted desc = volume quota exceeded
^ a real failure, and the message comes from the BACKEND, not Kubernetes

Access modes are a contract the driver enforces, not a lock Kubernetes applies:

  • ReadWriteOnce (RWO) — mountable read-write by one node. Several pods on that same node can share it. This surprises people in both directions: they expect exclusivity and do not get it, or they expect to scale to 2 replicas and the second pod hangs in ContainerCreating because it landed elsewhere.
  • ReadWriteOncePod (RWOP) — genuinely one pod. This is the one people usually meant.
  • ReadWriteMany (RWX) — many nodes at once. Needs a filesystem that supports it (CephFS, NFS, Azure Files). Most block drivers cannot do it at all.
  • ReadOnlyMany (ROX) — many nodes, read-only.

Why your Deployment rollout hangs on RWO

A Deployment with the default RollingUpdate strategy starts the new pod before terminating the old one. With an RWO volume and the new pod on a different node, the new pod cannot attach until the old one releases — and the old one will not terminate until the new one is ready. It deadlocks until the progress deadline expires.

recognising the RWO rollout deadlock
$ oc get pods -l app=db
db-6f8-old 1/1 Running 0 9d
db-7a1-new 0/1 ContainerCreating 0 6m
$ oc describe pod db-7a1-new | tail -3
Warning FailedAttachVolume Multi-Attach error for volume "pvc-3c80d":
Volume is already exclusively attached to one node and can't be attached to another
Fix: strategy Recreate for single-writer workloads, or a StatefulSet.
spec: { strategy: { type: Recreate } }

CSI splits the work across sidecars, and each one fails in its own log. Knowing which is which turns a storage incident from an afternoon into ten minutes:

StageComponentRuns onSymptom when it fails
Create volumeexternal-provisionerController podPVC stays Pending, ProvisioningFailed event
Attach to nodeexternal-attacherController podPod stuck ContainerCreating, FailedAttachVolume
Mount into podCSI node pluginDaemonSet on the nodeFailedMount, timeout after 2 minutes
Grow volumeexternal-resizerController podPVC capacity never changes, no error on the PVC

The pattern to internalise: provisioning and attaching are cluster-level, mounting is node-level. A FailedMount is a node problem — go to that node. A ProvisioningFailed is a backend problem — go to the controller and then to the storage system itself.

following a stuck volume through the layers
$ oc get volumeattachment | grep pvc-3c80d
csi-9f2... ebs.csi.aws.com pvc-3c80d worker-04 false
ATTACHED=false and it has been minutes -> attacher, not the node
$ oc logs -n openshift-cluster-csi-drivers deploy/aws-ebs-csi-driver-controller \
-c csi-attacher --tail=40 | grep pvc-3c80d
if attachment IS true but the pod still will not start, it is the node plugin:
$ oc logs -n openshift-cluster-csi-drivers ds/aws-ebs-csi-driver-node \
--field-selector spec.nodeName=worker-04 -c csi-driver --tail=40

Expansion works if allowVolumeExpansion: true was on the StorageClass at creation time. Edit the PVC's requested size and the resizer grows the backend volume, then the filesystem — online, for most drivers. Some need a pod restart to finish the filesystem step, which shows as capacity updated on the PV but not inside the container.

Shrinking is not supported. Not by Kubernetes, not by any CSI driver. The only route is create-new, copy, swap.

reclaimPolicy is the one that ends careers. With Delete, removing a PVC destroys the backing volume immediately and irreversibly — including when the PVC is removed as a side effect of deleting a namespace or a Helm release. With Retain, the PV survives in Released state holding the data, and you can rebind it.

rebinding a Retained volume to a new claim
$ oc get pv | grep Released
pvc-3c80d 50Gi RWO Retain Released prod/data-0 gp3-csi
claimRef still points at the deleted PVC — that is what blocks rebinding
$ oc patch pv pvc-3c80d --type=json \
-p '[{"op":"remove","path":"/spec/claimRef"}]'
PV goes Available; a new PVC with matching size and class will bind to it
Check reclaimPolicy before you delete a namespace

oc delete project deletes the PVCs in it. On a Delete-policy class that is an irreversible data loss with no confirmation prompt and no undo. For any class holding real data, set Retain — the cost is orphaned volumes you clean up deliberately, which is a much better problem.

In practice

ADVANCED TROUBLESHOOTING

Networking and storage failures both present as "the pod is not working", and both have a layered path where the failing layer is rarely the one the symptom points at. Walk the path.

A request that does not arrive

TestCommandIf this works, the fault is above
Is the Route admitted?oc get route app -o yaml | grep -A4 conditionsDNS or the load balancer
Does the router know about it?oc -n openshift-ingress rsh deploy/router-default cat haproxy.config | grep appThe Route object
Does the Service have endpoints?oc get endpointslice -l kubernetes.io/service-name=appThe router
Is the pod Ready?oc get pod -l app=appThe Service selector
Does the pod answer directly?oc rsh deploy/app curl -s localhost:8080/healthThe readiness probe
Does another pod reach it?oc run t --rm -it --image=curlimages/curl -- curl app.ns.svc:8080NetworkPolicy or DNS

A volume that does not mount

Event on the podLayerGo to
WaitForFirstConsumerNormal — not a failureThe pod's scheduling, not the PVC
ProvisioningFailedBackend refused to create itexternal-provisioner logs, then the storage system
FailedAttachVolume / Multi-AttachStill attached elsewhereoc get volumeattachment; likely an RWO rollout
FailedMount after ~2 minNode-level mountCSI node DaemonSet on that node
Mounted but read-onlySCC / fsGroup / filesystemThe pod's securityContext and the image's ownership
the one-shot triage script
everything unhealthy, in one pass
$ oc get co | grep -v 'True.*False.*False'
$ oc get route -A -o json | jq -r '.items[] | select(.status.ingress[]?.conditions[]?
| select(.type=="Admitted" and .status!="True")) | "\(.metadata.namespace)/\(.metadata.name)"'
$ oc get pvc -A --field-selector=status.phase=Pending
$ oc get volumeattachment -o json | jq -r '.items[]
| select(.status.attached!=true) | .metadata.name'
$ oc get endpointslice -A -o json | jq -r '.items[]
| select((.endpoints|length)==0) | "\(.metadata.namespace)/\(.metadata.name)"'
The MTU test first, when it is 'intermittent'

If the report is 'some requests work, big ones hang', 'it works from inside the cluster but not from outside', or 'TLS connects then stalls' — check MTU before anything else. It costs one ping -M do and it is the answer far more often than its reputation suggests.

Reference

CHEATSHEET

CommandWhat it answers
oc get route -AEvery route and the host it claims
oc get route r -o yaml | grep -A5 conditionsWhether the router admitted it — a 503's real cause
oc -n openshift-ingress get pods -o wideWhere the routers run
oc -n openshift-ingress rsh deploy/router-default cat haproxy.configWhat the router actually configured
oc get endpointslice -l kubernetes.io/service-name=<s>Whether a Service has any Ready backend
oc get networkpolicy -AWhich namespaces are isolated
oc get network.operator cluster -o yamlCNI, cluster CIDR and MTU
oc rsh <pod> ping -M do -s 1372 <ip>The real path MTU
oc get pvc -A --field-selector=status.phase=PendingEvery stuck claim in the cluster
oc get scBinding mode, reclaim policy, expansion — before you commit
oc get volumeattachmentWhether a volume is attached and to which node
oc describe pvc <c>The provisioner's own error message
oc get pv | grep ReleasedRetained volumes waiting to be rebound
oc adm must-gather -- /usr/bin/gather_network_logsThe supported network dump