OnCallReady

Lesson 30.18 · OpenShift · 22 min read

Builds, Source-to-Image and ImageStreams

In plain words

Imagine a bakery that will bake bread from your family recipe. You hand over the recipe card, the baker already has the right oven and tins for "sourdough" or "rye", and out comes a finished loaf with a label. The bakery keeps a shelf list: "Grandma's bread, latest = loaf number 42", and remembers loaf 41 too, so you can go back to yesterday's batch.

Source-to-Image is the baker: a builder image for Node.js, Python or Java takes your Git source, runs its assemble script and produces a runnable image, with no Dockerfile. A BuildConfig says how to build, each Build like nodejs-ex-2 is one run in a build pod, and an ImageStream is the shelf list: tags pointing at image digests, with history. oc new-app nodejs~https://... sets it all up.

Why the cluster builds images

In an older OpenShift project you find no Dockerfile and no CI pipeline - yet new code reaches production. The cluster itself cloned the Git repo, built the image, pushed it and rolled the Deployment. When that chain breaks, you need to know its links.

What you need to know already: images, tags and digests (10.5); a Dockerfile and reading a build (10.8); registries (10.54); a Deployment rolling to a new image and ImagePullBackOff (15.16, 18.28); pipelines that build and promote images (25.1, 25.11); Secrets (15.33); projects and their RoleBindings (30.4); arbitrary UIDs (30.9).

The words you need first

The cluster can build your images

Vanilla Kubernetes runs images someone built elsewhere. OpenShift can also build them, inside the cluster, from a Git repository - and track the results in an object that knows the history of every tag. Three kinds carry that:

(Triggers: config change = the BuildConfig itself changed; image change = the builder image got a new version; webhooks = a URL your Git server calls on every push.)

Many enterprise teams build in their CI instead (GitLab, Jenkins, Tekton - Tekton is a pipeline tool that runs as Kubernetes objects; 25.14) and only deploy on OpenShift - you will still meet BuildConfigs in older projects, and ImageStreams are everywhere. The newer build API is Builds for OpenShift (upstream Shipwright, shipwright.io API group), an optional operator; this lesson is about the built-in build.openshift.io one.

Source-to-Image (S2I)

A builder image knows how to turn source code into a runnable image: Red Hat ships them for Node.js, Python, Java, PHP, Ruby, .NET, nginx, httpd... in the openshift project, as image streams everyone can read. oc get is lists image streams; the IMAGE REPOSITORY column is where the images live in the internal registry, TAGS the versions available:

$ oc get is -n openshift
NAME     IMAGE REPOSITORY                                                  TAGS                             UPDATED
httpd    image-registry.openshift-image-registry.svc:5000/openshift/httpd    2.4-ubi9,latest                  13 days ago
java     image-registry.openshift-image-registry.svc:5000/openshift/java     latest,openjdk-17-ubi9,openjdk-21-ubi9   13 days ago
nginx    image-registry.openshift-image-registry.svc:5000/openshift/nginx    1.24-ubi9,1.26-ubi9,latest       13 days ago
nodejs   image-registry.openshift-image-registry.svc:5000/openshift/nodejs   20-ubi9,22-ubi10,22-ubi9,latest  13 days ago
python   image-registry.openshift-image-registry.svc:5000/openshift/python   3.11-ubi9,3.12-ubi9,latest       13 days ago

S2I takes your source, copies it into the builder image, runs the builder's /usr/libexec/s2i/assemble script (npm install, pip install, mvn package...), commits the result as a new image whose command is /usr/libexec/s2i/run, and pushes it. No Dockerfile in your repo. The images are UBI-based (30.9) and built for arbitrary UIDs (USER 1001, app directories owned by group 0) - an S2I image never has the problem of lesson 30.9.

oc new-app from source

oc new-app creates everything an app needs from one argument. nodejs~URL means "use the nodejs builder image on the source at URL". The output first says which builder it found, then lists what it creates:

