Kubernetes · OpenShift

OpenShift Architecture & Fundamentals

What the distribution adds on top of Kubernetes — the operator ownership chain, projects, SCC admission and the control plane — and how each one changes where you look when production breaks.

28 min read Level: core → advanced OpenShift 01 / 03
The model

HOW A REQUEST BECOMES CLUSTER STATE

Nothing reaches etcd without passing OAuth and admission first — which is why most OpenShift-specific failures are rejections, not crashes.

CLIENTSoc / kubectlWeb consoleCI pipelineAUTHOAuth serverIdentity providerToken / kubeconfigAPIkube-apiserveropenshift-apiserverAdmission plugins + SCCSTATEetcd (quorum 2/3)CONTROLkube-controller-mgrschedulerCluster Version OperatorOPERATORSIngressNetworkStorageMonitoringAuthenticationMachine ConfigNODESkubeletCRI-OOVN-KubernetesWORKLOADPod in a ProjectBound to an SCC

The layers below the API are all reconciled by operators, and the operators are reconciled by the CVO. That chain is the debugging path: ClusterVersion → ClusterOperator → operator Deployment → the resource it manages → Pods.

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 an operator actually doing in an OpenShift cluster?
CLUSTER VERSION OPERATOR AND ITS OPERANDSrelease payloadcluster stateoperandsreconciledcluster versionstatusCVOreads the release imagea manifest of manifestsreconciles ~30 ClusterOperatorsblocks on Degradedupgrade stops hereEACH CLUSTEROPERATORowns its operande.g. the router, etcdreports Available / Progressing /Degradedowns its CRDsMACHINE LAYERMachineConfigOperatornode OS configrenders MachineConfigper poolreboots nodes to applyone pool at a time
This is the real difference from vanilla Kubernetes: the cluster's own components are workloads managed by operators, so `oc get clusteroperators` is the single most useful command for 'is the cluster healthy'.
ConnectionWhat does oc new-app actually set in motion?
oc new-app <repo>BuildConfig createdS2I strategybuild pod runsclones, buildsimage pushedinternal registryImageStream tagrecords the digesttrigger fireson tag changeDeployment rollsnew podsService + Routereachable
The ImageStream is the hop with no Kubernetes equivalent. It pins a digest and fires triggers on change, which is how a rebuild redeploys without anything editing the Deployment.
Core

CORE CONCEPTS

The four things that make OpenShift behave differently from the Kubernetes you already know.

OpenShift is a conformant Kubernetes distribution. Every kubectl command works, every Kubernetes object behaves the way the upstream docs say. What Red Hat adds is not a different API — it is a managed lifecycle for the whole platform.

That distinction is the single most useful thing to internalise, because it tells you where to look when something breaks. On vanilla Kubernetes, the cluster is whatever you assembled: if the ingress controller is broken, you go and look at the ingress controller. On OpenShift, almost every platform component is owned by an operator, and those operators are owned by the Cluster Version Operator (CVO). A broken router is not just a broken deployment — it is a ClusterOperator reporting Degraded=True, and the CVO will keep reconciling it back to its declared state.

What that means in practice

  • You cannot fix the platform by editing its Deployments. Scale the router deployment to zero and the ingress operator scales it back. The change belongs on the operator's custom resource, not the object it manages.
  • Cluster health has a single front door. oc get clusteroperators is the first command of any OpenShift incident — it tells you which of ~30 platform components believes it is unhealthy, before you go looking at pods.
  • Upgrades are a cluster-level transaction, not a set of component upgrades you sequence yourself.
the first two commands of any OpenShift incident
$ oc get clusteroperators
NAME VERSION AVAILABLE PROGRESSING DEGRADED SINCE
authentication 4.16.9 True False False 4d
ingress 4.16.9 True False True 22m
monitoring 4.16.9 False True True 18m
$ oc describe clusteroperator/ingress | sed -n '/Conditions/,/Events/p'
the Degraded condition's message names the actual failing resource

The CVO is the root of the ownership tree. It reads the release payload — a container image holding every manifest for that exact version — and reconciles the cluster towards it. Each platform component gets a second-level operator; the CVO owns those operators, and they own their own workloads.

The chain is worth saying out loud because it is the debugging path:

ClusterVersionCVOClusterOperator → second-level operator Deployment → the workload it manages → Pods.

The failure mode people hit first

