Ravindra BagaleCourses & study guides मराठी

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.
Create the orders-app folder and app code with printf. Write requirements.txt with pinned versions. Write .dockerignore. Write a naive Dockerfile and build it. Write the multi-stage, non-root Dockerfile. Build both and compare sizes. Run the container and call the endpoints.
Source code to a small, safe image

How: step by step

  1. Create the orders-app folder and app code with printf. Three endpoints.
  2. Write requirements.txt with pinned versions. Repeatable installs.
  3. Write .dockerignore. Small, clean context.
  4. Write a naive Dockerfile and build it. Baseline size.
  5. Write the multi-stage, non-root Dockerfile. Production image.
  6. Build both and compare sizes. Big difference.
  7. Run the container and call the endpoints. Works locally.
  8. 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)

  1. Folder

    mkdir -p orders-app && cd orders-app
    

    What it does: Project.

    (no output)
    
  2. App

    printf '%s\n' ... > app.py
    

    What it does: Flask code.

    (no output)
    
  3. Deps

    printf '%s\n' 'flask==3.1.2' 'gunicorn==23.0.0' > requirements.txt
    

    What it does: Pinned.

    (no output)
    
  4. Ignore

    printf '%s\n' '.git' '.venv' '.env' > .dockerignore
    

    What it does: Clean context.

    (no output)
    
  5. Naive

    docker build -f Dockerfile.naive -t orders:naive .
    

    What it does: Baseline.

    naming to docker.io/library/orders:naive
    
  6. Prod

    docker build -t orders:dev .
    

    What it does: Multi-stage.

    naming to docker.io/library/orders:dev
    
  7. Sizes

    docker images orders
    

    What it does: Compare.

    orders   dev     171MB
    orders   naive   1.03GB
    
  8. User

    docker run --rm orders:dev id
    

    What it does: Non-root.

    uid=10001(app)
    
  9. Run

    docker run -d --name orders -p 8080:8080 -e APP_VERSION=1.0.0 --read-only --tmpfs /tmp orders:dev
    

    What it does: Started.

    3f9e8d7c6b5a
    
  10. Health

    curl -s localhost:8080/healthz
    

    What it does: ok.

    ok
    
  11. Stop

    time docker stop orders
    

    What 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

  1. Copy /install to /usr/local.
  2. 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

  1. Set PYTHONDONTWRITEBYTECODE=1.
  2. 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

  1. 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

  1. 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

  1. Mount a tmpfs or emptyDir where it writes.

Practice tasks

Easy Task 1: App

Why: Create.

What to do: Create app.py with printf.

How:
  1. Three endpoints.
Solution
python3 -c "import ast;ast.parse(open('app.py').read())"

Easy Task 2: Ignore

Why: Context.

What to do: Write .dockerignore.

How:
  1. .git and .env.
Solution
cat .dockerignore

Easy Task 3: Build

Why: Naive.

What to do: Build Dockerfile.naive.

How:
  1. Note size.
Solution
docker images orders

Medium Task 4: Multi

Why: Prod.

What to do: Build the multi-stage image.

How:
  1. slim base.
Solution
docker images orders:dev

Medium Task 5: User

Why: Check.

What to do: Run id in both images.

How:
  1. root vs 10001.
Solution
docker run --rm orders:dev id

Medium Task 6: Run

Why: Read-only.

What to do: Run with --read-only --tmpfs /tmp.

How:
  1. curl /version.
Solution
curl -s localhost:8080/version

Hard Task 7: Stop

Why: Graceful.

What to do: Time docker stop.

How:
  1. Under 1 second.
Solution
time docker stop orders

Hard Task 8: Zomato

Why: Main task.

What to do: Menu API image end to end.

How:
  1. All checks.
Solution
docker images

Homework: 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

  1. 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.

  2. Why must containers handle SIGTERM?

    Model answer: Kubernetes sends SIGTERM on every rollout and scale-down; ignoring it causes dropped requests and slow stops.

  3. Why not run as root?

    Model answer: A container escape or app bug would have root rights inside the container and often more.

  4. 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?