The Ops Atlas xOps School

Cloud & Infrastructure Ambiguous / not standardised Partly verified

ContainerOps

ContainerOps is this Atlas's name for operating single containers below the orchestrator: building small, traceable OCI images, managing tags, digests and registries, and running containers predictably on a host's runtime, namespaces and cgroups.

Key takeaways

  • ContainerOps is not a standard term: it also names a Huawei DevOps project with no commits since 2018 and a consultancy page. The practice behind it, building and running containers, is well established.
  • A tag is a movable pointer and a digest identifies content. Deploy by digest and let a bot move the pin when the base image changes.
  • Images are rebuilt, never patched in place, so the security metric that matters is how fast a base-image fix reaches production.
  • Containers have no resource limits by default. A memory limit turns a leak into an out-of-memory (OOM) kill (exit 137); a CPU limit can throttle a process whose average use is well below the limit.
  • Runtimes, registries and base images are dependencies with lifecycles: runc, containerd and Docker Engine need patching, and a public registry namespace can change under you.
  • Run as a non-root user, keep the final image minimal, and log to stdout with rotation configured on the host.

Think of ContainerOps as everything done to a container short of scheduling it across machines: producing small, reproducible and traceable images, storing and naming them so that what runs is exactly what was tested, and running them on a host with the limits, identity and logging that make their behaviour predictable.

A container is an ordinary Linux process isolated by namespaces and constrained by control groups (cgroups), started from a filesystem and configuration packaged as an image.[1][2][3] The Open Container Initiative (OCI) standardises the image, runtime and registry contracts, so the skills transfer between Docker, Podman, containerd and Kubernetes.[4] It serves developers, host operators and leaders deciding what “we run containers” commits them to.

Question Short answer
Goal Small, reproducible, traceable images; containers with predictable resources, identity and failures
Users Application developers, operations and platform engineers, security engineers (as partners)
Scope OCI images and layers, Dockerfile practice, base images, tags and digests, registries and retention, runtimes (containerd, runc), namespaces, cgroups and limits, rootless containers, Compose and single-host operation, container logging
Out of scope Orchestration, signing, software bills of materials (SBOMs) and provenance in depth, admission policy, managed platforms and the host OS (neighbour table)
Prerequisites Linux processes, files and permissions; a little shell; what a build pipeline is
Key outputs Base-image policy, Dockerfile standard, tagging and promotion rules, registry retention policy, runtime defaults, rebuild automation
Term status Ambiguous name for a mature practice

Containers make shipping cheap, including shipping mistakes. NIST notes that the build system installs the application into an immutable image, so developers, the pipeline and production all run the same artefact.[5] That property creates four duties:

  • Know what runs. A tag can be moved to new content at any time, so you “might end up with a mix of Pods running the old and new code”.[6] Only digests prove which bytes run.
  • Rebuild to fix. A fixed library in the base helps only after every image built on it is rebuilt and redeployed, hence Docker’s advice to rebuild images regularly.[7]
  • Bound behaviour. By default a container has no resource limits;[8] one leak can starve its neighbours, and one badly chosen limit can kill or throttle a healthy service.
  • Own dependencies. Base images, registries and runtimes change under you, as Bitnami’s 2025 catalogue move showed (use cases).[9]

What it is not: the naming problem and the boundaries

Link to section “What it is not: the naming problem and the boundaries”

Nobody owns the word “ContainerOps”. Searching in October 2026, we found it attached to three unrelated things and to no book, certification, conference or standard:

  1. Huawei’s ContainerOps, an open-source project whose README calls it “a DevOps orchestration platform, and its architecture is micro services” (Apache-2.0; last commit on its default branch on 11 December 2018).[10] A Kubernetes case study describes it as “a DevOps pipeline engine”.[11]
  2. A consultancy’s methodology page defining ContainerOps as “systematic management for applications packaged within isolated runtime environments”:[12] one firm’s framing, not evidence of an industry term.
  3. A student continuous-deployment tool named ContainerOps, shown at an IEEE NITK virtual expo in 2025.[13]

The practice taught here is usually just called building and running containers; “ContainerOps” is only this Atlas’s page name.

Its boundaries with neighbours are editorial distinctions, not industry rules (neighbour table).

  • 2013: Solomon Hykes first demonstrated Docker publicly at PyCon on 15 March.[14]
  • 2015: Docker, CoreOS and others launched the Open Container Initiative on 22 June 2015.[4]
  • 2022: Kubernetes 1.24 removed dockershim; nodes run a Container Runtime Interface (CRI) runtime such as containerd or CRI-O.[15]
  • 2025–2026: Docker Engine 29 made the containerd image store the default for new installations; Docker and Kubernetes 1.35 deprecated cgroup v1 (FAQ).[16][17][18]

Mental model: an image is a content-addressed bill of materials, digests pointing at a configuration and ordered layers; a container is a process started from it inside namespaces and cgroups. You control the bytes you ship (image), how you name and store them (registry), and the box the process runs in (runtime).

Term Meaning Why operators care
Layer In the OCI spec’s words, “a changeset that describes a container’s filesystem”[3] Layer order drives cache reuse and size
Manifest / index One platform’s config and layers / a list of manifests per platform[19] Holds platforms and attestations
Digest A content identifier: a collision-resistant hash such as sha256:…[20] The only immutable name for an image
Tag “a custom, human-readable pointer to a manifest”[21] Movable, so unreliable for deployment
Runtime containerd (lifecycle, images), a shim process per container or pod, and runc (creates the process)[22][23][24] All need patching; runc sets up the limits
Namespace A process’s own view of PIDs, mounts, network, users and more[1] The isolation boundary
cgroup Kernel accounting and limits for CPU, memory and PIDs[2] Where --memory and --cpus become real
OOM kill The kernel’s out-of-memory killer ending a process, at the cgroup’s memory.max or when the host runs out[2] Exit 137 with OOMKilled true
PID 1 (init) The first process in a PID namespace: it adopts orphans and gets only signals it handles, except SIGKILL and SIGSTOP from outside[25] Decides whether docker stop is graceful
BuildKit “the builder backend used by Docker”[26] Cache and secret mounts, multi-platform builds
Attestation Build metadata attached to the image: an SBOM lists its contents, provenance records how it was built[27] Evidence for audits and scanners
Rootless Engine and containers run as a non-root user[28] An escape lands in an unprivileged account[29]

The split is by layer; in many organisations one platform team does all of it.

