## SCRIPT #!/usr/bin/env bash # 安全清理 Docker 匿名悬空 volume。 # 默认只 dry-run;只有显式 --execute 才会调用 docker volume rm。 set -euo pipefail EXECUTE=0 ROOT_MOUNT="/" declare -a CANDIDATES=() CANDIDATE_BYTES=0 log() { local level="$1" shift printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S%z')" "$level" "$*" } fatal() { log "fatal" "$*" exit 1 } usage() { log "enter" "usage" cat <<'USAGE' 用法: clean-docker-anonymous-volumes.sh [--execute] [--root-mount /] 默认 dry-run,只列出可安全清理候选,不删除任何 volume。 可显式传入 --dry-run;只有传入 --execute 才会删除候选 volume。 安全条件(必须全部满足): 1. volume 名称是 64 位 hex hash 2. docker ps -a --filter volume= 没有任何容器引用 3. 不使用 docker volume prune,避免误删命名业务卷 USAGE log "success" "usage" } parse_args() { log "enter" "parse_args args=$*" while [ "$#" -gt 0 ]; do case "$1" in --execute) EXECUTE=1 shift ;; --dry-run) EXECUTE=0 shift ;; --root-mount) [ "${2:-}" != "" ] || fatal "--root-mount 缺少参数" ROOT_MOUNT="$2" shift 2 ;; -h|--help) usage exit 0 ;; *) fatal "未知参数: $1" ;; esac done log "success" "parse_args execute=$EXECUTE root_mount=$ROOT_MOUNT" } require_docker() { log "enter" "require_docker" command -v docker >/dev/null 2>&1 || fatal "docker 命令不存在" docker info >/dev/null 2>&1 || fatal "docker daemon 不可用或当前用户无权限" log "success" "require_docker" } print_disk_state() { log "enter" "print_disk_state mount=$ROOT_MOUNT" echo "--- df -hT $ROOT_MOUNT ---" df -hT "$ROOT_MOUNT" log "success" "print_disk_state" } print_docker_df() { log "enter" "print_docker_df" echo "--- docker system df ---" docker system df || fatal "docker system df 失败" log "success" "print_docker_df" } is_hex_anonymous_volume() { log "enter" "is_hex_anonymous_volume volume=$1" >&2 local volume="$1" if [[ "$volume" =~ ^[0-9a-f]{64}$ ]]; then log "success" "is_hex_anonymous_volume volume=$volume decision=yes" >&2 return 0 fi log "skip" "is_hex_anonymous_volume volume=$volume decision=no reason=not_64_hex" >&2 return 1 } volume_ref_count() { log "enter" "volume_ref_count volume=$1" >&2 local volume="$1" local count count="$(docker ps -a --filter "volume=$volume" --format '{{.ID}}' | awk 'NF{c++} END{print c+0}')" printf '%s\n' "$count" log "success" "volume_ref_count volume=$volume count=$count" >&2 } volume_size() { log "enter" "volume_size volume=$1" >&2 local volume="$1" local path="" local human="unknown" local bytes="0" path="$(docker volume inspect "$volume" --format '{{.Mountpoint}}' 2>/dev/null || true)" if [ -z "$path" ]; then log "skip" "volume_size volume=$volume reason=inspect_mountpoint_empty" >&2 elif ! sudo -n true 2>/dev/null; then log "skip" "volume_size volume=$volume reason=sudo_without_password_unavailable path=$path" >&2 elif sudo test -d "$path"; then human="$(sudo du -sh "$path" 2>/dev/null | awk '{print $1}' || true)" bytes="$(sudo du -sb "$path" 2>/dev/null | awk '{print $1}' || true)" [ -n "$human" ] || human="unknown" [ -n "$bytes" ] || bytes="0" else log "skip" "volume_size volume=$volume reason=path_missing path=$path" >&2 fi printf '%s %s ' "$human" "$bytes" log "success" "volume_size volume=$volume path=$path human=$human bytes=$bytes" >&2 } collect_candidates() { log "enter" "collect_candidates" local volume refs size_line human bytes CANDIDATES=() CANDIDATE_BYTES=0 echo "--- docker volume decisions ---" printf 'decision\tsize\trefs\tvolume\treason\n' while IFS= read -r volume; do [ -n "$volume" ] || continue if ! is_hex_anonymous_volume "$volume"; then printf 'skip\t-\t-\t%s\tnot_64_hex_or_named_business_volume\n' "$volume" continue fi refs="$(volume_ref_count "$volume" | tail -n 1)" size_line="$(volume_size "$volume" | tail -n 1)" human="$(printf '%s' "$size_line" | awk -F '\t' '{print $1}')" bytes="$(printf '%s' "$size_line" | awk -F '\t' '{print $2}')" if [ "$refs" != "0" ]; then printf 'skip\t%s\t%s\t%s\treferenced_by_container\n' "$human" "$refs" "$volume" log "skip" "collect_candidates volume=$volume refs=$refs size=$human reason=referenced" continue fi CANDIDATES+=("$volume") CANDIDATE_BYTES=$((CANDIDATE_BYTES + bytes)) printf 'candidate\t%s\t0\t%s\tunused_64_hex_anonymous_volume\n' "$human" "$volume" log "success" "collect_candidates candidate=$volume size=$human bytes=$bytes" done < <(docker volume ls -q | sort) log "success" "collect_candidates count=${#CANDIDATES[@]} bytes=$CANDIDATE_BYTES" } human_bytes() { log "enter" "human_bytes bytes=$1" >&2 local bytes="$1" awk -v b="$bytes" 'BEGIN { split("B KB MB GB TB", u, " "); i=1; while (b>=1024 && i<5) { b=b/1024; i++ } printf "%.2f%s", b, u[i] }' log "success" "human_bytes bytes=$bytes" >&2 } execute_removal() { log "enter" "execute_removal execute=$EXECUTE count=${#CANDIDATES[@]}" if [ "${#CANDIDATES[@]}" -eq 0 ]; then log "skip" "execute_removal reason=no_candidates" return 0 fi if [ "$EXECUTE" -ne 1 ]; then log "skip" "execute_removal reason=dry_run add_--execute_to_remove" return 0 fi local volume refs for volume in "${CANDIDATES[@]}"; do refs="$(volume_ref_count "$volume" | tail -n 1)" if [ "$refs" != "0" ]; then log "skip" "execute_removal volume=$volume refs=$refs reason=became_referenced" continue fi log "enter" "docker volume rm volume=$volume" docker volume rm "$volume" log "success" "docker volume rm volume=$volume" done log "success" "execute_removal" } main() { log "enter" "main args=$*" parse_args "$@" require_docker if [ "$EXECUTE" -eq 1 ]; then echo "模式: execute" else echo "模式: dry-run" fi print_disk_state print_docker_df collect_candidates echo "--- summary ---" echo "would_remove_count=${#CANDIDATES[@]}" echo "would_free_approx=$(human_bytes "$CANDIDATE_BYTES")" execute_removal if [ "$EXECUTE" -eq 1 ]; then print_disk_state print_docker_df fi log "success" "main" } main "$@" ## DRY RUN SUMMARY 模式: dry-run [2026-05-28 20:53:44+0800] [enter] collect_candidates candidate 41M 0 2c391a486c49f5460a0a50c6f43212c7e82c317fe33a30f135ac074be424f7f2 unused_64_hex_anonymous_volume [2026-05-28 20:53:44+0800] [success] collect_candidates candidate=2c391a486c49f5460a0a50c6f43212c7e82c317fe33a30f135ac074be424f7f2 size=41M bytes=47645635 candidate 51M 0 aae8f1481c178b4328236d5c709e71ae1f395103ddcb7212501f31aa38abef61 unused_64_hex_anonymous_volume [2026-05-28 20:53:45+0800] [success] collect_candidates candidate=aae8f1481c178b4328236d5c709e71ae1f395103ddcb7212501f31aa38abef61 size=51M bytes=53253290 [2026-05-28 20:53:45+0800] [skip] is_hex_anonymous_volume volume=coolify-db decision=no reason=not_64_hex skip - - coolify-db not_64_hex_or_named_business_volume [2026-05-28 20:53:45+0800] [skip] is_hex_anonymous_volume volume=coolify-redis decision=no reason=not_64_hex skip - - coolify-redis not_64_hex_or_named_business_volume [2026-05-28 20:53:45+0800] [skip] is_hex_anonymous_volume volume=deploy_postgres_data decision=no reason=not_64_hex skip - - deploy_postgres_data not_64_hex_or_named_business_volume [2026-05-28 20:53:45+0800] [skip] is_hex_anonymous_volume volume=deploy_redis_data decision=no reason=not_64_hex skip - - deploy_redis_data not_64_hex_or_named_business_volume [2026-05-28 20:53:45+0800] [success] collect_candidates count=2 bytes=100898925 would_remove_count=2 would_free_approx=96.22MB [2026-05-28 20:53:45+0800] [skip] execute_removal reason=dry_run add_--execute_to_remove ## CANDIDATE INSPECT READONLY ### 2c391a486c49f5460a0a50c6f43212c7e82c317fe33a30f135ac074be424f7f2 name=2c391a486c49f5460a0a50c6f43212c7e82c317fe33a30f135ac074be424f7f2 driver=local mount=/var/lib/docker/volumes/2c391a486c49f5460a0a50c6f43212c7e82c317fe33a30f135ac074be424f7f2/_data labels={"com.docker.volume.anonymous":""} scope=local ### aae8f1481c178b4328236d5c709e71ae1f395103ddcb7212501f31aa38abef61 name=aae8f1481c178b4328236d5c709e71ae1f395103ddcb7212501f31aa38abef61 driver=local mount=/var/lib/docker/volumes/aae8f1481c178b4328236d5c709e71ae1f395103ddcb7212501f31aa38abef61/_data labels={"com.docker.volume.anonymous":""} scope=local ## NAMED BUSINESS VOLUMES READONLY name=coolify-db mount=/var/lib/docker/volumes/coolify-db/_data labels={"com.docker.compose.config-hash":"e0f88caa463f1cc7838968f31d2c58c09254eb86f9d6731164e7dae788334481","com.docker.compose.project":"source","com.docker.compose.version":"2.38.2","com.docker.compose.volume":"coolify-db"} ref=postgres Up 2 months (healthy) name=coolify-redis mount=/var/lib/docker/volumes/coolify-redis/_data labels={"com.docker.compose.config-hash":"798e9a2a07783f6f7e5897507b48bbeddd544c948f3662ff6562d10a3b29bce8","com.docker.compose.project":"source","com.docker.compose.version":"2.38.2","com.docker.compose.volume":"coolify-redis"} name=deploy_postgres_data mount=/var/lib/docker/volumes/deploy_postgres_data/_data labels={"com.docker.compose.project":"deploy","com.docker.compose.version":"2.24.1","com.docker.compose.volume":"postgres_data"} name=deploy_redis_data mount=/var/lib/docker/volumes/deploy_redis_data/_data labels={"com.docker.compose.project":"deploy","com.docker.compose.version":"2.24.1","com.docker.compose.volume":"redis_data"}