From dbf78aa1d552be9572f16d0fce48e25fa0980687 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 16 Oct 2026 15:28:25 +0500 Subject: [PATCH] =?UTF-8?q?=D0=95=D0=B6=D0=B5=D0=B4=D0=BD=D0=B5=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20ClickHouse=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D0=BC=D0=B0=D1=88=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/clickhouse_backup.sh выгружает вчерашнюю партицию в Parquet.gz, передаёт её по rsync, импортирует и сверяет количество строк - scripts/clickhouse_import.sh импортирует архив в ClickHouse домашнего сервера и повторный импорт дня не дублирует строки - на VPS удаляются только партиции старше грейса, и только после успешной сверки - env/backup.env.example с параметрами переноса, цель make backup - версия 0.3.4 --- Makefile | 6 +- README.md | 2 +- common/version.py | 2 +- env/backup.env.example | 28 +++++++++ scripts/clickhouse_backup.sh | 109 +++++++++++++++++++++++++++++++++++ scripts/clickhouse_import.sh | 58 +++++++++++++++++++ tests/test_version.py | 2 +- 7 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 env/backup.env.example create mode 100644 scripts/clickhouse_backup.sh create mode 100644 scripts/clickhouse_import.sh diff --git a/Makefile b/Makefile index 8d0d740..b2744cb 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml .DEFAULT_GOAL := help .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: > @echo "Контур задаётся через E=test|prod (по умолчанию test):" @@ -23,6 +23,7 @@ help: > @echo " make check E=test проверка контура (скрипт)" > @echo " make psql E=test psql в БД контура" > @echo " make ch E=test clickhouse-client контура" +> @echo " make backup E=test бэкап вчерашних событий на домашний сервер" up: > $(COMPOSE) up -d @@ -53,3 +54,6 @@ psql: ch: > $(COMPOSE) exec clickhouse sh -lc 'clickhouse-client --user "$$CLICKHOUSE_USER" --password "$$CLICKHOUSE_PASSWORD" --database analytics' + +backup: +> ./scripts/clickhouse_backup.sh $(E) diff --git a/README.md b/README.md index f4b8c6f..3434a76 100644 --- a/README.md +++ b/README.md @@ -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`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/common/version.py b/common/version.py index 119ace1..7d94984 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/env/backup.env.example b/env/backup.env.example new file mode 100644 index 0000000..b89df68 --- /dev/null +++ b/env/backup.env.example @@ -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= diff --git a/scripts/clickhouse_backup.sh b/scripts/clickhouse_backup.sh new file mode 100644 index 0000000..6a4c621 --- /dev/null +++ b/scripts/clickhouse_backup.sh @@ -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} дней сырых событий" diff --git a/scripts/clickhouse_import.sh b/scripts/clickhouse_import.sh new file mode 100644 index 0000000..3bb29af --- /dev/null +++ b/scripts/clickhouse_import.sh @@ -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}')" diff --git a/tests/test_version.py b/tests/test_version.py index 4fa3475..617387e 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.3.3" + assert __version__ == "0.3.4"