Animated Architecture Patterns

Docker & Containers

Package an app and its dependencies as an image and run it as an isolated process: layers, registries, namespaces, cgroups.

Also known as Docker, Containers, Container images, OCI images

developer git push to main edited src/price.ts CI pipeline docker buildx build --push Dockerfile abridged FROM node:24 AS deps COPY package*.json ./ RUN npm ci --omit=dev FROM deps AS build RUN npm ci COPY . . RUN npm run build FROM node:24-slim COPY --from=deps … COPY --from=build … USER node CMD ["node", …] final stage cachedcachedcachedcachedcachedrebuiltrebuiltcachedcachednew layerno layerno layer Amazon ECR repository catalog tag → manifest digest 1.4.1 sha256:51ab… 1.4.2sha256:9f2c… scan every push,sign it, pin the digest image layers (read-only) 6 skipped1 uploaded app code c41e… dependencies 8d7a… Node.js 24 2b9f… +3 Debian 12 slim e1f0… docker pull Container host one in every environment, same image host-1 laptopDocker Desktopcatalog@sha256:9f2c…debug this exact buildstagingAmazon ECScatalog@sha256:9f2c…integration tests passproductionKubernetes (Amazon EKS)catalog@sha256:9f2c…promoted, not rebuiltPulled by digest: the image tested in staging is the image in production. dockerdcontainerdrunc app codec41e… missingapp codec41e…dependencies8d7a…Node.js 242b9f… +3Debian 12 slime1f0… 6 already here1 layer pulled image store on host-11.4.1sha256:51ab…pulled last release1.4.2sha256:9f2c…+1 layer, 6 reused image layersread-only,shared by both $ docker run -d --cpus=0.5 --memory=512m \ --memory-swap=512m $ECR/catalog@sha256:9f2c… Linux kernelshared by every container on host-1Linux kernelshared kernel: weaker isolation than a VMOOM killer cgroup0.5 CPU · 512 MiBcatalog-1namespacespidPID 1 is nodenetown eth0mntown mountsutsown hostnamethin writable layercgroup0.5 CPU · 512 MiBcatalog-2namespacespidPID 1 is nodenetown eth0mntown mountsutsown hostnamethin writable layermemory usedlimit: memory.max 512 MiBlimit reached: 512 MiBOOMKilledexit code 137 (SIGKILL)catalog-1 keeps runningdeleted on docker rm BUILD ONCE, RUN ANYWHERECI builds catalog:1.4.2 once and pushes it to Amazon ECR, where the tag points to a digest: sha256:9f2c…, a hash of the image manifest.Laptop, staging and production pull that digest, not the tag, so the image that passed in staging is, byte for byte, what runs in production. LAYERS AND THE BUILD CACHEEach RUN, COPY or ADD saves its file changes as a read-only layer named by a hash of its content; the node:24 build stages are left behind.Only src/ changed, so BuildKit re-runs the three steps that depend on it and takes the rest from its cache. Push and pull move just the new layer. A CONTAINER IS AN ISOLATED PROCESSdockerd asks containerd, and runc starts node as PID 1 in new pid, net, mnt, uts, ipc and cgroup namespaces, so it sees only its own world.Its cgroup caps it: cpu.max 50000 100000 (0.5 CPU), memory.max 512 MiB. Writes land in a thin writable layer; image layers and kernel are shared. THE LIMITScatalog-2 hit memory.max, so the kernel's OOM killer sent it SIGKILL (exit code 137). Removing it deletes its writable layer and all in it.one shared kernel: weaker isolation than a VMkeep state in volumes or a databasescan, sign and pin images by digest

Step 1 · Where it sits. Acme Shop's CI builds the Catalog service's image catalog:1.4.2 once and pushes it to Amazon ECR, where the tag points to a manifest digest, sha256:9f2c…. A developer's laptop (Docker Desktop), staging (Amazon ECS) and production (Kubernetes on Amazon EKS) all pull that digest, so the image that passed the tests in staging is, byte for byte, the one that runs in production. The image carries the service and everything it needs above the kernel: a Debian userland, Node.js 24, its npm dependencies and its compiled code.

