#!/usr/bin/env bash # Ежедневный бэкап сырых событий ClickHouse с VPS на домашний сервер. # # Что делает: # 1) выгружает партицию «вчерашнего» дня из analytics.events в Parquet.gz; # 2) передаёт файл на домашний сервер (rsync) и импортирует его в тамошний ClickHouse; # 3) сверяет количество строк на VPS и на домашнем сервере; # 4) только после успешной сверки удаляет на VPS партиции старше BACKUP_GRACE_DAYS. # # Ошибка на любом шаге оставляет данные на VPS и возвращает ненулевой код. # # Запуск (на VPS, из каталога проекта): # ./scripts/clickhouse_backup.sh test # или prod # make backup E=test # # Настройки (env/<контур>.env + env/backup.env, см. env/backup.env.example): # CLICKHOUSE_* доступ к ClickHouse контура # BACKUP_REMOTE user@host домашнего сервера # BACKUP_DIR каталог архива на домашнем сервере # BACKUP_SSH_KEY приватный ключ для ssh/rsync # BACKUP_GRACE_DAYS сколько дней сырых событий оставлять на VPS (по умолчанию 7) # BACKUP_TZ таймзона расчёта «вчера» (по умолчанию Europe/Moscow) set -euo pipefail E="${1:-test}" ENV_FILE="env/${E}.env" BACKUP_ENV="env/backup.env" COMPOSE="docker compose -p ${E} --env-file ${ENV_FILE} -f docker-compose.prod.yml" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } fail() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ОШИБКА: $*" >&2 exit 1 } [ -f "$ENV_FILE" ] || fail "нет файла окружения ${ENV_FILE}" # shellcheck disable=SC1090 set -a && . "./${ENV_FILE}" && set +a if [ -f "$BACKUP_ENV" ]; then # shellcheck disable=SC1090 set -a && . "./${BACKUP_ENV}" && set +a fi : "${BACKUP_REMOTE:?задайте BACKUP_REMOTE в env/backup.env}" : "${BACKUP_DIR:?задайте BACKUP_DIR в env/backup.env}" BACKUP_SSH_KEY="${BACKUP_SSH_KEY:-$HOME/.ssh/id_rsa}" BACKUP_GRACE_DAYS="${BACKUP_GRACE_DAYS:-7}" BACKUP_TZ="${BACKUP_TZ:-Europe/Moscow}" REMOTE_IMPORT_SCRIPT="${REMOTE_IMPORT_SCRIPT:-./clickhouse_import.sh}" REMOTE_CLICKHOUSE_HOST="${REMOTE_CLICKHOUSE_HOST:-localhost}" REMOTE_CLICKHOUSE_PORT="${REMOTE_CLICKHOUSE_PORT:-9000}" REMOTE_CLICKHOUSE_USER="${REMOTE_CLICKHOUSE_USER:-default}" REMOTE_CLICKHOUSE_PASSWORD="${REMOTE_CLICKHOUSE_PASSWORD:-}" SSH=(ssh -i "$BACKUP_SSH_KEY" -o BatchMode=yes "$BACKUP_REMOTE") ch() { $COMPOSE exec -T clickhouse clickhouse-client \ --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" "$@" } 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')" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT log "Контур ${E}: бэкап событий за ${DAY} (грейс ${BACKUP_GRACE_DAYS} дней, порог ${GRACE_CUTOFF})" if ! $COMPOSE ps --services --status running 2>/dev/null | grep -qx clickhouse; then fail "контейнер clickhouse контура ${E} не запущен" fi ROWS_LOCAL="$(ch --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate('${DAY}')" | tr -d '[:space:]')" log "На VPS строк за ${DAY}: ${ROWS_LOCAL}" if [ "$ROWS_LOCAL" = "0" ]; then log "ВНИМАНИЕ: за ${DAY} событий нет — партиция пустая, продолжаю (сверка 0 = 0)" fi ARCHIVE="${TMP_DIR}/events_${DAY}.parquet.gz" log "Выгружаю партицию в ${ARCHIVE}" ch --query "SELECT * FROM analytics.events WHERE toDate(event_time) = toDate('${DAY}') FORMAT Parquet" | gzip -9 >"$ARCHIVE" [ -s "$ARCHIVE" ] || fail "не удалось выгрузить архив ${DAY}" log "Размер архива: $(du -h "$ARCHIVE" | cut -f1)" log "Передаю архив на ${BACKUP_REMOTE}:${BACKUP_DIR}" rsync -az -e "ssh -i ${BACKUP_SSH_KEY} -o BatchMode=yes" "$ARCHIVE" "${BACKUP_REMOTE}:${BACKUP_DIR}/" log "Импортирую архив в ClickHouse домашнего сервера" ROWS_REMOTE="$("${SSH[@]}" \ "BACKUP_DIR='${BACKUP_DIR}' CLICKHOUSE_HOST='${REMOTE_CLICKHOUSE_HOST}' CLICKHOUSE_PORT='${REMOTE_CLICKHOUSE_PORT}' \ CLICKHOUSE_USER='${REMOTE_CLICKHOUSE_USER}' CLICKHOUSE_PASSWORD='${REMOTE_CLICKHOUSE_PASSWORD}' \ '${REMOTE_IMPORT_SCRIPT}' '${DAY}'" | tail -n1 | tr -d '[:space:]')" log "На домашнем сервере строк за ${DAY}: ${ROWS_REMOTE}" [ "$ROWS_LOCAL" = "$ROWS_REMOTE" ] || fail "расхождение строк (VPS ${ROWS_LOCAL}, домашний ${ROWS_REMOTE}) — партиции на VPS не удаляю" log "Удаляю на VPS партиции старше ${GRACE_CUTOFF}" OLDER="$(ch --query "SELECT DISTINCT partition FROM system.parts WHERE database='analytics' AND table='events' AND active AND partition < '${GRACE_CUTOFF}'")" if [ -z "$OLDER" ]; then log "Удалять нечего" else for partition in $OLDER; do log "DROP PARTITION ID '${partition}'" ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${partition}'" done fi log "Готово: события за ${DAY} в архиве, на VPS остались агрегаты и последние ${BACKUP_GRACE_DAYS} дней сырых событий"