Module 08Containers~1 hour

Virtualisation and Containers

Understand the 'works on my machine' problem and learn to package any application in a Docker container that runs identically everywhere.

Covers:MLO6MLO7
80

The environment problem in depth

Software depends on its environment — the operating system, the runtime version, the system libraries, the installed packages, the environment variables, the file paths. When any of these differ between two machines, the same code can behave differently.

This problem compounds across a development team's lifecycle:

  • Developer A uses Python 3.11 on macOS. Developer B uses Python 3.9 on Ubuntu. A feature that uses a 3.10+ API works for A and fails for B.
  • The CI server runs Ubuntu and Python 3.11 but a different minor version. A subtle difference in a library's behaviour causes intermittent failures.
  • The production server was set up two years ago with a different library version. A change that passed all tests breaks in production.

This is not a rare edge case — it is the default state of software development without explicit environment management. Containers solve it.

81

From bare metal to containers

Environment management has evolved through several generations:

ApproachMechanismProsCons
Bare metalAll applications share the OS directlySimple, fastNo isolation, environment conflicts
Virtual machinesEach application gets its own OS on virtualised hardwareStrong isolationLarge (GBs), slow to start, resource-heavy
ContainersApplications share the host OS kernel but have isolated filesystemsLightweight, fast, portableLess isolation than VMs, Linux-centric

A container does not include an operating system. It includes only the application and its dependencies — the libraries, configuration, and runtime it needs. It shares the host's Linux kernel. This is why containers are measured in megabytes, not gigabytes, and start in seconds, not minutes.

◆ Analogy: shipping containers

Before standardised shipping containers (invented in the 1950s), cargo was loaded and unloaded piece by piece at every port. Different ports had different equipment. Each shipment was a custom operation.

The standardised container transformed logistics: any crane could handle any container; any ship could carry any combination of containers; the contents were irrelevant to the infrastructure. Docker containers do the same for software: any host with Docker can run any container, regardless of what is inside.

82

Docker architecture

Docker uses a client-server architecture:

Docker daemon
A background service (dockerd) that manages images, containers, networks, and volumes. Runs on the host.
Docker client
The docker command you type. Sends instructions to the daemon via a REST API.
Docker registry
A storage service for images. Docker Hub is the public registry. Images like python:3.11 come from here.
Image
A read-only template for creating containers. Built from a Dockerfile.
Container
A running instance of an image. Isolated from other containers and the host.
Docker client
Docker daemon
Image pull/build
Container run
83

Images and layers

A Docker image is not a single large file. It is a stack of layers. Each instruction in the Dockerfile creates a new layer. Layers are cached and reused:

plain — image layers
Layer 5: COPY . .          ← your application code
Layer 4: RUN pip install   ← your dependencies
Layer 3: COPY requirements.txt .
Layer 2: WORKDIR /app
Layer 1: FROM python:3.11-slim  ← base OS + Python

When you rebuild the image, Docker checks each layer. If the layer's input has not changed, the cached version is reused. This is why order matters: put things that change rarely (base image, system packages) before things that change often (application code).

ℹ Layer caching order

A common pattern: copy requirements.txt first, run pip install, then copy the rest of the application. This way, the expensive pip install layer is only rebuilt when requirements.txt changes, not every time you change a line of application code.

84

Writing a Dockerfile

Dockerfile — complete example with comments
# Base image: official Python image, slim variant for smaller size
FROM python:3.11-slim

# Set environment variables
# Prevent Python from writing .pyc files
ENV PYTHONDONTWRITEBYTECODE=1
# Prevent Python from buffering stdout/stderr
ENV PYTHONUNBUFFERED=1

# Create and set the working directory inside the container
WORKDIR /app

# Copy ONLY requirements first (better layer caching)
COPY requirements.txt .

# Install dependencies
# --no-cache-dir reduces image size
RUN pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application
COPY . .

# Document which port the application uses (informational)
EXPOSE 8000

# The command to run when the container starts
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0"]
85

Dockerfile instructions reference

FROM
Base image. Every Dockerfile starts with this. Use official slim images where possible.
WORKDIR
Set the working directory. Created automatically if it does not exist.
COPY
Copy files from the build context (your local directory) into the image. Prefer COPY over ADD for simple file copies.
RUN
Execute a command during the build. Creates a new layer. Chain related commands with && to reduce layers.
ENV
Set an environment variable available at build time and runtime.
ARG
Define a build-time variable (not available at runtime).
EXPOSE
Document which port the container listens on. Informational only — does not publish the port.
CMD
Default command when the container starts. Can be overridden at runtime.
ENTRYPOINT
Fixed command. Arguments to docker run are appended. Combined with CMD for flexible entry points.
86

