Ежедневный бэкап событий ClickHouse на домашний сервер

- scripts/clickhouse_backup.sh выгружает вчерашнюю партицию в Parquet.gz, передаёт её по rsync, импортирует и сверяет количество строк
- scripts/clickhouse_import.sh импортирует архив в ClickHouse домашнего сервера и повторный импорт дня не дублирует строки
- на VPS удаляются только партиции старше грейса, и только после успешной сверки
- env/backup.env.example с параметрами переноса, цель make backup
- версия 0.3.4
This commit is contained in:
MpakobecEx 2026-10-16 15:28:25 +05:00
parent 2031133538
commit dbf78aa1d5
7 changed files with 203 additions and 4 deletions

View File

@ -9,7 +9,7 @@ COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.RECIPEPREFIX := > .RECIPEPREFIX := >
.PHONY: help up pull down restart logs ps images check psql ch .PHONY: help up pull down restart logs ps images check psql ch backup
help: help:
> @echo "Контур задаётся через E=test|prod (по умолчанию test):" > @echo "Контур задаётся через E=test|prod (по умолчанию test):"
@ -23,6 +23,7 @@ help:
> @echo " make check E=test проверка контура (скрипт)" > @echo " make check E=test проверка контура (скрипт)"
> @echo " make psql E=test psql в БД контура" > @echo " make psql E=test psql в БД контура"
> @echo " make ch E=test clickhouse-client контура" > @echo " make ch E=test clickhouse-client контура"
> @echo " make backup E=test бэкап вчерашних событий на домашний сервер"
up: up:
> $(COMPOSE) up -d > $(COMPOSE) up -d
@ -53,3 +54,6 @@ psql:
ch: ch:
> $(COMPOSE) exec clickhouse sh -lc 'clickhouse-client --user "$$CLICKHOUSE_USER" --password "$$CLICKHOUSE_PASSWORD" --database analytics' > $(COMPOSE) exec clickhouse sh -lc 'clickhouse-client --user "$$CLICKHOUSE_USER" --password "$$CLICKHOUSE_PASSWORD" --database analytics'
backup:
> ./scripts/clickhouse_backup.sh $(E)

View File

@ -73,7 +73,7 @@ pytest
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.3`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.4`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.3.3" __version__ = "0.3.4"

28
env/backup.env.example vendored Normal file
View File

@ -0,0 +1,28 @@
# Параметры ежедневного бэкапа событий ClickHouse с VPS на домашний сервер.
# Скопируйте в env/backup.env (env/*.env в git не попадает) и заполните.
#
# Используется скриптами:
# scripts/clickhouse_backup.sh (запускается на VPS по cron, см. DEPLOY.md)
# scripts/clickhouse_import.sh (лежит на домашнем сервере)
# Куда передавать архивы: пользователь@хост и каталог на домашнем сервере.
BACKUP_REMOTE=user@home.example
BACKUP_DIR=/srv/dispelevilbot/events
# Приватный ключ для ssh/rsync (без пароля; на домашнем сервере — соответствующий public key).
BACKUP_SSH_KEY=/root/.ssh/dispelevilbot_backup
# Сколько дней сырых событий оставлять на VPS (остальное удаляется после успешной сверки).
BACKUP_GRACE_DAYS=7
# Таймзона расчёта «вчерашнего» дня.
BACKUP_TZ=Europe/Moscow
# Как запускать импорт на домашнем сервере (путь к scripts/clickhouse_import.sh).
REMOTE_IMPORT_SCRIPT=./clickhouse_import.sh
# ClickHouse домашнего сервера. Пароль лучше без кавычек и пробелов — он передаётся по ssh.
REMOTE_CLICKHOUSE_HOST=localhost
REMOTE_CLICKHOUSE_PORT=9000
REMOTE_CLICKHOUSE_USER=default
REMOTE_CLICKHOUSE_PASSWORD=

View File

@ -0,0 +1,109 @@
#!/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} дней сырых событий"

View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Импорт дневного архива событий в ClickHouse домашнего сервера.
#
# Запускается на домашнем сервере (обычно по ssh из scripts/clickhouse_backup.sh).
# Рядом должны лежать этот скрипт и канонический DDL кликхаус-схемы
# (clickhouse/init/001_events.sql из репозитория).
#
# Использование: ./clickhouse_import.sh 2026-01-01
# В stdout печатается количество строк загруженного дня (для сверки), диагностика — в stderr.
#
# Настройки (окружение):
# BACKUP_DIR каталог, куда складываются архивы events_YYYY-MM-DD.parquet.gz
# SCHEMA_FILE путь к DDL схемы (по умолчанию ./001_events.sql рядом со скриптом)
# CLICKHOUSE_CLIENT бинарь клиента (по умолчанию clickhouse-client)
# CLICKHOUSE_HOST/PORT/USER/PASSWORD доступ к ClickHouse домашнего сервера
set -euo pipefail
DAY="${1:?укажите дату в формате YYYY-MM-DD}"
PARTITION="${DAY//-/}"
ARCHIVE_DIR="${BACKUP_DIR:-.}"
ARCHIVE="${ARCHIVE_DIR}/events_${DAY}.parquet.gz"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCHEMA_FILE="${SCHEMA_FILE:-${SCRIPT_DIR}/001_events.sql}"
CLIENT="${CLICKHOUSE_CLIENT:-clickhouse-client}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2; }
ch() {
"$CLIENT" \
--host "${CLICKHOUSE_HOST:-localhost}" \
--port "${CLICKHOUSE_PORT:-9000}" \
--user "${CLICKHOUSE_USER:-default}" \
--password "${CLICKHOUSE_PASSWORD:-}" "$@"
}
[ -f "$ARCHIVE" ] || {
log "нет файла архива ${ARCHIVE}"
exit 1
}
[ -f "$SCHEMA_FILE" ] || {
log "нет файла схемы ${SCHEMA_FILE} (скопируйте clickhouse/init/001_events.sql на домашний сервер)"
exit 1
}
log "Применяю схему из ${SCHEMA_FILE}"
ch --multiquery <"$SCHEMA_FILE"
# Повторный импорт того же дня не должен дублировать строки.
log "Очищаю партицию ${PARTITION} перед импортом"
ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${PARTITION}'" 2>/dev/null || true
log "Импортирую ${ARCHIVE} в analytics.events"
gzip -dc "$ARCHIVE" | ch --query "INSERT INTO analytics.events FORMAT Parquet"
log "Импорт завершён"
ch --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate('${DAY}')"

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set(): def test_version_is_set():
assert __version__ assert __version__
assert __version__ == "0.3.3" assert __version__ == "0.3.4"