Figure 1. Where ContainerOps sits: layers and handoffs
verifies imagesattested byrebuilds onKubernetesSecOps:admissionKubernetesOps:orchestratorServerlessOps:managed platformsContainerOps: images,runtime, limitsDevSecOps:signing, SBOMPatchOps:patch policySysOps:host and kernel
Diagram source (Mermaid)
flowchart TB
  KS["`KubernetesSecOps:
  admission`"]:::risk
  KO["`KubernetesOps:
  orchestrator`"]:::neutral
  SL["`ServerlessOps:
  managed platforms`"]:::neutral
  CO["`ContainerOps: images,
  runtime, limits`"]:::core
  DS["`DevSecOps:
  signing, SBOM`"]:::risk
  PA["`PatchOps:
  patch policy`"]:::neutral
  SY["`SysOps:
  host and kernel`"]:::neutral
  KS -.->|verifies images| KO
  KO --> CO
  SL --> CO
  CO -.->|attested by| DS
  CO -.->|rebuilds on| PA
  CO --> SY
  DS ~~~ SY
  PA ~~~ SY
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029

Text equivalent: KubernetesOps and ServerlessOps sit on ContainerOps, which sits on SysOps. KubernetesSecOps verifies images at admission to the orchestrator; DevSecOps attests ContainerOps’s images, which are rebuilt on PatchOps deadlines.

Neighbour This page owns The neighbour owns
KubernetesOps Images, runtimes, what a limit does in the kernel How scheduler and kubelet use requests and limits
DevSecOps Build practice; scan and attest hooks Scanners, SBOMs, signing, provenance, gates
KubernetesSecOps Non-root and capability defaults in images Admission policy, runtime detection
SysOps Runtime packages, daemon configuration Host OS, kernel, cgroup version
PatchOps, VulnerabilityOps Rebuilding images when a fix lands Deadlines; prioritisation
ServerlessOps The image handed to a managed platform Cold starts, concurrency, cost model
PlatformOps, ObservabilityOps Image standards; stdout logging and the log driver Golden paths; log pipelines

Nothing official exists, so this charter is the Atlas’s proposal; edit it to fit.

Mission: every production container runs an image whose content, origin and base are known, that can be rebuilt with a fix within an agreed time, and that runs with explicit limits, a non-root identity and logs that outlive it.

Principles

  1. Name content, not intentions. Deploy by digest; tags are for humans and for bots that move pins.
  2. Rebuild, don’t patch. Fixes come from a new build, never from exec into a running container.[5]
  3. Ship only what runs. Multi-stage builds (several FROM stages; only artefacts reach the last) and minimal bases keep compilers and shells out.[30][31]
  4. Every container has limits. Memory and PIDs always; CPU once you understand throttling.
  5. Least privilege by default. Non-root, dropped capabilities, read-only root where possible.
  6. The process is PID 1 and handles SIGTERM. Exec form or an init; shutdown within the stop timeout.
  7. Every dependency has an owner and a lifecycle: bases, registries, runtimes, build tools.

Non-goals: choosing an orchestrator, writing security policy, containerising everything.

Fundamentals: images, runtimes and the kernel

Link to section “Fundamentals: images, runtimes and the kernel”

A tag points to a manifest.[21] For a multi-platform image that is an index listing one manifest per platform; each manifest names a config and ordered layers, all by digest.[3][19] BuildKit stores provenance and SBOM attestations as extra manifests, one per platform, with platform unknown/unknown, so runtimes do not run them.[32] Change one byte of a layer and every digest above it changes.

Figure 2. Anatomy of a multi-platform OCI image
Tag app:1.4.2(movable)Image indexsha256:a1…Manifestlinux/amd64Manifestlinux/arm64Attestations,1 perplatformConfig: user,env,entrypointLayers in order:base OS,runtime, app
Diagram source (Mermaid)
%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 36, "padding": 8}}}%%
flowchart TB
  TAG["`Tag app:1.4.2
  (movable)`"]:::decision
  IDX["`Image index
  sha256:a1…`"]:::core
  M1["`Manifest
  linux/amd64`"]:::core
  M2["`Manifest
  linux/arm64`"]:::core
  ATT["`Attestations,
  1 per
  platform`"]:::neutral
  CFG["`Config: user,
  env,
  entrypoint`"]:::neutral
  LS["`Layers in order:
  base OS,
  runtime, app`"]:::neutral
  TAG --> IDX
  IDX --> M1
  IDX --> M2
  IDX --> ATT
  M1 --> CFG
  M1 --> LS
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029

Text equivalent: A tag points to an image index identified by a digest. The index lists manifests for linux/amd64 and linux/arm64 and attestation manifests, one per platform, with platform unknown/unknown. The amd64 manifest points to a config (user, environment, entrypoint) and to its layers in order: base OS, language runtime, application.

On the host, Docker Engine hands execution to containerd, whose shim (containerd-shim-runc-v2) calls runc to create the process from an OCI bundle (config.json).[16][23][33] runc asks the kernel for:

Kernel feature What it does for a container Typical setting
PID, mount, UTS, IPC, network namespaces Own process tree, filesystem view, hostname, IPC and network stack[1] Always on
User namespace Root inside maps to an unprivileged user outside[1] Rootless or user-namespaced runtimes
cgroup v2 memory.max Hard limit; if usage reaches it and cannot be reclaimed, the OOM killer runs in the cgroup[2] --memory 256m
cgroup v2 cpu.max Quota per period, default period 100 ms; over quota means throttled until the next period[2][34] --cpus 1.5 = 150 ms per 100 ms[8]
cgroup v2 pids.max Caps the number of processes[2] --pids-limit 200
Capabilities, seccomp Docker keeps an allowlist of capabilities and its default seccomp profile disables around 44 of 300+ system calls[35][36] --cap-drop ALL, add back only what is needed

Namespaces do not make a VM: all containers share the host kernel, a risk NIST lists.[5] For a stronger boundary use a sandboxed runtime: gVisor, an application kernel with “higher per-system call overhead”, or Kata Containers, which uses lightweight virtual machines.[37][38]

Rootless mode “executes the Docker daemon and containers inside a user namespace”; unlike userns-remap, where the daemon stays root, neither runs with root privileges.[28] Each user needs newuidmap and newgidmap (package uidmap) and at least 65,536 subordinate IDs in /etc/subuid and /etc/subgid, and runs a private daemon.[28][39] Docker documents the costs:[39][40]

  • limit flags need cgroup v2 and systemd, and are otherwise ignored; CPU limits also need the cpu controller delegated by systemd (typically only memory and pids are);
  • ports below 1024 need a sysctl or a capability on rootlesskit;
  • published ports do not pass client source addresses by default, and user-mode networking is generally slower;
  • overlay networks are unsupported; storage is overlay2 (kernel 5.11+) or fuse-overlayfs.

R = responsible, A = accountable, C = consulted, I = informed.

Activity App team Platform / ContainerOps Security Host / SysOps
Approved base images and their update policy C A/R C I
Dockerfile for a service A/R C (standard, review) I —
Rebuild and redeploy after a base-image fix R A (automation, deadlines) C —
Registry, retention, mirrors, access I A/R C —
Runtime and daemon configuration (logging, limits defaults, rootless) I A/R C R (packages, kernel)
Resource limits per service A/R C (defaults, guidance) — I
Scanning, signing, attestation policy I R (build hooks) A —
Runtime CVE response (runc, containerd, Engine) I A/R C R

Skills: Linux processes and signals, Dockerfile and BuildKit, one registry in depth, cgroups, scripting for inventories.

Figure 3. Image lifecycle with the rebuild loop
fix or new basesuperseded1. Choose and pin a baseimage2. Build: multi-stage, cached3. Scan and attest(DevSecOps hooks)4. Push; record the digest5. Promote the same digestdev → prod6. Run: limits, non-root,stdout logs7. Watch: base update, CVE,OOM, throttling8. Retire: retention and GC
Diagram source (Mermaid)
flowchart TB
  A["1. Choose and pin a base image"]:::core --> B["2. Build: multi-stage, cached"]:::core
  B --> C["3. Scan and attest (DevSecOps hooks)"]:::neutral
  C --> D["4. Push; record the digest"]:::core
  D --> E["5. Promote the same digest dev → prod"]:::core
  E --> F["6. Run: limits, non-root, stdout logs"]:::core
  F --> G{"7. Watch: base update, CVE, OOM, throttling"}:::decision
  G -->|fix or new base| A
  G -->|superseded| H["8. Retire: retention and GC"]:::neutral
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029

Text equivalent: Eight steps: choose and pin a base; build; scan and attest (DevSecOps hooks); push and record the digest; promote the same digest to production; run with limits, a non-root user and stdout logs; watch for base updates, CVEs, OOM kills and throttling. A fix or new base loops back to step 1; superseded images are retired through retention and garbage collection.

Day 0 sets bases, owners, tagging, registries and runtime defaults; Day 1 adopts the Dockerfile standard and deploys by digest with limits; Day 2 runs the loop above, plus runtime patching and limit reviews.

  • Policy as files, enforced in CI: a base-image allowlist, a Dockerfile lint (Lab 2 shows the idea; Hadolint is the production tool[41]), and a check that deployments reference digests.
  • Exceptions with expiry: root, a writable root filesystem or an unapproved base needs a dated, owned exception.
  • Cadence: weekly fix-to-production and stale-image review; monthly OOM, throttling and registry review; quarterly base and runtime review.
  • Escalation: a runtime escape CVE (case 2) goes to the incident process (IncidentOps), not the backlog.

Reference architecture: one image supply chain, many hosts

Link to section “Reference architecture: one image supply chain, many hosts”
  • Source repository: Dockerfile, .dockerignore, lock files, and base-image pins moved by a bot.
  • Builder (CI with BuildKit or Buildah): the only identity allowed to push; build secrets arrive through secret mounts, never ENV or ARG.[42]
  • Internal registry (for example Harbor or Amazon ECR): images by digest, immutable release tags, retention; hosts get pull-only credentials.
  • Pull-through cache for public registries: absorbs rate limits and, for cached images, upstream outages.[43]
  • Hosts with Docker Engine or Podman. The docker group “grants root-level privileges”, so treat membership as root access.[44]
  • Log driver: rotation on the host; shipping belongs to ObservabilityOps.
Figure 4. What runs where when a container starts
runcshimcontainerddockerdkernel: namespaces, cgroups, seccompcreate app@sha256start shimcreate from config.jsonapp runs as PID 1exit statusevent: exit 137
Diagram source (Mermaid)
sequenceDiagram
  participant D as dockerd
  participant C as containerd
  participant S as shim
  participant R as runc
  D->>C: create app@sha256
  C->>S: start shim
  S->>R: create from config.json
  Note over R: kernel: namespaces, cgroups, seccomp
  R-->>S: app runs as PID 1
  S-->>C: exit status
  C-->>D: event: exit 137

Text equivalent: dockerd asks containerd to create a container from an image digest; containerd starts a shim, which asks runc to create it from config.json; runc has the kernel set up namespaces, cgroups and seccomp, and the application runs as PID 1. On exit the shim reports the status to containerd, which sends dockerd an event such as exit 137.

containerd reconnects to running shims when it boots,[23] and Docker’s live-restore keeps standalone containers running through a daemon patch upgrade.[45] Each layer has CVEs and a support window: containerd 1.7 reached end of life on 2026-09-30; 2.3, the long-term release, is supported until 2028-04-30.[46]

Networking on one host: bridges, published ports and firewalls

Link to section “Networking on one host: bridges, published ports and firewalls”

Three facts decide exposure:

  • Networks. Without --network, a container joins the default bridge; --network host removes its network isolation.[47] Compose creates one network per project, where services reach each other by name on container ports, so web needs no published port to reach db.[48]
  • Publishing is the exposure decision, and Docker calls it “insecure by default”: without a host address, -p 8080:8080 listens on every IPv4 and IPv6 address.[49]
  • ufw and firewalld do not guard it. Published traffic is routed in the nat table before ufw’s rules apply; with firewalld, Docker adds a docker zone with target ACCEPT.[50]

The reference deployment therefore publishes web on 127.0.0.1 for a reverse proxy (loopback-only from Engine 28.0; older releases let hosts on the same layer-2 segment in) and puts db on an internal: true network, which has no connection to the host’s interfaces.[49][48]

End-to-end workflow: a base-image security fix

Link to section “End-to-end workflow: a base-image security fix”

Happy path. (1) The base image’s publisher pushes a fix, so the digest behind your pinned tag changes. (2) Renovate or Dependabot opens a pull request that moves the FROM digest.[51][7] (3) After the merge, a release tag makes CI rebuild with --pull, test, scan and attest (DevSecOps owns the gates), push, and record the new digest. (4) The same digest goes to staging, then production; the previous digest stays in the registry for rollback. (5) The inventory shows no production container on the old base, and fix-to-production time is recorded (metrics).

Exception paths.

  • No fix upstream yet: remove the vulnerable package in your own layer, switch base, or record a dated exception. Deadlines come from PatchOps, prioritisation from VulnerabilityOps.
  • The rebuild breaks the application: keep the old digest running, fix forward, escalate if the deadline is at risk. Never fall back to a floating tag.
Practice Goal Verification
Multi-stage build, minimal final stage Compilers and shells stay in the build stage[30] No package manager or compiler in the final image
Cache-friendly layer order Change-heavy steps last[52] CI cache hit rate
Pin bases by digest, update by bot Reproducible builds that still receive fixes[7][51] Lint rejects unpinned FROM; bot PRs merged within SLA
Deploy and promote by digest; immutable release tags What was tested is what runs[6][53] Manifests contain @sha256:; re-pushing a release tag fails
Mirror or pull-through cache Survive rate limits and upstream changes[54][43] No production pull hits a public registry
Retention and garbage collection Bounded storage that keeps rollback targets[55][56] Dry runs; deployed digests excluded
Non-root, dropped capabilities, read-only root Smaller blast radius[7][57] Inspect shows user ≠ 0 and ReadonlyRootfs true

Playbook: choosing a base image by run-time needs, then by maintainer and fix speed.

Base What you get Trade-off
scratch Nothing at all Static binaries only; copy in CA certificates and time zones yourself
Distroless “only your application and its runtime dependencies”; static-debian13 is about 2 MiB; nonroot tags run as UID 65532[31][58] No shell; the :debug variants add busybox
Alpine musl libc and busybox; small[59] musl is not glibc, so test native dependencies; branches supported about two years[60]
Debian or Ubuntu slim glibc and a package manager Larger; more packages for scanners to flag
Hardened catalogues, e.g. Docker Hardened Images (Apache-2.0 since December 2025) Images that ship with an SBOM, CVE data and SLSA (Supply-chain Levels for Software Artifacts) Build Level 3 provenance, per Docker[61] A vendor dependency: check cadence and exit options

Playbook: PID 1 and shutdown. A shell-form ENTRYPOINT runs under /bin/sh -c, “which does not pass signals”, so the application never sees the SIGTERM from docker stop;[42] after the grace period (10 s by default in Compose) comes SIGKILL.[62] Use exec form, handle SIGTERM, and add --init or init: true when the process spawns children.[57]

Playbook: limits. Always set memory and PID limits; add CPU limits with care, because they throttle bursts (worked example 1; when to keep them). Check that the language runtime reads cgroup limits (runbook).

Playbook: logging. Write unbuffered to stdout and stderr and leave routing to the environment.[63] Docker’s default json-file driver performs no rotation by default; the local driver keeps 100 MB per container, compressed.[64][65] Delivery is blocking by default, and Docker warns that applications “are likely to fail in unexpected ways” when stdout or stderr block; non-blocking mode drops messages when its buffer is full.[64]

Source Governs Status (October 2026) Binding?
OCI Image Specification Manifest, index, config, layers, descriptors, annotations[3] v1.1.1 (March 2025) Normative for OCI-compliant tools
OCI Runtime Specification config.json, lifecycle states and operations, hooks[33] v1.3.0 (November 2025) Normative for runtimes such as runc
OCI Distribution Specification Registry API, tags, referrers[21] Referrers API since 1.1 Normative for registries and clients
NIST SP 800-190 Container risks and countermeasures[5] Final, September 2017 Guidance
Compose Specification The compose.yaml format[62] Living specification Normative for Compose tools
Dockerfile reference, build best practices Instructions and recommended patterns[42][7] Vendor documentation Recommendations

Tools were checked on 2026-10-16; they are examples, not endorsements.

Category Examples Selection criteria Watch-outs
Image builders BuildKit (docker buildx); Buildah and Podman; Cloud Native Buildpacks (no Dockerfile; rebase swaps the base without a full rebuild)[66][67] Rootless builds, cache export, attestations, multi-platform Builders retire: Kaniko was archived in 2025[68]
Engines and runtimes Docker Engine, Podman, containerd, runc; sandboxed gVisor (runsc) and Kata[22][24][37][38] Rootless support, cgroup v2, support window Runtime CVEs; Kubernetes version pairing[46]
Registries Harbor, Amazon ECR, Docker Hub, CNCF Distribution[55][69][56] Immutability, retention, replication, pull-through cache Rate limits; space freed only by GC
Inspection skopeo (no daemon or root for most operations), docker buildx imagetools inspect[70][71] Works against remote registries; scriptable Media-type support varies
Linting and updates Hadolint;[41] Renovate and Dependabot[51][7] Rule coverage; digest pinning support PR fatigue without auto-merge rules
Scanning and signing See DevSecOps — Scanners are third-party code too

Automate the lifecycle loop (pins, rebuilds, retention, runtime patching) first. AI assistants can draft Dockerfiles and explain scanner output; their output passes the same lint and review, and base choices stay with named owners.

Reference implementation: a service image and its single-host deployment

Link to section “Reference implementation: a service image and its single-host deployment”

A baseline for one Go HTTP service, built in CI and run with Docker Compose on one host. Assumptions:

  • Docker Engine 29 or later with BuildKit, on a cgroup v2 host with systemd; a registry at registry.example.com; Renovate maintaining the pins.
  • With the default docker driver, registry cache export, multi-platform images and attestations need the containerd image store,[72][73][27] the default only on new Engine 29 installations.[16] On an upgraded host, enable it or run docker buildx create --name container-builder --driver docker-container --bootstrap --use.
  • Each REPLACE_WITH_DIGEST becomes a digest you resolved (Lab 1); until then the build fails, deliberately.

Example status: not built (no container runtime on the authoring host); the Dockerfile passes the Lab 2 gate.

# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.27-trixie@sha256:REPLACE_WITH_DIGEST AS build
WORKDIR /src
# Dependencies first: this layer is reused until go.mod or go.sum change.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# Platform arguments must be declared inside the stage that uses them.
ARG TARGETOS
ARG TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -o /out/app ./cmd/app

# Final stage: no shell, no package manager, non-root (UID 65532).
FROM gcr.io/distroless/static-debian13:nonroot@sha256:REPLACE_WITH_DIGEST
COPY --from=build /out/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
# .dockerignore: keep the build context small and secrets out of it
.git
**/.env
**/*.pem
dist/

The CI build tags every image sha-<commit> (plus v1.4.2 on a release commit), attaches an SBOM and provenance, reuses a registry cache and records the pushed digest. Buildx 0.10 and later pass SOURCE_DATE_EPOCH to the build, fixing metadata timestamps; file timestamps in layers also need the exporter option rewrite-timestamp=true.[74][27][52][75]

Example status: bash syntax-checked; tag logic tested with a stub docker; no real build.

#!/usr/bin/env bash
set -euo pipefail
IMAGE=registry.example.com/shop/web
GIT_SHA="$(git rev-parse HEAD)"
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"

# Build tag for every image; release tag only when the commit carries a v* Git tag
# (the CI checkout must fetch tags).
TAGS=(--tag "${IMAGE}:sha-${GIT_SHA:0:12}")
if RELEASE="$(git describe --tags --exact-match --match 'v*' 2>/dev/null)"; then
  TAGS+=(--tag "${IMAGE}:${RELEASE}")
fi

docker buildx build \
  --pull \
  --platform linux/amd64,linux/arm64 \
  --sbom=true --provenance=mode=max \
  --annotation "org.opencontainers.image.source=https://git.example.com/shop/web" \
  --annotation "org.opencontainers.image.revision=${GIT_SHA}" \
  --cache-from "type=registry,ref=${IMAGE}:buildcache" \
  --cache-to "type=registry,ref=${IMAGE}:buildcache,mode=max" \
  --metadata-file build.json \
  "${TAGS[@]}" \
  --push .

# The digest of what was pushed; deploy this, never the tag.
python3 -c 'import json; print(json.load(open("build.json"))["containerimage.digest"])' \
  > image.digest

mode=max provenance adds the full Dockerfile, build steps and build-argument values, readable by anyone who can pull the image (secret mounts never appear); the default mode=min omits build-argument values.[76]

The Compose file below applies the playbooks’ runtime defaults to both services.[62][77]

Example status: YAML checked against the Compose Specification schema; not run. Verify limits with docker inspect (Lab 3).

name: shop
services:
  web:
    image: registry.example.com/shop/web@sha256:REPLACE_WITH_DIGEST
    ports:
      - "127.0.0.1:8080:8080"   # loopback only: a reverse proxy on the host faces clients
    networks: [front, back]
    user: "65532:65532"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    init: true
    stop_grace_period: 20s
    restart: unless-stopped
    environment:
      DB_HOST: db
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      # Exec form: distroless has no shell. The binary implements -healthcheck
      # (request http://127.0.0.1:8080/healthz, exit 0 or 1).
      test: ["CMD", "/app", "-healthcheck"]
      interval: 15s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 256M
          pids: 200
    logging:
      driver: local
      options:
        max-size: 20m
        max-file: "5"
  db:
    image: postgres:17-alpine@sha256:REPLACE_WITH_DIGEST
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    networks: [back]
    volumes:
      - db-data:/var/lib/postgresql/data   # path up to postgres 17; 18 moved it
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          memory: 1G
          pids: 300
    restart: unless-stopped
networks:
  front: {}
  back:
    internal: true   # no connection to the host's interfaces
volumes:
  db-data: {}
secrets:
  db_password:
    file: ./secrets/db_password.txt   # host file: owner root, group 65532, mode 0640

Compose bind-mounts a file secret and silently ignores its uid, gid and mode,[62] so the host file must be readable by group 65532 (mode 0640), not by everyone. Docker records health in docker inspect and a health_status event;[42] restart policies act only when a container exits or stops,[78] so an unhealthy container runs on until something reacts.

Volumes hold the data.

  • The named volume db-data lives in a host directory that Docker manages and outlives the container; Docker prefers volumes to bind mounts of host paths.[79]
  • docker compose down keeps it; down --volumes deletes it.[80]
  • A process running as UID 65532 writes only where that UID may: chown a bind-mounted directory to it, or create the mount path in the image owned by it, since an empty named volume is seeded from the image’s files there, ownership included.[79][81]
  • A volume is not a backup: PostgreSQL needs the server shut down for a usable file copy,[82] so use its own tools (BackupOps); storage capacity belongs to StorageOps.

Host defaults in /etc/docker/daemon.json: the local log driver with explicit rotation, live-restore, and a mirror, which can only front Docker Hub.[64][45][54] Existing containers keep their old log settings until re-created.

Example status: JSON syntax-checked; not applied.

{
  "log-driver": "local",
  "log-opts": { "max-size": "20m", "max-file": "5" },
  "live-restore": true,
  "registry-mirrors": ["https://mirror.example.com"]
}

Retention as an Amazon ECR lifecycle policy, matched to the CI tags (untagged images include superseded cache manifests). Rules apply by priority, and “An image that matches the tagging requirements of a rule cannot be expired or archived by a rule with a lower priority”, so a release that also carries a sha- tag is safe from rule 2; images referenced by an index wait for the index. Use AWS’s preview first.[83] The repository is IMMUTABLE_WITH_EXCLUSION with filters sha-* and buildcache, so release tags cannot be re-pushed.[53] The policy does not know what is deployed: production runs releases only.

Example status: JSON syntax-checked and compared with AWS’s documented examples[84] and evaluation rules; not applied.

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep the 50 newest release images",
      "selection": {
        "tagStatus": "tagged",
        "tagPatternList": ["v*"],
        "countType": "imageCountMoreThan",
        "countNumber": 50
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "Expire build images 30 days after push",
      "selection": {
        "tagStatus": "tagged",
        "tagPatternList": ["sha-*"],
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 30
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 3,
      "description": "Expire untagged images 14 days after push",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 14
      },
      "action": { "type": "expire" }
    }
  ]
}

Labs 1, 2 and 4 were executed on 2026-10-16 (Python 3.8.10, Ubuntu 20.04, kernel 5.15, no container runtime installed). Lab 3 was not executed; its expected outputs come from the cited documentation.

Lab 1 (beginner): read an image without running it

Link to section “Lab 1 (beginner): read an image without running it”
  • Objective: resolve a tag to a digest and see one tag hold many platforms, attestations and layers, using only the registry API.[21]
  • Prerequisites: Python 3.8+, internet access, 10 minutes; no Docker. One HEAD request (not counted) and two GETs, which Docker Hub counted as one pull (one architecture) in our runs.[85]
  1. Save the script as inspect_image.py.

Example status: executed on 2026-10-16 (output below); Python syntax-checked by the validator.

#!/usr/bin/env python3
"""Inspect a public Docker Hub image with the OCI distribution API, no runtime."""
import json
import sys
import urllib.error
import urllib.request

REGISTRY = "https://registry-1.docker.io"
ACCEPT = ", ".join([
    "application/vnd.oci.image.index.v1+json",
    "application/vnd.docker.distribution.manifest.list.v2+json",
    "application/vnd.oci.image.manifest.v1+json",
    "application/vnd.docker.distribution.manifest.v2+json",
])


def request(url, token=None, method="GET"):
    headers = {"Accept": ACCEPT}
    if token:
        headers["Authorization"] = "Bearer " + token
    req = urllib.request.Request(url, headers=headers, method=method)
    with urllib.request.urlopen(req, timeout=30) as resp:
        return resp.headers, resp.read()


def main(repo, tag):
    auth = ("https://auth.docker.io/token?service=registry.docker.io"
            "&scope=repository:%s:pull" % repo)
    token = json.loads(request(auth)[1])["token"]

    # 1. Resolve the tag to a digest. HEAD does not count as a pull.
    headers, _ = request("%s/v2/%s/manifests/%s" % (REGISTRY, repo, tag), token, "HEAD")
    print("tag %s:%s -> %s" % (repo, tag, headers.get("Docker-Content-Digest")))
    print("media type:", headers.get("Content-Type"))
    print("rate limit:", headers.get("ratelimit-limit"),
          "| remaining:", headers.get("ratelimit-remaining"))

    # 2. Read the index: one entry per platform (plus any attestation manifests).
    _, body = request("%s/v2/%s/manifests/%s" % (REGISTRY, repo, tag), token)
    amd64 = None
    for m in json.loads(body).get("manifests", []):
        p = m.get("platform", {})
        parts = (p.get("os"), p.get("architecture"), p.get("variant"))
        name = "/".join(x for x in parts if x)
        kind = m.get("annotations", {}).get("vnd.docker.reference.type", "image")
        print("  %-22s %-20s %s..." % (name, kind, m["digest"][:19]))
        if name == "linux/amd64":
            amd64 = m["digest"]

    # 3. Read the linux/amd64 image manifest: one config blob plus ordered layers.
    if amd64:
        _, body = request("%s/v2/%s/manifests/%s" % (REGISTRY, repo, amd64), token)
        manifest = json.loads(body)
        layers = manifest.get("layers", [])
        print("linux/amd64 config:", manifest["config"]["digest"][:19] + "...")
        print("layers: %d, compressed size: %.1f MiB" % (
            len(layers), sum(layer["size"] for layer in layers) / 1048576))


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("usage: inspect_image.py <namespace/repository> <tag>")
    try:
        main(sys.argv[1], sys.argv[2])
    except urllib.error.HTTPError as e:
        sys.exit("registry error %d for %s" % (e.code, e.url))
  1. Run python3 -I inspect_image.py library/alpine 3.24.

Expected output (abridged; recorded 2026-10-16, your digests will differ because the tag moves when Alpine rebuilds):

tag library/alpine:3.24 -> sha256:294b683cb724975bec92580e1e685676bd4b50bda910dd... (cut)
media type: application/vnd.oci.image.index.v1+json
rate limit: 100;w=3600 | remaining: 99;w=3600
  linux/amd64            image                sha256:d56c381f961d...
  unknown/unknown        attestation-manifest sha256:350f747a86e8...
  linux/arm/v6           image                sha256:d3c3fda3e4d3...
  unknown/unknown        attestation-manifest sha256:e1ca081cf7a9...
  (… arm/v7, arm64/v8, 386, ppc64le, riscv64, s390x, each followed by its attestation)
linux/amd64 config: sha256:320994c3b997...
layers: 1, compressed size: 3.7 MiB
  • Validation: a sha256: digest; eight platforms, each followed by an unknown/unknown attestation manifest;[32] one 3.7 MiB amd64 layer.
  • Notice: our window was one hour (w=3600), not the six hours Docker documents.[85]
  • Troubleshooting: registry error 404: wrong repository or tag (official images live under library/); 429: pull limit.
  • Teardown: none. With Docker installed, docker buildx imagetools inspect alpine:3.24 and skopeo inspect show the same data.[71][70]

Lab 2 (beginner to intermediate): a Dockerfile policy gate

Link to section “Lab 2 (beginner to intermediate): a Dockerfile policy gate”
  • Objective: turn this page’s rules (no latest, digest pins, non-root final stage, no secrets in ENV/ARG, exec-form entrypoints) into a check that fails a build.
  • Prerequisites: Python 3.8+, 20 minutes. This teaching gate does not replace Hadolint, which parses Dockerfiles properly and checks shell code with ShellCheck.[41]

Example status: executed on 2026-10-16 against the two Dockerfiles below and a missing file; Python syntax-checked.

#!/usr/bin/env python3
"""Teaching-sized Dockerfile policy gate. Exit 1 on any ERROR; use Hadolint in real CI."""
import re
import sys

SECRET_NAME = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY)", re.I)


def instructions(text):
    """Join continuation lines, drop comments, yield (line_no, INSTRUCTION, args)."""
    buf, start = "", 0
    for no, line in enumerate(text.splitlines(), 1):
        if not buf and line.strip().startswith("#"):
            continue
        if not buf:
            start = no
        buf += line.rstrip("\\").strip() + " "
        if not line.rstrip().endswith("\\"):
            if buf.strip():
                word, _, args = buf.strip().partition(" ")
                yield start, word.upper(), args.strip()
            buf = ""


def check(text):
    findings, stages, user, final_from = [], set(), None, None
    for no, ins, args in instructions(text):
        if ins == "FROM":
            parts = [p for p in args.split() if not p.startswith("--")]  # skip --platform
            image = parts[0]
            if len(parts) >= 3 and parts[1].upper() == "AS":
                stages.add(parts[2].lower())
            user, final_from = None, no  # policy: each stage sets USER explicitly
            if image.lower() in stages or image == "scratch":
                continue
            name = image.split("@")[0].rsplit("/", 1)[-1]
            if "@sha256:" not in image:
                if ":" not in name or name.endswith(":latest"):
                    findings.append(("ERROR", no,
                                     "base image %s has no version tag or uses latest" % image))
                else:
                    findings.append(("WARN", no,
                                     "base image %s is not pinned by digest" % image))
        elif ins == "USER":
            user = args.split(":")[0]
        elif ins in ("ENV", "ARG") and SECRET_NAME.search(args.split("=")[0]):
            findings.append(("ERROR", no, "%s %s looks like a secret; use a build secret mount"
                             " instead" % (ins, args.split("=")[0])))
        elif ins == "ADD" and re.search(r"https?://", args):
            findings.append(("WARN", no,
                             "ADD from a URL; prefer a checksummed download or ADD --checksum"))
        elif ins in ("ENTRYPOINT", "CMD") and not args.startswith("["):
            findings.append(("WARN", no, "%s in shell form: the process will not be PID 1"
                             " and will miss SIGTERM" % ins))
        elif ins == "RUN" and "apt-get update" in args and "apt-get install" not in args:
            findings.append(("WARN", no,
                             "apt-get update without install in the same RUN (stale cache layer)"))
    if final_from is not None and user in (None, "root", "0"):
        findings.append(("ERROR", final_from,
                         "final stage runs as root (add a USER with a non-zero UID)"))
    return findings


if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else "Dockerfile"
    try:
        with open(path) as f:
            results = check(f.read())
    except OSError as e:
        print("cannot read %s: %s" % (path, e.strerror), file=sys.stderr)
        sys.exit(2)
    for level, no, msg in sorted(results, key=lambda r: r[1]):
        print("%s:%d %s %s" % (path, no, level, msg))
    errors = sum(1 for r in results if r[0] == "ERROR")
    print("%d error(s), %d warning(s)" % (errors, len(results) - errors))
    sys.exit(1 if errors else 0)
  1. Save the gate as dockerfile_gate.py, and save this deliberately bad file as bad.Dockerfile:
FROM python:latest
ENV API_TOKEN=changeme
RUN apt-get update
RUN apt-get install -y curl
ADD https://example.com/tool.tar.gz /opt/
COPY . /app
CMD python /app/main.py
  1. Run python3 -I dockerfile_gate.py bad.Dockerfile; echo "exit=$?".

Expected output (as executed):

bad.Dockerfile:1 ERROR base image python:latest has no version tag or uses latest
bad.Dockerfile:1 ERROR final stage runs as root (add a USER with a non-zero UID)
bad.Dockerfile:2 ERROR ENV API_TOKEN looks like a secret; use a build secret mount instead
bad.Dockerfile:3 WARN apt-get update without install in the same RUN (stale cache layer)
bad.Dockerfile:5 WARN ADD from a URL; prefer a checksummed download or ADD --checksum
bad.Dockerfile:7 WARN CMD in shell form: the process will not be PID 1 and will miss SIGTERM
3 error(s), 3 warning(s)
exit=1
  1. Run the gate against the reference Dockerfile above (saved as Dockerfile): it prints 0 error(s), 0 warning(s) and exits 0.
  • Validation: exit 1 for the bad file, 0 for the reference file, 2 for a missing file; in CI a non-zero exit fails the job.
  • Troubleshooting: distroless :nonroot still needs an explicit USER (deliberate policy); FROM ${BASE} is not resolved.
  • Teardown: none.

Lab 3 (intermediate): build small, run hardened, then break it on purpose

Link to section “Lab 3 (intermediate): build small, run hardened, then break it on purpose”
  • Objective: measure what multi-stage builds save, verify that hardening is applied, and watch an OOM kill and throttling.
  • Prerequisites: a disposable Linux VM with Docker Engine 29+ on cgroup v2 (docker info --format '{{.CgroupVersion}}' prints 2),[86] 2 GB free disk, 45 minutes. Risk: the OOM step kills only the lab container, but keep it off shared hosts.
  1. Create lab3/go.mod containing module example.com/lab3 and go 1.25, and lab3/cmd/app/main.go:

Example status: Go source checked with gofmt -e (syntax only); not compiled or run.

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()

	ok := func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) }
	srv := &http.Server{Addr: ":8080", Handler: http.HandlerFunc(ok)}
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}()
	log.Println("listening on :8080")

	<-ctx.Done() // reached on SIGTERM only if this process receives the signal
	log.Println("SIGTERM received, draining")
	shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdown); err != nil {
		log.Println("shutdown:", err)
	}
}
  1. Dockerfile.single: one stage (FROM golang:1.27-trixie, WORKDIR /src, COPY . ., RUN go build -o /app ./cmd/app, CMD /app). Dockerfile.multi: the reference Dockerfile without digests and without copying go.sum (no dependencies).
  2. Build both (docker build -f Dockerfile.single -t lab3:single ., likewise lab3:multi) and run docker image ls lab3. Expected: the single-stage image carries the Go toolchain (hundreds of megabytes); the distroless one is about 2 MiB plus your binary.[31]
  3. Run hardened and verify instead of trusting the flags:

Example status: bash syntax-checked; not executed.

docker run -d --name web -p 127.0.0.1:8080:8080 \
  --read-only --cap-drop ALL --security-opt no-new-privileges \
  --pids-limit 100 --memory 64m --cpus 0.5 lab3:multi
curl -s 127.0.0.1:8080          # expect: ok
docker inspect -f 'user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}}
mem={{.HostConfig.Memory}} cpu={{.HostConfig.NanoCpus}}' web
# expect two lines: user=65532:65532 ro=true / mem=67108864 cpu=500000000
time docker stop web   # expect: well under a second; logs show "SIGTERM received, draining"
docker inspect -f '{{.State.ExitCode}}' web   # expect: 0
  1. Compare the shell-form image: docker run -d --name single lab3:single, then time docker stop single. Expected per the documentation: the shell does not pass SIGTERM, so the stop waits for the timeout and ends in SIGKILL, exit 137.[42] Some shells exec a lone command and hide this; entrypoint scripts that run other commands first do not.
  2. Trigger an OOM kill:

Example status: bash syntax-checked; not executed.

docker run --name hog --memory 128m --memory-swap 128m python:3.14-slim \
  python -c 'b = b"x" * (512 * 1024 * 1024)'
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' hog
# expect: exit=137 oom=true
  1. docker run -d --name spin --cpus 0.5 python:3.14-slim python -c 'while True: pass'; after 30 seconds run docker exec spin cat /sys/fs/cgroup/cpu.stat. Expected: nr_throttled close to nr_periods: the loop wants a whole CPU and gets 50 ms per 100 ms.[2][34]
  • Validation: inspect output matches the flags; exit=137 oom=true; throttled periods near 100 % for the spinner.
  • Troubleshooting: on cgroup v1 the cpu.stat path differs; rootless Docker ignores limits without cgroup v2 and systemd, and --cpus needs a delegated cpu controller.[39]
  • Teardown: docker rm -f web single hog spin and docker image rm lab3:single lab3:multi.

Lab 4 (advanced): namespaces and cgroups by hand

Link to section “Lab 4 (advanced): namespaces and cgroups by hand”
  • Objective: see that a container is a process with namespaces and cgroups, without container tooling.
  • Prerequisites: Linux with unprivileged user namespaces enabled (unshare from util-linux); 20 minutes; no root, no host changes.

Example status: steps 1–3 executed on 2026-10-16 as an unprivileged user (outputs below).

  1. unshare --user --map-root-user --pid --fork --mount-proc ps -o pid,user,comm prints one process, 1 root ps: inside the new PID namespace ps is PID 1 and you appear as root.
  2. unshare --user --map-root-user sh -c 'id; cat /etc/shadow' prints uid=0(root), then Permission denied; outside, id -u prints your own UID. The user namespace maps IDs but grants no power over host files.[1]
  3. unshare --user --map-root-user --uts sh -c 'hostname lab-ns && hostname' prints lab-ns; the host’s name is unchanged.
  4. See limits as the process sees them, on a host and inside containers with different --cpus and --memory:

Example status: Python syntax-checked; executed on the cgroup v1 authoring host; v2 branch tested only with synthetic files.

#!/usr/bin/env python3
"""Print the CPU and memory limits this process runs under (cgroup v2, v1 fallback)."""
import os


def read(path):
    try:
        with open(path) as f:
            return f.read().strip()
    except OSError:
        return None


def v2(base):
    print("cgroup version: 2  (path %s)" % base)
    quota, period = (read(base + "/cpu.max") or "max 100000").split()
    cpus = "unlimited" if quota == "max" else "%.2f" % (int(quota) / int(period))
    print("cpu limit (cpus):", cpus)
    mem = read(base + "/memory.max")
    mib = "unlimited" if mem in (None, "max") else "%d MiB" % (int(mem) // 1048576)
    print("memory.max:", mib)
    stat = dict(line.split() for line in (read(base + "/cpu.stat") or "").splitlines())
    if stat.get("nr_periods", "0") != "0":
        ratio = int(stat["nr_throttled"]) / int(stat["nr_periods"])
        print("throttled periods: %s of %s (%.1f%%)"
              % (stat["nr_throttled"], stat["nr_periods"], ratio * 100))
    events_text = read(base + "/memory.events") or ""
    events = dict(line.split() for line in events_text.splitlines())
    print("oom_kill events:", events.get("oom_kill", "n/a"))


def v1():
    print("cgroup version: 1 (deprecated by Kubernetes 1.35 and Docker Engine 29)")
    cpu = "/sys/fs/cgroup/cpu,cpuacct/"
    quota, period = read(cpu + "cpu.cfs_quota_us"), read(cpu + "cpu.cfs_period_us")
    cpus = "unlimited" if quota in (None, "-1") else "%.2f" % (int(quota) / int(period))
    print("cpu limit (cpus):", cpus)
    mem = read("/sys/fs/cgroup/memory/memory.limit_in_bytes")
    # v1 reports "no limit" as a huge page-aligned number.
    unlimited = mem is None or int(mem) > 2 ** 60
    print("memory limit:", "unlimited" if unlimited else "%d MiB" % (int(mem) // 1048576))


if __name__ == "__main__":
    print("logical CPUs visible (os.cpu_count):", os.cpu_count())
    if read("/sys/fs/cgroup/cgroup.controllers") is not None:
        rel = read("/proc/self/cgroup").splitlines()[-1].split(":", 2)[2]
        base = "/sys/fs/cgroup" + ("" if rel == "/" else rel)
        # In a container the namespaced path is "/": the root files describe it.
        v2(base if os.path.exists(base + "/cpu.max") or rel == "/" else "/sys/fs/cgroup")
    else:
        v1()

Expected output on the authoring host (cgroup v1, no limits): 8 CPUs, the v1 line, unlimited twice. Synthetic v2 files (cpu.max 150000 100000, memory.max 256 MiB, 300 of 1,200 periods throttled, one oom_kill) gave 1.50, 256 MiB, 25.0 % and 1.

  • Validation: inside docker run --cpus 1.5 --memory 256m expect 1.50 and 256 MiB while os.cpu_count() still shows every host CPU: runtimes that size thread pools from the CPU count misjudge containers.
  • Troubleshooting: Operation not permitted from unshare means unprivileged user namespaces are disabled; use a VM.
  • Teardown: none; namespaces disappear with their last process.

Case 1 (documented): a public image namespace changes under you. On 2025-07-16 Bitnami announced that from 28 August 2025 its existing images, “including older or versioned tags”, would move from docker.io/bitnami to a legacy catalogue that “will receive no further updates or support”; the free images left were “only available on the “latest” tag”.[9] Broadcom told users they “will need to update their pipelines, internal mirrors, and Kubernetes clusters” and, for those moving to the unsupported legacy registry as “an option of last resort”, strongly recommended copying the images they use into their own registry.[87]

  • Method: inventory image references; copy the images you run into your registry by digest (skopeo copy needs no daemon[70]); repoint deployments; choose a long-term source. A copy buys time, not security.
  • Success: no production pull from the old namespace after the move; an owner and replacement date per copied image.

Case 2 (documented): a runtime escape. CVE-2024-21626 in runc (published 2024-01-31) allowed “several container breakouts due to internally leaked fds”, triggerable by a malicious image or, in Docker, a Dockerfile’s ONBUILD. It affected runc 1.0.0-rc93 to 1.1.11; 1.1.12 fixed it.[88] CVE-2025-31133 (2025-11-05), another escape, was fixed in runc 1.2.8, 1.3.3 and 1.4.0-rc.3; its advisory recommends user namespaces with host root unmapped, rootless containers, and not running untrusted images.[29]

  • Method: keep a runtime inventory (Engine, containerd, runc) covering servers, CI runners and laptops; patch via SysOps packages and KubernetesOps node images. runc is a CLI invoked when containers are created or exec’d,[24] so an updated binary protects later invocations; restart exposed long-running containers; for distribution packages, check vendor advisories.
  • Success: every host fixed within the PatchOps deadline; untrusted images blocked.

Case 3 (illustrative scenario on documented limits): CI starved by rate limits. Thirty CI runners behind one NAT address pull three public images per job anonymously: at 60 jobs an hour, 180 pulls an hour against a documented budget of 100 per six hours, so jobs fail with 429.

  • Method: authenticate (Personal accounts get 200 per six hours; paid plans are unlimited under fair use[85]); add a pull-through cache. On ECR it needs Docker Hub credentials in a Secrets Manager secret (name prefix ecr-pullthroughcache/), so upstream pulls count against that account, and it revalidates a cached tag about once a day.[43]
  • Success: no 429 in CI for a month.
KPI Definition or formula Source Pitfall
Digest coverage Production deployments referencing @sha256: ÷ all production deployments Manifests, Compose files A digest with an unknown base is still opaque
Fix-to-production time Fixed base available → last production container on a rebuilt image (p90, per severity) Bot PRs, deploy log, inventory Measure to the last container, not the first merge
Base-image age Now − build date of each running image’s base (p90) org.opencontainers.image.base.digest[89] Pair with CVE data
Runtime currency Hosts on a supported, patched Engine/containerd/runc ÷ all hosts Inventory, release tables[46] Backported packages need vendor data
OOM kill rate oom_kill events per 1,000 container-days memory.events[2] Exits undercount: a killed worker may leave PID 1 running
Throttle ratio nr_throttled ÷ nr_periods over a window cpu.stat[2] Averages hide bursts
Image size and registry growth Compressed final-stage bytes; GiB added per month Registry API (Lab 1) Deleted tags free nothing until GC[56]

Worked example 1: throttling with a low average. A container runs with --cpus 2: 200 ms of CPU time per 100 ms period.[8] Over one minute cpu.stat shows usage_usec 90,000,000, nr_periods 600, nr_throttled 240 and throttled_usec 6,000,000. Average use is 90 s of CPU in 60 s, 1.5 CPUs, yet 40 % of periods were throttled (240 ÷ 600), and the group’s throttled time summed over its CPUs reached 6 s. The service bursts on many threads, spends its quota early and waits.[34] Raise the limit, cap the thread pool (Go 1.25 sizes GOMAXPROCS from the limit[90]), or drop the limit where a scheduler reserves CPU (KubernetesOps).

Worked example 2: registry storage (illustrative). Forty services build 25 times a week for two platforms, each build adding about 15 MiB of unique layers: 40 × 25 × 2 × 15 MiB = 30,000 MiB, about 29 GiB a week. Under the policy above, build images live 30 days (about 126 GiB) and at most 50 releases per service outlive them (40 × 50 × 2 × 15 MiB, about 59 GiB): a steady state of at most about 185 GiB, plus untagged images in their 14-day window.

Targets: digest coverage 100 % in production; fix-to-production p90 within patch policy (for example 7 days for critical); throttle ratio under 5 % for latency-sensitive services. Image counts are vanity metrics.

NIST SP 800-190’s risk list maps onto this page’s controls:[5]

NIST risk ContainerOps control
Image vulnerabilities; stale images Minimal bases, digest pins moved by bots, fix-to-production target, retention
Image configuration defects Dockerfile gate (Lab 2), non-root, no SSH inside containers
Embedded clear-text secrets Build secret mounts and .dockerignore; never ENV or ARG[42]
Untrusted images Base allowlist, internal mirror; signature checks in KubernetesSecOps
Insecure registry connections, weak access control TLS, pull-only host credentials, push only from CI
Runtime vulnerabilities, insecure runtime configuration Runtime currency; no --privileged; dropped capabilities; no-new-privileges[57]
Shared kernel Sandboxed runtimes (gVisor, Kata) for untrusted code[37][38]

Mounting the Docker socket hands a container the daemon, which is root-level access.[44] Auditors ask what ran, from which source, with which fixes; digests, annotations and attestations answer (ComplianceOps).

Image bytes cost three times: registry storage, transfer on every cold pull, and start-up time; shared base layers, good layer order and a pull-through cache are the cheap wins. Rightsizing belongs to FinOps, cluster requests to KubernetesOps. We found no reliable per-image carbon figures (GreenOps).

  • Inventory job: nightly, list running image digests per host or cluster, join them with registry metadata and base annotations, and publish the metrics above.
  • Drift checks: a running image missing from the registry, a tag in production, or a container without a memory limit or running as UID 0 becomes an owned exception.
  • Drill and audit: twice a year, time a pretend critical base fix to production; monthly, audit five images.

On a Docker host, .Config.Image is the reference each container was created from:[86] a tag there means no digest pin.

Example status: bash syntax-checked; with a stub docker listing three containers, one by tag, it printed one by tag: line and digest coverage: 2 of 3; not run against Docker.

docker ps --quiet |
  xargs --no-run-if-empty docker inspect --format '{{.Name}} {{.Config.Image}}' |
  awk '{ n++ } $2 ~ /@sha256:/ { d++; next } { print "by tag:", $0 }
       END { printf "digest coverage: %d of %d\n", d, n }'

docker run itself returns 125 for an error in Docker, 126 when the command cannot be invoked and 127 when it is not found; other codes are the process’s own status.[91] Exit 0 is not an error: a container stops when its main process returns, even one that only started a background service.[57] A process killed by signal N reports 128 + N,[92] so 137 is SIGKILL and 143 SIGTERM. On cgroup v2, OOMKilled also covers host-wide memory exhaustion: the cgroup’s oom_kill counts kills “by any kind of OOM killer”, containerd reports each increase and Docker sets the flag.[2][93][94]

Figure 5. Triage by exit code, from docker run or docker inspect
yesnonoyesyesnoyesnoyesnoExit code 125?Engine error126 or 127?143?BadentrypointNormal stop(SIGTERM)137?OOMKilled?Process's owncode: read logs;0 = process endedOOM kill: limitor host memorySIGKILL: stoptimeout or kill
Diagram source (Mermaid)
%%{init: {"flowchart": {"nodeSpacing": 30, "padding": 8}}}%%
flowchart TB
  Q1{"Exit code 125?"}:::decision
  Q1 -->|yes| E1["Engine error"]:::neutral
  Q1 -->|no| Q2{"126 or 127?"}:::decision
  Q2 -->|no| Q3{"143?"}:::decision
  Q2 -->|yes| E2["`Bad
  entrypoint`"]:::neutral
  Q3 -->|yes| E3["`Normal stop
  (SIGTERM)`"]:::neutral
  Q3 -->|no| Q4{"137?"}:::decision
  Q4 -->|yes| Q5{"OOMKilled?"}:::decision
  Q4 -->|no| E4["`Process's own
  code: read logs;
  0 = process ended`"]:::neutral
  Q5 -->|yes| R1["`OOM kill: limit
  or host memory`"]:::risk
  Q5 -->|no| R2["`SIGKILL: stop
  timeout or kill`"]:::risk
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029

Text equivalent: 125 from docker run: an engine error, such as a bad flag. 126 or 127: an entrypoint that is not executable or missing. 143: a normal stop by SIGTERM. Other codes except 137: the process’s own code; read the logs (0 means the process ended). 137 with OOMKilled true: an OOM kill, at the memory limit or because the host ran out of memory; with OOMKilled false: another SIGKILL, such as an expired stop timeout or a manual kill.

Symptom Likely cause Diagnose Fix and prevent
Exit 137, OOMKilled true Limit too low, leak, runtime unaware of cgroup limits, or host out of memory docker inspect, memory.events, kernel log Fix or raise; upgrade runtime (runbook)
Exit 137, OOMKilled false, after docker stop Process ignored SIGTERM: shell-form entrypoint or no handler Stop time ≈ timeout Exec form, signal handling, --init[42][57]
Latency spikes, average CPU low CPU quota throttling (Completely Fair Scheduler, CFS, bandwidth control) cpu.stat throttle ratio Worked example 1
Host disk full Unrotated json-file logs, old images, build cache docker system df local driver; scheduled prune with filters[64][95]
Service reachable from outside although ufw blocks the port Port published on all addresses; Docker’s nat rules bypass ufw Probe from another host; docker port Bind to 127.0.0.1 or one interface (networking)[50]
429 Too Many Requests on pull Registry rate limit Response headers Authenticate, cache, mirror (case 3)
Read-only file system or Permission denied after hardening App writes outside volumes, or files owned by root Error path tmpfs for scratch paths; COPY --chown
Old tools fail against a new Engine Engine 29 raised the minimum API version to 1.44[16] Client error message Upgrade clients and CI images
Data missing after a major image upgrade Volume path changed (PostgreSQL 18 moved its declared volume)[96] Compare docker inspect mounts Read release notes; test upgrades on a copy

Worked runbook: a service keeps restarting with exit code 137

Link to section “Worked runbook: a service keeps restarting with exit code 137”

Illustrative incident; standard Docker CLI commands, not executed here.

  1. Confirm the signal. docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}} {{.RestartCount}}' api shows 137 true 6: the OOM killer, six times.
  2. Check limit and usage. docker inspect -f '{{.HostConfig.Memory}}' api returns 536870912 (512 MiB); docker stats --no-stream api shows usage near the limit before each kill. Had usage been well below the limit, the host ran out of memory: the kernel log (journalctl -k) says “Memory cgroup out of memory” for a limit and “Out of memory” for the host.[97]
  3. Ask what changed. The running digest differs from the previous deployment’s: the base moved from Node.js 22 to a Node.js 18 image during a “downgrade for compatibility”.
  4. Mitigate. Redeploy the previous digest; restarts stop. Rollback must be by digest, because the old tag may already point elsewhere.
  5. Cause and fix. Node.js 18 does not reliably read cgroup v2 memory limits and may size its heap from host memory;[18] return to an LTS line (22 or 24; 26 from 2026-10-28; detection arrived in 20.3, and 20 reached end of life on 2026-04-30[98]) or set --max-old-space-size below the limit, leaving headroom for non-heap memory. Check other runtimes too: OpenJDK needs jdk8u372, 11.0.16 or 15+;[18] Go sizes GOMAXPROCS from the CPU limit only from 1.25, and only with go 1.25 or later in go.mod.[90][99]
  6. Verify and prevent. No OOM kills for 24 hours at the same limit; add runtime minimums to the base policy and alert on any OOM kill.
  • “Pinning digests freezes our security fixes.” Only without a bot to move the pins (judgement call).[7]
  • “One process per container is a law.” Docker calls it “a good rule of thumb, but it’s not a hard and fast rule”;[7] the real rule is one concern, with an init when there are children.
  • “We scan images, so we are covered.” A scan without fast rebuilds produces a list, not a fix.

Containerise when several services or runtimes share hosts, the tested artefact must be the deployed one, you target an orchestrator or managed container platform, or developers need production-like environments.

Think twice when a static binary or a platform’s own packaging (a buildpack, a function zip) already gives reproducible deploys: containers add a registry, a rebuild loop and a runtime to patch. Be cautious when tenants must not share a kernel and you have no sandboxed runtime, and when nobody will own rebuilds: an unowned estate quietly ages. Managed platforms (ServerlessOps) reduce these duties but do not remove them.

Figure 6. Choosing how to run containers, including not using them
noyesnoyesnoyesyesnoIs an image theright package?Do notcontaineriseyetMulti-hostscheduling?Run ownhosts?One host:Compose orPodman QuadletManagedcontainerserviceRunclusters?Kubernetes(KubernetesOps)
Diagram source (Mermaid)
%%{init: {"flowchart": {"nodeSpacing": 14, "rankSpacing": 36, "padding": 8}}}%%
flowchart TB
  Q0{"`Is an image the
  right package?`"}:::decision
  Q0 -->|no| N["`Do not
  containerise
  yet`"]:::neutral
  Q0 -->|yes| Q1{"`Multi-host
  scheduling?`"}:::decision
  Q1 -->|no| Q2{"`Run own
  hosts?`"}:::decision
  Q2 -->|yes| H["`One host:
  Compose or
  Podman Quadlet`"]:::core
  Q2 -->|no| M["`Managed
  container
  service`"]:::core
  Q1 -->|yes| Q3{"`Run
  clusters?`"}:::decision
  Q3 -->|yes| K["`Kubernetes
  (KubernetesOps)`"]:::core
  Q3 -->|no| M
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029

Text equivalent: If the platform’s own packaging suffices, do not containerise yet. Otherwise, without a need for multi-host scheduling, use one host with Compose or Podman Quadlet if the team will run hosts, or a managed container service if not. With that need, use Kubernetes (see KubernetesOps) if a team can run clusters, otherwise a managed container service.

Option Choose when Watch-outs
Compose on one host Small systems; seconds of downtime acceptable up recreates the service’s containers[100]; one host is one failure domain
Podman Quadlet on one host systemd should own containers, rootless units allowed[101] podman auto-update restarts on a new digest and rolls back on failure[102]
Managed container service No appetite for hosts or clusters Platform limits and cost model (ServerlessOps)
Kubernetes Many services, multi-host scheduling A platform to run (KubernetesOps)

This is the Atlas’s model, not an industry standard.

Level Observable behaviour Exit criterion
0. Ad hoc Laptop builds, latest, root, no limits All images built in CI
1. Repeatable CI builds, version tags, some scanning Base allowlist and Dockerfile lint in CI
2. Managed Digests, non-root, memory limits, rotated logs, retention 100 % digest coverage; limits everywhere
3. Measured Inventory; fix-to-production, throttling and runtime currency reviewed Fix-to-production within policy for two quarters
4. Optimised Automated rebuild and promotion, reproducible builds, verified attestations, sandboxes for untrusted code A drill meets target without heroics
Window Actions Milestone
Days 1–30 Inventory images, bases and runtimes; daemon log defaults; mirror or pull-through cache; lint in warn mode; pick two or three bases Inventory published
Days 31–60 Deploy by digest; enable Renovate or Dependabot; non-root and memory limits for the top ten services; retention dry run Digest coverage over 80 %; metric baseline
Days 61–90 Blocking lint; fix-to-production target agreed with PatchOps; retention applied; runtime-CVE runbook; first drill Drill recorded; exceptions owned and dated

New image (preflight)

  • Approved base, pinned by digest, moved by a bot; multi-stage with a minimal final stage
  • Non-zero USER; no secrets in ENV, ARG or layers; .dockerignore present
  • Exec-form ENTRYPOINT; SIGTERM handled within the stop timeout
  • Source and revision annotations; SBOM and provenance attached

Host readiness

  • cgroup v2 (stat -fc %T /sys/fs/cgroup prints cgroup2fs)[18]
  • Supported, patched Engine, containerd and runc, with an owner for runtime CVEs
  • Default log driver with rotation; disk alerts on the data root
  • docker group treated as root; rootless where workloads allow[44][28]

Monthly review

  • Digest coverage, fix-to-production, base age, runtime currency
  • OOM kills and throttle ratio per service; limits adjusted
  • Registry growth, retention results, GC run; exceptions renewed or expired
BASE-IMAGE POLICY (one page)
Approved bases:      <name, digest-pinned, owner, update channel, end-of-support date>
Runtime minimums:    <supported lines that read cgroup v2 limits, e.g. Node.js 22/24 LTS,
                      OpenJDK 11.0.16+ or 15+, Go 1.25+ with `go 1.25` in go.mod>
Update mechanism:    <Renovate/Dependabot; auto-merge rules; who reviews>
Fix-to-production:   <critical: N days; high: N days; measured p90>
Exceptions:          <who approves; maximum duration; where recorded>
Retirement:          <how a base is removed from the allowlist; notice period>
CONTAINER EXCEPTION RECORD
Service / image digest:   <...>
Policy waived:            <non-root | read-only root | approved base | limit | other>
Reason and risk:          <why it is needed; what an attacker or failure gains>
Compensating controls:    <e.g. sandboxed runtime, network isolation>
Owner / expiry date:      <name> / <YYYY-MM-DD, at most 90 days>
Way to name an image Stable content? Readable? Use for
Mutable tag (app:1.4, latest) No: “any Docker image tag can change content”[51] Yes Development, bot inputs
Immutable tag (registry setting) Within one registry[53] Yes Releases
Digest (app@sha256:…) Everywhere[20] No Deployment, promotion, rollback

Should every image be distroless? No. Distroless suits compiled and well-packaged runtimes, and debugging needs the :debug variants;[31] a slim Debian base with a fast update cadence is defensible.

Do we still need CPU limits? On a shared host without a scheduler, a CPU limit caps a noisy neighbour (shares only weight contention[8]) at the price of throttled bursts; with accurate requests on Kubernetes, the KubernetesOps baseline sets none.

How often should we rebuild if nothing changed? Whenever the base digest changes, and on a schedule so build-time dependencies refresh too.[7]

Is a pull-through cache a backup? No. It serves the last cached copy when upstream fails,[43] but never had what you did not pull. Copy critical images into a registry you own.

Can we stay on cgroup v1? Not for long: Kubernetes 1.35 kubelets refuse cgroup v1 by default, and Docker supports it until May 2029.[18][17]

  • Lazy pulling. The SOCI snapshotter lets containerd start a container before the image has fully downloaded.[103] Since v0.10.0 its default (index v2) needs a conversion step that produces a SOCI-enabled image; lazy loading unconverted images is opt-in (as of October 2026).[104]
  • Referrers. The distribution spec’s referrers API (since 1.1) attaches SBOMs and signatures to a digest;[21] verification policy lives in KubernetesSecOps.
  • One day: Labs 1 and 2; Docker’s build best practices.[7]
  • One week: Labs 3 and 4 on a VM; Renovate on one repository.
  • Thirty days: the 30-day roadmap on a small estate, with one measured fix-to-production.
  • Specialise: supply chain (DevSecOps), clusters (KubernetesOps) or the platform product (PlatformOps).
  • Portfolio: a hardened service image, a pipeline that deploys by digest, a pin-moving bot and a metrics dashboard.
Decide Do Measure Watch out for
Approved bases and owners Pin by digest; bot moves pins Digest coverage latest and floating tags in production
Fix-to-production target Rebuild on every base change; promote by digest Fix-to-production p90 Scans without rebuilds
Runtime defaults Non-root, fewer capabilities, read-only root, memory and PID limits OOM kills, throttling CPU limits on bursty services
Registry policy Immutable release tags, mirror, retention, GC Growth, pull failures Rate limits; vendor namespace changes
Runtime lifecycle Patch Engine, containerd, runc; move to cgroup v2 Runtime currency Escapes via runc; cgroup v1 deadlines

A judgement call: float the tag or pin the digest?

Link to section “A judgement call: float the tag or pin the digest?”

Situation (illustrative). A team of five runs twelve services built FROM python:3.13-slim. One engineer wants the tag left floating so rebuilds pick up fixes without pull requests; another wants digest pins moved by Renovate.

Option For Against
A. Floating tag, nightly rebuild Fixes arrive with no human work Two builds of one commit can differ; an upstream regression ships unannounced; rollback by tag is unreliable[6]
B. Digest pin, bot PRs, auto-merge on green tests Reproducible; every base change visible, tested and revertible[51] Needs a bot, trustworthy tests and an owner for failed PRs
C. Digest pin, manual updates Full control Fixes stall; Docker warns that pinning opts out of automated fixes[7]

Reasoning. A looks like free security, and that is the trap: it swaps a visible, reversible change for an invisible one, so after an upstream regression you cannot say which base ran yesterday. C is the worst of both. Default: B, auto-merging patch-level base updates that pass tests. Assumptions: the tests catch gross breakage and someone owns failed bot PRs. If not, fix that first; meanwhile A with recorded digests is the lesser evil for non-critical services.

You can now explain the label, read an image from a registry, gate a hardened Dockerfile, verify limits, expose a service deliberately, diagnose exit codes and design the rebuild loop.

  1. A deployment references shop/web:2.3. Name two ways it can run different bytes tomorrow without a code change.
Answer

The tag can be re-pushed with new content, and a multi-platform tag resolves to a different manifest on another CPU architecture. A digest fixes the first; for the second, test every platform you deploy.[21][19]

  1. A container exits with 137 and OOMKilled is false. What happened?
Answer

Something other than an OOM kill sent SIGKILL (128 + 9): usually docker stop hit its timeout because the process, as PID 1, ignored SIGTERM (shell-form entrypoint or no handler), or someone killed it.[92][25]

  1. Average CPU is 1.2 with --cpus 2, but p99 latency doubles at peak. What do you measure first?
Answer

The throttle ratio, nr_throttled ÷ nr_periods from cpu.stat: bursts can exhaust the per-period quota while the average stays low.[34][2]

  1. A developer removes a token with RUN rm /app/.token in the last layer. Is the image clean?
Answer

No. The earlier layer still contains the file, because each layer is a separate changeset.[3] Rebuild with a secret mount and rotate the token.[42]

  1. A Compose service publishes 8080:8080 on a host whose ufw denies port 8080. Is the port closed to the network?
Answer

No. Without a host address Docker publishes on every address and routes the traffic before ufw’s rules apply.[49][50] Bind to 127.0.0.1 or one interface.

  1. Your organisation creates a “ContainerOps” team. What should its charter cover, and why is the label ambiguous?
Answer

Bases and rebuilds, registries, runtime defaults and patching, limits, exposure, data and logging, with handoffs to orchestrator, security and host teams. No standard defines the name; it also labels a dormant Huawei project, a consultancy page and a student tool.[10][12][13]

ContainerOps is a page name, not a profession; what it labels is owning the bytes you ship and the box they run in. Name content by digest, so what you tested is what runs. Rebuild instead of patching, and measure how long a fix takes to reach production: that, not the scan report, is your exposure. Give every container limits, a non-root identity, deliberate exposure and a clean way to stop, and treat bases, registries and runtimes as dependencies that change under you. Get these right and every orchestrator, security tool and platform above has solid ground.

Sources were opened on 2026-10-16 or 2026-10-17; the ledger records support and gaps. Re-check cadence: quarterly and on each Engine or containerd minor release, re-verify the dated facts (Hub limits, runtime defaults and support windows, language-runtime lines, SOCI, hardened-image terms) and update last_verified.

Changelog: 2026-10-16 — first draft. 2026-10-17 — round 1: 18 review findings fixed (retention, networking, Huawei date, Go cache path). 2026-10-17 — round 2: OOM triage on cgroup v2, rootless mode, volumes, inventory command, re-check cadence. 2026-10-17, post-score polish: rootless CPU delegation, exit 0 in triage, narrower figures, Node.js 26 date.


  1. namespaces(7) — Linux man-pages 6.19, 2026-02-08. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. Control Group v2 — Linux kernel documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. OCI Image Format Specification — OCI; release v1.1.1, 2025-03-03. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  4. About the OCI — Open Container Initiative. Checked 2026-10-16. ↩︎ ↩︎

  5. SP 800-190 Application Container Security Guide — NIST, September 2017. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. Images — Kubernetes. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  7. Building best practices — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  8. Resource constraints — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  9. Upcoming changes to the Bitnami catalog — Bitnami, GitHub, 2025-07-16. Checked 2026-10-16. ↩︎ ↩︎

  10. Huawei/containerops — Huawei, GitHub; last commit on master 2018-12-11. Checked 2026-10-17. ↩︎ ↩︎

  11. Huawei case study — Kubernetes. Checked 2026-10-16. ↩︎

  12. What is ContainerOps? — Opsio, updated 2025-09-17. Checked 2026-10-16. ↩︎ ↩︎

  13. C06_ContainerOps — IEEE NITK Virtual Expo, 2025. Checked 2026-10-16. ↩︎ ↩︎

  14. Docker: Nine Years Young — Docker, 2022-03-15. Checked 2026-10-16. ↩︎

  15. Container Runtimes — Kubernetes. Checked 2026-10-16. ↩︎

  16. Docker Engine v29 — Docker, 2025-11-11. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  17. Deprecated Engine features — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  18. About cgroup v2 — Kubernetes. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  19. OCI Image Index — OCI. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  20. OCI Content Descriptors — OCI. Checked 2026-10-16. ↩︎ ↩︎

  21. OCI Distribution Specification — OCI. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  22. containerd — containerd project. Checked 2026-10-16. ↩︎ ↩︎

  23. Runtime v2 — containerd, GitHub. Checked 2026-10-17. ↩︎ ↩︎ ↩︎

  24. runc — OCI, GitHub. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  25. pid_namespaces(7) — Linux man-pages. Checked 2026-10-17. ↩︎ ↩︎

  26. BuildKit — Docker Docs. Checked 2026-10-17. ↩︎

  27. Build attestations — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  28. Rootless mode — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎

  29. GHSA-9493-h29p-rfm2 (CVE-2025-31133) — runc, 2025-11-05. Checked 2026-10-16. ↩︎ ↩︎

  30. Multi-stage builds — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  31. Distroless container images — GoogleContainerTools. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  32. Attestation storage — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  33. OCI Runtime Specification — OCI; release v1.3.0, 2025-11-04. Checked 2026-10-16. ↩︎ ↩︎

  34. CFS Bandwidth Control — Linux kernel documentation. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  35. Docker Engine security — Docker Docs. Checked 2026-10-16. ↩︎

  36. Seccomp security profiles for Docker — Docker Docs. Checked 2026-10-16. ↩︎

  37. gVisor documentation — gVisor. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  38. Kata Containers — Kata Containers. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  39. Rootless mode tips — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎

  40. Rootless mode troubleshooting — Docker Docs. Checked 2026-10-17. ↩︎

  41. Hadolint — Hadolint, GitHub. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  42. Dockerfile reference — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  43. ECR pull through cache — AWS. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  44. Linux post-installation steps — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  45. Live restore — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  46. containerd releases — containerd project. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  47. Networking overview — Docker Docs. Checked 2026-10-17. ↩︎

  48. Networking in Compose — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎

  49. Port publishing and mapping — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎

  50. Packet filtering and firewalls — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎

  51. Docker — Renovate docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  52. Optimize cache usage in builds — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  53. ECR tag immutability — AWS. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  54. Mirror the Docker Hub library — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  55. Create tag retention rules — Harbor. Checked 2026-10-16. ↩︎ ↩︎

  56. Garbage collection — CNCF Distribution. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  57. docker container run — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  58. common/variables.bzl — GoogleContainerTools. Checked 2026-10-16. ↩︎

  59. About Alpine Linux — Alpine Linux. Checked 2026-10-16. ↩︎

  60. Alpine Linux releases — Alpine Linux. Checked 2026-10-16. ↩︎

  61. Docker makes Hardened Images free — Docker, 2025-12-17. Checked 2026-10-16. ↩︎

  62. Services top-level element — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  63. XI. Logs — The Twelve-Factor App. Checked 2026-10-16. ↩︎

  64. Configure logging drivers — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎

  65. Local file logging driver — Docker Docs. Checked 2026-10-16. ↩︎

  66. Cloud Native Buildpacks — Buildpacks project. Checked 2026-10-16. ↩︎

  67. Rebase — Buildpacks project. Checked 2026-10-16. ↩︎

  68. kaniko — GoogleContainerTools, archived 2025-06-03. Checked 2026-10-16. ↩︎

  69. Lifecycle policy properties — AWS. Checked 2026-10-16. ↩︎

  70. skopeo — containers, GitHub. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  71. docker buildx imagetools inspect — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎

  72. Cache storage backends — Docker Docs. Checked 2026-10-17. ↩︎

  73. Multi-platform builds — Docker Docs. Checked 2026-10-17. ↩︎

  74. Build reproducibility — BuildKit, GitHub. Checked 2026-10-16. ↩︎

  75. docker buildx build — Docker Docs. Checked 2026-10-16. ↩︎

  76. Provenance attestations — Docker Docs. Checked 2026-10-17. ↩︎

  77. Compose Deploy Specification — Docker Docs. Checked 2026-10-16. ↩︎

  78. Start containers automatically — Docker Docs. Checked 2026-10-17. ↩︎

  79. Volumes — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎

  80. docker compose down — Docker Docs. Checked 2026-10-17. ↩︎

  81. daemon/container/container_unix.go — Moby, GitHub. Checked 2026-10-17. ↩︎

  82. File System Level Backup — PostgreSQL 18 documentation. Checked 2026-10-17. ↩︎

  83. Lifecycle policies in Amazon ECR — AWS. Checked 2026-10-16. ↩︎

  84. Examples of lifecycle policies — AWS. Checked 2026-10-16. ↩︎

  85. Docker Hub pull usage and limits — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎

  86. Docker Engine API v1.57 specification — Moby, GitHub. Checked 2026-10-17. ↩︎ ↩︎

  87. Preparing for the Bitnami changes — Broadcom community, 2025-08-18. Checked 2026-10-16. ↩︎

  88. GHSA-xr7r-f8xq-vfvv (CVE-2024-21626) — runc, 2024-01-31. Checked 2026-10-16. ↩︎

  89. OCI Annotations — OCI. Checked 2026-10-16. ↩︎

  90. Go 1.25 Release Notes — The Go project, August 2025. Checked 2026-10-16. ↩︎ ↩︎

  91. Running containers — Docker Docs. Checked 2026-10-16. ↩︎

  92. Exit Status — GNU Bash manual. Checked 2026-10-16. ↩︎ ↩︎

  93. pkg/oom/v2/v2.go — containerd, GitHub. Checked 2026-10-17. ↩︎

  94. daemon/monitor.go — Moby, GitHub. Checked 2026-10-17. ↩︎

  95. Prune unused Docker objects — Docker Docs. Checked 2026-10-16. ↩︎

  96. postgres image documentation — Docker Official Images, GitHub. Checked 2026-10-16. ↩︎

  97. mm/oom_kill.c — Linux kernel source. Checked 2026-10-17. ↩︎

  98. Node.js Release Working Group — Node.js, GitHub. Checked 2026-10-17. ↩︎

  99. Go, Backwards Compatibility, and GODEBUG — The Go project. Checked 2026-10-17. ↩︎

  100. Use Compose in production — Docker Docs. Checked 2026-10-16. ↩︎

  101. podman-systemd.unit(5) — Podman. Checked 2026-10-16. ↩︎

  102. podman-auto-update(1) — Podman. Checked 2026-10-16. ↩︎

  103. SOCI Snapshotter — AWS Labs, GitHub. Checked 2026-10-16. ↩︎

  104. SOCI Index Manifest v2 — AWS Labs, GitHub. Checked 2026-10-17. ↩︎

Related Ops

KubernetesOps

KubernetesOps is this Atlas's name for operating Kubernetes clusters and the workloads on them: keeping the control plane, nodes and add-ons healthy and current, making workloads schedule and scale correctly, and diagnosing failures.

DevSecOps

DevSecOps is the practice of building security into the software delivery pipeline and into the teams that run it, so that every change is checked, traceable and safe to ship without a separate security phase that stalls delivery.

KubernetesSecOps

KubernetesSecOps is a rare name for Kubernetes security operations: running the controls that decide who may change a cluster, what may run in it, what may talk to what, and how an attack in progress is noticed and contained.

SysOps

SysOps (systems operations) is the hands-on work of keeping servers healthy: services, users and privileges, logs, scheduled jobs, packages, filesystems, time and performance triage. The label is informal; the established practice is system administration.

ServerlessOps

ServerlessOps is the practice of running serverless compute (functions and request-billed containers) in production: owning event retries and duplicates, concurrency, safe deployments and per-invocation cost. The label itself is a niche practitioner term, used mostly between 2016 and 2020.

PlatformOps

PlatformOps is a less common name for platform engineering. This page uses it for the operating half: running an internal developer platform as a product, with service levels, support, upgrades and deprecation that keep its self-service golden paths trustworthy.

PatchOps

PatchOps is an informal name for patch management run as an operation: getting vendor fixes onto every asset through rings, maintenance windows, reboots and rollback, then proving they took effect. The established practice is enterprise patch management.

ObservabilityOps

ObservabilityOps is this Atlas's name for running observability as an internal product: the telemetry pipeline, standards, budgets and alert hygiene that let engineers answer new questions about production at a predictable cost. The label itself is not standardised.

Practise and get certified

From xOps School

Practise ContainerOps and get certified

The free ContainerOps track on xOps School has study notes, a 25-question practice quiz with explanations, and a verifiable certificate when you score 70% or more. Sign in with Google to start.

For teams

Instructor-led training

Live batches for your team, with completion certificates and reports for L&D/HR.

Source: master.md (the editorial source of this page) · built 2026-10-17 · diagrams rendered with @mermaid-js/mermaid-cli 11.17.0 (mermaid 11.17.2)