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