What ContainerOps is
Link to section “What ContainerOps is”In 30 seconds
Link to section “In 30 seconds”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.
At a glance
Link to section “At a glance”| 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 |
The problem it solves
Link to section “The problem it solves”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:
- 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]
- 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.
- 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).
Where it comes from and where it sits
Link to section “Where it comes from and where it sits”Origins and evolution
Link to section “Origins and evolution”- 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]
Glossary and mental model
Link to section “Glossary and mental model”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] |
How ContainerOps relates to other Ops
Link to section “How ContainerOps relates to other Ops”The split is by layer; in many organisations one platform team does all of it.
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 |
Principles and operating model
Link to section “Principles and operating model”Charter and principles
Link to section “Charter and principles”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
- Name content, not intentions. Deploy by digest; tags are for humans and for bots that move pins.
- Rebuild, don’t patch. Fixes come from a new build, never from
execinto a running container.[5] - Ship only what runs. Multi-stage builds (several
FROMstages; only artefacts reach the last) and minimal bases keep compilers and shells out.[30][31] - Every container has limits. Memory and PIDs always; CPU once you understand throttling.
- Least privilege by default. Non-root, dropped capabilities, read-only root where possible.
- The process is PID 1 and handles SIGTERM. Exec form or an init; shutdown within the stop timeout.
- 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.
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
cpucontroller 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+) orfuse-overlayfs.
Roles, ownership and RACI
Link to section “Roles, ownership and RACI”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.
Lifecycle: from base image to retirement
Link to section “Lifecycle: from base image to retirement”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.
Operating model and governance
Link to section “Operating model and governance”- 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.
Architecture and workflow
Link to section “Architecture and workflow”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
ENVorARG.[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
dockergroup “grants root-level privileges”, so treat membership as root access.[44] - Log driver: rotation on the host; shipping belongs to ObservabilityOps.
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 hostremoves its network isolation.[47] Compose creates one network per project, where services reach each other by name on container ports, sowebneeds no published port to reachdb.[48] - Publishing is the exposure decision, and Docker calls it “insecure by default”: without a host address,
-p 8080:8080listens on every IPv4 and IPv6 address.[49] - ufw and firewalld do not guard it. Published traffic is routed in the
nattable before ufw’s rules apply; with firewalld, Docker adds adockerzone with targetACCEPT.[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.
Practices, standards and tools
Link to section “Practices, standards and tools”Core practices and playbooks
Link to section “Core practices and playbooks”| 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]
Methods, standards and frameworks
Link to section “Methods, standards and frameworks”| 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 |
Tooling landscape
Link to section “Tooling landscape”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 |
Automation and AI
Link to section “Automation and AI”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.
ContainerOps in practice
Link to section “ContainerOps in practice”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
dockerdriver, 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 rundocker buildx create --name container-builder --driver docker-container --bootstrap --use. - Each
REPLACE_WITH_DIGESTbecomes 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-datalives in a host directory that Docker manages and outlives the container; Docker prefers volumes to bind mounts of host paths.[79] docker compose downkeeps it;down --volumesdeletes 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" }
}
]
}
Hands-on labs
Link to section “Hands-on labs”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]
- 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))
- 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 anunknown/unknownattestation 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 underlibrary/);429: pull limit. - Teardown: none. With Docker installed,
docker buildx imagetools inspect alpine:3.24andskopeo inspectshow 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 inENV/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)
- Save the gate as
dockerfile_gate.py, and save this deliberately bad file asbad.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
- 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
- Run the gate against the reference Dockerfile above (saved as
Dockerfile): it prints0 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
:nonrootstill needs an explicitUSER(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}}'prints2),[86] 2 GB free disk, 45 minutes. Risk: the OOM step kills only the lab container, but keep it off shared hosts.
- Create
lab3/go.modcontainingmodule example.com/lab3andgo 1.25, andlab3/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)
}
}
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).- Build both (
docker build -f Dockerfile.single -t lab3:single ., likewiselab3:multi) and rundocker 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] - 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
- Compare the shell-form image:
docker run -d --name single lab3:single, thentime 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. - 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
docker run -d --name spin --cpus 0.5 python:3.14-slim python -c 'while True: pass'; after 30 seconds rundocker exec spin cat /sys/fs/cgroup/cpu.stat. Expected:nr_throttledclose tonr_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.statpath differs; rootless Docker ignores limits without cgroup v2 and systemd, and--cpusneeds a delegatedcpucontroller.[39] - Teardown:
docker rm -f web single hog spinanddocker 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 (
unsharefrom 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).
unshare --user --map-root-user --pid --fork --mount-proc ps -o pid,user,commprints one process,1 root ps: inside the new PID namespacepsis PID 1 and you appear as root.unshare --user --map-root-user sh -c 'id; cat /etc/shadow'printsuid=0(root), thenPermission denied; outside,id -uprints your own UID. The user namespace maps IDs but grants no power over host files.[1]unshare --user --map-root-user --uts sh -c 'hostname lab-ns && hostname'printslab-ns; the host’s name is unchanged.- See limits as the process sees them, on a host and inside containers with different
--cpusand--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 256mexpect 1.50 and 256 MiB whileos.cpu_count()still shows every host CPU: runtimes that size thread pools from the CPU count misjudge containers. - Troubleshooting:
Operation not permittedfromunsharemeans unprivileged user namespaces are disabled; use a VM. - Teardown: none; namespaces disappear with their last process.
Use cases and scenarios
Link to section “Use cases and scenarios”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 copyneeds 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
429in CI for a month.
Measure, secure and pay
Link to section “Measure, secure and pay”Metrics and KPIs
Link to section “Metrics and KPIs”| 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.
Security, quality and compliance duties
Link to section “Security, quality and compliance duties”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).
Cost, capacity and sustainability
Link to section “Cost, capacity and sustainability”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).
Assurance of the operating model
Link to section “Assurance of the operating model”- 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 }'
When things go wrong
Link to section “When things go wrong”Common failures and troubleshooting
Link to section “Common failures and troubleshooting”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]
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.
- Confirm the signal.
docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}} {{.RestartCount}}' apishows137 true 6: the OOM killer, six times. - Check limit and usage.
docker inspect -f '{{.HostConfig.Memory}}' apireturns 536870912 (512 MiB);docker stats --no-stream apishows 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] - 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”.
- Mitigate. Redeploy the previous digest; restarts stop. Rollback must be by digest, because the old tag may already point elsewhere.
- 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-sizebelow the limit, leaving headroom for non-heap memory. Check other runtimes too: OpenJDK needs jdk8u372, 11.0.16 or 15+;[18] Go sizesGOMAXPROCSfrom the CPU limit only from 1.25, and only withgo 1.25or later in go.mod.[90][99] - 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.
Anti-patterns and myths
Link to section “Anti-patterns and myths”- “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.
Deciding and adopting
Link to section “Deciding and adopting”When to use and when not to use
Link to section “When to use and when not to use”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.
Decision framework
Link to section “Decision framework”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) |
Maturity model
Link to section “Maturity model”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 |
Adoption roadmap: 30/60/90 days
Link to section “Adoption roadmap: 30/60/90 days”| 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 |
Toolkit
Link to section “Toolkit”Checklists
Link to section “Checklists”New image (preflight)
- Approved base, pinned by digest, moved by a bot; multi-stage with a minimal final stage
- Non-zero
USER; no secrets inENV,ARGor layers;.dockerignorepresent - 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/cgroupprintscgroup2fs)[18] - Supported, patched Engine, containerd and runc, with an owner for runtime CVEs
- Default log driver with rotation; disk alerts on the data root
-
dockergroup 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
Templates
Link to section “Templates”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>
Deep comparisons
Link to section “Deep comparisons”| 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]
Going further
Link to section “Going further”Advanced topics and emerging direction
Link to section “Advanced topics and emerging direction”- 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.
Learning path
Link to section “Learning path”- 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.
Wrap-up
Link to section “Wrap-up”Executive recap and cheat sheet
Link to section “Executive recap and cheat sheet”| 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.
Learning objectives and mastery check
Link to section “Learning objectives and mastery check”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.
- A deployment references
shop/web:2.3. Name two ways it can run different bytes tomorrow without a code change.
Answer
- A container exits with 137 and
OOMKilledis false. What happened?
Answer
- Average CPU is 1.2 with
--cpus 2, but p99 latency doubles at peak. What do you measure first?
Answer
- A developer removes a token with
RUN rm /app/.tokenin the last layer. Is the image clean?
Answer
- A Compose service publishes
8080:8080on a host whose ufw denies port 8080. Is the port closed to the network?
Answer
- Your organisation creates a “ContainerOps” team. What should its charter cover, and why is the label ambiguous?
Answer
Conclusion
Link to section “Conclusion”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 and further reading
Link to section “Sources and further reading”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.
namespaces(7) — Linux man-pages 6.19, 2026-02-08. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Control Group v2 — Linux kernel documentation. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
OCI Image Format Specification — OCI; release v1.1.1, 2025-03-03. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
About the OCI — Open Container Initiative. Checked 2026-10-16. ↩︎ ↩︎
SP 800-190 Application Container Security Guide — NIST, September 2017. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Building best practices — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Resource constraints — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Upcoming changes to the Bitnami catalog — Bitnami, GitHub, 2025-07-16. Checked 2026-10-16. ↩︎ ↩︎
Huawei/containerops — Huawei, GitHub; last commit on
master2018-12-11. Checked 2026-10-17. ↩︎ ↩︎Huawei case study — Kubernetes. Checked 2026-10-16. ↩︎
What is ContainerOps? — Opsio, updated 2025-09-17. Checked 2026-10-16. ↩︎ ↩︎
C06_ContainerOps — IEEE NITK Virtual Expo, 2025. Checked 2026-10-16. ↩︎ ↩︎
Docker: Nine Years Young — Docker, 2022-03-15. Checked 2026-10-16. ↩︎
Container Runtimes — Kubernetes. Checked 2026-10-16. ↩︎
Docker Engine v29 — Docker, 2025-11-11. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Deprecated Engine features — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
About cgroup v2 — Kubernetes. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
OCI Image Index — OCI. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
OCI Content Descriptors — OCI. Checked 2026-10-16. ↩︎ ↩︎
OCI Distribution Specification — OCI. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
containerd — containerd project. Checked 2026-10-16. ↩︎ ↩︎
Runtime v2 — containerd, GitHub. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
pid_namespaces(7) — Linux man-pages. Checked 2026-10-17. ↩︎ ↩︎
Build attestations — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Rootless mode — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎
GHSA-9493-h29p-rfm2 (CVE-2025-31133) — runc, 2025-11-05. Checked 2026-10-16. ↩︎ ↩︎
Multi-stage builds — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
Distroless container images — GoogleContainerTools. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Attestation storage — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
OCI Runtime Specification — OCI; release v1.3.0, 2025-11-04. Checked 2026-10-16. ↩︎ ↩︎
CFS Bandwidth Control — Linux kernel documentation. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Docker Engine security — Docker Docs. Checked 2026-10-16. ↩︎
Seccomp security profiles for Docker — Docker Docs. Checked 2026-10-16. ↩︎
gVisor documentation — gVisor. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Kata Containers — Kata Containers. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Rootless mode tips — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Rootless mode troubleshooting — Docker Docs. Checked 2026-10-17. ↩︎
Dockerfile reference — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
ECR pull through cache — AWS. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Linux post-installation steps — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Live restore — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
containerd releases — containerd project. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Networking overview — Docker Docs. Checked 2026-10-17. ↩︎
Networking in Compose — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎
Port publishing and mapping — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Packet filtering and firewalls — Docker Docs. Checked 2026-10-17. ↩︎ ↩︎ ↩︎
Optimize cache usage in builds — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
ECR tag immutability — AWS. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Mirror the Docker Hub library — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
Create tag retention rules — Harbor. Checked 2026-10-16. ↩︎ ↩︎
Garbage collection — CNCF Distribution. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
docker container run — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
common/variables.bzl — GoogleContainerTools. Checked 2026-10-16. ↩︎
About Alpine Linux — Alpine Linux. Checked 2026-10-16. ↩︎
Alpine Linux releases — Alpine Linux. Checked 2026-10-16. ↩︎
Docker makes Hardened Images free — Docker, 2025-12-17. Checked 2026-10-16. ↩︎
Services top-level element — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Configure logging drivers — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Local file logging driver — Docker Docs. Checked 2026-10-16. ↩︎
Cloud Native Buildpacks — Buildpacks project. Checked 2026-10-16. ↩︎
kaniko — GoogleContainerTools, archived 2025-06-03. Checked 2026-10-16. ↩︎
Lifecycle policy properties — AWS. Checked 2026-10-16. ↩︎
docker buildx imagetools inspect — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎
Cache storage backends — Docker Docs. Checked 2026-10-17. ↩︎
Multi-platform builds — Docker Docs. Checked 2026-10-17. ↩︎
Build reproducibility — BuildKit, GitHub. Checked 2026-10-16. ↩︎
docker buildx build — Docker Docs. Checked 2026-10-16. ↩︎
Provenance attestations — Docker Docs. Checked 2026-10-17. ↩︎
Compose Deploy Specification — Docker Docs. Checked 2026-10-16. ↩︎
Start containers automatically — Docker Docs. Checked 2026-10-17. ↩︎
docker compose down — Docker Docs. Checked 2026-10-17. ↩︎
daemon/container/container_unix.go — Moby, GitHub. Checked 2026-10-17. ↩︎
File System Level Backup — PostgreSQL 18 documentation. Checked 2026-10-17. ↩︎
Lifecycle policies in Amazon ECR — AWS. Checked 2026-10-16. ↩︎
Examples of lifecycle policies — AWS. Checked 2026-10-16. ↩︎
Docker Hub pull usage and limits — Docker Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Docker Engine API v1.57 specification — Moby, GitHub. Checked 2026-10-17. ↩︎ ↩︎
Preparing for the Bitnami changes — Broadcom community, 2025-08-18. Checked 2026-10-16. ↩︎
GHSA-xr7r-f8xq-vfvv (CVE-2024-21626) — runc, 2024-01-31. Checked 2026-10-16. ↩︎
OCI Annotations — OCI. Checked 2026-10-16. ↩︎
Go 1.25 Release Notes — The Go project, August 2025. Checked 2026-10-16. ↩︎ ↩︎
Running containers — Docker Docs. Checked 2026-10-16. ↩︎
Exit Status — GNU Bash manual. Checked 2026-10-16. ↩︎ ↩︎
pkg/oom/v2/v2.go — containerd, GitHub. Checked 2026-10-17. ↩︎
daemon/monitor.go — Moby, GitHub. Checked 2026-10-17. ↩︎
Prune unused Docker objects — Docker Docs. Checked 2026-10-16. ↩︎
postgres image documentation — Docker Official Images, GitHub. Checked 2026-10-16. ↩︎
mm/oom_kill.c — Linux kernel source. Checked 2026-10-17. ↩︎
Node.js Release Working Group — Node.js, GitHub. Checked 2026-10-17. ↩︎
Go, Backwards Compatibility, and GODEBUG — The Go project. Checked 2026-10-17. ↩︎
Use Compose in production — Docker Docs. Checked 2026-10-16. ↩︎
podman-systemd.unit(5) — Podman. Checked 2026-10-16. ↩︎
podman-auto-update(1) — Podman. Checked 2026-10-16. ↩︎
SOCI Snapshotter — AWS Labs, GitHub. Checked 2026-10-16. ↩︎
SOCI Index Manifest v2 — AWS Labs, GitHub. Checked 2026-10-17. ↩︎