All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Три независимые правки инфраструктурной гигиены прод-VPS, замеренные живьём 2026-08-15 (ssh gendesign, read-only). 1. ops/docker-prune.sh: новая секция снимает зависшие (running, но брошенные) job-контейнеры Forgejo Actions раннера — три штуки Up 4-8 недель на проде, docker container prune их не видит (фильтрует только status=exited). Фильтр по имени — якорь ^FORGEJO-ACTIONS-TASK- (regex, не substring), сервисные контейнеры (forgejo/forgejo-runner*/gendesign-*/tradein-*/couchdb) под него не подпадают + explicit-skip как страховка. Возраст — из docker inspect .State.StartedAt, порог JOB_CONTAINER_MAX_AGE_HOURS (default 24 — CI job столько никогда не идёт). DRY_RUN уже существующий флаг, переиспользован. Идемпотентно на пустом списке (mapfile + `|| true`). 2. ops/journald-gendesign.conf.example: SystemMaxUse=500M для journald. Замер: journalctl --disk-usage без прав группы systemd-journal/adm недосчитывает (показал 174M) — реальный du -sh /var/log/journal = 2.5G, ~80% всех 3.1G /var/log. journald.conf на проде сейчас без лимита вообще. Установка — руками на сервере (systemd-конфиг вне /opt/gendesign, deploy.yml его не синкает): инструкция в шапке файла. 3. Caddyfile: убран мёртвый блок status.gendsgn.ru (указывал на несуществующий uptime-kuma, дёргал ACME раз в 6 часов на staging-эндпоинте). Заодно убрана dangling forward-ссылка "описан для status.gendsgn.ru ниже" в комментарии у meraocenka.ru. grep подтвердил: других ссылок на этот хост в Caddyfile/compose/скриптах не осталось (docker-compose.uptime.yml и README упоминают — вне scope, стек сам по себе не трогается). caddy validate через prod-контейнер (stdin, без записи файлов на диск) — конфиг валиден, как и baseline до правки.
180 lines
11 KiB
Bash
Executable file
180 lines
11 KiB
Bash
Executable file
#!/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
|