Step 2 · Layers and the cache. Each RUN, COPY or ADD instruction saves its file changes as a read-only layer, named by a hash of its content. Only src/price.ts changed, so BuildKit re-runs COPY . ., the build and the COPY that takes its output, takes every other step from its cache, and the image gets one new layer; the node:24 build stages, with TypeScript and the dev dependencies, never reach it, because the last stage starts FROM node:24-slim and copies in only what it needs. Push and pull compare digests, so ECR and host-1 receive just that one layer.

Step 3 · Running a container. docker run goes to dockerd, which asks containerd to create the container; containerd has runc start node as PID 1 in new pid, net, mnt, uts, ipc and cgroup namespaces, so it gets its own process tree, network interface, mounts and hostname. A cgroup caps it at 0.5 CPU (cpu.max 50000 100000) and 512 MiB (memory.max). Each container adds only a thin writable layer (copy-on-write) on top of the image layers, which both containers share, and both run on host-1's one kernel.

Step 4 · Limits and trade-offs. When catalog-2 reaches its 512 MiB limit, the kernel's OOM killer sends it SIGKILL: docker inspect reports OOMKilled and exit code 137 (128 + 9). Its writable layer is deleted when the container is removed, so state belongs in volumes or in a database. Every container shares the host's kernel, so isolation is weaker than a virtual machine's: run as non-root, drop capabilities, keep the root filesystem read-only, and scan, sign and pin images by digest.

The problem

Acme Shop's Catalog service is a Node.js application written in TypeScript. To run it, a machine needs a particular Node.js release, the exact npm packages in its lockfile, a few system libraries and the compiled JavaScript. When each environment is prepared by hand or by its own scripts, they drift apart: the laptop has another Node.js version, staging got an operating-system patch that production hasn't, and a native package that builds on one machine fails on the next. Every deploy installs everything again, so what runs in production is not quite what was tested. Giving each service its own virtual machine removes the conflicts, but costs a whole operating system per service, and machine images are slow to build and to boot.

A container image packages the service with everything it needs above the kernel: a Linux distribution's userland, the runtime, the dependencies and the code. CI builds it once, and every environment runs the same bytes, identified by a digest. A running container is an ordinary Linux process that the kernel isolates with namespaces and limits with cgroups, so nothing has to boot. Docker made this way of working common, and in 2015 it gave its image format and its runtime, runc, to the newly founded Open Container Initiative (OCI), whose specifications the other tools now follow too.

How it works

An image is layers, a config and a manifest

An image is made of three kinds of object, each stored as a blob and named by the SHA-256 digest of its bytes:

  • Layers. A layer is a tar archive of filesystem changes: files added or changed, and deletions recorded as whiteout entries. Unpacked in order, the layers form the container's root filesystem.
  • The config. A JSON document with the defaults for running the image (command, environment, working directory, user) and the DiffIDs of its layers, the digests of the uncompressed archives.
  • The manifest. A JSON document that lists the config and the layers by digest. Its own digest therefore stands for the whole image: change one byte in any layer and the manifest's digest changes too. A multi-platform image adds an image index that points to one manifest per platform, and BuildKit also uses an index to attach its attestations.

Only instructions that change files create layers: RUN, COPY and ADD. USER, CMD, ENV and the others change only the config. The Catalog image has seven layers. Its base, node:24-slim, brings five: a Debian 12 root filesystem, then one layer each for a node user, Node.js, Yarn and an entrypoint script (the diagram draws those four as one bar, marked +3). The Dockerfile adds two: the production dependencies and the compiled app.

A tag such as catalog:1.4.2 is a name in a repository that points to a manifest or an index, and it can be moved. A digest such as sha256:9f2c… can't. Deploying by digest is how the laptop, staging and production in step 1 know they run the same bytes.

Building: the Dockerfile, stages and the cache

# syntax=docker/dockerfile:1
FROM node:24 AS deps
WORKDIR /src
COPY package*.json ./
RUN npm ci --omit=dev

