feat(ops): у волта Obsidian появился автоматический бэкап #3090

Merged
lekss361 merged 1 commit from feat/3057-couchdb-backup into main 2026-08-25 05:44:17 +00:00
4 changed files with 325 additions and 0 deletions

View file

@ -53,6 +53,12 @@ on:
# Ровно то, что исполняется НА ОСТАЮЩЕМСЯ хосте. Намеренно НЕ включены
# backend/** и frontend/** — их образы туда не едут.
- "ops/*.sh"
# ops/*.cron — эталоны crontab. Деплой их не исполняет, но после
# разъезда хостов (#3057) правка crontab-beget.cron иначе доезжала бы
# только до продового хоста: строка ops/*.cron есть лишь в deploy.yml.
# Одиночная звёздочка не пересекает `/`, поэтому это именно файлы в
# корне ops/, как и ops/*.sh рядом.
- "ops/*.cron"
- "Caddyfile"
- "caddy/**"
- "docker-compose.prod.yml"

249
ops/backup-couchdb.sh Normal file
View file

@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Backup CouchDB (волт Obsidian, LiveSync) в S3.
#
# ЗАЧЕМ. До этого скрипта у волта не было автоматического бэкапа ВООБЩЕ:
# ops/backup.sh закрывает Site Finder, backup-tradein-db.sh — МЕРУ,
# backup-forgejo.sh — Forgejo, а CouchDB не закрывал никто. При этом в волте
# лежит вся накопленная база знаний проекта — решения, разборы инцидентов,
# ограничения, — и восстановить её из кода нельзя. Единственной копией был
# ручной месячный прогон на машине владельца.
#
# ПОЧЕМУ КОНТЕЙНЕР ОСТАНАВЛИВАЕТСЯ. Файлы CouchDB пишутся append-only, но
# снимок работающего узла может застать запись между обновлением данных и
# обновлением заголовка базы, а .shards/ и _dbs.couch — рассогласованными между
# собой. Документация CouchDB прямо говорит: file-level копия делается на
# остановленном узле. Поэтому здесь stop -> tar -> start, а не tar на живом.
# Простой — секунды (данных ~85 МБ), окно ночное, и LiveSync к обрывам связи
# устойчив по своей природе: клиенты досинхронизируются сами.
#
# ГАРАНТИЯ ПОДЪЁМА. Остановленный и не поднятый обратно CouchDB — отказ хуже
# отсутствия бэкапа, поэтому старт висит на trap EXIT: он отработает при любом
# выходе, включая ошибку tar, нехватку места и прерывание с клавиатуры.
# Отдельно проверяется, что контейнер после старта действительно отвечает;
# если нет — notify().
#
# S3 ОБЯЗАТЕЛЕН. Как в backup-forgejo.sh и (после #3085) в backup.sh: без ключей
# скрипт падает, а не делает молча локальную копию. Смысл бэкапа — копия ВНЕ
# машины; локальная копия рядом с оригиналом не переживает потерю хоста.
#
# ЧТО КЛАДЁТСЯ. Два тома целиком: данные (базы, шарды, индексы) и конфиг
# (local.d — админ, порты, CORS). Конфиг без данных бесполезен, данные без
# конфига поднимаются, но требуют повторного bootstrap — держим оба.
#
# ВОССТАНОВЛЕНИЕ. Том обязан быть ПУСТ перед распаковкой: если развернуть
# снимок поверх живого каталога, останутся файлы, которых в снимке уже нет, и
# CouchDB не соберёт шарды. Порядок — остановить контейнер, очистить том,
# распаковать в него архив, повторить для тома конфига, поднять контейнер.
# Проверять восстановимость надо на ОТДЕЛЬНОМ томе, а не на боевом.
#
# Конфиг: /etc/default/gendesign-backup-couchdb
# (см. ops/gendesign-backup-couchdb.default.example)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
# --- config (env-overridable) ---
COUCHDB_CONTAINER="${COUCHDB_CONTAINER:-gendesign-couchdb}"
COUCHDB_DATA_VOLUME="${COUCHDB_DATA_VOLUME:-gendesign-obsidian_couchdb_data}"
COUCHDB_CONFIG_VOLUME="${COUCHDB_CONFIG_VOLUME:-gendesign-obsidian_couchdb_config}"
LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-/opt/gendesign/backups/couchdb}"
KEEP="${KEEP:-7}"
# Порог — про ОБЪЁМ, а не про «нашлись ли файлы»: пустой tar.gz весит около
# сотни байт, и без порога такой «успешный» бэкап неотличим от настоящего.
MIN_DATA_BYTES="${MIN_DATA_BYTES:-1048576}" # 1 МБ: волт заведомо больше
MIN_CONFIG_BYTES="${MIN_CONFIG_BYTES:-256}"
SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
START_TIMEOUT="${START_TIMEOUT:-60}"
COUCHDB_BACKUP_ENV_FILE="${COUCHDB_BACKUP_ENV_FILE:-/etc/default/gendesign-backup-couchdb}"
# shellcheck source=/dev/null
[[ -f "$COUCHDB_BACKUP_ENV_FILE" ]] && source "$COUCHDB_BACKUP_ENV_FILE"
COUCHDB_S3_ENDPOINT="${COUCHDB_S3_ENDPOINT:-}"
COUCHDB_S3_BUCKET="${COUCHDB_S3_BUCKET:-}"
COUCHDB_S3_PREFIX="${COUCHDB_S3_PREFIX:-couchdb/}"
COUCHDB_S3_ACCESS_KEY="${COUCHDB_S3_ACCESS_KEY:-}"
COUCHDB_S3_SECRET_KEY="${COUCHDB_S3_SECRET_KEY:-}"
# Фолбэк на конфиг основного бэкапа. Отдельный ключ для волта — правильнее
# (узкая область, как у forgejo), но требовать его как условие запуска значит
# оставить волт вообще без бэкапа до того дня, когда до ключа дойдут руки.
# Поэтому: заданы COUCHDB_S3_* — берём их; не заданы — берём S3_* оттуда, где
# они уже настроены и проверены. Префикс при этом всё равно свой (couchdb/),
# так что чужие серии в бакете не перемешиваются.
MAIN_BACKUP_ENV_FILE="${MAIN_BACKUP_ENV_FILE:-/etc/default/gendesign-backup}"
if [[ -z "$COUCHDB_S3_ENDPOINT" && -z "$COUCHDB_S3_ACCESS_KEY" && -f "$MAIN_BACKUP_ENV_FILE" ]]; then
# source В ПОДОБОЛОЧКЕ, наружу выносим ровно четыре значения.
# Прямой `source` здесь был бы тихой диверсией: конфиг основного бэкапа
# задаёт свои LOCAL_BACKUP_DIR, KEEP и SENTINEL_FILE, и они затёрли бы уже
# вычисленные выше значения для волта. Архивы легли бы в чужой каталог, а
# write_sentinel в конце обновил бы сентинел ОСНОВНОГО бэкапа — его сторож
# свежести зеленел бы по чужому прогону, то есть пропущенный дамп базы
# перестал бы замечаться вовсе.
eval "$(
# shellcheck source=/dev/null
source "$MAIN_BACKUP_ENV_FILE" >/dev/null 2>&1 || true
printf 'COUCHDB_S3_ENDPOINT=%q\n' "${S3_ENDPOINT:-}"
printf 'COUCHDB_S3_BUCKET=%q\n' "${S3_BUCKET:-}"
printf 'COUCHDB_S3_ACCESS_KEY=%q\n' "${S3_ACCESS_KEY:-}"
printf 'COUCHDB_S3_SECRET_KEY=%q\n' "${S3_SECRET_KEY:-}"
)"
[[ -n "$COUCHDB_S3_ACCESS_KEY" ]] && \
log "S3: отдельный ключ для волта не задан — беру настройки из ${MAIN_BACKUP_ENV_FILE}, префикс ${COUCHDB_S3_PREFIX}"
fi
# --- guard 0: S3 обязателен, и проверяется ДО всякой остановки контейнера ---
# Порядок важен: незачем ронять волт ради бэкапа, которому некуда уехать.
if [[ -z "$COUCHDB_S3_ENDPOINT" || -z "$COUCHDB_S3_BUCKET" || -z "$COUCHDB_S3_ACCESS_KEY" || -z "$COUCHDB_S3_SECRET_KEY" ]]; then
log "ERROR: бэкап CouchDB НЕ НАСТРОЕН." >&2
log "ERROR: не заданы COUCHDB_S3_ENDPOINT / COUCHDB_S3_BUCKET / COUCHDB_S3_ACCESS_KEY / COUCHDB_S3_SECRET_KEY." >&2
log "ERROR: заполни ${COUCHDB_BACKUP_ENV_FILE} (образец — ops/gendesign-backup-couchdb.default.example)" >&2
log "ERROR: либо настрой S3_* в ${MAIN_BACKUP_ENV_FILE:-/etc/default/gendesign-backup} — оттуда берётся фолбэк." >&2
log "ERROR: локальная копия рядом с оригиналом смысла не имеет — не делаю её и контейнер не трогаю." >&2
exit 1
fi
# --- guard 1: контейнер и оба тома существуют ---
if ! docker inspect "$COUCHDB_CONTAINER" >/dev/null 2>&1; then
log "ERROR: контейнер ${COUCHDB_CONTAINER} не найден — переопредели COUCHDB_CONTAINER." >&2
exit 1
fi
for v in "$COUCHDB_DATA_VOLUME" "$COUCHDB_CONFIG_VOLUME"; do
if ! docker volume inspect "$v" >/dev/null 2>&1; then
log "ERROR: том ${v} не найден — переопредели COUCHDB_DATA_VOLUME / COUCHDB_CONFIG_VOLUME." >&2
exit 1
fi
done
mkdir -p "$LOCAL_BACKUP_DIR"
TS="$(date -u +%Y%m%d_%H%M%S)"
data_out="${LOCAL_BACKUP_DIR}/couchdb-data_${TS}.tar.gz"
config_out="${LOCAL_BACKUP_DIR}/couchdb-config_${TS}.tar.gz"
# --- гарантия подъёма: старт висит на EXIT, а не на успешном пути ---
COUCHDB_WAS_STOPPED=0
restart_couchdb() {
local rc=$?
if [[ "$COUCHDB_WAS_STOPPED" == "1" ]]; then
log "Поднимаю ${COUCHDB_CONTAINER} обратно."
if ! docker start "$COUCHDB_CONTAINER" >/dev/null 2>&1; then
log "CRITICAL: не удалось запустить ${COUCHDB_CONTAINER} — волт лежит." >&2
notify "CouchDB backup: контейнер ${COUCHDB_CONTAINER} ОСТАНОВЛЕН и не поднялся. Волт недоступен, нужно вмешательство."
return "$rc"
fi
local up=0
for _ in $(seq 1 "$START_TIMEOUT"); do
if docker exec "$COUCHDB_CONTAINER" curl -fsS http://localhost:5984/_up >/dev/null 2>&1; then
up=1
break
fi
sleep 1
done
if [[ "$up" == "1" ]]; then
log "CouchDB отвечает."
else
log "CRITICAL: ${COUCHDB_CONTAINER} запущен, но /_up молчит ${START_TIMEOUT}с." >&2
notify "CouchDB backup: контейнер поднят, но /_up не отвечает ${START_TIMEOUT}с. Проверь волт."
fi
fi
return "$rc"
}
trap restart_couchdb EXIT
log "Останавливаю ${COUCHDB_CONTAINER} для снятия консистентного снимка."
docker stop "$COUCHDB_CONTAINER" >/dev/null
COUCHDB_WAS_STOPPED=1
# tar идёт в одноразовом контейнере: том примонтирован read-only, писать он
# может только в каталог бэкапов.
#
# Про владельца. Читать файлы тома надо от root (внутри они принадлежат uid
# CouchDB), поэтому контейнер запускается от root — и архив наружу выходит
# root:root, в отличие от остальных серий в этом каталоге. Само по себе это
# ротацию не ломает (удаление зависит от прав КАТАЛОГА, а он наш), но ставит
# каталог в зависимость от того, что никто не включит на нём sticky-бит, и
# ломает ручную работу с файлами из-под непривилегированного пользователя.
# Поэтому chown делаем тут же, внутри контейнера, где root ещё есть.
HOST_UID="$(id -u)"
HOST_GID="$(id -g)"
tar_volume() {
local volume="$1" out="$2" name
name="$(basename "$out")"
docker run --rm \
-v "${volume}":/src:ro \
-v "${LOCAL_BACKUP_DIR}":/out \
alpine:3 \
sh -c "tar czf /out/${name} -C /src . && chown ${HOST_UID}:${HOST_GID} /out/${name}"
}
log "Снимок данных: ${COUCHDB_DATA_VOLUME}"
tar_volume "$COUCHDB_DATA_VOLUME" "$data_out"
log "Снимок конфига: ${COUCHDB_CONFIG_VOLUME}"
tar_volume "$COUCHDB_CONFIG_VOLUME" "$config_out"
# Контейнер поднимается СРАЗУ после копирования, до проверок и выгрузки:
# держать волт лежачим на время верификации архивов и заливки в S3 незачем.
log "Поднимаю ${COUCHDB_CONTAINER} — копирование закончено."
docker start "$COUCHDB_CONTAINER" >/dev/null
COUCHDB_WAS_STOPPED=0
for _ in $(seq 1 "$START_TIMEOUT"); do
docker exec "$COUCHDB_CONTAINER" curl -fsS http://localhost:5984/_up >/dev/null 2>&1 && break
sleep 1
done
# --- проверки целостности ---
check_archive() {
local f="$1" floor="$2" what="$3"
local bytes
bytes=$(stat -c %s "$f" 2>/dev/null || echo 0)
if (( bytes < floor )); then
log "ERROR: ${what} — всего ${bytes} байт (< ${floor}), снимок пустой или битый." >&2
notify "CouchDB backup: ${what}${bytes} байт, ниже порога ${floor}. Бэкап НЕ засчитан."
exit 1
fi
if ! tar -tzf "$f" >/dev/null 2>&1; then
log "ERROR: ${what} не читается как tar.gz." >&2
notify "CouchDB backup: архив ${what} повреждён, tar -tzf не прошёл. Бэкап НЕ засчитан."
exit 1
fi
log "${what} OK: $(basename "$f") (${bytes} байт, файлов: $(tar -tzf "$f" | wc -l))"
}
check_archive "$data_out" "$MIN_DATA_BYTES" "снимок данных"
check_archive "$config_out" "$MIN_CONFIG_BYTES" "снимок конфига"
# --- выгрузка в S3 (обязательна — см. guard 0) ---
for f in "$data_out" "$config_out"; do
key="${COUCHDB_S3_PREFIX%/}/$(basename "$f")"
log "Выгружаю в s3://${COUCHDB_S3_BUCKET}/${key}"
# AWS_CA_BUNDLE — та же поправка, что в backup.sh и backup-forgejo.sh:
# у aws-cli v2 свой вшитый набор корней, и корня Selectel в нём нет.
docker run --rm \
-e AWS_ACCESS_KEY_ID="$COUCHDB_S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$COUCHDB_S3_SECRET_KEY" \
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
-v "$LOCAL_BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$COUCHDB_S3_ENDPOINT" \
s3 cp --no-progress "/backup/$(basename "$f")" "s3://${COUCHDB_S3_BUCKET}/${key}"
done
log "Выгрузка в S3 прошла."
# --- локальная ротация ---
for pattern in "couchdb-data_" "couchdb-config_"; do
# shellcheck disable=SC2012
ls -1t "${LOCAL_BACKUP_DIR}/${pattern}"*.tar.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
done
log "Бэкап CouchDB готов. Локальных копий оставлено: до ${KEEP} на каждый том."
# Сентинел пишется ТОЛЬКО после выгрузки: сторож свежести обязан зеленеть по
# факту копии вне машины, а не по факту того, что скрипт дошёл до конца.
write_sentinel "$SENTINEL_FILE"