An upgrade "hangs". oc get clusterversion shows PROGRESSING=True for an hour with no version change. The CVO applies manifests in order and stops at the first one that will not become ready — so the answer is never "the upgrade is slow", it is always "component N is not reconciling and everything behind it is queued".

finding where an upgrade is actually stuck
$ oc get clusterversion -o jsonpath='{.items[0].status.conditions}' | jq -r '.[]|select(.type=="Progressing").message'
Working towards 4.16.11: 412 of 907 done (45% complete), waiting on machine-config
'waiting on X' names the blocking cluster operator — go straight there
$ oc get co machine-config -o yaml | yq '.status.conditions[] | select(.type=="Degraded")'
message: 'Unable to apply 4.16.11: error during syncRequiredMachineConfigPools:
pool master has not progressed: node/master-1 is reporting Unschedulable'

A Project is a namespace with an OpenShift wrapper around it. The underlying object really is a namespace — oc get namespace shows it — but creating one through the Project API does more than create the namespace:

  • Applies the project template, which can inject a ResourceQuota, a LimitRange, a default NetworkPolicy and RoleBindings automatically. This is the hook to use when you want every new team namespace to arrive pre-governed.
  • Makes the creator the project admin via a RoleBinding.
  • Respects the self-provisioner cluster role, which decides whether ordinary users may create projects at all.

Projects also gate what a user can see. A user with no access to a namespace gets a Forbidden on the namespace API but an empty list from the Project API — which is why the console shows a user only their own projects without leaking the names of others.

The trap

Creating the namespace directly with kubectl create namespace skips the template entirely. The namespace exists, workloads run, and three months later someone notices this one namespace has no quota and no default NetworkPolicy. If your platform relies on the project template for guardrails, namespace creation must be restricted to the Project API.

making the template do the governing
$ oc get template project-request -n openshift-config
if it does not exist, the default template is used and nothing is injected
$ oc adm create-bootstrap-project-template -o yaml > project-template.yaml
edit it: add ResourceQuota, LimitRange, a default-deny NetworkPolicy
$ oc create -f project-template.yaml -n openshift-config
$ oc patch project.config.openshift.io/cluster --type=merge \
-p '{"spec":{"projectRequestTemplate":{"name":"project-request"}}}'
and take away blanket project creation if the template is your control point
$ oc adm policy remove-cluster-role-from-group self-provisioner \
system:authenticated:oauth

An operator is a controller plus a CRD. The CRD gives you an object that describes intent (I want a 3-node PostgreSQL with PITR); the controller watches that object and does whatever a human operator would do to make reality match — provision, configure, back up, fail over, upgrade.

The part that matters operationally is the reconcile loop. It is level-driven, not edge-driven: the controller does not react to your change, it repeatedly compares desired state to actual state and corrects the difference. So any manual change to a managed resource has a half-life measured in seconds.

Two operator systems, and they are not the same

SystemInstallsManaged by
CVOThe ~30 core platform operatorsRed Hat, tied to the cluster version
OLM (Operator Lifecycle Manager)Optional and third-party operators from catalogsYou, via Subscription objects

OLM adds its own vocabulary: a CatalogSource is a catalog of operators, a Subscription says "install this one and keep it updated on this channel", an InstallPlan is the pending upgrade, and a ClusterServiceVersion (CSV) is the installed operator version. When an operator will not upgrade, the InstallPlan is almost always where the answer is — most often waiting on a manual approval nobody gave.

why an operator is stuck on an old version
$ oc get subscription -A
$ oc get installplan -n openshift-logging
NAME CSV APPROVAL APPROVED
install-x7k2n cluster-logging.v5.9.4 Manual false
Manual approval and nobody approved it — the operator never moves
$ oc patch installplan install-x7k2n -n openshift-logging --type=merge \
-p '{"spec":{"approved":true}}'
Manual is the safe default, until it is not

Manual approval stops an operator upgrading itself into an incident. It also means an operator can sit months behind, quietly, with nothing alerting on it. If you choose Manual, alert on pending InstallPlans — otherwise you have chosen 'never upgrade' without deciding to.

Advanced

ADVANCED

Node configuration, admission policy, the image supply chain, and the one component that has no graceful degradation.

On OpenShift the node OS (RHCOS) is not configured by you logging in. It is configured by MachineConfig objects, rendered by the Machine Config Operator into an Ignition config, and applied by a per-node daemon that cordons, drains, writes, and reboots the node.

MachineConfigs are grouped by MachineConfigPoolmaster and worker by default. The MCO renders all MachineConfigs matching a pool into one merged config and rolls it out node by node, respecting maxUnavailable.