FROM deps AS build
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
COPY --from=deps /src/node_modules /app/node_modules
COPY --from=build /src/dist /app/dist
USER node
CMD ["node", "/app/dist/server.js"]

The first two stages start from the full node:24 image, which has compilers and build tools: deps installs only the production dependencies, and build adds the dev dependencies (TypeScript among them) and compiles src/ into dist/. The last stage starts again from the much smaller node:24-slim and copies in just the two directories it needs, so neither the compilers nor the dev dependencies reach the image that ships. BuildKit, the default builder in Docker Desktop and Docker Engine, builds only the stages the target depends on and runs independent stages in parallel.

Every step's result is cached. For COPY and ADD, the builder computes a checksum of the files being copied (their modification times don't count); for RUN it compares only the command string, so the builder never notices that a package registry now serves something newer. Once a step misses, every later step in that stage runs again. That is why the Dockerfile copies the package files and installs dependencies before it copies the source. In step 2 only src/price.ts changed, so COPY . ., npm run build and the final COPY --from=build ran again, everything else came from the cache, and 1.4.2 differs from 1.4.1 by one layer.

docker buildx build --push sends the result straight to the registry. A CI runner that starts empty every time has no cache to reuse; export it with --cache-to and read it back with --cache-from (there are registry, GitHub Actions, S3 and local backends). BuildKit also attaches a minimal provenance attestation to the image by default, and an SBOM when asked with --sbom=true.

Registries: push, pull, tags and digests

Registries implement the OCI Distribution Specification. To push, the client first asks whether the registry already has each blob, with a HEAD request per digest, and uploads only the missing ones; a blob that already sits in another repository of the same registry can be mounted instead of uploaded. Then it uploads the manifest and points the tag at it. A pull runs the other way: fetch the manifest by tag or by digest, then download only the layers the host doesn't have (Docker Engine downloads three layers at a time and uploads five at a time by default). In step 2, ECR already had the other six layers and host-1 already had them from 1.4.1, so each transfer moved a single layer.

Amazon ECR keeps private repositories per account and Region. Clients authenticate through IAM: aws ecr get-login-password returns a token for docker login that is valid for 12 hours. A repository can make its tags immutable (pushing a tag that exists then fails with ImageTagAlreadyExistsException), with optional exclusions. ECR scans images with basic scanning (operating-system vulnerabilities) or enhanced scanning through Amazon Inspector (operating-system and language packages, on push and continuously); lifecycle policies expire old images; pull-through cache rules mirror upstream registries such as Docker Hub, with the credentials kept in AWS Secrets Manager; and managed signing with AWS Signer signs images automatically.

Docker Hub limits pulls. As of October 2026, unauthenticated clients get 100 pulls per 6 hours, counted per IPv4 address or IPv6 /64 subnet; a free Personal account gets 200 per 6 hours; Pro, Team and Business accounts are unlimited, subject to fair use. A pull is one manifest download, a multi-architecture image counts once per architecture, and HEAD checks don't count. CI runners behind one NAT address share the unauthenticated quota, so log in, or mirror the base images, for example through an ECR pull-through cache.

Running: Docker Engine, containerd and runc

docker run is a client call. The CLI sends it to dockerd, the Docker Engine daemon, which leaves creating, starting and stopping containers to containerd. containerd runs each container through a shim that calls runc, its default runtime, the one Docker gave to the OCI. runc reads the container's bundle, a root filesystem plus a config.json that lists the namespaces, cgroup limits, mounts, capabilities and seccomp filter, sets all of it up, and starts node as the container's first process. Since Engine 29.0, fresh installs also keep images in containerd's image store, which uses containerd's snapshotters instead of Docker's older storage drivers.

On a Kubernetes node there is no dockerd: the kubelet talks to containerd or CRI-O through the Container Runtime Interface (CRI). Kubernetes removed its built-in Docker integration, dockershim, in version 1.24 (May 2022). Images built with docker build run unchanged under every CRI runtime, because they are OCI images, and Docker Engine itself can still serve a cluster through the cri-dockerd adapter.

