gendesign/ops/backup-couchdb.sh
bot-backend 0111b32cb2
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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
feat(ops): у волта Obsidian появился автоматический бэкап
Site Finder закрывает backup.sh, МЕРУ — backup-tradein-db.sh, Forgejo —
backup-forgejo.sh. CouchDB не закрывал никто: автоматического бэкапа волта не
существовало вообще, единственной копией был ручной месячный прогон на машине
владельца. При этом в волте вся накопленная база знаний проекта — решения,
разборы инцидентов, ограничения, — и восстановить её из кода нельзя.

Контейнер на время снимка останавливается. Файлы CouchDB пишутся append-only,
но копия работающего узла может застать запись между обновлением данных и
обновлением заголовка базы, а .shards/ и _dbs.couch — рассогласованными между
собой; документация прямо требует снимать file-level копию с остановленного
узла. Данных около 85 МБ, простой измерен и составляет примерно минуту, окно
ночное, а LiveSync к обрывам связи устойчив по своей природе.

Оставить волт лежать — отказ хуже отсутствия бэкапа, поэтому старт висит на
trap EXIT и отрабатывает при любом выходе, включая ошибку tar и нехватку
места. После подъёма проверяется, что /_up отвечает, иначе notify().

S3 обязателен, как в backup-forgejo.sh и в backup.sh после #3085: без ключей
скрипт падает ДО остановки контейнера. Ронять волт ради копии, которой некуда
уехать, незачем.

Отдельный ключ для волта не требуется. Если COUCHDB_S3_* не заданы, берутся
S3_* из конфига основного бэкапа — того же ключа, которым уже выгружается
Site Finder, но под своим префиксом couchdb/. Требовать узкий ключ как условие
запуска значило бы оставить волт без бэкапа до того дня, когда до ключа дойдут
руки; завести его никто не мешает, файл-образец для этого есть.

Фолбэк читается в ПОДОБОЛОЧКЕ, наружу выносятся ровно четыре значения. Прямой
source был бы тихой диверсией: конфиг основного бэкапа задаёт свои
LOCAL_BACKUP_DIR, KEEP и SENTINEL_FILE, они затёрли бы вычисленные для волта.
Архивы легли бы в чужой каталог, а write_sentinel обновил бы сентинел
ОСНОВНОГО бэкапа — его сторож свежести зеленел бы по чужому прогону, и
пропущенный дамп базы перестал бы замечаться.

Владелец архивов выравнивается внутри контейнера: читать том надо от root,
поэтому наружу файлы выходили root:root, в отличие от соседних серий. Ротацию
это не ломало, но ставило каталог в зависимость от отсутствия sticky-бита.

ops/*.cron добавлен в paths deploy-infra.yml: эта строка была только в
deploy.yml, и после разъезда хостов правка crontab-beget.cron доезжала бы
лишь до продового хоста — то есть не туда, где живёт волт.

Проверено на боевом Beget, дважды подряд:
- без S3-настроек — код возврата 1, контейнер не тронут (Up 12 hours);
- полный прогон — контейнер остановлен, снят снимок 53 219 001 байт (15
  файлов) и конфиг 788 байт (3 файла), контейнер поднят и healthy, оба архива
  выгружены, сентинел записан, весь прогон 103 с;
- изоляция подоболочки прогнана на подставном конфиге: S3-значения
  подхватываются, LOCAL_BACKUP_DIR / SENTINEL_FILE / KEEP не затираются;
- объекты в бакете подтверждены НЕЗАВИСИМЫМ читающим ключом:
  couchdb/couchdb-data_*.tar.gz 50.8 MiB и couchdb/couchdb-config_*.tar.gz.

Refs #3057, #3085
2026-08-25 08:37:02 +03:00

249 lines
15 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
# 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"