diff --git a/Caddyfile b/Caddyfile index 8624f14f..d00dd0e5 100644 --- a/Caddyfile +++ b/Caddyfile @@ -222,8 +222,7 @@ www.gendsgn.ru { # резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит # ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же # Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются — -# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что -# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО +# автоматический HTTPS в Caddy изолирован per-hostname. Повторные неудачные попытки ДО # появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed # validations/hostname/hour) — не критично, просто подождать; `docker volume # rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval). @@ -370,25 +369,6 @@ errors.gendsgn.ru { } } -# Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1). -# DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека). -# Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared -# gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на -# этом домене, main-сайт не страдает (как obsidian.gendsgn.ru). -# -# ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75). -# Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma — -# НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup. -status.gendsgn.ru { - encode zstd gzip - - reverse_proxy uptime-kuma:3001 - - log { - output file /var/log/caddy/status.gendsgn.ru.log - } -} - # Forgejo — self-hosted git (migration 2026-05-16). # DNS: A-record git.gendsgn.ru → IP VPS. # Forgejo container из forgejo-migration/docker-compose.yml на shared diff --git a/ops/docker-prune.sh b/ops/docker-prune.sh index 31ca32d1..34a0d30d 100755 --- a/ops/docker-prune.sh +++ b/ops/docker-prune.sh @@ -18,7 +18,15 @@ # 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'ы): @@ -35,6 +43,9 @@ 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')" "$*"; } @@ -93,6 +104,73 @@ else 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}%)" diff --git a/ops/journald-gendesign.conf.example b/ops/journald-gendesign.conf.example new file mode 100644 index 00000000..414f8236 --- /dev/null +++ b/ops/journald-gendesign.conf.example @@ -0,0 +1,36 @@ +# systemd-journald drop-in — cap persistent journal disk usage on prod VPS. +# +# ЗАМЕР 2026-08-15 (ssh gendesign, read-only): `/var/log` занимал 3.1G. Наивная +# первая проверка `journalctl --disk-usage` показала только 174M и навела на +# ложный след «основной объём — не journald». На деле `journalctl --disk-usage`, +# запущенный НЕ из группы systemd-journal/adm, недосчитывает — он не может +# полноценно перечислить архивные *.journal файлы без прав на чтение. Прямой +# `du -sh /var/log/journal` дал 2.5G — это ~80% всего `/var/log`, ровно 100 +# файлов по ~48M в /var/log/journal//. Второй по размеру вклад — +# традиционный rsyslog (syslog/syslog.1/auth.log/kern.log/ufw.log/dmesg/btmp, +# ~0.6G) — те уже ротируются через logrotate (видны .1/.4.gz копии), отдельного +# вмешательства не требуют и вне scope этого файла. +# +# В /etc/systemd/journald.conf на проде НЕТ SystemMaxUse (все ключи закомменчены +# дефолтами) — без явного лимита journald довольствуется default-правилом +# «до 10% файловой системы», на VPS с диском ~145G это фактически безлимит. +# +# УСТАНОВКА НА СЕРВЕРЕ (руками, deploy.yml этот файл НЕ подхватывает — +# systemd-конфиги вне /opt/gendesign, деплой синкает только сам репозиторий): +# sudo mkdir -p /etc/systemd/journald.conf.d +# sudo cp /opt/gendesign/ops/journald-gendesign.conf.example \ +# /etc/systemd/journald.conf.d/gendesign-max-use.conf +# sudo systemctl restart systemd-journald +# +# `restart systemd-journald` применяет лимит немедленно — journald сам +# провакуумит существующие архивные файлы вниз до SystemMaxUse (ожидаемый +# эффект: /var/log/journal схлопнется примерно с 2.5G до ~500M). Это НЕ +# `docker volume rm` / `caddy reload` — под общий deploy-guard не подпадает, +# но всё равно на живом проде: делает user сам после ревью PR. +# +# Значение 500M — консервативный запас на 4 vCPU/4-16G VPS с активным CI +# (docker/forgejo-runner логи в journald тоже льются). При необходимости +# больше retention для дебага — поднять SystemMaxUse, не удалять файл. + +[Journal] +SystemMaxUse=500M