The failure mode that matters

A malformed MachineConfig does not fail at admission. It renders, rolls out to the first node, and that node fails to come back. The pool then reports Degraded and stops — which is the MCO protecting you. The cluster is now in a half-applied state and the fix is to delete the offending MachineConfig and let the pool re-render.

a pool that stopped mid-roll
$ oc get mcp
NAME CONFIG UPDATED UPDATING DEGRADED MACHINECOUNT READY
master rendered-master-a91f2 True False False 3 3
worker rendered-worker-3c80d False True True 12 9
$ oc describe mcp worker | grep -A5 'Degraded'
$ oc get nodes -l node-role.kubernetes.io/worker -o custom-columns=\
NAME:.metadata.name,STATE:.metadata.annotations.machineconfiguration\\.openshift\\.io/state
worker-04 Degraded
$ oc logs -n openshift-machine-config-operator ds/machine-config-daemon \
-c machine-config-daemon --tail=50 | grep -i error

SCC is OpenShift's pod-level admission policy, and it predates Kubernetes' own Pod Security Admission. It controls what a pod may ask for: running as root, host networking, host paths, privileged mode, which capabilities, which SELinux context, which UID range.

By default every authenticated user gets restricted-v2, which refuses root, drops nearly all capabilities, and assigns a random high UID from the namespace's range. That last part is what breaks third-party charts: an image with USER 1000 and files owned by 1000 runs as UID 1000734512 instead and cannot write to its own data directory.

Diagnosing it

The give-away is a pod that will not schedule with a message naming SCC, or a pod that starts and immediately fails on permissions. The annotation on a running pod tells you which SCC actually admitted it.

which SCC admitted this pod, and which one would
$ oc get pod api-7d9f -o jsonpath='{.metadata.annotations.openshift\\.io/scc}'
restricted-v2
the error when it will not admit at all:
Error creating: pods "api-" is forbidden: unable to validate against any
security context constraint: [provider "anyuid": Forbidden: not usable by user]
$ oc adm policy scc-subject-review -z api-sa -n prod -f deployment.yaml
tells you which SCC WOULD admit this workload, before you grant anything
Do not reach for anyuid

Granting anyuid to the service account makes the error go away and hands the workload the right to run as root. Fix the image instead: make the data directory group-writable and owned by GID 0, which is what OpenShift-compatible images do — the random UID is always in group 0. When you genuinely need elevated access, create a custom SCC granting only the specific capability, and bind it to one service account.

the image fix, not the policy fix
in the Dockerfile — works on OpenShift AND everywhere else
RUN mkdir -p /data && chgrp -R 0 /data && chmod -R g=u /data
USER 1001

An ImageStream is a pointer to images, not a store of them. Each tag resolves to an immutable digest, and the stream records the history of what that tag pointed at. That gives you two things vanilla Kubernetes does not have out of the box: a rollback target, and a trigger — a Deployment can be told to redeploy when a stream tag moves.

BuildConfig is the in-cluster build. Source-to-Image (S2I) takes application source plus a builder image and produces a runnable image without a Dockerfile; Docker strategy builds a Dockerfile; Custom runs your own builder image.

Where teams get bitten

  • A tag that points at :latest in an external registry resolves once, at import. It does not follow upstream unless scheduled: true is set on the tag — so "we pushed a new latest and nothing happened" is expected behaviour, not a bug.
  • The internal registry is not a backup. On many installs it is backed by ephemeral or single-replica storage. If your only copy of a release image is there, a registry rebuild loses it.
why the new image did not deploy
$ oc get is app -o jsonpath='{.spec.tags[?(@.name=="latest")]}' | jq
{ "name": "latest", "from": {"kind":"DockerImage","name":"quay.io/org/app:latest"},
"importPolicy": {} }
importPolicy empty = imported once, never re-checked
$ oc tag quay.io/org/app:latest app:latest --scheduled
$ oc import-image app:latest --confirm # force a check now
$ oc rollout latest deploy/app # or push the tag yourself

Every object in the cluster is etcd state. etcd is a Raft cluster of 3 (or 5) members and needs a strict majority to accept writes: 3 members tolerate 1 failure, 5 tolerate 2. Lose quorum and the API server goes read-only — the cluster does not "run degraded", it stops accepting change.