The OCI publishes the three standards underneath (as of October 2026): the Image Format Specification v1.1.1 (March 2025), the Runtime Specification v1.3.0 (November 2025) and the Distribution Specification v1.1.1 (January 2025). The current releases of the stack are Docker Engine 29.8.2 (30 September 2026), containerd 2.4 and runc 1.5.

Namespaces: what a container can see

Linux has eight kinds of namespace: cgroup, IPC, network, mount, PID, time, user and UTS. Docker gives each container new pid, net, mnt, uts and ipc namespaces, and on cgroup v2 hosts a private cgroup namespace, which is dockerd's default. So node is PID 1 and sees only its own processes; it has its own network interface, connected to a bridge on the host; its own mount table, rooted in the image layers; and its own hostname, the container's ID unless you choose one. A user namespace is not used by default (see the security section below).

Being PID 1 has a catch: the kernel delivers a signal to a namespace's first process only if that process has installed a handler for it, and SIGKILL is the exception. docker stop sends SIGTERM and, after a grace period, SIGKILL, so a Node.js app that doesn't handle SIGTERM ignores it and is killed at the end of the grace period instead of shutting down cleanly. Handle the signal in the app, or start the container with --init, which adds a small init process that forwards signals and reaps child processes.

cgroups: what a container can use

The command in step 3 sets limits that runc writes into the container's cgroup (version 2):

  • --cpus=0.5 is a CFS bandwidth quota of 50,000 µs in every 100,000 µs period, written to cpu.max as 50000 100000. A container that wants more is throttled until the next period; it isn't killed.
  • --memory=512m becomes memory.max (536870912 bytes). When the cgroup's usage reaches it and the kernel can't reclaim enough, the OOM killer chooses a process in that cgroup and sends it SIGKILL. When that is the container's main process, the container exits with code 137 (128 + 9, SIGKILL's signal number) and docker inspect shows OOMKilled: true.
  • --memory-swap=512m, the same value as --memory, keeps the container off swap. Left unset, a container may use as much swap as its memory limit on a host that has swap.

--pids-limit caps the number of processes too. Docker Engine 29.0 deprecated cgroup v1; Docker supports it until at least May 2029.

Storage and networks

The image layers are read-only and shared: both containers in step 3 use the same copy on disk. Each container adds one thin writable layer on top. With the overlay2 driver, the first write to a file from an image layer copies the whole file up into the writable layer (copy-on-write), and a deletion is recorded as a whiteout. Heavy writes are therefore slow, and the writable layer is deleted together with the container. Put data that must outlive the container in a volume, whose contents exist outside any container's lifecycle; use a tmpfs mount for scratch space; and add --read-only to make the root filesystem read-only altogether. For a service like Catalog, the state belongs outside the host anyway, in a database or an object store.

Each container's network namespace has its own interface. Unless told otherwise, Docker attaches it to the default bridge network, where containers reach each other only by IP address; on a user-defined bridge network (docker network create) they also find each other by name through Docker's built-in DNS. Nothing in a container is reachable from outside the host until a port is published: -p 8080:3000 maps port 8080 on the host to port 3000 in the container, through firewall rules that Docker writes on the host.

Security: one kernel for all

Every container on a host shares its kernel, so the kernel is the isolation boundary. Docker's defaults narrow what a container may ask of it: a short default list of Linux capabilities instead of root's full set, and a seccomp profile that blocks around 44 of the more than 300 system calls. Hardening goes further: a non-root USER (here node), --cap-drop=ALL with only the capabilities the app needs added back, --read-only, and --security-opt no-new-privileges. User namespaces map root inside the container to an unprivileged user ID on the host: userns-remap does this for the containers while dockerd itself still runs as root, and rootless mode runs both the daemon and the containers without root. Where tenants don't trust each other, put a stronger boundary around the workload: gVisor intercepts the container's system calls and handles them itself, and Kata Containers runs containers inside lightweight virtual machines.

