Containers
Packaging an application with everything it needs: images and containers, Docker, registries, and how containers differ from virtual machines.
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:1tells the builder to use the current stable Dockerfile syntax. -
FROM python:3.12-slimis 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 usinglatest, 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. -
ENVsets environment variables inside the image: no.pycfiles and unbuffered output, so log lines appear immediately. -
WORKDIR /appcreates the directory and makes it the working directory for the following steps and for the running container. -
COPY requirements.txt .andRUN pip install …install the pinned dependencies first. As long asrequirements.txtdoes not change, this slow step comes from the cache.--no-cache-dirkeeps 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 appusermakes everything after it, including the running application, run as that user instead of root. -
EXPOSE 8000documents the port the application listens on. It does not publish the port; that happens atdocker run. -
CMDis 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
-
-druns the container detached (in the background) and prints its full ID. -
-p 8080:8000publishes 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=productionsets 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/datamounts 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
| 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
USERin 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--privilegedor 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.