Chapter 66: Docker and Kubernetes Together: Dockerfile to Registry to Cluster, Jenkins and Kompose
66.1 The app and its Dockerfile: from source code to a small, safe image
Why: the problem
Every Kubernetes story starts before Kubernetes. A cluster can only run what is inside an image, so a slow, huge or insecure image becomes a slow, expensive or insecure Deployment. When Swiggy's orders service ships a 1.2 GB image, every new node spends a minute pulling it, autoscaling reacts late during the dinner rush, and the security scanner reports hundreds of vulnerabilities from build tools nobody needs at runtime. In this chapter we follow one app all the way: a small Python Flask orders API with three endpoints, /healthz for probes, /orders to list orders and /version to print the release. This lesson writes its Dockerfile the way production teams do. A multi-stage build installs dependencies in a builder stage and copies only the result into a slim runtime stage. The app runs as a non-root user with a fixed UID, so Kubernetes can enforce runAsNonRoot. Configuration comes from environment variables, never baked in, so the same image runs in dev, staging and production. A .dockerignore keeps the Git folder, local virtual environments and secrets out of the build context. The process listens on a port above 1024 and handles SIGTERM, so Kubernetes can stop it gracefully. Why these rules? Kubernetes assumes containers are disposable: it kills and recreates them at any time, on any node, and checks them with probes. An image that needs a writable root folder, runs as root, ignores SIGTERM or reads a config file from inside the image fights the platform. An image built with these rules simply works with Deployments, probes, rolling updates and security policies from the earlier chapters. We create every file with printf so you can paste the commands as they are, build the image, compare sizes against a naive Dockerfile, run it with docker run, and check that it behaves before any YAML exists.
What: every term in plain English
- Build context — The folder sent to the Docker engine for a build; .dockerignore trims it.
- Multi-stage build — Several FROM stages in one Dockerfile; the final image copies only what it needs.
- Base image — The starting image, such as python:3.12-slim.
- Layer — One filesystem change per instruction; cached and shared between images.
- Non-root user — A user with a fixed UID such as 10001, so the container cannot act as root.
- ENTRYPOINT and CMD — The program to run and its default arguments.
- Exec form — JSON array form of CMD, so the app is PID 1 and receives SIGTERM directly.
- gunicorn — Production WSGI server for Python web apps.
- HEALTHCHECK — Docker-level health command; Kubernetes ignores it and uses probes instead.
- Image size — Compressed size affects pull time on every new node.
How: step by step
- Create the orders-app folder and app code with printf. Three endpoints.
- Write requirements.txt with pinned versions. Repeatable installs.
- Write .dockerignore. Small, clean context.
- Write a naive Dockerfile and build it. Baseline size.
- Write the multi-stage, non-root Dockerfile. Production image.
- Build both and compare sizes. Big difference.
- Run the container and call the endpoints. Works locally.
- Stop it and check graceful shutdown. Exit in under a second.
Think of the image as a contract between the developer and the platform. The developer promises five things: the process listens on a documented port (8080 here), it logs to stdout and stderr, it reads every setting from environment variables or mounted files, it runs as a non-root UID, and it exits cleanly on SIGTERM within the grace period, usually thirty seconds in Kubernetes. The platform promises to provide those settings, restart the container when it dies, route traffic only when it is ready and give it time to finish requests on shutdown. Teams at Flipkart and Razorpay write this contract down in a short checklist that every new service must pass before it gets a Kubernetes namespace, and the lab below is exactly that checklist in action. Keep the checklist as a CHECKLIST.md file next to your Dockerfile and review it in every pull request that touches the image and run it on every major change: build size, id, read-only run, version endpoint and stop time. When any of the five promises breaks, the symptoms show up later in Kubernetes as CrashLoopBackOff, permission errors, missing logs or dropped requests during a rollout, and they are much harder to trace there than here with docker run.
Example 1: Create the orders app with printf
Goal. Create the orders app with printf.
create-app.sh
mkdir -p orders-app && cd orders-app
printf '%s\n' \
'import os, signal, sys' \
'from flask import Flask, jsonify' \
'app = Flask(__name__)' \
'VERSION = os.environ.get("APP_VERSION", "dev")' \
'ORDERS = [{"id": 1, "item": "masala dosa"}, {"id": 2, "item": "filter coffee"}]' \
'@app.get("/healthz")' \
'def healthz(): return "ok"' \
'@app.get("/orders")' \
'def orders(): return jsonify(ORDERS)' \
'@app.get("/version")' \
'def version(): return jsonify(version=VERSION, region=os.environ.get("REGION", "local"))' \
'signal.signal(signal.SIGTERM, lambda *a: sys.exit(0))' > app.py
printf '%s\n' 'flask==3.1.2' 'gunicorn==23.0.0' > requirements.txt
printf '%s\n' '.git' '.venv' '__pycache__' '*.pyc' '.env' 'Dockerfile*' 'k8s/' > .dockerignore
ls -a
What each line does
printf with one quoted line per argument— Creates exact file content with no heredoc, safe to paste.APP_VERSION and REGION— Settings come from the environment, so one image fits every stage./healthz— Cheap endpoint for liveness and readiness probes later.Pinned versions— flask==3.1.2 installs the same code every build..dockerignore— Keeps .git, local virtual environments and .env files with secrets out of the image.
Green output
$ bash create-app.sh
. .. .dockerignore app.py requirements.txt
$ python3 -m venv .venv && . .venv/bin/activate && pip install -q -r requirements.txt
$ APP_VERSION=dev flask --app app run --port 8080 &
$ curl -s localhost:8080/orders
[{"id":1,"item":"masala dosa"},{"id":2,"item":"filter coffee"}]
$ curl -s localhost:8080/version
{"region":"local","version":"dev"}
$ kill %1
Expected result. The app runs locally first. Testing without Docker separates code bugs from container bugs, which saves a lot of guessing later.
Example 2: Naive image versus multi-stage, non-root image
Goal. Naive image versus multi-stage, non-root image.
Dockerfile.naive
FROM python:3.12
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD python app.py
Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /src
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN groupadd --gid 10001 app && useradd --uid 10001 --gid app --no-create-home app
COPY --from=build /install /usr/local
WORKDIR /app
COPY --chown=10001:10001 app.py .
USER 10001
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--graceful-timeout", "20", "app:app"]
What each line does
python:3.12 vs slim— The full image carries compilers and docs; slim is a fraction of the size.requirements first— Dependencies are cached until requirements.txt changes, so code edits rebuild in seconds.--prefix=/install— Installs packages into one folder that the final stage copies.USER 10001— Numeric UID so Kubernetes can verify runAsNonRoot.Exec-form CMD— gunicorn is PID 1 and gets SIGTERM; --graceful-timeout lets in-flight requests finish.
Green output
$ docker build -f Dockerfile.naive -t orders:naive .
$ docker build -t orders:dev .
=> [build 3/3] RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
=> [stage-1 4/5] COPY --from=build /install /usr/local
=> exporting to image
$ docker images orders
REPOSITORY TAG IMAGE ID SIZE
orders dev 4c1d9e2f7a10 171MB
orders naive 9a8b7c6d5e4f 1.03GB
$ docker run --rm orders:naive id
uid=0(root) gid=0(root) groups=0(root)
$ docker run --rm orders:dev id
uid=10001(app) gid=10001(app) groups=10001(app)
Expected result. The production image is about six times smaller and runs as UID 10001, while the naive one runs as root. On a fresh Kubernetes node that is the difference between a few seconds and most of a minute to pull.
Example 3: Run the container like Kubernetes will
Goal. Run the container like Kubernetes will.
run.sh
docker run -d --name orders -p 8080:8080 -e APP_VERSION=1.0.0 -e REGION=ap-south-1 --read-only --tmpfs /tmp orders:dev
sleep 2
curl -s localhost:8080/healthz; echo
curl -s localhost:8080/version; echo
time docker stop orders
docker logs orders | tail -3
docker rm orders
What each line does
-e— Configuration through environment variables, exactly as a ConfigMap will do later.--read-only --tmpfs /tmp— Root filesystem is read-only, like readOnlyRootFilesystem in a pod; gunicorn needs only /tmp.docker stop— Sends SIGTERM, waits, then SIGKILL; Kubernetes does the same with terminationGracePeriodSeconds.time— Shows the app stops fast instead of waiting the full 10 seconds.Logs to stdout— PYTHONUNBUFFERED makes logs appear at once, so kubectl logs works later.
Green output
$ bash run.sh
3f9e8d7c6b5a...
ok
{"region":"ap-south-1","version":"1.0.0"}
orders
real 0m0.62s
[2026-10-25 02:41:10 +0000] [1] [INFO] Handling signal: term
[2026-10-25 02:41:10 +0000] [7] [INFO] Worker exiting (pid: 7)
[2026-10-25 02:41:11 +0000] [1] [INFO] Shutting down: Master
Expected result. The image reads its settings from the environment, works with a read-only root filesystem and stops in under a second on SIGTERM. It is ready for Kubernetes; next we give it a proper name and ship it.
Every command, step by step (kind lab with kubectl)
-
Folder
mkdir -p orders-app && cd orders-appWhat it does: Project.
(no output) -
App
printf '%s\n' ... > app.pyWhat it does: Flask code.
(no output) -
Deps
printf '%s\n' 'flask==3.1.2' 'gunicorn==23.0.0' > requirements.txtWhat it does: Pinned.
(no output) -
Ignore
printf '%s\n' '.git' '.venv' '.env' > .dockerignoreWhat it does: Clean context.
(no output) -
Naive
docker build -f Dockerfile.naive -t orders:naive .What it does: Baseline.
naming to docker.io/library/orders:naive -
Prod
docker build -t orders:dev .What it does: Multi-stage.
naming to docker.io/library/orders:dev -
Sizes
docker images ordersWhat it does: Compare.
orders dev 171MB orders naive 1.03GB -
User
docker run --rm orders:dev idWhat it does: Non-root.
uid=10001(app) -
Run
docker run -d --name orders -p 8080:8080 -e APP_VERSION=1.0.0 --read-only --tmpfs /tmp orders:devWhat it does: Started.
3f9e8d7c6b5a -
Health
curl -s localhost:8080/healthzWhat it does: ok.
ok -
Stop
time docker stop ordersWhat it does: Graceful.
real 0m0.62s
When it is red 1
ModuleNotFoundError: No module named 'flask'
Cause. Final stage did not get the packages, or --prefix path differs.
Fix, step by step
- Copy /install to /usr/local.
- docker run --rm orders:dev pip list.
When it is red 2
PermissionError: [Errno 13] Permission denied: '/app/__pycache__'
Cause. Non-root user writing into a folder it does not own.
Fix, step by step
- Set PYTHONDONTWRITEBYTECODE=1.
- Use --chown on COPY.
When it is red 3
docker stop takes exactly 10 seconds
Cause. Shell-form CMD; the shell is PID 1 and does not pass SIGTERM.
Fix, step by step
- Use exec-form CMD with a JSON array.
When it is red 4
Build context is 900MB
Cause. No .dockerignore, so .venv and .git are sent.
Fix, step by step
- Add .dockerignore.
When it is red 5
OSError: [Errno 30] Read-only file system
Cause. App writes outside /tmp with --read-only.
Fix, step by step
- Mount a tmpfs or emptyDir where it writes.
Practice tasks
Easy Task 1: App
Why: Create.
What to do: Create app.py with printf.
- Three endpoints.
Solution
python3 -c "import ast;ast.parse(open('app.py').read())"Easy Task 2: Ignore
Why: Context.
What to do: Write .dockerignore.
- .git and .env.
Solution
cat .dockerignoreEasy Task 3: Build
Why: Naive.
What to do: Build Dockerfile.naive.
- Note size.
Solution
docker images ordersMedium Task 4: Multi
Why: Prod.
What to do: Build the multi-stage image.
- slim base.
Solution
docker images orders:devMedium Task 5: User
Why: Check.
What to do: Run id in both images.
- root vs 10001.
Solution
docker run --rm orders:dev idMedium Task 6: Run
Why: Read-only.
What to do: Run with --read-only --tmpfs /tmp.
- curl /version.
Solution
curl -s localhost:8080/versionHard Task 7: Stop
Why: Graceful.
What to do: Time docker stop.
- Under 1 second.
Solution
time docker stop ordersHard Task 8: Zomato
Why: Main task.
What to do: Menu API image end to end.
- All checks.
Solution
docker imagesHomework: try at home
For Zomato's restaurant menu API, write a Flask app with /healthz, /menu and /version using printf, a multi-stage Dockerfile running as UID 10001, a .dockerignore, and prove image size, user, read-only run and a sub-second graceful stop.
Recap
- A good Kubernetes Deployment starts with a good image.
- Multi-stage builds keep build tools out of the runtime image.
- Run as a numeric non-root UID and read config from environment variables.
- Use exec-form CMD so the app receives SIGTERM and stops gracefully.
- Test the container with docker run before writing any YAML.
FAQ
Q. Should I use Alpine for Python?
Usually slim is safer. Alpine uses musl, so many Python wheels must compile, which makes builds slow and images sometimes bigger. Distroless images are another option for small runtime images.
Q. Do I need HEALTHCHECK in the Dockerfile?
Not for Kubernetes, which ignores it and uses liveness and readiness probes. It still helps with plain docker or Compose.
Q. Where do secrets go?
Never into the image. Kubernetes Secrets, a secrets manager, or IAM roles give them at runtime.
Q. Should I pin the base image by digest?
For strict repeatability, yes: python:3.12-slim@sha256:... and let a bot such as Renovate update it.
Interview questions
-
How do you make a Docker image smaller?
Model answer: Slim or distroless base, multi-stage builds, fewer layers, .dockerignore and no build tools at runtime.
-
Why must containers handle SIGTERM?
Model answer: Kubernetes sends SIGTERM on every rollout and scale-down; ignoring it causes dropped requests and slow stops.
-
Why not run as root?
Model answer: A container escape or app bug would have root rights inside the container and often more.
-
How do you keep one image for every environment?
Model answer: Read all configuration from environment variables or mounted files at runtime.
Quick quiz
Q1. Why copy requirements.txt before the code?
Layer cache for dependencies.
Q2. Why a numeric USER?
So Kubernetes can verify non-root.
Q3. Which CMD form receives SIGTERM?
Exec form.
Q4. Where does config come from?
Environment variables.
Q5. What keeps .env out of the image?
.dockerignore.
Samajla ka?