#!/bin/bash # ============================================================================================== # ============================= Docker Prune Images ============================================ # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Removes orphaned Docker images. Two modes: # # Default — dangling only (safe, fast): # Removes untagged images (no name, no container reference). These accumulate # after container updates pull a new image, leaving the old one untagged. # Running containers are never affected. # # --all — full orphan cleanup: # Step 1: removes stopped containers (exited/created state). # Step 2: removes all images not used by any running container. # Use this to clear tagged images left behind by removed or stopped apps. # CAUTION: also removes intentionally stopped containers — only run when you # know all stopped containers are safe to delete. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Safe Default, Explicit Escalation # The default mode (dangling only) is always safe — running containers are # never affected. The --all mode requires deliberate opt-in and carries an # explicit caution in the description, because it removes stopped containers # that may be intentionally paused. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Default (dangling only): # docker image prune -f — removes untagged, unreferenced images only. Every tagged # image survives, running containers are untouched, and nothing stopped is removed. # # --all (full orphan cleanup): # 1. Remove containers in exited/created state # 2. Remove every image not used by a RUNNING container # Step 1 is what makes step 2 reach further: with the stopped containers gone, their # images are no longer referenced and become eligible. That is also precisely why # --all is destructive to anything deliberately kept stopped. # # Reclaimed space is reported for both modes. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # This tool is deliberately unconfigured — it reads live Docker state rather than any # configured list, so there is nothing host-specific to alias and no thresholds to tune. # Scope is controlled entirely by the mode flag (default vs --all). # # Note it does not call detect_hosts(): nothing here is host-specific, and it acts only on # the local daemon. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # Docker prune operations require root. # # Lock Acquisition # acquire_lock prevents concurrent prune runs racing on the same image store. # # Dangling-Only Default # The default mode removes untagged, unreferenced images only. Reaching anything # tagged, running, or deliberately stopped requires --all explicitly — the safe # behaviour is what you get by not thinking about it. # # Running Containers Never Touched # Neither mode removes a running container or an image a running container uses. # --all widens the blast radius to STOPPED containers and their images, never to # anything currently up. # # Dry Run Support # --dry-run lists everything that would be removed and removes nothing. Worth using # before --all specifically, since that mode deletes intentionally-stopped containers. # # Status Mode # --status lists current dangling images and stopped containers without changing # anything, so the scope of a prospective --all is visible up front. # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # docker_prune_images.sh # Remove dangling (untagged) images only. # # docker_prune_images.sh --all # Remove stopped containers, then remove all unused images. # # docker_prune_images.sh --dry-run # Show what would be removed without making changes. # # docker_prune_images.sh --status # Show dangling images and stopped containers. No changes. # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" # ── Handle --all before parse_args ──────────────────────────────────────────── ALL_MODE=false FILTERED_ARGS=() for _arg in "$@"; do [[ "$_arg" == "--all" ]] && ALL_MODE=true || FILTERED_ARGS+=("$_arg") done parse_args "${FILTERED_ARGS[@]}" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi acquire_lock # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY DOCKER PRUNE IMAGES STATUS ━━━━━" DANGLING=$(docker images -f "dangling=true" --format "{{.ID}}\t{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}" 2>/dev/null) STOPPED=$(docker ps -a --filter "status=exited" --filter "status=created" \ --format "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}" 2>/dev/null) echo "" echo "$ICON_CONTAINERS Dangling images (no tag, no container):" if [[ -z "$DANGLING" ]]; then echo " none" else echo "$DANGLING" | while IFS=$'\t' read -r id repo size age; do echo " $id $repo $size $age" done fi echo "" echo "$ICON_CONTAINERS Stopped containers (--all would remove these first):" if [[ -z "$STOPPED" ]]; then echo " none" else echo "$STOPPED" | while IFS=$'\t' read -r id name image status; do echo " $name ($image) $status" done fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Run ━━━ # ============================================================================================== MODE_LABEL=$([[ "$ALL_MODE" == true ]] && echo "full orphan cleanup" || echo "dangling only") log "$ICON_GEAR Config: mode=${MODE_LABEL} dry-run=${DRY_RUN}" echo "━━━ $ICON_CONTAINERS Docker Prune Images ($MODE_LABEL) — $(date '+%Y-%m-%d %H:%M:%S') ━━━" TOTAL_RECLAIMED=0 # ── Step 1 (--all only): remove stopped containers ──────────────────────────── if [[ "$ALL_MODE" == true ]]; then STOPPED_IDS=$(docker ps -a --filter "status=exited" --filter "status=created" -q 2>/dev/null) STOPPED_COUNT=$(echo "$STOPPED_IDS" | grep -c . || true) if [[ "$STOPPED_COUNT" -eq 0 ]]; then echo "No stopped containers" elif [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would remove $STOPPED_COUNT stopped container(s):" docker ps -a --filter "status=exited" --filter "status=created" \ --format " {{.Names}} {{.Image}} {{.Status}}" 2>/dev/null else echo "Removing $STOPPED_COUNT stopped container(s)..." docker container prune -f 2>&1 | grep -v "^Total\|^$" || true success "Removed $STOPPED_COUNT stopped container(s)" fi echo "" fi # ── Step 2: prune images ─────────────────────────────────────────────────────── PRUNE_FLAGS=$([[ "$ALL_MODE" == true ]] && echo "-a" || echo "") PRUNE_FILTER=$([[ "$ALL_MODE" == true ]] && echo "" || echo '-f "dangling=true"') # Preview count if [[ "$ALL_MODE" == true ]]; then # Images not used by any running container RUNNING_IMAGES=$(docker ps --format "{{.Image}}" 2>/dev/null) UNUSED_COUNT=$(docker images --format "{{.Repository}}:{{.Tag}}" 2>/dev/null \ | grep -vxF "$RUNNING_IMAGES" | grep -c . || true) TARGET_LABEL="$UNUSED_COUNT unused image(s)" else DANGLING_IDS=$(docker images -f "dangling=true" -q 2>/dev/null) UNUSED_COUNT=$(echo "$DANGLING_IDS" | grep -c . || true) TARGET_LABEL="$UNUSED_COUNT dangling image(s)" fi if [[ "$UNUSED_COUNT" -eq 0 ]]; then success "No images to remove" exit 0 fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would remove $TARGET_LABEL:" if [[ "$ALL_MODE" == true ]]; then docker images --format " {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}" 2>/dev/null \ | grep -vF "$(docker ps --format '{{.Image}}' 2>/dev/null)" || true else docker images -f "dangling=true" \ --format " {{.ID}} {{.Size}} created {{.CreatedSince}}" 2>/dev/null fi exit 0 fi log "Pruning $TARGET_LABEL..." OUTPUT=$(docker image prune $PRUNE_FLAGS -f 2>&1) [[ "$ENABLE_LOGGING" == "true" ]] && echo "$OUTPUT" | sed 's/^/ /' RECLAIMED=$(echo "$OUTPUT" | grep -E "^Total reclaimed" || echo "Total reclaimed space: unknown") success "Done — $RECLAIMED"