agentleFS
riteshsonawane1372riteshsonawane1372/devops-skills/skills/docker/container-image-review/SKILL.md

container-image-review

Skill0 starsChanged 2 months agoApache-2.0View on GitHub

Review a Dockerfile or container image for correctness, size, build speed, security and reproducibility - base image choice, layer caching, multi-stage builds, non-root users, pinned versions, secrets leaked into layers, signal handling and healthchecks. Use when reviewing a Dockerfile, when an image is unexpectedly large or slow to build, or before an image is used in production.

  • Reads credentials
  • Deletes or force-pushes
  • Installs packages

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 4 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Read scanner output with judgement.

    line 213
  2. Distinctive:

    Verify in this order, because optimisation can break correctness silently:

    line 316
  3. Distinctive:

    Bad: Use Alpine everywhere for size.

    line 337
  4. Distinctive:

    Bad: Treat every scanner finding as a blocker.

    line 345

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Container Image Review

Review an image for the four things that actually matter: does it work, how big is it, how fast does it build, and what does it expose.

Purpose

Use this when:

  • Reviewing a Dockerfile in a pull request.
  • An image is unexpectedly large, or builds slowly.
  • Preparing an image for production use.
  • A vulnerability scan reports findings and you need to know which matter.

Do not use this when:

  • A container is failing at runtime — use docker-debugging.
  • The question is Kubernetes configuration around the image — use kubernetes-production-readiness.

What this skill assumes: access to the Dockerfile and, ideally, the built image. Reviewing the Dockerfile alone misses what the base image contributes, which is often most of the size and most of the vulnerabilities.

Operating Procedure

  1. Build it and measure. Size, layer count, build time. Numbers before opinions.
  2. Review correctness first. An image that is small and insecure is better than one that does not run.
  3. Check the base image. It usually determines size, CVE count and libc compatibility.
  4. Check layer ordering for cache efficiency — the difference between a 10-second and a 10-minute rebuild.
  5. Check what got in that should not: secrets, build tools, caches, .git.
  6. Check the runtime posture: user, signals, healthcheck, filesystem.
  7. Produce ranked findings — correctness, then security, then size, then speed.

Size and layer data from docker images and docker history are Observed. A claim that an image "should" be smaller is a Recommendation until you have built the alternative.

Investigation

Step 1 - measure

docker build -t <image>:review .
docker images <image>:review --format '{{.Size}}'
docker history <image>:review --human --format '{{.Size}}\t{{.CreatedBy}}' | head -25

docker history shows the size of each layer and the instruction that produced it. Sort your attention by size: usually two or three layers account for nearly everything, and they are the only ones worth optimising.

docker run --rm <image>:review sh -c 'du -sh /* 2>/dev/null | sort -rh | head -10'

This shows where the size actually is inside the final filesystem — frequently a package cache, a dependency directory that should have been pruned, or a copied build context nobody noticed.

Step 2 - base image

BaseTypical sizeUse whenWatch for
scratch0Fully static binariesNo shell, no CA certificates, no timezone data
distroless2-20 MBCompiled languages, productionNo shell — debug with :debug variants or ephemeral containers
alpine~5 MBSmall images, tolerant of muslmusl, not glibc — breaks glibc-compiled binaries and can change DNS behaviour
debian:*-slim~30 MBglibc compatibility with moderate sizeStill ships a package manager
ubuntu~70 MBFamiliarity, broad toolingLarge, more CVEs to triage
Language images (node, python)300 MB-1 GBBuild stagesRarely appropriate as a final stage

The Alpine question comes up constantly and the honest answer is conditional. Alpine is genuinely small, but musl is not glibc: some binaries will not run, some language runtimes have known performance differences, and DNS resolution behaviour differs in ways that surface as intermittent failures. debian:slim with a multi-stage build often gets you most of the size benefit without the compatibility risk.

docker image inspect <image>:review --format '{{.Config.User}} {{.Architecture}} {{.Os}}'

Step 3 - multi-stage builds

The single highest-impact change for most images. Build in a full-featured stage, copy only the artifact into a minimal one:

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Two things this gets right that are easy to miss:

  • Dependencies are fetched before the source is copied. The go mod download layer is cached until the manifest changes, so ordinary code edits skip it entirely. The same principle applies to package.json before npm ci, and requirements.txt before pip install.
  • CGO_ENABLED=0 produces a static binary, which is what makes static-debian12 or scratch viable. A dynamically linked binary in one of those images fails with exit 127, and the cause is not obvious from the error.

