gendesign/ops/docker-prune.sh
bot-backend ddb76a137b
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 59s
CI Trade-In / frontend-checks (pull_request) Successful in 2m8s
CI / frontend-tests (pull_request) Successful in 2m14s
CI / openapi-codegen-check (pull_request) Successful in 3m2s
CI Trade-In / backend-tests (pull_request) Successful in 5m27s
CI / backend-tests (pull_request) Successful in 17m38s
chore(ops): чинить корень утечки docker-томов + еженедельная уборка
Диск был занят на 76% (110 из 145 ГБ). Разбор: 201 том-сирота на 12.6 ГБ —
125 анонимных (каталоги данных PostgreSQL от тестовых прогонов CI) и 76
окружений задач Forgejo Actions. Прод-данных среди них нет ни одного.

Корневая причина: ci.yml и ci-tradein.yml поднимают свой postgres и снимают
его через `docker rm -f` БЕЗ `-v`. Контейнер уходит, анонимный том с данными
остаётся сиротой — по одному на каждый прогон CI.

- ci.yml / ci-tradein.yml: `docker rm -f "$CI_PG"` → `docker rm -fv` (4 места).
  В deploy-*.yml тот же вызов применяется к БОЕВЫМ контейнерам — туда -v
  добавлять нельзя, снесло бы тома с данными прода. Не тронуто.

- ops/docker-prune.sh — страховка на то, что runner не убрал за собой.
  Удаляет: остановленные контейнеры старше 24ч, висячие образы старше 7 суток
  и тома-сироты ТОЛЬКО двух известных форм (64-символьный hex и
  FORGEJO-ACTIONS-TASK-*). Именованные тома не трогаются никогда — голый
  `docker volume prune` такой разницы не делает, поэтому здесь не используется.
  Порядок важен: контейнеры → образы → тома, иначе освободившиеся после
  контейнеров тома останутся до следующего запуска (на этом я и споткнулся
  при ручной чистке — после prune осталось ещё 78 сирот).

Проверено на проде: DRY_RUN, затем боевой прогон, затем повторный — no-op.
Тома 220 → 15, все используются, освобождать нечего. Диск 76% → 67%.
Cron поставлен: вс 04:00 UTC (свободный слот рядом с бэкапами).
2026-08-15 16:53:25 +03:00

102 lines
5.8 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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`
# такой разницы не делает, поэтому здесь он намеренно не используется.
#
# 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}"
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
after_pct="$(disk_used_pct)"
log "готово: диск занят ${after_pct}% (было ${before_pct}%)"
# Сигнал в лог, если места всё равно мало — повод посмотреть глазами.
if [[ "$after_pct" -ge 85 ]]; then
log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно"
fi