The problem containers solve

"It works on my machine" is one of the oldest sentences in IT. An application that runs fine on a developer's laptop fails on the test server because that server has a different Python version, a missing library or another configuration file. Installing several applications on one server makes it worse: application A needs version 1 of a library, application B needs version 2.

Virtual machines solve this by giving each application a whole computer, but a full operating system per application is heavy: gigabytes of disk, minutes to boot, and one more system to patch. Containers take a lighter route: they package the application with its libraries and settings, and run it isolated on a shared operating system. The package that worked on the laptop is exactly the package that runs in production.

This page is the overview; Containers & Kubernetes covers running, building, storing, networking, securing and debugging containers in depth.

What a container is

A container is a process (or a small group of processes) that runs isolated from the rest of the system, with its own view of the file system, network and process list, while sharing the host's operating system kernel. It is not a small virtual machine: there is no guest operating system and no virtual hardware.

Two features of the Linux kernel make this possible:

  • Namespaces control what a process can see. In its own namespaces, a container sees only its own processes (its main process has process ID 1), its own network interfaces and IP address, its own host name and its own file system.
  • Control groups (cgroups) control what a process can use: how much processor (CPU) time, memory and disk I/O it may take, so that one container cannot starve the others.

Because containers share the host's kernel, a Linux container needs a Linux kernel. Docker Desktop on Windows and macOS therefore runs a small Linux virtual machine in the background (on Windows usually through the Windows Subsystem for Linux, WSL 2). Windows containers also exist and need a Windows host.

The processor architecture matters too. An image contains compiled programs for one architecture, such as amd64 (x86-64, most servers and PCs) or arm64 (Apple silicon Macs, Raspberry Pi, AWS Graviton). An amd64-only image does not run natively on an arm64 host. Multi-architecture images solve this: one tag points to a variant per architecture, and the runtime pulls the one that matches the host.

Images and layers

Image versus container

A container image is a read-only package that contains everything an application needs to run: a minimal set of operating system files, a language runtime, libraries, the application code and metadata such as the command to start. A container is a running instance of an image, with a thin writable layer on top. The relationship is like a class and its objects, or an installation DVD and the installed computers: one image, any number of containers.

Layers and caching

An image is built from a stack of image layers. Each layer holds the file changes made by one build step: one layer for the base operating system files, one for the installed libraries, one for the application code. The layers are read-only and identified by a hash of their content, which has two benefits:

  • Sharing. Ten images built on the same base share its layers on disk and download them only once.
  • Build caching. When you rebuild an image, unchanged steps are taken from the cache. Only the first changed step and everything after it are rebuilt. That is why a good Dockerfile puts rarely changing steps (installing dependencies) before often changing ones (copying your own code).

A Dockerfile, line by line

A Dockerfile is a text file with the step-by-step instructions to build an image. Docker made the format popular, and other tools such as Podman and Buildah read it too. This one packages a small Python web application programming interface (API), served by the Gunicorn web server:

# syntax=docker/dockerfile:1
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

RUN useradd --create-home --uid 10001 appuser \
    && mkdir /app/data \
    && chown appuser /app/data
USER appuser

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
  • # syntax=docker/dockerfile:1 tells the builder to use the current stable Dockerfile syntax.
  • FROM python:3.12-slim is the base image: a slimmed-down Debian with Python 3.12 from the official image on Docker Hub. The tag pins the Python version instead of using latest, which can change under you at any moment. For fully reproducible builds you can pin the exact image by digest (python:3.12-slim@sha256:…) and update it deliberately.
  • ENV sets environment variables inside the image: no .pyc files and unbuffered output, so log lines appear immediately.
  • WORKDIR /app creates the directory and makes it the working directory for the following steps and for the running container.
  • COPY requirements.txt . and RUN pip install … install the pinned dependencies first. As long as requirements.txt does not change, this slow step comes from the cache. --no-cache-dir keeps pip's download cache out of the image.
  • COPY app.py . adds the application code last, because it changes most often.
  • RUN useradd … creates an unprivileged user and a data directory it owns; USER appuser makes everything after it, including the running application, run as that user instead of root.
  • EXPOSE 8000 documents the port the application listens on. It does not publish the port; that happens at docker run.
  • CMD is the command a container runs when it starts, written in the JSON (JavaScript Object Notation, exec) form so that the application receives stop signals directly.

A .dockerignore file next to the Dockerfile keeps files such as .git, local virtual environments and .env files out of the build, so they cannot end up in the image by accident.

Registries

A container registry is a server that stores and distributes container images, much as a package repository does for software packages. Docker Hub is the largest public registry and hosts the official base images; it limits how many pulls anonymous and free users may make. Every cloud provider, including Amazon Web Services (AWS), Microsoft Azure and Google Cloud, offers a private registry next to its other services:

Provider Registry Image name looks like
AWS Amazon Elastic Container Registry (ECR) <account>.dkr.ecr.<region>.amazonaws.com/shop-api
Azure Azure Container Registry (ACR) <name>.azurecr.io/shop-api
Google Cloud Artifact Registry (replaced Container Registry) <region>-docker.pkg.dev/<project>/<repository>/shop-api
Others GitHub Container Registry, GitLab, Harbor (self-hosted) ghcr.io/<owner>/shop-api

Tags and digests

An image reference such as shop-api:1.4.0 consists of a repository name and a tag. Tags are labels that can be moved: today python:3.12-slim points to one image, after the next patch release to another. A digest (sha256: followed by 64 hexadecimal characters) is a hash of the image content and always identifies exactly the same image. Use clear version tags for your own images, never rely on latest in production, and use digests where you need certainty about exactly what runs.