Anything present only in an earlier stage is genuinely absent from the final image — compilers, credentials used at build time, source code.

Step 4 - layer caching and ordering

docker build --progress=plain -t <image>:review . 2>&1 | grep -E 'CACHED|DONE'

Order instructions from least to most frequently changing:

FROM node:22-slim              # changes rarely
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
COPY package*.json ./          # changes occasionally
RUN npm ci --omit=dev
COPY . .                       # changes every commit

Rules that matter:

  • COPY . . invalidates everything after it, so it goes as late as possible.
  • Combine apt-get update with install in one RUN. Split across layers, a cached update layer serves a stale package index and the install fails or pulls the wrong versions.
  • Clean up in the same RUN that created the mess. rm -rf /var/lib/apt/lists/* in a later layer removes nothing from the image — the earlier layer still contains the files. Layers are additive; deleting in a later layer only hides.
  • Use a .dockerignore. Without one, .git, node_modules and local build output are sent to the daemon and frequently end up in the image.
cat .dockerignore 2>/dev/null || echo "NO .dockerignore"

Step 5 - secrets and what leaked in

docker history --no-trunc <image>:review | grep -iE 'token|password|secret|key|ARG'
docker run --rm <image>:review sh -c 'ls -la /root/.ssh /root/.aws 2>/dev/null; cat /app/.env 2>/dev/null'

Anything that exists in any layer is in the image, permanently, even if a later layer deletes it. Anyone who can pull the image can extract it.

WrongRight
ARG NPM_TOKEN then using it in a RUNRUN --mount=type=secret,id=npm ... (BuildKit)
COPY .npmrc . with credentialsBuildKit secret mount
ENV API_KEY=...Inject at runtime from a secret store
COPY . . with a .env in the context.dockerignore
Baking a private key into the imageMount it at runtime

ARG values are visible in docker history for anyone with the image. BuildKit secret mounts never become part of a layer, which is the entire point of them.

Step 6 - runtime posture

USER 10001:10001
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s CMD ["/app", "healthcheck"]
ENTRYPOINT ["/app"]
CheckWhy
USER set to a non-root, numeric UIDRoot in a container is root on the host in a container escape. A numeric UID also lets Kubernetes enforce runAsNonRoot.
Exec-form ENTRYPOINTShell form makes sh PID 1, so SIGTERM never reaches the application and every stop is a SIGKILL
Versions pinnedFROM node:22-slim moves; FROM node:22.8.0-slim@sha256:... does not
No build tools in the final stageCompilers and package managers are attack surface and size
HEALTHCHECK present for non-Kubernetes useKubernetes uses its own probes and ignores this
CA certificates present if the app makes HTTPS callsscratch and distroless/static include them; scratch alone does not

Step 7 - vulnerability scan, read critically

trivy image --severity HIGH,CRITICAL <image>:review
trivy image --severity HIGH,CRITICAL --ignore-unfixed <image>:review

Read scanner output with judgement. Most findings in a typical image are in base-image packages that the application never invokes, and many have no fix available. Triage in this order:

  1. Is it reachable? A CVE in a library your code never calls is a lower priority than one in your request path.
  2. Is there a fix? --ignore-unfixed separates what you can act on from what you can only track.
  3. Is it in the base image? Then the fix is a base image update, not a code change — and rebuilding on a current base fixes many at once.

A smaller base image reduces the count dramatically, which is a real security benefit and also the reason the raw count is a poor metric on its own.

Decision Tree

Reviewing an image
├── Does it run? → docker-debugging first if not
├── Size
│   ├── > 1 GB → build tools in the final stage → multi-stage
│   ├── 200 MB-1 GB → language base as final stage → slim or distroless
│   └── Package cache present → clean in the same RUN
├── Build speed
│   ├── Dependencies reinstalled every build → COPY manifests before source
│   └── No .dockerignore → add one
├── Security
│   ├── Running as root → USER with a numeric UID
│   ├── Secrets in history → rotate them, then use BuildKit mounts
│   ├── Unpinned base → pin by digest
│   └── Build tools in final stage → multi-stage
└── Runtime
    ├── Shell-form ENTRYPOINT → exec form
    └── No CA certificates but makes HTTPS calls → add them

Common Causes

FindingEffectFixRisk
Language base as final stage10-50x larger than neededMulti-stage into slim/distrolessmutating
COPY . . before dependency installFull reinstall on every commitCopy manifests firstmutating
Cleanup in a separate RUNFiles still in the earlier layerClean in the same RUNmutating
No .dockerignore.git and local artifacts in the imageAdd onemutating
Secret in ARG or ENVExtractable by anyone with the imageRotate, then BuildKit secret mountmutating
Running as rootLarger blast radius on escapeUSER with a numeric UIDmutating
Unpinned base imageIrreproducible buildsPin by digestmutating
glibc binary on AlpineExit 127 at runtimeMatch libc, or build staticmutating

Safety

Read-only

docker history, image inspect, trivy image, reading the Dockerfile, and running the image with an overridden entrypoint to list files. These inspect and do not modify.

Building an image is effectively read-only with respect to your systems, but it does execute arbitrary instructions from the Dockerfile and pull from the network. Do not build an untrusted Dockerfile on a machine with credentials you care about — RUN executes as root inside the build, with network access.

Potentially disruptive

  • Changing the base image — can change libc, the shell, the package manager, the default user, and the CA bundle. Test the resulting image before shipping; this is the change most likely to break something subtly.
  • Adding USER — the application may write to paths it can no longer write to. Fix ownership in the build rather than reverting to root.
  • Pinning by digest — good practice, and it stops automatic security updates from the base. Pair it with automated digest bumps.
  • Pushing a new tag over an existing one — anything pulling that tag gets different content with no visible change. This is the argument against mutable tags in production.

Destructive

  • docker system prune -a to reclaim build space — removes all unused images and the build cache. With --volumes it also removes volumes not attached to a running container, which includes stopped databases. Required first: confirm what would be removed by listing images and volumes explicitly.
  • Deleting image tags from a registry — if something in production references that tag or digest, it cannot be pulled again, and any node that needs to restart a pod will fail. Required first: confirm no running deployment references it, and check the registry's retention and immutability settings.
  • Rotating a secret found in image history — necessary, and it will break anything still using the old value. Required first: identify every consumer, then rotate. Note the secret must be treated as compromised regardless: it is in every copy of that image everywhere.

Verification

Before: 1.2 GB, 47 layers, 4m12s rebuild on a code change, runs as root, 34 HIGH CVEs
After:  38 MB, 9 layers, 11s rebuild on a code change, runs as UID 10001, 2 HIGH CVEs
docker images <image>:review --format '{{.Size}}'
docker image inspect <image>:review --format '{{.Config.User}}'
time docker build -t <image>:review .          # second run, after a source-only change
docker run --rm <image>:review <smoke-test-command>
trivy image --severity HIGH,CRITICAL --ignore-unfixed <image>:review

Verify in this order, because optimisation can break correctness silently:

  1. It still runs and passes a smoke test. A smaller image that fails at runtime is a regression.
  2. The cache works. Change one source file and rebuild; dependency layers should show CACHED.
  3. The runtime posture is what you intended — user, entrypoint form, certificates present.
  4. Then the size and CVE numbers.

Anti-Patterns

Bad: FROM node:22 for the final stage because the build needs npm. Better: Multi-stage. The final image needs the runtime, not the toolchain — usually a 10-20x size difference and a large reduction in attack surface.

Bad: RUN rm -rf /var/lib/apt/lists/* as its own instruction. Better: Layers are additive. Deleting in a later layer leaves the files in the earlier one and adds a layer. Clean in the same RUN.

Bad: ARG GITHUB_TOKEN to install private dependencies. Better: It is visible in docker history forever. Use a BuildKit secret mount; if one has already shipped, the token is compromised and must be rotated.

Bad: Use Alpine everywhere for size. Better: musl is not glibc. Check that your runtime and all native dependencies support it; debian:slim in a multi-stage build is often the better trade.

Bad: Ship with :latest. Better: You cannot say what is running, cannot reproduce a build, and cannot roll back to a known image. Pin by digest.

Bad: Treat every scanner finding as a blocker. Better: Triage by reachability and fixability. A wall of unfixable base-image CVEs teaches the team to ignore the scanner entirely, which is worse than not running it.

Composition

  • docker-debugging — when the image does not run correctly.
  • kubernetes-production-readiness — the deployment configuration around the image.
  • secrets-management-review — when the review finds credentials in layers or history.
  • github-actions-debugging — the pipeline that builds and pushes the image.

References

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.