From 5646ecd6dd7ce56428c33a09b9d2309514fc7268 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 1 Sep 2026 11:00:36 +0700 Subject: [PATCH] fix cron after first run + add hc --- .env.example | 4 ++ Dockerfile | 1 + README.md | 28 +++++++++++++ docker-compose.yml | 2 + scripts/backup.sh | 88 +++++++++++++++++++++++++++++++++------ scripts/deleteold.sh | 16 ++++--- scripts/entrypoint.sh | 16 +++++-- scripts/minio_uploader.sh | 24 ++++++++--- 8 files changed, 151 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index dd1919c..6bf9e6a 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,7 @@ MINIO_ACCOUNT_ID=account MINIO_APPLICATION_KEY=key MINIO_ENDPOINT=https://s3.domain.ru MINIO_LOCATION=ru-nsk + +# healthchecks.io (self-hosted instance is fine), leave empty to disable +HEALTHCHECK_URL=https://hc.domain.ru/ping +HEALTHCHECK_UUID=00000000-0000-0000-0000-000000000000 diff --git a/Dockerfile b/Dockerfile index 01cbfe3..483753b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ LABEL maintainer="Vassiliy Yegorov " RUN apk add --no-cache \ sqlite \ wget \ + curl \ bash \ tzdata \ openssl \ diff --git a/README.md b/README.md index 1d2a7dd..abfa8f9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ services: - MINIO_APPLICATION_KEY - MINIO_ENDPOINT - MINIO_LOCATION + - HEALTHCHECK_URL + - HEALTHCHECK_UUID ``` пример .env @@ -32,8 +34,34 @@ MINIO_ACCOUNT_ID=account MINIO_APPLICATION_KEY=key MINIO_ENDPOINT=https://s3.domain.ru MINIO_LOCATION=ru-nsk + +# необязательно: пинг в healthchecks.io (в т.ч. self-hosted) +HEALTHCHECK_URL=https://hc.domain.ru/ping +HEALTHCHECK_UUID=00000000-0000-0000-0000-000000000000 ``` 1. myminio - системно! не менять 2. проверьте настройки локации, не должно быть пусто 3. DELETE_AFTER - в днях + +## Healthchecks + +Если `HEALTHCHECK_URL` пуст — пинги не отправляются, бекап работает как обычно. + +Итоговый адрес: `${HEALTHCHECK_URL}/${HEALTHCHECK_UUID}` + +| Момент | Запрос | +|---|---| +| старт бекапа | `.../${HEALTHCHECK_UUID}/start` | +| дамп снят, проверен `PRAGMA integrity_check`, запакован, залит в s3, размер в s3 сверен с локальным, старое удалено | `.../${HEALTHCHECK_UUID}` | +| любая ошибка на любом шаге | `.../${HEALTHCHECK_UUID}/fail` | + +Для self-hosted инстанса `HEALTHCHECK_URL` — это адрес его ping-эндпоинта, например `https://hc.domain.ru/ping`. + +## Как передаётся окружение в cron + +`crond` из busybox запускает джобы с пустым окружением, поэтому `entrypoint.sh` +сохраняет env контейнера в `/tmp/container.env` (права 600), а cron-запись +вызывает `/scripts/backup.sh --from-cron`, который его подгружает. +Ручной запуск (`docker exec ... /scripts/backup.sh`) использует то окружение, +с которым его вызвали. diff --git a/docker-compose.yml b/docker-compose.yml index 1861155..a0960bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,3 +18,5 @@ services: - MINIO_APPLICATION_KEY - MINIO_ENDPOINT - MINIO_LOCATION + - HEALTHCHECK_URL + - HEALTHCHECK_UUID diff --git a/scripts/backup.sh b/scripts/backup.sh index 0e6db19..10c9a81 100644 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,15 +1,79 @@ #!/bin/sh -BACKUP_FILE="sqlite_$(date "+%F-%H%M%S")" - -sqlite3 ${DB_FILE} ".backup '/tmp/db.sqlite'" -tar -zcvf /tmp/${BACKUP_FILE}.tar.gz /tmp/db.sqlite - -/scripts/minio_uploader.sh copy /tmp/${BACKUP_FILE}.tar.gz ${MINIO_PATH} - -rm /tmp/${BACKUP_FILE}.tar.gz - -if [ ! -z $DELETE_AFTER ] && [ $DELETE_AFTER -gt 0 ] -then - /scripts/deleteold.sh ${MINIO_PATH} +# crond gives the job a bare environment (HOME/PATH/SHELL only), so the cron +# entry passes --from-cron and the env dumped by entrypoint.sh is restored here. +# A manual run keeps the environment it was invoked with. +if [ "${1:-}" = "--from-cron" ] && [ -f /tmp/container.env ]; then + . /tmp/container.env fi + +set -u + +BACKUP_FILE="sqlite_$(date "+%F-%H%M%S").tar.gz" +DUMP=/tmp/db.sqlite +ARCHIVE="/tmp/${BACKUP_FILE}" +DEST="${MINIO_PATH%/}/" + +log() { echo "[$(date '+%F %T')] $*"; } + +# healthchecks.io ping. Self-hosted instance is supported via HEALTHCHECK_URL. +# Final url: ${HEALTHCHECK_URL}/${HEALTHCHECK_UUID}[/start|/fail] +hc_ping() { + [ -n "${HEALTHCHECK_URL:-}" ] || return 0 + url="${HEALTHCHECK_URL%/}" + [ -n "${HEALTHCHECK_UUID:-}" ] && url="${url}/${HEALTHCHECK_UUID}" + [ -n "${1:-}" ] && url="${url}/$1" + curl -fsS -m 10 --retry 3 -o /dev/null "$url" \ + || log "WARN: healthcheck ping failed: ${url}" +} + +cleanup() { rm -f "$DUMP" "$ARCHIVE"; } + +fail() { + log "ERROR: $*" + cleanup + hc_ping fail + exit 1 +} + +[ -n "${DB_FILE:-}" ] || fail "DB_FILE is not set" +[ -f "$DB_FILE" ] || fail "database not found: $DB_FILE" +[ -n "${MINIO_PATH:-}" ] || fail "MINIO_PATH is not set" + +hc_ping start +log "backup started: $DB_FILE -> ${DEST}${BACKUP_FILE}" + +# 1. Consistent snapshot +rm -f "$DUMP" +sqlite3 "$DB_FILE" ".backup '$DUMP'" || fail "sqlite3 .backup failed" +[ -s "$DUMP" ] || fail "dump is empty" + +# 2. Verify the snapshot before shipping it anywhere +INTEGRITY=$(sqlite3 "$DUMP" "PRAGMA integrity_check;" 2>&1) \ + || fail "integrity_check failed to run: $INTEGRITY" +[ "$INTEGRITY" = "ok" ] || fail "integrity_check: $INTEGRITY" + +# 3. Pack +tar -C /tmp -zcf "$ARCHIVE" "$(basename "$DUMP")" || fail "tar failed" +LOCAL_SIZE=$(wc -c < "$ARCHIVE" | tr -d ' ') +[ "$LOCAL_SIZE" -gt 0 ] || fail "archive is empty" + +# 4. Upload +/scripts/minio_uploader.sh copy "$ARCHIVE" "$DEST" || fail "upload failed" + +# 5. Verify the remote object exists and matches the local size +REMOTE_SIZE=$(/scripts/minio_uploader.sh size "${DEST}${BACKUP_FILE}") +[ -n "$REMOTE_SIZE" ] || fail "uploaded file not found at ${DEST}${BACKUP_FILE}" +[ "$REMOTE_SIZE" = "$LOCAL_SIZE" ] \ + || fail "size mismatch: local=${LOCAL_SIZE} remote=${REMOTE_SIZE}" + +log "uploaded and verified: ${DEST}${BACKUP_FILE} (${LOCAL_SIZE} bytes)" +cleanup + +# 6. Rotate old backups +if [ -n "${DELETE_AFTER:-}" ] && [ "${DELETE_AFTER}" -gt 0 ] 2>/dev/null; then + /scripts/deleteold.sh || fail "rotation of old backups failed" +fi + +log "backup finished successfully" +hc_ping diff --git a/scripts/deleteold.sh b/scripts/deleteold.sh index ad6998c..b30664a 100644 --- a/scripts/deleteold.sh +++ b/scripts/deleteold.sh @@ -1,24 +1,28 @@ #!/bin/bash +set -u + # Seconds since epoch for current time DATE_NOW=$(date +%s) +DEST="${MINIO_PATH%/}/" -/scripts/minio_uploader.sh list $MINIO_PATH | grep "sqlite_" | while read LINE +/scripts/minio_uploader.sh list "$DEST" | grep "sqlite_" | while read -r LINE do - BACKUP_FILENAME=$(echo $LINE | awk '{ print $3 }') + # `rclone ls` prints " " + BACKUP_FILENAME=$(echo "$LINE" | awk '{ print $2 }') - BACKUP_DATE=$(echo $BACKUP_FILENAME | awk 'BEGIN { FS = "[_-]" } ; { printf "%s-%s-%s",$2,$3,$4 }') + BACKUP_DATE=$(echo "$BACKUP_FILENAME" | awk 'BEGIN { FS = "[_-]" } ; { printf "%s-%s-%s",$2,$3,$4 }') if [[ $BACKUP_DATE =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] then - BACKUP_DATE_SECS=$(date -d $BACKUP_DATE +%s) + BACKUP_DATE_SECS=$(date -d "$BACKUP_DATE" +%s) - DAYS_DIFF=$(( ($DATE_NOW - $BACKUP_DATE_SECS) / (60*60*24) )) + DAYS_DIFF=$(( (DATE_NOW - BACKUP_DATE_SECS) / (60*60*24) )) if [ "$DAYS_DIFF" -gt "$DELETE_AFTER" ] then echo "File $BACKUP_FILENAME is $DAYS_DIFF days old (greater than $DELETE_AFTER days). Deleting it." - /scripts/minio_uploader.sh delete /$BACKUP_FILENAME + /scripts/minio_uploader.sh delete "${DEST}${BACKUP_FILENAME}" fi fi done diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 6caf48e..2533f8e 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,14 +1,22 @@ #!/bin/sh -set +e +set -e + +# crond runs jobs with a bare environment (HOME/PATH/SHELL only), so the +# container env is dumped here and sourced back by backup.sh on every run. +ENV_FILE=/tmp/container.env +export -p | grep -v -E "^export (PWD|SHLVL|OLDPWD|_)=" > "$ENV_FILE" +chmod 600 "$ENV_FILE" # Create crontab in a writable location and set proper permissions mkdir -p /tmp/crontabs -echo "${CRONTAB:-"0 * * * *"} /scripts/backup.sh >> /proc/1/fd/1 2>&1" > /tmp/crontabs/root +echo "${CRONTAB:-"0 * * * *"} /scripts/backup.sh --from-cron >> /proc/1/fd/1 2>&1" > /tmp/crontabs/root chmod 644 /tmp/crontabs/root -# Run initial backup -/scripts/backup.sh +echo "[entrypoint] schedule: ${CRONTAB:-"0 * * * *"} (TZ=${TZ:-UTC})" + +# Run initial backup (do not abort the container if it fails) +/scripts/backup.sh || echo "[entrypoint] initial backup failed, continuing to crond" # Start crond in foreground with debug output exec crond -f -c /tmp/crontabs -d 0 diff --git a/scripts/minio_uploader.sh b/scripts/minio_uploader.sh index 25801e3..f29036c 100644 --- a/scripts/minio_uploader.sh +++ b/scripts/minio_uploader.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -u + # Create rclone config on-the-fly in a writable location RCLONE_CONFIG_DIR="/tmp/rclone" mkdir -p "$RCLONE_CONFIG_DIR" @@ -16,18 +18,28 @@ region = $MINIO_LOCATION acl = private no_check_bucket = true EOF +chmod 600 "$RCLONE_CONFIG" -case $1 in +# All commands take full remote paths; callers own the path building. +case "${1:-}" in copy) echo "copy from $2 to $3" - rclone copy --progress $2 $3 + rclone copy --stats-one-line --retries 3 "$2" "$3" ;; list) - echo "list ${MINIO_PATH}/$2" - rclone ls ${MINIO_PATH}/$2 + echo "list $2" >&2 + rclone ls "$2" + ;; + size) + # bytes of a single remote object, empty output if it does not exist + rclone lsf --format s "$2" 2>/dev/null ;; delete) - echo "delete ${MINIO_PATH}/$2" - rclone delete ${MINIO_PATH}/$2 + echo "delete $2" + rclone deletefile "$2" + ;; + *) + echo "usage: $0 {copy SRC DST|list PATH|size FILE|delete FILE}" >&2 + exit 2 ;; esac