Most registries can scan images for known vulnerabilities in their packages (Amazon ECR scanning, Microsoft Defender for Containers, Google Artifact Analysis, or tools such as Docker Scout and Trivy). Scanning is only useful if someone acts on the results by rebuilding on an updated base image.

Running containers

The Docker command-line tool builds, runs and manages images and containers. These sessions are illustrative; the Containers & Kubernetes topic shows output recorded with Docker 28.2. Building the image from the Dockerfile above and listing it:

$ docker build -t shop-api:1.4.0 .
$ docker image ls shop-api
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
shop-api     1.4.0     5c3e8a91d2f7   45 seconds ago   171MB

Docker Engine 29 shows a new tree view of images by default, so its output of this command looks different.

Starting a container in the background, publishing a port, passing configuration and attaching a volume:

$ docker run -d --name shop-api \
    -p 8080:8000 \
    -e APP_ENV=production \
    -v shop-data:/app/data \
    shop-api:1.4.0
$ docker ps
a41f9c2e7b3d8e05f1c6a9d4b7e2f08c3d5a6b9e1f4c7d0a2b5e8f1c4d7a0b3e
CONTAINER ID   IMAGE            COMMAND                  CREATED         STATUS         PORTS                                         NAMES
a41f9c2e7b3d   shop-api:1.4.0   "gunicorn --bind 0.0…"   8 seconds ago   Up 7 seconds   0.0.0.0:8080->8000/tcp, [::]:8080->8000/tcp   shop-api
  • -d runs the container detached (in the background) and prints its full ID.
  • -p 8080:8000 publishes a port: connections to port 8080 on the host go to port 8000 in the container. Without it, the application is unreachable from outside.
  • -e APP_ENV=production sets an environment variable, the usual way to pass configuration, so the same image can run in test and in production with different settings.
  • -v shop-data:/app/data mounts a named volume at /app/data (see below).

docker logs shop-api shows what the application wrote to its standard output, docker stop shop-api stops it, and docker rm shop-api removes it.

Containers are ephemeral

Anything a container writes to its own writable layer disappears when the container is removed, and containers are removed all the time: every update replaces the old container with a new one from a new image. Design for that. Keep state outside the container: in a volume (storage managed by the container engine that outlives containers), in a managed database, or in object storage. A container you can delete and recreate at any moment without losing anything is a healthy container. Volumes and bind mounts in practice: Volumes and Bind Mounts.

Containers versus virtual machines

Two stacks side by side. Left: physical hardware, a hypervisor, and three virtual machines, each containing a guest OS, libraries and an app. Right: physical hardware, one host operating system with a shared kernel, a container runtime, and three containers, each containing only libraries and an app. VM 1 App Libraries Guest OS VM 2 App Libraries Guest OS VM 3 App Libraries Guest OS Hypervisor Physical hardware Virtual machines each with its own kernel Container App Libraries Container App Libraries Container App Libraries Container runtime Host OS (shared kernel) Physical hardware Containers sharing the host kernel
Every virtual machine carries its own operating system; containers share the host's kernel and carry only the application and its libraries.
Aspect Container Virtual machine
Startup Seconds or less (starting a process) Tens of seconds to minutes (booting an operating system)
Size Megabytes to a few hundred megabytes Gigabytes
Isolation Process level; all containers share the host kernel Hardware level; each virtual machine has its own kernel
Operating system Same kernel as the host (Linux containers on Linux) Any operating system for the processor architecture
Density Many per host Fewer per host
Typical use Packaging and scaling applications and microservices Whole servers, different operating systems, strong isolation between tenants

The two are not rivals. In the cloud, containers almost always run inside virtual machines: the VM provides strong isolation between customers, containers provide fast, consistent packaging within it. See Virtualization and Hypervisors.

Standards and containers in the cloud

The OCI standard and alternatives to Docker

The Open Container Initiative (OCI), a Linux Foundation project, defines open standards for the image format, the container runtime and how registries distribute images. Because of it, an image built with Docker runs on any OCI-compatible runtime. Podman is a popular alternative to Docker with nearly the same commands; it needs no background service (daemon) and runs containers as an ordinary user (rootless) by default. Under the hood, Docker uses containerd and runc to start containers; Kubernetes talks to a runtime such as containerd or CRI-O (through the Container Runtime Interface, CRI), which usually also starts containers with runc.

Managed container services

Running a few containers on one server is easy. Running hundreds over many servers, restarting failed ones and spreading load, needs an orchestrator; Kubernetes is the standard (see Kubernetes Basics). Cloud providers offer several levels of help. AWS also has its own, simpler orchestrator, Amazon Elastic Container Service (ECS).

What you want AWS Azure Google Cloud
Run a container without managing servers AWS Fargate (with ECS or Amazon Elastic Kubernetes Service, EKS) Azure Container Apps, Azure Container Instances Cloud Run
Managed Kubernetes Amazon EKS Azure Kubernetes Service (AKS) Google Kubernetes Engine (GKE)

Container security basics

  • Do not run as root. Set a USER in the Dockerfile. If an attacker breaks out of a container whose process runs as root, they are much closer to root on the host. Never use --privileged or mount the Docker socket into a container unless you understand that it hands over control of the host.
  • Keep images updated. A container image is a frozen copy of its packages. Rebuild regularly on an updated base image and redeploy; scan images in the registry.
  • No secrets in images. Passwords, API keys and certificates baked into an image can be read by anyone who can pull it, and a file deleted in a later step still exists in an earlier layer. Pass secrets at runtime from a secrets manager or the orchestrator's secret mechanism; see also Linux Security.
  • Use trusted, minimal images. Start from official or verified images, pin versions, and include nothing the application does not need.
  • Limit resources and privileges. Set memory and CPU limits, and use a read-only root file system where the application allows it.

Each of these is worked out on Container Security, including rootless mode, capabilities, image scanning and signing.