From ddb76a137bd22fe5eae021f9931a304aadb0a768 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 16:53:25 +0300 Subject: [PATCH] =?UTF-8?q?chore(ops):=20=D1=87=D0=B8=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D1=8C=20=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D1=87=D0=BA=D0=B8=20docker-=D1=82=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D0=B5=D0=B6=D0=B5=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=83=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Диск был занят на 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 (свободный слот рядом с бэкапами). --- .forgejo/workflows/ci-tradein.yml | 4 +- .forgejo/workflows/ci.yml | 4 +- ops/docker-prune.sh | 102 ++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 ops/docker-prune.sh diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 522cba14..0fdc2732 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -116,7 +116,7 @@ jobs: # бы, а тесты всё равно скипались. run: | set -u - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \ postgis/postgis:16-3.4 @@ -221,7 +221,7 @@ jobs: # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. if: always() working-directory: . - run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1bcf6635..eccedb9e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: # здесь не нужен вовсе, в отличие от tradein-лэйна. run: | set -u - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ postgres:16 @@ -279,7 +279,7 @@ jobs: working-directory: . run: | echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})" - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true echo "### шаг «Снести тестовый Postgres» закончился успешно" frontend-tests: diff --git a/ops/docker-prune.sh b/ops/docker-prune.sh new file mode 100644 index 00000000..31ca32d1 --- /dev/null +++ b/ops/docker-prune.sh @@ -0,0 +1,102 @@ +#!/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 -- 2.45.3