.dockerignore

Just as .gitignore tells Git which files to ignore, .dockerignore tells Docker which files to exclude from the build context. A smaller build context means faster builds:

plain — .dockerignore
.git
.gitignore
__pycache__
*.pyc
.pytest_cache
*.egg-info
dist/
build/
.env
*.md
tests/
# We do not need tests inside the production image
87

Docker commands in depth

bash — essential Docker commands
# Build image, tag it, from Dockerfile in current dir
docker build -t my-app:latest .

# Build with a different Dockerfile
docker build -f Dockerfile.prod -t my-app:prod .

# Force rebuild — ignore all caches
docker build --no-cache -t my-app:latest .

# List all local images
docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
my-app       latest    a1b2c3d4       2 mins ago    142MB

# Run a container (exits when command completes)
docker run my-app:latest

# Run interactively with a terminal
docker run -it my-app:latest bash

# Run detached (in background)
docker run -d -p 8000:8000 my-app:latest

# Run and automatically delete container on exit
docker run --rm my-app:latest

# Pass environment variable
docker run -e APP_ENV=production my-app:latest

# List running containers
docker ps

# List all containers (including stopped)
docker ps -a

# Stop a running container
docker stop 

# View logs from a running container
docker logs -f 

# Delete all stopped containers and unused images
docker system prune
88

Volumes and bind mounts

Containers have isolated filesystems. When a container exits, any files written inside it are lost. Volumes and bind mounts persist data outside the container:

bash — bind mount for development
# Mount current directory into the container
# Changes to local files appear immediately inside the container
docker run -v $(pwd):/app my-app:latest

# Named volume for persistent data (e.g. database)
docker run -v db-data:/var/lib/postgresql/data postgres:16

Bind mounts are useful during development — you edit code on your machine and the running container sees the changes immediately. Named volumes are used for persistence (databases, uploaded files).

89

Networking basics

Containers on the same Docker network can communicate with each other by container name. By default, containers are isolated from the host network:

bash — port publishing
# -p host_port:container_port
# Forward requests to localhost:8000 → container:8000
docker run -p 8000:8000 my-app:latest

# Map to a different host port
docker run -p 3000:8000 my-app:latest
# localhost:3000 → container:8000
90

Key terms

container
An isolated process with its own filesystem, sharing the host OS kernel.
image
A read-only, layered template for creating containers.
Dockerfile
A text file of instructions that build a Docker image.
layer
One instruction's contribution to an image. Cached and reused if unchanged.
FROM
The base image a Dockerfile builds on.
build context
The directory sent to Docker during build. Controlled by .dockerignore.
bind mount
Mounts a host directory into a container. Used for development.
named volume
Docker-managed persistent storage, survives container deletion.
docker run -p
Publish a container port to the host so external processes can reach it.
docker ps
List running containers. Add -a for all including stopped.
PYTHONUNBUFFERED
Ensures Python output appears in docker logs immediately.
91

Exercises

✎ Lab exercises — approximately 55 minutes

Part A: Exploring the runtime

  1. Pull and run the Python image interactively: docker run -it python:3.11-slim bash
  2. Inside the container, run python3 --version, pip list, and ls /.
  3. Create a file: echo hello > /tmp/test.txt. Exit the container.
  4. Run the same image again. Is /tmp/test.txt still there? Why not?

Part B: Build your first image

  1. Write a Python script that prints a greeting and today's date.
  2. Write a Dockerfile for it using python:3.11-slim as the base.
  3. Add a .dockerignore excluding __pycache__ and .git.
  4. Build it: docker build -t hello:v1 .
  5. Run it: docker run --rm hello:v1

Part C: Layer caching

  1. Add a requirements.txt with one package (e.g. requests).
  2. Update your Dockerfile to copy requirements.txt first, install, then copy the rest.
  3. Build twice: docker build -t hello:v2 .. How does the second build differ?
  4. Change only your Python script and rebuild. Which layers are rebuilt? Which are cached?

Part D: Running a web application

  1. Install Flask locally and write a minimal Flask app that returns 'Hello from Docker'.
  2. Add Flask to requirements.txt.
  3. Update the Dockerfile CMD to run Flask: ["python3", "-m", "flask", "run", "--host=0.0.0.0"]
  4. Build and run with port mapping: docker run -p 5000:5000 hello:v3
  5. Visit http://localhost:5000 in your browser.