The image is software you ship, so its supply chain needs the same care as the code: generate an SBOM, scan on push and keep scanning, sign the digest, verify signatures before deploying, and pin base images by digest. Sigstore's cosign signs keylessly: it uses an ephemeral key and a certificate tied to the OIDC identity of the CI workflow, and records the signing in a public transparency log. Docker Content Trust, Docker's older signing feature, was removed from the Docker CLI in Engine 29.0.

Where it fits

  • Solutions. The unit of deployment for services on Kubernetes (Amazon EKS) and Amazon ECS (on EC2 instances or AWS Fargate); AWS Lambda functions packaged as container images, up to 10 GB uncompressed, stored in Amazon ECR in the function's Region; CI pipelines that build, test and scan inside containers; and local development with Docker Desktop and Docker Compose, which runs a service next to its database and cache.
  • Patterns it implements or supports. Immutable infrastructure: a release is a new image, never a change to a running one. Blue-green deployment, rolling updates and canary releases move traffic from one image digest to another, and GitOps keeps the digest each environment should run in Git. A sidecar is a second container that shares the pod's network namespace with the app. cgroup limits give each container its own bulkhead on a shared host, and containers are the usual building block of microservices.
  • Usual neighbours. A CI system with a build cache; a registry such as Amazon ECR, Docker Hub, GitHub Container Registry or Harbor; image scanners and signers; an orchestrator; a secrets manager; and log and metrics agents that collect what the containers write to stdout and stderr.
  • Managed offerings. Amazon ECR is AWS's registry, Amazon ECS, AWS Fargate and Amazon EKS run containers, and Lambda runs container images as functions. Docker Hub is Docker's own registry.
  • Licences. Docker Engine (the Moby project), the Docker CLI, containerd and runc are open source under the Apache License 2.0. Docker Desktop is not: under the Docker Subscription Service Agreement it is free for small businesses (fewer than 250 employees and less than US$10 million in annual revenue), personal use, education and non-commercial open-source projects, and needs a paid Pro, Team or Business subscription for professional use in larger organizations and for government entities. The common alternatives are Apache 2.0 too: Podman (6.1 as of October 2026) runs the same images without a daemon, also rootless, with a Docker-compatible command line (alias docker=podman works for most uses), and has Podman Desktop for laptops; Buildah builds OCI images without a daemon.

When to use it

Package a service as a container image when it has to run the same way on laptops, in CI and in several environments; when you deploy often and want a rollback to be a change of digest; when several services share hosts but need their own dependencies and resource limits; and when an orchestrator such as Kubernetes or Amazon ECS will run it. Don't treat a container on its own as the security boundary between workloads that don't trust each other: use virtual machines, microVMs or a sandboxed runtime. And for short, event-driven work a function can be simpler, because Lambda runs your image without hosts or a cluster to look after.

Virtual machine Container microVM (Firecracker) Function (AWS Lambda)
Isolation boundary A hypervisor; each VM runs its own kernel Namespaces, cgroups, capabilities and seccomp on one shared kernel A hypervisor (KVM) with a minimal device model; each microVM runs its own guest kernel AWS's own: Lambda is built on Firecracker microVMs
What you ship A machine image with a whole operating system An OCI image: userland, runtime, dependencies and code A guest kernel and a root filesystem A .zip archive or an OCI image of up to 10 GB, plus a handler
Starting one Boots an operating system Starts a process; nothing boots Boots a minimal guest kernel, in under 125 ms according to the project A cold start creates an environment; later requests reuse it
Overhead per instance A guest operating system and its memory A process; the image layers are shared on disk Under 5 MiB of memory per microVM, according to the project Not visible: you pay per request and per GB-second
Where it runs EC2 or your own hypervisor Any Linux host; Amazon ECS, Amazon EKS, AWS Fargate Inside platforms such as Lambda and Fargate, or on your own hosts with KVM On AWS only
Choose it for Other kernels or operating systems, strong isolation between tenants, legacy systems Packaging and running services densely, with fast and repeatable deploys Multi-tenant platforms that need VM isolation at close to container speed Short event-driven work with nothing to run underneath

Firecracker's figures come from the project's site, its use in Lambda and Fargate from the NSDI 2020 paper, and Lambda's image limit from its quotas page (October 2026).