# in project shop, as the builds mission does it
oc new-app nodejs~https://github.com/sclorg/nodejs-ex.git
--> Found image 8c1a2d7 (2 weeks old) in image stream "openshift/nodejs" under tag "22-ubi9" for "nodejs"

    Node.js 22
    ----------
    Node.js 22 available as container is a base platform for building and running various Node.js 22 applications and frameworks. ...

    Tags: builder, nodejs, nodejs22

    * A source build using source code from https://github.com/sclorg/nodejs-ex.git will be created
      * The resulting image will be pushed to image stream tag "nodejs-ex:latest"
      * Use 'oc start-build' to trigger a new build

--> Creating resources ...
    imagestream.image.openshift.io "nodejs-ex" created
    buildconfig.build.openshift.io "nodejs-ex" created
    deployment.apps "nodejs-ex" created
    service "nodejs-ex" created
--> Success
    Build scheduled, use 'oc logs -f buildconfig/nodejs-ex' to track its progress.
    Application is not exposed. You can expose services to the outside world by executing one or more of the commands below:
     'oc expose service/nodejs-ex'
    Run 'oc status' to view your app.

builder~repo picks the builder explicitly; without it new-app detects the language from the repo (package.json -> nodejs, requirements.txt -> python, pom.xml -> java, a Dockerfile -> Docker strategy). Four objects, all labelled app=nodejs-ex:

oc new-build is the same without the Deployment and Service. oc new-app --image=REF (or just a name like nginx:1.27) skips the build and deploys an image, creating an ImageStream that tracks it.

The build

# the builds mission's checkout project
oc get builds
NAME          TYPE     FROM          STATUS    STARTED          DURATION
nodejs-ex-1   Source   Git@e3d1c2a   Running   12 seconds ago
oc get pods
NAME                         READY   STATUS             RESTARTS   AGE
nodejs-ex-1-build            1/1     Running            0          12s
nodejs-ex-6d8c9b7f4d-x2lqp   0/1     ImagePullBackOff   0          12s

Note the second pod: the Deployment exists before its image does. Until the build pushes, the kubelet cannot pull nodejs-ex:latest from the internal registry (manifest unknown). That is normal for the first minute of a new-app.

oc logs -f bc/NAME follows (-f, like tail -f) the log of the BuildConfig's latest build. It reads like a Dockerfile build (10.8), because S2I generates one:

