#!/usr/bin/env bash # Периодическая уборка docker-мусора на прод-VM. # # ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201 # том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых # прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не # было ни одного. # # Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml # снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил, # а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он # подбирает то, что runner не убрал за собой, и то, что накопилось раньше. # # ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно): # - остановленные контейнеры старше 24ч; # - висячие (dangling) образы старше 7 суток; # - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и # FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data, # tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются # НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune` # такой разницы не делает, поэтому здесь он намеренно не используется; # - зависшие (running, но фактически брошенные) job-контейнеры раннера Forgejo # Actions старше JOB_CONTAINER_MAX_AGE_HOURS. 2026-08-15: живьём на проде # обнаружены три штуки в статусе Up 4-8 недель (раннер не убрал контейнер # после прерванного/упавшего workflow — task killed, рестарт раннера в # процессе job'а и т.п.). CI job физически не идёт сутками, поэтому что # угодно с этим именем старше порога — гарантированный мусор, а не активная # задача. `docker container prune` их не видит: тот фильтрует только # status=exited, а эти контейнеры формально Up. # # Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает). # Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы): # 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1 # # Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup # tradein, 05:00+ backfill'ы. # # Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI) # копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая. set -euo pipefail DRY_RUN="${DRY_RUN:-0}" STOPPED_AGE="${STOPPED_AGE:-24h}" IMAGE_AGE="${IMAGE_AGE:-168h}" # Job CI никогда не идёт сутками — что угодно с именем job-контейнера раннера # старше этого порога снимается безусловно (см. секцию 4 ниже). JOB_CONTAINER_MAX_AGE_HOURS="${JOB_CONTAINER_MAX_AGE_HOURS:-24}" log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; } disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; } before_pct="$(disk_used_pct)" log "старт: диск занят ${before_pct}%" if [[ "$DRY_RUN" == "1" ]]; then log "DRY_RUN=1 — только показываю" fi # ── 1. остановленные контейнеры ─────────────────────────────────────────────── if [[ "$DRY_RUN" == "1" ]]; then # `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»), # поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка. log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \ $(docker container ls -aq --filter "status=exited" | wc -l)" else log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \ 2>&1 | tail -1)" fi # ── 2. висячие образы ───────────────────────────────────────────────────────── if [[ "$DRY_RUN" == "1" ]]; then log "висячих образов: $(docker image ls -qf dangling=true | wc -l)" else log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)" fi # ── 3. тома-сироты известных форм ───────────────────────────────────────────── # Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой # отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся. mapfile -t candidates < <( docker volume ls -qf dangling=true \ | grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true ) skipped="$(docker volume ls -qf dangling=true \ | grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)" if [[ -n "$skipped" ]]; then log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')" fi if [[ "${#candidates[@]}" -eq 0 ]]; then log "томов-сирот известных форм нет" elif [[ "$DRY_RUN" == "1" ]]; then log "томов к удалению: ${#candidates[@]}" else removed=0 for v in "${candidates[@]}"; do if docker volume rm "$v" >/dev/null 2>&1; then removed=$((removed + 1)) fi done log "томов удалено: ${removed} из ${#candidates[@]}" fi # ── 4. зависшие job-контейнеры раннера Forgejo Actions ─────────────────────── # Фильтр по имени — ЯКОРЬ на начало (`^FORGEJO-ACTIONS-TASK-`), не "содержит # подстроку": `docker ps --filter name=` матчит как regex, поэтому `^...` # гарантирует точный префикс, а не случайное совпадение где-то в середине # имени сервисного контейнера. Долгоживущие сервисные контейнеры (forgejo, # forgejo-runner*, gendesign-*, tradein-*, couchdb) под этот префикс не # подпадают вообще — но ниже всё равно есть explicit-skip как страховка на # случай будущего переименования, а не молчаливая надежда на то, что фильтр # никогда не ошибётся. # # Возраст — из `docker inspect .State.StartedAt` (RFC3339), НЕ из текстового # "Up 4 weeks" в выводе `docker ps`: тот округляет к ближайшей крупной единице # и не пригоден для сравнения с порогом в часах. mapfile -t job_ids < <(docker ps -aq --filter "name=^FORGEJO-ACTIONS-TASK-" || true) job_removed=0 job_candidates=0 if [[ "${#job_ids[@]}" -eq 0 ]]; then log "зависших job-контейнеров нет" else for id in "${job_ids[@]}"; do name="$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true)" [[ -z "$name" ]] && continue case "$name" in forgejo | forgejo-runner* | gendesign-* | tradein-* | couchdb) log "job-контейнеры: ПРОПУЩЕН сервисный '${name}' (не должен был пройти фильтр имени)" continue ;; esac started_at="$(docker inspect --format '{{.State.StartedAt}}' "$id" 2>/dev/null || true)" [[ -z "$started_at" || "$started_at" == "0001-01-01T00:00:00Z" ]] && continue started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || echo 0)" [[ "$started_epoch" -eq 0 ]] && continue now_epoch="$(date -u +%s)" age_hours=$(((now_epoch - started_epoch) / 3600)) [[ "$age_hours" -lt "$JOB_CONTAINER_MAX_AGE_HOURS" ]] && continue job_candidates=$((job_candidates + 1)) size="$(docker ps -a --filter "id=${id}" --size --format '{{.Size}}' 2>/dev/null \ | awk '{print $1}' || true)" if [[ "$DRY_RUN" == "1" ]]; then log "job-контейнеры: [dry-run] снял бы '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?})" continue fi if docker rm -f "$id" >/dev/null 2>&1; then job_removed=$((job_removed + 1)) log "job-контейнеры: снят '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?} освобождён)" else log "job-контейнеры: НЕ удалось снять '${name}' (id ${id:0:12})" fi done if [[ "$job_candidates" -eq 0 ]]; then log "job-контейнеры: ${#job_ids[@]} шт., ни один не старше порога ${JOB_CONTAINER_MAX_AGE_HOURS}ч" elif [[ "$DRY_RUN" == "1" ]]; then log "job-контейнеры: к снятию ${job_candidates} из ${#job_ids[@]}" else log "job-контейнеры: снято ${job_removed} из ${job_candidates} кандидатов (порог ${JOB_CONTAINER_MAX_AGE_HOURS}ч)" fi fi after_pct="$(disk_used_pct)" log "готово: диск занят ${after_pct}% (было ${before_pct}%)" # Сигнал в лог, если места всё равно мало — повод посмотреть глазами. if [[ "$after_pct" -ge 85 ]]; then log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно" fi