gendesign/ops/backup-couchdb.sh
lekss361 00d434f88d
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 3s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 8s
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m32s
chore(ops): backup-couchdb.sh исполняемый, как остальные бэкапы (#3091)
2026-08-25 05:48:41 +00:00

249 lines
15 KiB
Bash
Executable file
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"