Containers, from namespaces to a production image
What is a container really, and what makes an image small, fast to build and safe to run?
A container is an ordinary Linux process that the kernel shows a restricted view of the system (namespaces) and a budget of resources (cgroups), started from a stack of read-only filesystem layers. There is no guest kernel: good images are small, layered for caching, run as non-root and handle signals as PID 1.
THE MENTAL MODEL
Think of a container as a process with blinders and a leash. Namespaces are the blinders: its own PID 1, network interfaces, mount table, hostname and (optionally) user IDs. Cgroups are the leash: a memory ceiling the kernel enforces with the OOM killer, a CPU quota it enforces by throttling, a cap on processes. The filesystem is an image, a stack of immutable, content-addressed tar layers merged by a union/overlay filesystem, plus one thin writable layer that dies with the container. Everything else follows from that: the host kernel is shared (so a kernel exploit or a root process with too many capabilities is a host problem), layer order decides build cache hits, deleted files still ship in earlier layers, and your app inherits PID 1 duties it was never written for.
HOW IT FITS TOGETHER
- Your app processnode, python or a static binary. Becomes PID 1 inside its PID namespace unless an init such as tini runs first.
- Image filesystem (layers)Read-only, content-addressed layers merged by overlayfs or a containerd snapshotter, plus a per-container writable layer (copy-on-write).
- OCI runtime (runc, crun)Reads the OCI bundle, creates namespaces and cgroups, applies seccomp, capabilities and mounts, then execs your entrypoint.
- Engine (dockerd, containerd)Pulls images, manages snapshots, networks and volumes, and calls the runtime through a shim. Podman does this without a daemon.
- Namespaces + cgroupsNamespaces (pid, net, mnt, uts, ipc, user, cgroup) decide what the process sees; cgroup v2 decides how much memory, CPU and PIDs it gets.
- Host Linux kernelOne kernel for all containers. On macOS and Windows, Docker Desktop runs this kernel in a lightweight VM.
- Dockerfile + build contextContext is filtered by .dockerignore before it reaches the builder. Secrets belong in --mount=type=secret, never in COPY or ARG.
- BuildKit buildEach instruction is checked against the cache; the first changed input invalidates it and every later step. Independent stages run in parallel.
- Image: layers + configAn OCI manifest listing layer digests plus a config with ENTRYPOINT, CMD, USER, ENV. Multi-arch images add an index.
- Push to registryOnly layers the registry lacks are uploaded. Sign and attach SBOM/provenance attestations here.
- Pull by digestimage@sha256:... guarantees the exact bytes that were tested; :latest or :1 can change under you.
- Create + startRuntime unpacks layers into a snapshot, adds the writable layer, sets namespaces, cgroup limits and user, then starts PID 1.
KEY TERMS
- Namespaces and cgroups
- Namespaces isolate what a process can see (PIDs, network stack, mounts, hostname, IPC, user IDs); cgroup v2 limits what it can use. memory.max is a hard ceiling enforced by the OOM killer inside the cgroup, cpu.max ("quota period") throttles rather than kills, pids.max stops fork bombs. Nothing here is a VM: the kernel is shared.
- Images vs containers
- An image is an immutable OCI artifact: ordered read-only layers plus a config. A container is a running (or stopped) instance with its own writable layer on top. Writes to that layer are copy-on-write and disappear when the container is removed; anything that must survive goes to a volume.
- Layer caching
- BuildKit reuses a step only if the instruction and all its inputs are unchanged, and a miss invalidates every later step. Copy lockfiles and install dependencies before COPY . ., use RUN --mount=type=cache for package manager caches, and remember that rm in a later layer only hides a file: it still ships in the earlier one.
- Multi-stage and minimal images
- Build in a stage with compilers and dev dependencies, then COPY --from only the artifacts into a slim or distroless runtime stage. Distroless images contain the runtime and CA certificates but no shell or package manager, which shrinks the attack surface and the CVE scanner noise; the :debug tags add busybox for troubleshooting.
- PID 1 and signals
- The process that runs as PID 1 ignores any signal whose action is the default, so SIGTERM from docker stop or Kubernetes does nothing unless the app installs a handler, and after the grace period it is SIGKILLed. PID 1 must also reap zombies. Use exec-form CMD/ENTRYPOINT (no shell wrapper), exec "$@" in entry scripts, and docker run --init (tini) or handle SIGTERM yourself.
- Non-root and least privilege
- Root in a container is root on the host kernel, minus some capabilities. Set USER to a fixed non-zero UID, keep app files owned by root and read-only, drop capabilities (--cap-drop ALL), use --read-only with explicit writable volumes, and never mount the Docker socket into an app container.
- Rootless engines
- Docker rootless mode and rootless Podman run the engine and containers as an unprivileged user inside a user namespace mapped through /etc/subuid and /etc/subgid, so container root is an ordinary user on the host. userns-remap remaps containers but the daemon still runs as root. Podman is also daemonless and accepts most docker CLI commands.
IN YOUR STACK
Dockerfile · Node.js 24, multi-stage Server
# syntax=docker/dockerfile:1
FROM node:24-slim AS deps
WORKDIR /app
# Lockfiles first: this layer is reused until dependencies change.
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Files stay root-owned, so the app user cannot rewrite its own code.
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
# Exec form, node directly (not npm start) so SIGTERM reaches the process.
CMD ["node", "dist/server.js"]
# docker run --init -m 512m -p 3000:3000 app- Official node images ship a node user with UID 1000. Node was not designed to run as PID 1: run with --init (tini) or handle SIGTERM and SIGINT in code.
- npm start puts npm (and often a shell) in front of node, and signals are not reliably forwarded; bake the real command into CMD instead.
- The cache mount keeps ~/.npm between builds without putting it in a layer. Pin the base by digest in CI and let Dependabot or Renovate bump it.
Dockerfile · Go static binary, distroless Server
# syntax=docker/dockerfile:1
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
# No shell, no package manager: CA certs, tzdata, /etc/passwd and the binary.
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]- CGO_ENABLED=0 produces a static binary that needs no libc, so the static image fits. With cgo use distroless/base or cc instead.
- The :nonroot tag runs as UID 65532. There is no shell for docker exec; use the :debug-nonroot tag or an ephemeral debug container when troubleshooting.
- A Go server as PID 1 still needs signal.NotifyContext(ctx, syscall.SIGTERM) for graceful shutdown. Since Go 1.25 the runtime sets GOMAXPROCS from the cgroup CPU limit.
Dockerfile · Python 3.14 with uv Server
# syntax=docker/dockerfile:1
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.7 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_NO_DEV=1 UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Dependencies only: cached until uv.lock or pyproject.toml changes.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-editable
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-editable
# Same base image, so the venv's python symlink still resolves.
FROM python:3.14-slim
RUN useradd --system --uid 10001 app
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
USER app
CMD ["python", "-m", "myapp"]- This follows uv's documented intermediate-layer and multi-stage patterns. --no-editable installs the project into the venv, so only .venv is copied and the source stays behind.
- Pin the uv image to a version (or digest), not :latest. UV_PYTHON_DOWNLOADS=0 makes both stages use the base image's interpreter.
- --locked fails the build if uv.lock is out of date with pyproject.toml instead of silently re-resolving.
dockerignore · Build context filter Server
# Everything not excluded is sent to the builder and can be COPY'd.
.git
**/node_modules
**/.venv
**/__pycache__
dist
coverage
*.log
# Secrets must never enter the context, let alone a layer.
.env
.env.*
!.env.example
*.pem
*.key
# Changing these should not bust the COPY . . cache.
Dockerfile*
compose*.yaml
.dockerignore- Patterns work like .gitignore, with ! re-including. A missing .dockerignore makes COPY . . slow, invalidates the cache on every commit (.git changes) and can bake secrets into a layer.
- Excluding node_modules also stops host-built native modules for the wrong OS or architecture from leaking into the image.
YAML · Docker Compose Server
services:
app:
build: .
init: true # tini as PID 1: signal forwarding and zombie reaping
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app:app@db:5432/app
depends_on:
db:
condition: service_healthy # wait for pg_isready, not just "started"
restart: true
db:
image: postgres:18
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: app, POSTGRES_DB: app }
volumes: ["pgdata:/var/lib/postgresql"] # 18+ path; 17 and below used .../data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
pgdata:- depends_on alone only orders container start; condition: service_healthy waits for the healthcheck. The app should still retry its first connection.
- $$ escapes Compose interpolation so the variables are expanded inside the container. The top-level version key is obsolete.
- Postgres 18 images moved PGDATA to /var/lib/postgresql/18/docker and the VOLUME to /var/lib/postgresql. Keep mounting .../data and the entrypoint refuses to start rather than writing outside your volume.
Shell · cgroup v2 from inside Server
# docker run --rm -it -m 512m --cpus 2 --pids-limit 200 debian:13 bash
cat /proc/1/cgroup # "0::/" means cgroup v2 with a namespaced root
cat /sys/fs/cgroup/memory.max # 536870912, or "max" when unlimited
cat /sys/fs/cgroup/memory.current # usage incl. page cache
grep oom_kill /sys/fs/cgroup/memory.events # >0: the kernel OOM-killed a process here
cat /sys/fs/cgroup/cpu.max # "200000 100000" = 2 CPUs of quota per 100 ms
grep throttled /sys/fs/cgroup/cpu.stat # nr_throttled / throttled_usec
cat /sys/fs/cgroup/pids.max # 200
nproc # still the host's CPUs: a quota is not a cpuset
grep -E '^(Uid|CapEff)' /proc/self/status # who you are and which capabilities remain
ls -l /proc/self/ns # namespace ids; compare with the host to see sharing- Runtimes that size thread pools or heaps from the CPU or RAM count they see can overshoot the limit; check whether yours reads cgroup limits or set them explicitly.
- memory.current includes reclaimable page cache, so it reads higher than RSS. The OOM killer fires at memory.max, which is what Kubernetes reports as OOMKilled.
- cpu.max never kills: an over-quota container is throttled, which shows up as latency spikes in nr_throttled rather than as errors.
WHERE IT BITES
- Shell-form CMD or an entry script without exec: /bin/sh becomes PID 1, never forwards SIGTERM, and every deploy waits out the grace period before a SIGKILL drops in-flight requests.
- COPY . . before installing dependencies, or no .dockerignore: every source edit or commit reinstalls everything, and .env or .git ends up in the image.
- Secrets in ARG, ENV or a COPY followed by rm. They persist in image history and earlier layers; use RUN --mount=type=secret or inject at runtime.
- Running as root with default capabilities, privileged mode or a mounted /var/run/docker.sock. The kernel is shared, so a container escape becomes host root.
- Shipping :latest. Builds and deploys stop being reproducible; pin bases by digest, deploy by digest, and automate bumps.
- No memory limit, or a limit the runtime does not know about: the kernel OOM-kills the process with no stack trace instead of the app shedding load.
CLOSE THE AI. EXPLAIN THIS.
Your container takes 30 s to stop on every deploy and is sometimes OOMKilled under load. Using namespaces, cgroups and layers, explain both symptoms, then rewrite the Dockerfile's last three lines and the run flags to fix them.WHEN IT BREAKS IN PRODUCTION
SOURCES
- Docker: Building best practices (opens in new tab)Checked
- Docker: Storage drivers (layers and copy-on-write) (opens in new tab)Checked
- Docker: docker container run reference (opens in new tab)Checked
- Linux kernel: Control Group v2 (opens in new tab)Checked
- Docker: Rootless mode (opens in new tab)Checked
- Podman: podman(1) (opens in new tab)Checked
Explainer reviewed