oc logs -f bc/nodejs-ex
Cloning "https://github.com/sclorg/nodejs-ex.git" ...
	Commit:	e3d1c2a (Merge pull request #312 from sclorg/update-deps)
	Author:	sclorg-ci <[email protected]>
	Date:	Mon, 21 Sep 2026 20:00:00 +0000
...
Generating dockerfile with builder image image-registry.openshift-image-registry.svc:5000/openshift/nodejs@sha256:...
STEP 1/9: FROM image-registry.openshift-image-registry.svc:5000/openshift/nodejs@sha256:...
STEP 2/9: LABEL "io.openshift.build.image"="..." "io.openshift.build.commit.author"="sclorg-ci <[email protected]>" ...
STEP 3/9: ENV OPENSHIFT_BUILD_NAME="nodejs-ex-1" OPENSHIFT_BUILD_NAMESPACE="shop" ...
STEP 4/9: USER root
STEP 5/9: COPY upload/src /tmp/src
STEP 6/9: RUN chown -R 1001:0 /tmp/src
STEP 7/9: USER 1001
STEP 8/9: RUN /usr/libexec/s2i/assemble
---> Installing application source ...
---> Installing all dependencies
...
STEP 9/9: CMD /usr/libexec/s2i/run
COMMIT temp.builder.openshift.io/shop/nodejs-ex-1:4f3a2b1c
...
Pushing image image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex:latest ...
Getting image source signatures
Copying blob sha256:...
Writing manifest to image destination
Successfully pushed image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:f3fba511...
Push successful

Read it as the S2I recipe in Dockerfile form: chown -R 1001:0 - the arbitrary-UID pattern again - then assemble, then push by digest. oc logs -f bc/NAME follows the latest build; oc logs build/nodejs-ex-1 a specific one; oc describe build shows status, duration, trigger cause and events.

Build again (new commit, or a changed builder image) with oc start-build NAME; --follow streams the log while it runs:

oc start-build nodejs-ex --follow
build.build.openshift.io/nodejs-ex-2 started
...
Push successful
oc get builds
NAME          TYPE     FROM          STATUS     STARTED          DURATION
nodejs-ex-1   Source   Git@e3d1c2a   Complete   4 minutes ago    53s
nodejs-ex-2   Source   Git@e3d1c2a   Complete   47 seconds ago   41s

--wait makes start-build exit non-zero when the build fails - use it in scripts. oc cancel-build nodejs-ex-3 stops one. --from-dir=. does a binary build: uploads your local directory instead of cloning (handy before the code is pushed).

When a build fails

oc get builds
NAME        TYPE     FROM   STATUS                       STARTED          DURATION
catalog-1   Source   Git    Failed (FetchSourceFailed)   30 seconds ago   4s
oc logs build/catalog-1
Cloning "https://git.lab/shop/catalogue.git" ...
error: failed to fetch requested repository "https://git.lab/shop/catalogue.git" with provided credentials

The status carries a reason: FetchSourceFailed (URL, ref, credentials - private repos need a source secret: oc create secret generic git-auth --from-literal=username=... --from-literal=password=... and oc set build-secret --source bc/catalog git-auth), PullBuilderImageFailed, GenericBuildFailed (the assemble script failed - read the log), PushImageToRegistryFailed, InvalidOutputReference. The fix is almost always in the BuildConfig (oc edit bc) or the repo, followed by oc start-build.

ImageStreams: tags that point at digests

oc describe is NAME shows each tag with its history, newest first; oc get istag shows what each tag points at now:

oc describe is nodejs-ex
Name:                   nodejs-ex
Namespace:              shop
...
Image Repository:       image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex
Image Lookup:           local=false
Unique Images:          2
Tags:                   1

latest
  pushed image

  * image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:9a0c...
      2 minutes ago
    image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:f3fb...
      6 minutes ago
oc get istag
NAME               IMAGE REFERENCE                                                               UPDATED
nodejs-ex:latest   image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:9a0c...   2 minutes ago

An ImageStream does not store layers - the registry does. It stores pointers: every tag resolves to a digest, and the previous digests are kept as history (* marks the current one). Two consequences:

  1. A Deployment that follows a tag always gets an immutable reference (...@sha256:9a0c...), never a floating :latest the node might have cached. Nodes cannot drift.
  2. Rolling back is re-pointing a tag at an old digest - no rebuild.

Tags can also point at images outside the cluster. oc import-image STREAM:TAG --from=REF --confirm copies the reference in (--confirm creates the stream if it does not exist); oc tag SOURCE DEST points a tag at a source, which can be an external image or another tag:

$ oc import-image web:1.27 --from=docker.io/nginxinc/nginx-unprivileged:1.27 --confirm
imagestream.image.openshift.io/web imported
...
$ oc tag docker.io/nginxinc/nginx-unprivileged:1.28 web:1.28
Tag web:1.28 set to docker.io/nginxinc/nginx-unprivileged:1.28.
$ oc tag docker.io/nginxinc/nginx-unprivileged:1.28 web:1.28 --scheduled

--scheduled re-imports periodically (every 15 minutes by default), so the stream notices when upstream moves the tag - and any trigger fires. An import that fails shows up on the tag:

latest
  tagged from docker.io/nginxinc/nginx-unprivileged:9.9

  ! error: Import failed (InternalError): Internal error occurred: docker.io/nginxinc/nginx-unprivileged:9.9: reading manifest 9.9 in docker.io/nginxinc/nginx-unprivileged: manifest unknown

The internal registry

$ oc registry info
image-registry.openshift-image-registry.svc:5000

Every ImageStream is a repository image-registry.openshift-image-registry.svc:5000/<project>/<stream>. That name only resolves inside the cluster. Pulls are authorised with RBAC:

From outside (your laptop, CI) the registry is only reachable if an admin exposes its default route (oc patch configs.imageregistry.operator.openshift.io/cluster --type merge -p '{"spec":{"defaultRoute":true}}'), after which oc registry info --public prints default-route-openshift-image-registry.apps.<domain> and podman login -u $(oc whoami) -p $(oc whoami -t) <that host> works (podman: a Docker-compatible CLI; docker login works the same way) - your OpenShift token is your registry password.

What you can now do

Why it helps

You will meet BuildConfigs and ImageStreams in older OpenShift projects, and ImageStreams everywhere, including in oc new-app and the openshift namespace's builder images. When a build is Failed (FetchSourceFailed), you know to add a source secret; when a new app's pod shows ImagePullBackOff for the first minute, you know it is waiting for the first build to push.

ImageStreams matter for deployments: a tag resolves to an immutable digest, so nodes never drift on a cached :latest, and rolling back is re-pointing a tag. Cross-project pulls from the internal registry need system:image-puller grants, the cause of many ErrImagePull tickets. And in interviews for OpenShift roles, "what is S2I and would you use it?" comes up; the honest answer compares it with building in CI and with Shipwright (a newer Kubernetes build framework).

Commands in this lesson

oc

FAQ

What is Source-to-Image?

A way to build container images without a Dockerfile. A builder image for a language, such as openshift/nodejs:22-ubi9, contains an assemble script that installs dependencies and prepares the app, and a run script to start it. The build copies your source into the builder, runs assemble, and commits the result as a new image. S2I images are built for arbitrary UIDs, so they run cleanly under restricted-v2.

What is an ImageStream?

An OpenShift object that holds named tags, each pointing at an image by digest, with a history of previous digests. It does not store image layers; the registry does. Deployments with image triggers follow a tag and get an immutable digest reference, and rolling back means re-pointing the tag. ImageStreams can track external images with oc import-image or oc tag, optionally re-importing periodically with --scheduled.

Should I build images on the cluster or in CI?

Many enterprise teams build in CI (GitLab, Jenkins, Azure Pipelines, Tekton) and only deploy on OpenShift, which keeps build tooling, scanning and signing in one place and keeps build workloads off production clusters. In-cluster Builds are convenient for developer workflows and smaller teams. The newer supported option is Builds for OpenShift, based on Shipwright; the classic build.openshift.io API remains for existing projects.

Why does my new app's pod show ImagePullBackOff right after oc new-app?

oc new-app creates the Deployment at the same time as the BuildConfig and ImageStream. Until the first build finishes and pushes nodejs-ex:latest to the internal registry, the kubelet cannot pull it and reports manifest unknown. That is normal for the first minute. Follow the build with oc logs -f bc/nodejs-ex; the image trigger rolls the Deployment once the image exists.

How do I fix a build that fails with FetchSourceFailed?

The build pod could not clone the repository: wrong URL or ref, a network restriction, or a private repository without credentials. For credentials, create a source secret, for example oc create secret generic git-auth --from-literal=username=... --from-literal=password=<token>, attach it with oc set build-secret --source bc/catalog git-auth, and run oc start-build catalog --follow. oc describe build shows the reason and events.

In an interview Mid

What are BuildConfigs and ImageStreams in OpenShift?

Images live in the internal registry (image-registry.openshift-image-registry.svc:5000/<project>/<stream>); pulls across projects need system:image-puller. Many teams build in CI instead and only deploy on OpenShift.

Also asked: Compare building images with S2I on OpenShift against building in an external CI pipeline. · A build fails with FetchSourceFailed. What do you check? · How can a pod in one project pull an image from another project's ImageStream?

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