Trade-offs

  • The kernel is shared. A kernel vulnerability, a privileged container or a careless mount (the Docker socket, which controls the daemon and so the host, is the classic one) can let a container reach its neighbours or the host. The defaults help; code you don't trust needs a VM-level boundary.
  • The filesystem is temporary. Whatever is written outside a volume disappears with the container, and copy-on-write makes write-heavy work slow. State has to live in volumes or managed stores, which makes stateful services the hard part.
  • Images are software to maintain. A base image carries a distribution's packages and their vulnerabilities, and tags such as node:24-slim are rebuilt as fixes appear, so the same Dockerfile gives a different image next month. Pinning the base by digest makes builds repeatable, and it also means updating the pin deliberately to get the fixes.
  • Limits cut both ways. A tight CPU quota throttles bursts, because a container that has used its quota waits for the next 100 ms period, and a tight memory limit means OOM kills; loose limits waste the host. Size them from measurements.
  • The registry joins the critical path. Every deploy and every scale-out pulls from it, so its availability, its rate limits (Docker Hub's especially) and the size of the images all matter.
  • More moving parts. One container is simple; a hundred need an orchestrator, a registry, image policies, log collection and patching of the hosts. Docker Desktop also costs money in larger organizations.

Implementation notes

  • Keep images small and specific. Start from a slim or distroless base, use a multi-stage build, and add a .dockerignore (here node_modules, dist, .git and local .env files) so the build context stays small and secrets stay out of it.
  • Order the Dockerfile for the cache. Copy what rarely changes, such as lockfiles, and install dependencies before copying the source that changes with every commit.
  • Never bake secrets into an image. Build arguments and environment variables persist in the image. Pass build-time secrets with docker buildx build --secret and read them with RUN --mount=type=secret; give runtime secrets to the container from a secrets manager.
  • Run with limits and as little privilege as possible. The image already sets USER node; the run below adds the step 3 limits, a read-only root filesystem with a tmpfs for /tmp, no capabilities and no privilege escalation. The node user can't write to /app, which is what you want.
ECR=111122223333.dkr.ecr.us-east-1.amazonaws.com
IMAGE=$ECR/catalog@sha256:9f2c…   # the full digest CI printed for 1.4.2

aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$ECR"
docker run -d --name catalog-1 \
  --cpus=0.5 --memory=512m --memory-swap=512m --pids-limit=256 \
  --read-only --tmpfs /tmp \
  --cap-drop=ALL --security-opt no-new-privileges \
  "$IMAGE"

# Why did catalog-2 stop?
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' catalog-2
# true 137
  • Handle shutdown. Catch SIGTERM in the app, stop taking new work, finish what is in flight and exit before the grace period ends, or add --init.
  • Log to stdout and stderr, and let the engine or the orchestrator ship the logs; don't write log files into the writable layer.
  • Say the same thing to Kubernetes. The container spec below gives Catalog the requests the scheduler uses and the same limits and hardening. With runAsNonRoot the kubelet refuses to start the container as UID 0, and runAsUser: 1000 states the node user's UID explicitly.
containers:
  - name: catalog
    image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/catalog@sha256:9f2c…
    resources:
      requests: { cpu: 250m, memory: 256Mi }
      limits: { cpu: 500m, memory: 512Mi }
    securityContext:
      runAsNonRoot: true
      runAsUser: 1000
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
      capabilities: { drop: ["ALL"] }
  • Sign and verify the digest, not the tag. cosign sign "$IMAGE" in the CI job signs keylessly with the job's OIDC identity; cosign verify "$IMAGE" --certificate-identity=… --certificate-oidc-issuer=… checks the signer before a deploy, and an admission controller such as Sigstore's Policy Controller can enforce the same check in a cluster.
  • Build for the processors you run on. docker buildx build --platform linux/amd64,linux/arm64 puts an image for x86 and one for Arm (such as AWS Graviton) behind one tag.
  • Clean up. ECR lifecycle policies expire old and untagged images; on a host, docker system prune removes unused data.