#!/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"