muscles

Explain container images, layer by layer.
Find the waste. Gate it in CI.

$ go install github.com/rossedman/muscles/cmd/muscles@latest
~ zsh
$ muscles layers list app:dev
# SIZE + ~ ◌ - STEP
0 74 MB 3104 · · · FROM debian:bookworm-slim
1 41 MB 220 3 · · RUN apt-get install ⚠
3 182 MB 1204 12 · 310 RUN pip install ⚠
4 96 MB · · 412 · RUN chown -R app /app ⚠
$ muscles images check app.tar --baseline main
✗ fail waste 108 MB > 50 MB
✓ pass growth +38 MB ≤ +50 MB
  • 1×each layer is read once, then cached
  • 0pulls: registry images are streamed
  • 4output formats from the same values
  • 1static binary, no Docker needed

The problem

Every image is a stack of diffs.
Nobody reads them.

docker history gives you commands and sizes. What each layer actually put on disk, and what it left behind, takes an export, a pile of tarballs and an afternoon.

muscles replays the layers over a merged filesystem and tells you, per layer, what changed, where the bytes went, and what's worth fixing.

  • Each layer next to the Dockerfile step that made it
  • Notes for caches left behind and deletions that free nothing
  • What changed between two releases, file by file
074 MBFROM debian:bookworm-slim
141 MBRUN apt-get install -y curl git ⚠ apt lists left behind: 12 MB
3182 MBRUN pip install -r requirements.txt ⚠ deletes 12 MB that L1 still stores
496 MBRUN chown -R app /app ⚠ 96 MB permission-only rewrite

Four kinds of change

Every path, classified.
Every hidden byte, accounted for.

Each entry in a layer is compared with the filesystem below it. Waste is every byte a layer stores that a later layer hides, with both layers named.

+Added

The path didn't exist in any lower layer.

~Modified

The path existed below with different contents. The old copy is still stored: waste.

◌Rewritten

The same contents stored again, only permissions or owner changed. The classic chown -R tax.

-Deleted

A whiteout hid the path. The bytes are still in the layer that stored them.

How classification works

In CI

Budgets in a file.
A red build when they break.

Commit a .muscles.yaml next to your Dockerfile. muscles images check exits 5 when the image is too big, wastes too much, grew too fast against the main branch, or ships a path you forbid, like **/.env.

  • No Docker socket needed: reads tarballs, OCI layouts and registries
  • GitHub annotations and Markdown for the job summary
  • Stable JSON with a versioned schema
The CI guide
# .muscles.yaml
check:
  max_size: 800MB
  max_waste: 50MB
  max_growth: 5%
  forbid: ["**/.env", "**/.git/"]
$ muscles images check app.tar --baseline ghcr.io/acme/app:main
✓ pass  size       401 MB ≤ 800 MB
✗ fail  waste      108 MB > 50 MB  /app/: chown -R in L4
✓ pass  growth     +38 MB ≤ +40 MB vs ghcr.io/acme/app:main
✗ fail  forbid     /app/.env matches "**/.env"  added in L5 COPY . .
⚠ warn  package cache  apt lists left behind: 12 MB

2 failed, 1 warning                        exit 5

Why muscles

Small, fast, honest.
Built for people and pipelines.

Registry images without pulling

Layers stream straight from the registry and are read once. Auth comes from your Docker config and credential helpers, never from flags.

A cache that never goes stale

Layer indexes are keyed by content digest. A base image is read once and every image built on it opens instantly.

Release diffs

muscles images diff shows the layers two images share, the size of each one that differs, and every path that changed.

Follows CLI Style

Noun-verb commands, typed streams, documented exit codes, --json everywhere, real examples in every help page, and help --json for agents.

Nothing leaves your machine

No telemetry. The only network calls are reads from the registries you name.

One static binary

No cgo, no runtime, no Docker. Released for macOS, Linux and Windows with completions and a man page.

Quick start

From an image to an answer
in three commands.

Point muscles at anything that holds an image: a local tag, a registry reference, a tarball or an OCI directory. No config file needed.

Full getting-started guide
  1. Install

    $ brew install rossedman/tap/muscles
  2. Ask what to fix first

    $ muscles python:3.12-slim
    $ muscles layers list python:3.12-slim
  3. Open the layer that surprised you

    $ muscles changes list python:3.12-slim --layer 2
    $ muscles waste list python:3.12-slim

How it works

Read each layer once.
Replay them. Explain what you find.

Images from the Docker daemon, a registry, a tarball or an OCI layout are opened, indexed once per layer into a cache keyed by diffID, and analyzed into added, modified, rewritten and deleted paths with waste attributed. Five commands render the result.
Tap to open the full-size diagram

Commands

A small CLI that
reads like a sentence.

Every command has examples in its help, --json for scripts, and errors that say what to run next.

Explain an image

layers list
Size, changes, step and notes per layer
changes list
The paths a layer added, changed or deleted
waste list
Hidden bytes and the layers responsible

Compare and gate

images diff
What changed from one image to another
images check
Budgets and forbidden paths, for CI

Settings and more

config list
Every setting and where it came from
cache list
Cached layer indexes
help --json
The command tree, for agents

Full CLI reference

Know what's in your images.

Free and open source under the MIT license.