#!/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 } 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})" 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} дней сырых событий"