Skip to main content

CKAD Preparation — Build a container image

CKAD Preparation — Build a container image

Introduction

This article is part of an ongoing series designed to help you prepare for the Certified Kubernetes Application Developer (CKAD) exam through small, focused labs.

In this post, we’ll cover the requirements within the “Application Design and Build” domain:

Define, build and modify container images

We’ll walk through building a container image for a Python/FastAPI service, running it locally with Docker, and deploying it to Kubernetes — all with practical, step‑by‑step commands and examples.

You can start from the beginning of the series here: CKAD Preparation — What is Kubernetes.

Prerequisites

A linux or Windows machine with Docker installed and running. Alternatively, you can use this Single node Ubuntu Environment on KillerCoda.

For this exercise, you’ll need Python 3.12 (or newer) and Docker 28 (or newer) to build the container image.

On Windows, you can download and install Python from the official releases page: 👉 Download Python for Windows.

On Linux, if you use apt, run:

sudo apt update
sudo apt install python3

If you use yum as your package manager, run:

sudo yum update -y
sudo yum install -y yum-utils
sudo yum install -y python3

Alternatively, you can install Python by compiling it from source:

sudo apt install build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev \
libssl-dev libreadline-dev libffi-dev libsqlite3-dev libbz2-dev liblzma-dev
wget https://www.python.org/ftp/python/3.12.11/Python-3.12.11.tgz
tar -zxvf Python-3.12.11.tgz
cd Python-3.12.11/
./configure --enable-optimizations
make -j "$(nproc)"
sudo make altinstall
sudo ln -s /usr/local/bin/python3.12 /usr/local/bin/python

wget https://bootstrap.pypa.io/get-pip.py
python get-pip.py
sudo ln -s /usr/local/bin/pip3.12 /usr/local/bin/pip

Finally, we’ll try publishing the image to a Kubernetes cluster. You can use Kubernetes from Docker Desktop, Minikube, or a temporary environment on https://killercoda.com/playgrounds/course/kubernetes-playgrounds.

Getting the Resources

All manifests and examples mentioned in this post are available in the following repository:

git clone https://github.com/SupaaHiro/schwifty-lab.git
cd schwifty-lab/blog-posts/20251020-ckad

Project initialization

In this demo, we’ll walk through how to build a container for a simple API service that exposes both a health status and the software version.

Our project will have the following structure:

src/
├── app/
│   ├── main.py
│   └── metadata.json
├── pyproject.toml
└── Dockerfile

The metadata.json file is a placeholder for a file that, in a real-world scenario, would be generated by a CI pipeline — for example using GitVersion — when new builds are triggered on releases, merges into develop, and so on.

As our package manager, we’ll use uv instead of pip.

pip install uv
uv --version

Initialize the project:

mkdir src && cd src
mkdir app
uv init
uv add fastapi uvicorn
uv lock

Since we don’t have a CI pipeline injecting real metadata yet, let’s create a simple placeholder:

{
  "version": "1.0.0",
  "build": "local-dev",
  "commit": "0000000"
}

Save it as src/app/metadata.json.

In a CI/CD scenario, this file would typically be replaced automatically — for example, by GitVersion or by a job writing the correct version metadata into the container build context.

API Service Source Code

Here’s the source code for our API service that we’ll run inside the container:

from fastapi import FastAPI
import json
import uvicorn

app = FastAPI()

def load_metadata():
    try:
        with open("metadata.json", "r") as f:
            return json.load(f)
    except FileNotFoundError:
        return {"version": "unknown", "build": "n/a", "commit": "n/a"}

@app.get("/health")
async def health():
    return {"status": "ok"}

@app.get("/version")
async def version():
    return load_metadata()

if __name__ == "__main__":
    uvicorn.run("app:app", port=8000, reload=True)

Save it as src/app/main.py.

Testing the API Service

Activate the virtual environment.

On Linux:

source .venv/bin/activate
which python

On Windows:

.venv\Scripts\activate.bat
where python

Try launching the app manually to verify it works:

uv run uvicorn app.main:app --reload

You should see output similar to:

INFO:     Will watch for changes in these directories: ['C:\\repos\\github\\schwifty-lab\\blog-posts\\20251020-ckad\\src']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [26020] using StatReload
INFO:     Started server process [11200]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Now, call one of the service endpoints — for example, /health:

curl -svk http://127.0.0.1:8000/health

Expected output:

