124 lines
6.3 KiB
Bash
124 lines
6.3 KiB
Bash
#!/usr/bin/env bash
|
||
# Бэкап сырых событий ClickHouse: домашний сервер забирает данные с VPS (pull).
|
||
#
|
||
# Запускается НА ДОМАШНЕМ СЕРВЕРЕ по cron. Входящий SSH на домашний сервер не нужен:
|
||
# скрипт сам ходит на VPS по ключу и запускает там docker compose.
|
||
#
|
||
# Что делает:
|
||
# 1) по ssh выгружает на VPS вчерашнюю партицию analytics.events в Parquet;
|
||
# 2) складывает events_YYYY-MM-DD.parquet.gz в BACKUP_DIR;
|
||
# 3) импортирует архив в локальный ClickHouse (идемпотентно, scripts/clickhouse_import.sh);
|
||
# 4) сверяет количество строк на VPS и локально;
|
||
# 5) только после успешной сверки просит VPS удалить партиции старше BACKUP_GRACE_DAYS.
|
||
#
|
||
# Ошибка на любом шаге оставляет данные на VPS (партиции не удаляются) и возвращает ненулевой код.
|
||
#
|
||
# Использование:
|
||
# ./scripts/clickhouse_backup.sh test # или prod
|
||
# make backup E=test
|
||
#
|
||
# Настройки — env/backup.env (см. env/backup.env.example).
|
||
|
||
set -euo pipefail
|
||
|
||
E="${1:-test}"
|
||
BACKUP_ENV="${BACKUP_ENV:-env/backup.env}"
|
||
|
||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
||
fail() {
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ОШИБКА: $*" >&2
|
||
exit 1
|
||
}
|
||
|
||
[ -f "$BACKUP_ENV" ] || fail "нет файла настроек ${BACKUP_ENV} (скопируйте env/backup.env.example)"
|
||
# shellcheck disable=SC1090
|
||
set -a && . "./${BACKUP_ENV}" && set +a
|
||
|
||
: "${VPS_SSH:?задайте VPS_SSH в env/backup.env}"
|
||
: "${BACKUP_DIR:?задайте BACKUP_DIR в env/backup.env}"
|
||
VPS_PORT="${VPS_PORT:-22}"
|
||
VPS_PROJECT_DIR="${VPS_PROJECT_DIR:?задайте VPS_PROJECT_DIR в env/backup.env}"
|
||
VPS_SSH_KEY="${VPS_SSH_KEY:-$HOME/.ssh/id_ed25519_vps}"
|
||
VPS_COMPOSE_FILE="${VPS_COMPOSE_FILE:-docker-compose.prod.yml}"
|
||
BACKUP_GRACE_DAYS="${BACKUP_GRACE_DAYS:-7}"
|
||
BACKUP_TZ="${BACKUP_TZ:-Europe/Moscow}"
|
||
IMPORT_SCRIPT="${IMPORT_SCRIPT:-$(dirname "$0")/clickhouse_import.sh}"
|
||
SCHEMA_FILE="${SCHEMA_FILE:-$(dirname "$0")/../clickhouse/init/001_events.sql}"
|
||
|
||
SSH=(ssh -i "$VPS_SSH_KEY" -p "$VPS_PORT" -o BatchMode=yes -o ServerAliveInterval=30 "$VPS_SSH")
|
||
|
||
# Запустить команду на VPS. Пароль ClickHouse читается из окружения контейнера,
|
||
# поэтому клиент вызывается через `sh -c` уже внутри контейнера.
|
||
vps_ch() {
|
||
"${SSH[@]}" bash -s "$VPS_PROJECT_DIR" "$E" "$VPS_COMPOSE_FILE" "$@" <<'REMOTE'
|
||
set -euo pipefail
|
||
project_dir="$1"; contour="$2"; compose_file="$3"; shift 3
|
||
cd "$project_dir"
|
||
exec docker compose -p "$contour" --env-file "env/$contour.env" -f "$compose_file" exec -T clickhouse \
|
||
sh -c 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" "$@"' sh "$@"
|
||
REMOTE
|
||
}
|
||
|
||
# Какие сервисы контура запущены на VPS (пусто, если проекта/контура нет).
|
||
vps_running_services() {
|
||
"${SSH[@]}" bash -s "$VPS_PROJECT_DIR" "$E" "$VPS_COMPOSE_FILE" <<'REMOTE'
|
||
set -uo pipefail
|
||
if [ ! -d "$1" ]; then
|
||
echo "каталог проекта $1 на VPS не найден" >&2
|
||
exit 0
|
||
fi
|
||
cd "$1" || exit 0
|
||
docker compose -p "$2" --env-file "env/$2.env" -f "$3" ps --services --status running 2>/dev/null || true
|
||
REMOTE
|
||
}
|
||
|
||
DAY="$(TZ="$BACKUP_TZ" date -d 'yesterday' '+%Y-%m-%d')"
|
||
PARTITION="${DAY//-/}"
|
||
GRACE_CUTOFF="$(TZ="$BACKUP_TZ" date -d "${BACKUP_GRACE_DAYS} days ago" '+%Y%m%d')"
|
||
|
||
log "Бэкап событий контура ${E} за ${DAY} (грейс ${BACKUP_GRACE_DAYS} дней, порог ${GRACE_CUTOFF})"
|
||
|
||
# Бот может быть не запущен (или ещё не развёрнут) — это не ошибка, просто пропускаем прогон.
|
||
if ! RUNNING="$(vps_running_services)"; then
|
||
fail "не удалось получить состояние контура ${E} на VPS (проверьте ssh и VPS_SSH)"
|
||
fi
|
||
if ! grep -qx 'clickhouse' <<<"$RUNNING"; then
|
||
log "Контур ${E} на VPS не запущен — бэкап пропущен, на VPS ничего не меняем"
|
||
exit 0
|
||
fi
|
||
|
||
mkdir -p "$BACKUP_DIR"
|
||
ARCHIVE="${BACKUP_DIR}/events_${DAY}.parquet.gz"
|
||
|
||
ROWS_VPS="$(vps_ch --param_day="$DAY" --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate({day:String})" | tr -d '[:space:]')"
|
||
log "На VPS строк за ${DAY}: ${ROWS_VPS}"
|
||
if [ "$ROWS_VPS" = "0" ]; then
|
||
log "ВНИМАНИЕ: за ${DAY} событий нет — партиция пустая, продолжаю (сверка 0 = 0)"
|
||
fi
|
||
|
||
log "Выгружаю партицию в ${ARCHIVE}"
|
||
vps_ch --param_day="$DAY" --query "SELECT * FROM analytics.events WHERE toDate(event_time) = toDate({day:String}) FORMAT Parquet" |
|
||
gzip -9 >"${ARCHIVE}.part"
|
||
[ -s "${ARCHIVE}.part" ] || fail "не удалось выгрузить архив ${DAY}"
|
||
mv "${ARCHIVE}.part" "$ARCHIVE"
|
||
log "Размер архива: $(du -h "$ARCHIVE" | cut -f1)"
|
||
|
||
log "Импортирую архив в ClickHouse домашнего сервера"
|
||
ROWS_HOME="$(BACKUP_DIR="$BACKUP_DIR" SCHEMA_FILE="$SCHEMA_FILE" "$IMPORT_SCRIPT" "$DAY" | tail -n1 | tr -d '[:space:]')"
|
||
log "На домашнем сервере строк за ${DAY}: ${ROWS_HOME}"
|
||
|
||
[ "$ROWS_VPS" = "$ROWS_HOME" ] || fail "расхождение строк (VPS ${ROWS_VPS}, домашний ${ROWS_HOME}) — партиции на VPS не удаляю"
|
||
|
||
log "Удаляю на VPS партиции старше ${GRACE_CUTOFF}"
|
||
DROPPED="$(vps_ch --query "SELECT DISTINCT partition FROM system.parts WHERE database='analytics' AND table='events' AND active AND partition < '${GRACE_CUTOFF}'")"
|
||
if [ -z "$DROPPED" ]; then
|
||
log "Удалять нечего"
|
||
else
|
||
for partition in $DROPPED; do
|
||
log "DROP PARTITION ID '${partition}'"
|
||
vps_ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${partition}'"
|
||
done
|
||
fi
|
||
|
||
log "Готово: события за ${DAY} в архиве ${ARCHIVE}, на VPS остались агрегаты и последние ${BACKUP_GRACE_DAYS} дней сырых событий"
|