etcd is also brutally sensitive to disk latency, because every write is fsynced before it is acknowledged. The practical threshold is a 99th-percentile fsync under ~10 ms. Above that you get leader elections, and leader elections during an upgrade produce the "everything is slow and nothing is broken" incident.

the two numbers that predict an etcd incident
$ oc exec -n openshift-etcd etcd-master-0 -c etcdctl -- etcdctl endpoint status -w table
+------------------+----------+---------+--------+-----------+
| ENDPOINT | ID | VERSION | DB SIZE| IS LEADER |
| master-0:2379 | a1f2... | 3.5.14 | 1.2 GB| true |
DB size climbing towards 8 GB (the default quota) = defrag or compaction problem
and in Prometheus, the number that actually predicts trouble:
histogram_quantile(0.99,
rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m])) > 0.01
A backup you have not restored is a hypothesis

oc debug node/<master> then /usr/local/bin/cluster-backup.sh produces a snapshot and the static pod manifests. Restoring one is a documented but genuinely disruptive procedure that takes the cluster down and rolls every node. Do it once, on a cluster you can afford to break, before you need it.

In practice

ADVANCED TROUBLESHOOTING

OpenShift gives you one entry point for almost any platform failure, and it is not oc get pods. Work down the ownership chain — cluster operator, then the operator's own workload, then the resource it manages.

SymptomWhere it actually isFirst command
Console unreachable, API fineIngress operator or the router podsoc get co ingress -o yaml
oc login fails for everyoneAuthentication operator / OAuth pods / the IdP itselfoc get co authentication; oc logs -n openshift-authentication -l app=oauth-openshift
Pods Pending, nodes look fineScheduler constraints, or a quota on the projectoc describe pod — the Events tail names the predicate that failed
Pods rejected at creationSCC admissionoc adm policy scc-subject-review -z <sa> -f <file>
Node NotReady, no obvious causeMCO mid-rollout, or kubelet/CRI-O on the nodeoc get mcp; then oc debug node/<n> -- chroot /host journalctl -u kubelet -n 200
Upgrade stalled at N%The cluster operator named in the Progressing messageoc get clusterversion -o yaml
Everything slow, nothing downetcd fsync latency or a leader election stormoc logs -n openshift-etcd etcd-<master> -c etcd | grep -i 'elected\|slow'
Operator stuck on an old versionAn unapproved InstallPlanoc get installplan -A

must-gather — collect once, collect properly

oc adm must-gather is the supported way to capture cluster state. Run it with no arguments and you get the full platform dump; point it at a component image and you get that component's deep state instead. The full collection can run to several GB and take 15+ minutes, so scope it when you already know the area.

scoping a must-gather instead of collecting everything
$ oc adm must-gather --dest-dir=./mg -- /usr/bin/gather_network_logs
network-only. Others: gather_audit_logs, and per-operator images:
$ oc adm must-gather --image=registry.redhat.io/openshift-logging/cluster-logging-rhel9-operator:latest
for a point-in-time snapshot of just what is unhealthy, this is often enough:
$ oc get co -o json | jq -r '.items[] | select(.status.conditions[]
| select(.type=="Degraded" and .status=="True")) | .metadata.name'
Read the Events before the logs

On OpenShift the useful message is nearly always in oc describe output or oc get events --sort-by=.lastTimestamp, not in a container log. Admission rejections, scheduling failures, image pull errors, quota denials and SCC refusals are all Events — none of them ever reach a pod log, because the pod never started.

Reference

CHEATSHEET

CommandWhat it answers
oc get clusteroperatorsWhich of the ~30 platform components is unhealthy — start here
oc get clusterversionCurrent version, and what an in-flight upgrade is waiting on
oc get mcpWhether node config is mid-rollout or wedged
oc get nodes -o wideNode state, roles, kernel and runtime versions
oc get events -A --sort-by=.lastTimestamp | tail -40What the cluster just complained about
oc describe pod <p>Scheduling, admission and image-pull failures
oc get pod <p> -o yaml | grep sccWhich SCC admitted it
oc adm policy scc-subject-review -z <sa> -f f.yamlWhich SCC would admit a workload
oc get subscription,installplan,csv -AOLM operator state end to end
oc adm top nodesActual node CPU/memory pressure
oc debug node/<n> -- chroot /host journalctl -u kubeletNode-level logs without SSH
oc adm must-gather --dest-dir=./mgThe supported full cluster dump
oc get projectProjects you can see (never leaks ones you cannot)
oc status -n <ns>A readable summary of what is running in a project