*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> GET /health HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.14.1
> Accept: */*
>
< HTTP/1.1 200 OK
< date: Sun, 19 Oct 2025 18:24:22 GMT
< server: uvicorn
< content-length: 15
< content-type: application/json
<
{"status":"ok"}* Connection #0 to host 127.0.0.1 left intact

Building the Container Image

Now let’s create the Dockerfile — a text file containing the instructions Docker uses to build an image.

FROM python:3.12-slim
WORKDIR /app

# Install uv
RUN python -m pip install --no-cache-dir uv

# Copy project definition and lock file
COPY pyproject.toml uv.lock ./
RUN python -m uv sync --no-dev

# Copy application
COPY app/ .

# Create non-root user
RUN useradd -m appuser
USER appuser

EXPOSE 5000

# Default environment (can be overridden)
ENV UVICORN_PORT=5000

# Start API inside uv virtual environment
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]

A container image is an executable, immutable package that includes everything needed to run an application consistently across different environments.

Build the container image:

docker build -t fastapi:v1 .

Then, run it to verify that it works:

docker run -it --rm -p 5000:5000 fastapi:v1

Now try calling one of the endpoints, for example /version:

curl -svk http://127.0.0.1:5000/version

Expected output:

*   Trying 127.0.0.1:5000...
* Connected to 127.0.0.1 (127.0.0.1) port 5000
* using HTTP/1.x
> GET /version HTTP/1.1
> Host: 127.0.0.1:5000
> User-Agent: curl/8.14.1
> Accept: */*
>
< HTTP/1.1 200 OK
< date: Sun, 19 Oct 2025 18:43:43 GMT
< server: uvicorn
< content-length: 58
< content-type: application/json
<
{"version":"1.0.0","build":"local-dev","commit":"0000000"}* Connection #0 to host 127.0.0.1 left intact

Publishing to Kubernetes

Now that our image builds and runs correctly, let’s deploy it to Kubernetes.

To deploy to a non-local cluster, we first need to push it to a Container Registry — a repository for container images.

If you don’t already have one, create an account on Docker Hub and then log in:

docker login

Next, tag your image as your-account/image-name:tag:

docker tag fastapi:v1 <your-docker-account>/fastapi:v1
docker push <your-docker-account>/fastapi:v1

We’ll deploy the container as a Deployment:

# manifests/01-fastapi.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fastapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: fastapi
  template:
    metadata:
      labels:
        app: fastapi
    spec:
      containers:
      - name: fastapi
        image: <your-docker-account>/fastapi:v1
        ports:
        - containerPort: 5000

A Deployment is a Kubernetes resource that manages the lifecycle of your application’s Pods.

It ensures the desired number of replicas are always running, automatically replaces failed Pods, and allows zero-downtime updates through rolling deployments.

Apply the manifest:

k apply -f manifests/01-fastapi.yaml

The deployment should create two ReplicaSets, and these should create two Pods. Verify that all Pods reach the Running state:

k get deploy,rs -o=wide -l=app=fastapi
k get pod -o=wide -l=app=fastapi --watch

To test whether our API service works, let’s expose port 5000 using a ClusterIP service:

k expose deploy fastapi

Take note of the service IP:

k get svc -o=wide -l=app=fastapi

🧠 Note: When configured to use iptables, kube-proxy performs pseudo-random round-robin endpoint selection. With two backend Pods, the Pod responding to any given request is effectively random — but across many requests, the distribution should be roughly 50/50.

Now, open a temporary Pod and try reaching an endpoint, for example /health:

k run -it --rm --image=alpine -- sh
apk add curl
curl <service-ip>:5000/health
exit

Expected output:

/ # curl 192.168.1.4:5000/health
{"status":"ok"}/ #
Session ended, resume using 'kubectl attach sh -c sh -i -t' command when the pod is running
pod "sh" deleted

🏁 Wrapping Up: What We’ve Covered

In this article we reviewed the full lifecycle of building, running and publishing a container image for a simple API service. Key takeaways:

  • What a container image is and why it’s useful: an immutable, executable package that bundles app code, runtime and dependencies.
  • How to structure a small Python/FastAPI project for containerization (source layout, metadata.json for versioning).
  • How to create a Dockerfile that builds the app, installs dependencies and runs the service as a non-root user.
  • How to build and run the image locally with Docker, and verify endpoints (/health and /version).
  • How to push an image to a container registry and deploy it to Kubernetes using a Deployment and Service.

Final cleanup

When you’re done experimenting, you can remove the container image:

docker image rm fastapi:v1