View file

@ -24,6 +24,21 @@
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
# живёт здесь, а не в crontab-poincare. До #3057 у CouchDB не было
# автоматического бэкапа вообще — единственной копией был ручной месячный
# прогон на машине владельца.
# Скрипт ОСТАНАВЛИВАЕТ CouchDB на время снятия снимка (секунды, данных ~85 МБ):
# file-level копия работающего узла может оказаться рассогласованной. Время —
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
# чтобы три задачи не дрались за диск и сеть одновременно.
45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
# сутки плюс запас на сдвиг прогона.
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Уборка докера ───────────────────────────────────────────────────────────
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
# тома и растут. На Selectel нужна СВОЯ такая же запись (см. crontab-poincare).

View file

@ -0,0 +1,55 @@
# Образец /etc/default/gendesign-backup-couchdb
#
# Читается ops/backup-couchdb.sh. Файл содержит ключ S3 — держать 0600 и
# владельцем того пользователя, под которым стоит крон-задача.
#
# Установка:
# sudo install -m 0600 -o gendesign -g gendesign \
# ops/gendesign-backup-couchdb.default.example /etc/default/gendesign-backup-couchdb
# sudo -e /etc/default/gendesign-backup-couchdb # подставить реальные ключи
#
# ЭТОТ ФАЙЛ НЕОБЯЗАТЕЛЕН. Если COUCHDB_S3_ENDPOINT и COUCHDB_S3_ACCESS_KEY не
# заданы, скрипт берёт S3_* из /etc/default/gendesign-backup — того же ключа,
# которым уже выгружается основной бэкап, но под своим префиксом couchdb/.
# Заводить файл стоит, когда для волта появится ОТДЕЛЬНЫЙ узкий ключ (как у
# forgejo): своя область доступа лучше общей, просто не ценой отсутствия
# бэкапа вовсе.
#
# Если не настроено ни здесь, ни там — скрипт откажется работать и НЕ станет
# останавливать CouchDB: бэкап без выгрузки за пределы машины смысла не имеет.
# --- S3 (либо здесь, либо фолбэк на /etc/default/gendesign-backup) ----------
COUCHDB_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
COUCHDB_S3_BUCKET=gendsgn-backups
COUCHDB_S3_PREFIX=couchdb/
COUCHDB_S3_ACCESS_KEY=
COUCHDB_S3_SECRET_KEY=
# --- что копируем (значения по умолчанию совпадают с боевыми) ---------------
# Имена берутся из docker-compose.obsidian.yml, проект gendesign-obsidian.
#COUCHDB_CONTAINER=gendesign-couchdb
#COUCHDB_DATA_VOLUME=gendesign-obsidian_couchdb_data
#COUCHDB_CONFIG_VOLUME=gendesign-obsidian_couchdb_config
# --- локальное хранение -----------------------------------------------------
#LOCAL_BACKUP_DIR=/opt/gendesign/backups/couchdb
#KEEP=7
# --- пороги целостности -----------------------------------------------------
# Про объём, а не про наличие файла: пустой архив весит около сотни байт и без
# порога неотличим от настоящего бэкапа. Поднимать, если волт заметно вырос.
#MIN_DATA_BYTES=1048576
#MIN_CONFIG_BYTES=256
# Сколько секунд ждать ответа /_up после подъёма контейнера.
#START_TIMEOUT=60
# --- оповещения -------------------------------------------------------------
# Дублировать сюда ничего не надо: notify() из ops/lib-backup.sh, не найдя
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID в окружении, сам читает
# /etc/default/gendesign-backup. То есть канал общий с остальными бэкапами.
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
#TELEGRAM_BOT_TOKEN=
#TELEGRAM_CHAT_ID=
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
#BACKUP_ENV_FILE=/etc/default/gendesign-backup