Dive into every layer of a Docker image — find wasted space, leaked secrets, and bloat in seconds.
A single, fast Rust binary that pulls images straight from any registry and shows you exactly what's inside. No Docker daemon, no root, no dependencies. Works on Linux, macOS and Windows.
- ⚡ Fast & standalone — one static binary. Pulls layers directly from Docker Hub / any V2 registry, a local docker client, or a
.tarfile. Layers are cached and downloads resume automatically. - 🔍 Layer-by-layer explorer — an interactive TUI to walk the filesystem of every layer, with added / modified / removed files colorized.
- 📉 Waste & bloat detection — efficiency score, wasted bytes, cross-layer duplicate files, oversized layers, package-manager caches, dev/build artifacts, and a reconstructed Dockerfile with anti-pattern linting.
- 🛡️ Security hygiene checks — flags leaked secret files (
.env, SSH / cloud keys, certs), hardcoded credentials inENV/labels/Dockerfile (key names only, never the value), setuid & world-writable files, and containers that run as root. - 🤖 AI optimization report — hand the full analysis to any OpenAI-compatible model and get a prioritized fix list, plus version-over-version regression detection.
- 🚦 CI gate — fail the pipeline when an image drops below your efficiency / wasted-bytes thresholds.
- 🌐 Terminal · Web · MCP · JSON / Markdown · WeCom — explore interactively, expose an HTTP API or an MCP endpoint for AI agents, export a report, or push results to a chat.
Scope note: diving focuses on size, structure, and basic security checks (leaked secret files, file permissions, runs-as-root, etc.). It scans file paths and image metadata — it does not do CVE/vulnerability scanning or file-content scanning. Pair it with Trivy/grype/docker scout for vulnerability coverage.
# 1. install — pick one:
curl -fsSL https://raw.githubusercontent.com/vicanso/diving-rs/main/install.sh | sh # prebuilt binary
cargo install diving # from crates.io
# 2. dive in
diving redis:alpineThat's it — no Docker daemon required. Prebuilt binaries for Linux / macOS / Windows are also on the release page, or build the latest from source with cargo install --git https://github.com/vicanso/diving-rs.
Inside the TUI:
| Key | Action |
|---|---|
1 |
Show only Modified / Removed files of the current layer |
2 |
Show only files ≥ 1 MB |
Esc / 0 |
Reset the view |
diving accepts three source types:
# from a registry (default) — Docker Hub, quay.io, private registries…
diving redis:alpine
diving quay.io/prometheus/node-exporter
# pick an architecture for multi-arch images
diving redis:alpine?arch=arm64
# from the local docker client
diving docker://redis:alpine
# from a saved tar file
diving file:///tmp/redis.tar# JSON — the full analysis plus efficiencyScore / wastedSize / wastedPercent
diving redis:alpine --output-file result.json
# Markdown (detected by the .md extension)
diving redis:alpine --output-file result.md
# Markdown to stdout — base image layers are auto-detected and hidden by default
diving myimage:latest --output-file -
# include the base image layers
diving myimage:latest --output-file - --no-skip-baseRun diving in CI to keep images lean. With CI=true it prints the efficiency score and exits 1 when any threshold is exceeded (see exit codes).
CI=true diving redis:alpineThresholds are configurable in ~/.diving/config.yml, or in a file you point to with --config — handy for keeping the gate next to the code:
CI=true diving --config .diving.yml myimage:latest| Option | Default | Meaning |
|---|---|---|
lowest_efficiency |
0.95 |
Minimum acceptable efficiency score (0–1) |
highest_wasted_bytes |
20971520 (20 MB) |
Maximum wasted bytes |
highest_user_wasted_percent |
0.1 |
Maximum wasted percentage (0–1) |
fail_on_severity |
— (off) | Also fail when any recommendation is at this severity or above: high, medium, low or info |
By default the recommendations (leaked secret files, runs-as-root, …) are only printed and do not affect the exit code. Set fail_on_severity: high to make findings such as a private key baked into the image fail the pipeline. medium and below also count heuristic suggestions (Dockerfile lint, docs/locale files, …), so expect more noise. A misspelled value stops diving at startup instead of silently disabling the check.
| Code | Meaning |
|---|---|
0 |
Passed |
1 |
The image failed the gate (a threshold above, or fail_on_severity) |
2 |
diving itself failed: the image could not be pulled or analyzed, the config is invalid, an AI or WeCom call failed, … |
With CI=true the gate also applies when an AI report or WeCom push is configured: diving sends the report first, then runs the checks.
Every recommendation has a stable id, printed in parentheses in the CI output and available as id in the JSON. List the findings you have reviewed and accept under ignore_recommendations. They are still printed, marked ignored, but no longer count toward fail_on_severity (the three thresholds above are unaffected):
fail_on_severity: high
ignore_recommendations:
- secfiles| Id | Recommendation | Severity |
|---|---|---|
secfiles |
Potential secrets in image | high |
secmeta |
Secrets in image metadata (ENV / labels / Dockerfile) | high |
worldread |
World-readable secret files | high |
runtimecompat |
Entrypoint binary incompatible with the base image's libc | high (medium for a musl binary on a glibc image) |
wasted |
Reclaim wasted space | medium (high above 10% wasted) |
crossdup |
Files duplicated across layers | low (medium from 5 MB, high from 50 MB) |
pkgcache |
Remove package manager cache | medium |
devart |
Exclude development artifacts | medium |
runasroot |
Container runs as root | medium |
setuid |
setuid/setgid binaries | medium |
worldwrite |
World-writable files | medium |
oversized |
Oversized layer(s) | low or medium |
dflint |
Dockerfile anti-patterns | low or medium |
layercount |
Reduce layer count | low |
junk |
Editor/OS junk files | low |
slimbase |
Consider a slimmer base image | low |
buildonly |
Build-only files in runtime image | low |
doclocale |
Documentation / man / locale data | low |
logtemp |
Log / temp files baked into image | low |
toolchain |
Build toolchain present in final image | low |
bigfiles |
Large files added in recent layers | info |
netreclaim |
Net reclaimable estimate | info |
Provide an OpenAI-compatible API key and diving sends the full Markdown analysis (layers, reconstructed Dockerfile, wasted space, large files, security findings) to the model and prints a prioritized optimization report instead of opening the TUI. When the ENTRYPOINT/CMD points to a script inside the image, that script is read from the layers and included, so the model can review what the container actually runs.
# enable AI analysis (prints the report, skips the TUI)
diving redis:alpine --ai-api-key sk-xxxx
# custom endpoint / model
diving redis:alpine \
--ai-api-key sk-xxxx \
--ai-base-url https://your-gateway/v1 \
--ai-model gpt-4o
# configure via the environment
export OPENAI_API_KEY=sk-xxxx
diving redis:alpine
# control the report language (also affects terminal / Markdown output)
diving redis:alpine --ai-api-key sk-xxxx --lang zh| Flag | Environment | Default | Description |
|---|---|---|---|
--ai-api-key |
OPENAI_API_KEY |
— | OpenAI-compatible API key. Providing it enables AI analysis. |
--ai-base-url |
OPENAI_BASE_URL |
https://api.openai.com/v1 |
API base URL. A full .../chat/completions URL is also accepted. |
--ai-model |
OPENAI_MODEL |
gpt-4o |
Model name. |
--ai-system-prompt |
OPENAI_SYSTEM_PROMPT |
built-in DevSecOps template | Override the system prompt to fully replace the built-in one. |
--lang |
DIVING_LANG |
system locale | Output language: en or zh. |
--no-ai-history |
— | off | Skip the regression comparison for this run (the snapshot is still refreshed). |
Each run stores a snapshot under ~/.diving/ai_history/. On the next run of the same image, the previous snapshot is sent alongside the current one so the model can flag size regressions / bloat between versions. --no-ai-history skips that comparison for one run (e.g. when the baseline is stale); the snapshot is still refreshed so subsequent runs compare against this one.
Security tip: the API key, base URL and webhook are CLI/env only — they are never accepted as web query parameters, so they don't end up in access logs.
Pass a WeCom (企业微信) group-bot webhook to push the result straight into a chat instead of opening the TUI. Content is chosen so it always fits the bot's ~4096-byte markdown limit:
- with
--ai-api-keyset → the concise AI report is pushed - without AI → a short summary (efficiency score, wasted space, recommendations)
With CI=true the message opens with the CI gate verdict — passed, or failed with the checks that failed — so the chat shows the outcome without opening the pipeline.
# bot key (expanded to the standard webhook URL automatically)
diving redis:alpine --wecom-webhook 693a91f6-7aoc-4bc4-97a0-0ec2sifa5aaa
# or the full webhook URL
diving redis:alpine --wecom-webhook "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=KEY"
# push the AI report instead of the summary
diving redis:alpine --ai-api-key sk-xxxx --wecom-webhook KEY
# or from the environment
export WECOM_WEBHOOK=KEY
diving redis:alpine| Flag | Environment | Default | Description |
|---|---|---|---|
--wecom-webhook |
WECOM_WEBHOOK |
— | WeCom group-bot webhook URL, or a bare bot key. Providing it pushes the result and skips the TUI. |
Oversized content is truncated to the WeCom limit with a … (truncated) marker.
Run diving as an HTTP server with a React frontend for remote analysis.
# Create the data directory and grant it to the container user (UID/GID 1000)
mkdir -p $PWD/diving
chown -R 1000:1000 $PWD/diving
docker run -d --restart=always \
-p 7001:7001 \
-v $PWD/diving:/home/rust/.diving \
--name diving \
vicanso/divingOpen http://127.0.0.1:7001/ in the browser.
The container runs as a non-root UID (1000:1000); the chown above lets it write the layer cache (without it the container fails to start). The image is based on debian:trixie-slim plus a CA certificate bundle. It has no tzdata, so timestamps are in UTC, and it ships no wget/curl, so there is no in-image HEALTHCHECK — probe GET /ping from your orchestrator (Kubernetes livenessProbe, a sidecar, etc.) instead.
Change the listen address with --listen:
diving --mode web --listen 0.0.0.0:8080Analyze a Docker image and return the result.
| Parameter | Type | Required | Description |
|---|---|---|---|
image |
string | yes | Image reference (same formats as terminal mode) |
format |
string | no | Set to markdown to return a Markdown report instead of JSON |
skipBase |
bool | no | When format=markdown, auto-detect and hide base image layers (default true); set false to include them |
# JSON response (default)
curl "http://127.0.0.1:7001/api/analyze?image=redis:alpine"
# specify architecture
curl "http://127.0.0.1:7001/api/analyze?image=redis:alpine%3Farch%3Darm64"
# Markdown report
curl "http://127.0.0.1:7001/api/analyze?image=redis:alpine&format=markdown"
# Markdown report including base layers (hidden by default)
curl "http://127.0.0.1:7001/api/analyze?image=myimage:latest&format=markdown&skipBase=false"Web mode also serves an MCP endpoint at /mcp (Streamable HTTP transport), so AI agents such as Claude Code can analyze images themselves:
claude mcp add --transport http diving http://127.0.0.1:7001/mcpThe MCP button in the web UI's header shows the same setup, filled in with the address you are browsing: the endpoint, the Claude Code command and a JSON config for other clients, each with a copy button. It also warns when that address would be rejected by the Host check below.
| Tool | Returns |
|---|---|
analyze_image |
The Markdown report (same as format=markdown) |
get_findings |
Findings as JSON: efficiency, wasted bytes, recommendations, sensitive files, duplicates, runtime compatibility, per-layer summary |
list_files |
A page of files, filterable by layer, directory, keyword and size |
read_file |
One text file from a layer (up to 256 KiB; registry images only) |
latest_images |
Recently analyzed images |
Layer numbers are 1-based and match the report. MCP calls share the analysis cache, request deduplication and registry_allowlist with /api/analyze. A cold analysis can take minutes; when the client sends a progress token, diving emits a progress notification every 10 seconds.
Access control:
- By default
/mcponly accepts requests whoseHostis loopback (localhost,127.0.0.1,::1), which blocks DNS-rebinding attacks from web pages. To serve remote clients, pick one:- Set a token with
--mcp-token <token>(or$DIVING_MCP_TOKEN). Every request must then carryAuthorization: Bearer <token>, and theHostcheck is skipped:claude mcp add --transport http diving https://diving.example.com/mcp \ --header "Authorization: Bearer <token>" - Allow your hostnames in
~/.diving/config.ymlwithmcp_allowed_hosts: [diving.example.com]("*"turns the check off).
- Set a token with
--no-mcpturns the endpoint off.
The Docker image listens on 0.0.0.0, so for remote MCP clients pass the token as an environment variable, e.g. docker run -e DIVING_MCP_TOKEN=<token> …. Clients on the same host can use http://127.0.0.1:7001/mcp without one.
During analysis diving scans every file path against built-in rules (.env files, SSH private keys, AWS/GCP credentials, TLS private keys, kubeconfig, .htpasswd, an accidentally-copied .git directory, …) and reports matches under Security Warnings. (It scans paths and metadata, not file contents.)
Extend or suppress the rules with ~/.diving/sensitive-files — one rule per line:
| Line format | Effect |
|---|---|
<glob-pattern> |
Flag matching files (reason: "Custom sensitive file") |
<glob-pattern> | <reason> |
Flag with a custom reason label |
!<glob-pattern> |
Ignore / suppress matches (overrides built-in and custom patterns above) |
Lines starting with # and blank lines are ignored. Globs are case-insensitive; * matches across directory separators, and patterns are also tested against the filename alone (so *.pem matches a/b/cert.pem).
# ── Extra patterns ───────────────────────────────────────────
**/*.vault-token | Vault token
**/app-secrets.json | Application secrets
# ── Suppress built-in rules for intentional inclusions ───────
!**/.env.example
!**/.env.template
!**/certs/nginx.crt
!**/testdata/**
!**/fixtures/**
Config file: ~/.diving/config.yml. Use --config <file> (-c) or $DIVING_CONFIG to read a different file; the flag wins over the environment variable. A file given this way must exist — diving exits with an error instead of falling back to defaults — and is parsed as YAML whatever its extension. Only the config file moves: sensitive-files, ai_history/ and the default cache directories stay under ~/.diving/.
| Option | Default | Description |
|---|---|---|
layer_path |
~/.diving/layers |
Layer blob cache directory |
layer_ttl |
90d |
TTL for cached layer blobs and analysis results; an entry is purged if not accessed within this duration |
analysis_path |
~/.diving/analysis |
Analysis-result cache directory |
cleanup_interval_hours |
1 |
How often (hours) caches are swept for expired entries |
layer_concurrency |
min(layers, 2 × CPUs) |
Concurrent layer fetch + decompression tasks per image. Raise on fast networks with many layers; lower when sharing the host |
worker_threads |
number of CPUs | Tokio runtime worker threads. Raise for a web server that handles many requests at once |
threads |
— | Legacy single knob: used for both of the two options above when they are not set |
lowest_efficiency |
0.95 |
CI check — minimum efficiency score (0–1) |
highest_wasted_bytes |
20971520 |
CI check — maximum wasted bytes (20 MB) |
highest_user_wasted_percent |
0.1 |
CI check — maximum wasted percentage (0–1) |
fail_on_severity |
— | CI check — fail when any recommendation is at this severity or above (high / medium / low / info); unset = recommendations never fail the run |
ignore_recommendations |
— | CI check — recommendation ids excluded from fail_on_severity (see Accepting known findings) |
registry_allowlist |
— | Web mode: when non-empty, /api/analyze and MCP only accept images from these registry hosts (e.g. index.docker.io, ghcr.io); add local-file / local-docker to allow file:// / docker:// |
max_download_file_size |
104857600 |
Web mode: largest single file /api/file will serve (100 MB) |
analysis_memory_ttl |
1m |
Web mode: how long a finished analysis is kept in memory. Requests for the same image within this window are answered without contacting the registry; the price is that a re-pushed tag can take this long to show up. 0s turns it off |
max_concurrent_analyses |
— | Web mode: how many different images may be analyzed at once; further requests wait their turn. Unset = no limit |
max_layer_cache_size |
— | Total size cap for the layer cache; when exceeded, the least recently accessed blobs are evicted. Unset = TTL cleanup only |
mcp_allowed_hosts |
— | Web mode: extra Host values /mcp accepts besides loopback ("*" turns the check off; ignored when --mcp-token is set) |
layer_ttl: 30d
cleanup_interval_hours: 6
layer_concurrency: 4
lowest_efficiency: 0.95
highest_wasted_bytes: 20971520
highest_user_wasted_percent: 0.1diving keeps two on-disk caches under ~/.diving/, both governed by layer_ttl and swept hourly:
- Layer blobs (
~/.diving/layers/) — compressed layer downloads, keyed by layer digest. A hit skips the network download; decompression and file-tree construction still run. - Analysis results (
~/.diving/analysis/) — the fully analyzed result, keyed by theDocker-Content-Digest(from aHEADagainst the manifest endpoint) plus architecture. A hit short-circuits the entire pipeline.
The analysis cache is content-addressable, so re-pushing a mutable tag like :latest automatically invalidates the entry. If the HEAD probe fails for any reason, diving silently falls back to a full analysis — caching never blocks a request.
When the registry is unavailable. If the registry cannot be reached, is overloaded (5xx) or rate-limits the request (429), and the same image reference was analyzed before, diving shows that cached analysis instead of an error. The terminal UI, the Markdown report, the web UI, WeCom pushes and the JSON (staleAsOf) all say when it was made, because the tag may point at a different image by now. An error the registry itself returns (401, 403, 404) is never papered over this way, and neither is a CI run: with CI=true the gate has to judge the image as it is, so it fails with exit code 2.
Because layer data is downloaded from the source (e.g. Docker Hub), the first run on a large image can take a while. Interrupted downloads resume automatically. For privately-deployed registries, run diving (or its web image) on a host that can reach the registry.
Licensed under the Apache License 2.0.