Бэкап сайта на шаред-хостинге: bash + lftp + cron с чужого VPS

Год назад клиент написал в 23:47: «Сайт не открывается, хостинг говорит аккаунт заблокирован». Пока разбирались — выяснилось, что хостер сделал резервную копию, но пятидневной давности. Потеряли пять дней заказов интернет-магазина.

Самое обидное: у клиента стояло «резервное копирование» через галочку в панели хостинга. Просто никто не проверял куда. Оказалось — в папку на том же аккаунте. Том самом, который заблокировали.

После этого мы написали скрипт. Сейчас он с небольшими правками крутится на нескольких десятках клиентских сайтов.

Что имеется в распоряжении

На хостингах обычно доступно: SSH с ключом (chroot, без sudo), mysqldump, tar, gzip, lftp, curl. rsync — через раз, и об этом ниже.

Чего точно нет: root, apt install, /var/log в привычном виде, systemd. crontab через консоль — на части хостингов разрешён, на части только через веб-панель с ограниченным числом задач.

Почему не rsync

Честно — пробовали. rsync теоретически умеет работать через SSH, но без rsync-демона на принимающей стороне это значит «rsync к себе на тот же сервер», что бессмысленно. Если принимающая сторона — ваш VPS, то нужно настраивать rsync-демон на нём, пробрасывать порты, и внезапно это уже не «быстрое решение».

lftp с обычным FTP работает с первого раза и есть на любом хостинге, который я встречал за последние 8 лет.

Панельные бэкапы — отдельная история. Либо «скачать архив вручную», либо «сохранить на тот же аккаунт». Смысл второго варианта я так и не понял, но кнопка выглядит внушительно.

Как это устроено

[ shared-хостинг ]                       [ внешний FTP ]
   public_html/  ──►  tar.gz  ──┐
                                ├──►  site-backups/
   mysqldump   ──►   .sql   ───┘       site-20260501-040015.tar.gz
        ▲
        │           триггер по SSH
        └────  [ VPS с cron ]
               15 4 * * * ssh user@host '~/scripts/backup.sh'

Скрипт живёт на хостинге, запускается снаружи. Это позволяет писать лог с двух сторон и видеть «бэкап не запускался 25 часов» без дополнительных инструментов. Если своего VPS нет — cron из панели хостинга тоже работает, скрипт не меняется.

Скрипт

Кладём в ~/scripts/backup.sh, строго выше public_html. Права chmod 700 — иначе другие пользователи хостинга прочитают пароли в открытом виде.

#!/bin/bash
set -u

SITE_DIR="/home/user/site/public_html"
WORK_DIR="/home/user/scripts"
LOG="$WORK_DIR/backup.log"
TMP_DIR="$(mktemp -d /tmp/bk.XXXXXX)"

DB_HOST="localhost"
DB_NAME="mydb"
DB_USER="mydb"
DB_PASS='secret-here'

FTP_HOST="ftp.example.com"
FTP_USER="ftpuser"
FTP_PASS='ftpsecret'
FTP_DIR="site-backups"

KEEP=30
TS="$(date +%Y%m%d-%H%M%S)"
ARCHIVE="site-${TS}.tar.gz"
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE"

log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
trap 'rm -rf "$TMP_DIR"' EXIT
mkdir -p "$WORK_DIR"
log "=== backup start ($TS) ==="

# дамп базы
SQL_FILE="$TMP_DIR/db-${DB_NAME}-${TS}.sql"
MYSQL_PWD="$DB_PASS" mysqldump --single-transaction --quick \
    --routines --triggers --events --default-character-set=utf8mb4 \
    -h "$DB_HOST" -u "$DB_USER" "$DB_NAME" > "$SQL_FILE" 2>>"$LOG" \
    || { log "ERROR: mysqldump failed"; exit 1; }
log "mysqldump OK: $(stat -c %s "$SQL_FILE") bytes"

# архив файлов + дамп
tar --warning=no-file-changed -czf "$ARCHIVE_PATH" \
    --exclude='*/cache/*' --exclude='*/.git' --exclude='*/node_modules' \
    -C "$(dirname "$SITE_DIR")" "$(basename "$SITE_DIR")" \
    -C "$TMP_DIR" "$(basename "$SQL_FILE")" 2>>"$LOG" || true
[ -s "$ARCHIVE_PATH" ] || { log "ERROR: empty archive"; exit 1; }
log "archive: $(stat -c %s "$ARCHIVE_PATH") bytes"

# заливка + список файлов для ротации
lftp -u "$FTP_USER","$FTP_PASS" "$FTP_HOST" >>"$LOG" 2>&1 <<EOF
set ftp:ssl-allow no
set net:max-retries 3
mkdir -p $FTP_DIR
cd $FTP_DIR
put $ARCHIVE_PATH -o $ARCHIVE
cls -1 site-*.tar.gz > $TMP_DIR/list.txt
bye
EOF
log "upload OK: $ARCHIVE"

# ротация на FTP
TO_DEL=$(grep -E '^site-[0-9]{8}-[0-9]{6}\.tar\.gz$' "$TMP_DIR/list.txt" \
         | sort | head -n -"$KEEP")
if [ -n "$TO_DEL" ]; then
    {
        echo "set ftp:ssl-allow no"
        echo "cd $FTP_DIR"
        while IFS= read -r f; do echo "rm -f $f"; done <<< "$TO_DEL"
        echo "bye"
    } | lftp -u "$FTP_USER","$FTP_PASS" "$FTP_HOST" >>"$LOG" 2>&1
    log "rotation: deleted $(echo "$TO_DEL" | wc -l)"
fi
log "=== backup done ==="

Вещи, которые неочевидны при первом прочтении

MYSQL_PWD вместо -p. Это не паранойя. Пароль в аргументе командной строки виден через ps aux всем пользователям сервера — а на shared их сотни. MYSQL_PWD читается mysqldump из окружения и в список процессов не попадает. Формально чище ~/.my.cnf с секцией [client] и chmod 600, но для одного скрипта оба варианта эквивалентны.

--single-transaction. Снимает консистентный снимок InnoDB без блокировки таблиц. Без него на живом магазине дамп может поймать транзакцию в промежуточном состоянии. Для MyISAM этот флаг ничего не даёт — но в 2026 году MyISAM это уже отдельная история.

--quick. Без него mysqldump загружает таблицу целиком в память и только потом дампит. На таблице с двумя миллионами строк это oom-killer и бэкап которого нет. Узнали не из документации.

--warning=no-file-changed у tar. На живом сайте файлы меняются прямо во время архивации — сессии, кеши. tar возвращает код 1 и пишет предупреждения, хотя архив нормальный. Без флага мониторинг считает бэкап упавшим. Флаг подавляет именно эти предупреждения, критические ошибки по-прежнему видны.

Грабли, которые мы прошли

Cron из панели не видит mysqldump. cron-задача получает урезанный PATH, а mysqldump живёт не там где ожидается. Скрипт падает сразу и молча. Решение — export PATH=/usr/local/bin:$PATH в начало.

SSH-триггер глотает ошибки. ssh user@host 'script.sh' — если скрипт упал, снаружи не видно ничего без явного логирования. Пишем с обеих сторон: внутри скрипта в файл на хостинге, с VPS тоже в файл. Несколько раз это выловило ситуацию когда mysqldump тихо умирал из-за квоты.

mkdir: Access failed: 550. lftp ругается при попытке создать уже существующую папку — mkdir -p в bash идемпотентен, в lftp нет. На сам бэкап не влияет, но выглядит страшно. Лечится добавлением set cmd:fail-exit no перед mkdir если нервирует.

Все архивы в корне FTP. Через полгода — 600 файлов непонятно от каких проектов. Делайте подпапки сразу: site1-backups/, shop-backups/. Ротация по маске site-*.tar.gz тогда не цепляет чужое.

Ротация на хостинге, а не на FTP. Видели такой вариант у коллег. Выглядит логично, но всё хранится на той же машине. Если что-то случилось с аккаунтом — архивы пропадают вместе с ним. Это именно та ситуация с которой началась статья.

Про проверку восстановления

Бэкап без проверки восстановления — это файлы на FTP с неизвестным содержимым. Звучит банально, но у двух клиентов из десяти бэкапы оказались нерабочими при первой проверке.

Мы делаем раз в месяц: скачиваем последний архив, разворачиваем локально в Docker, смотрим что сайт стартует и база читается. 15–20 минут. Раз в полгода — полное восстановление на тестовый аккаунт.

За три года так нашли два битых дампа: один раз кодировка utf8 вместо utf8mb4 резала кириллицу в именах, второй раз tar без -p потерял права на uploads/ и WordPress не мог сохранять файлы.

Что ещё можно добавить

Шифрование: gpg --symmetric --batch --passphrase "$GPG_PASS" "$ARCHIVE_PATH" перед заливкой, грузить .tar.gz.gpg. Тогда компрометация FTP не означает слив данных.

Мониторинг без своего сервиса: в конец скрипта curl -s "https://hc-ping.com/uuid". Healthchecks.io бесплатно алертит, если пинг не пришёл вовремя.

restic или borg: если можно поставить бинарник на хостинге — инкрементальные бэкапы с дедупликацией. На shared обычно нельзя, но если есть VPS — стоит смотреть в эту сторону вместо tar.

Итого

Схема bash + lftp + внешний SSH-триггер работает на любом хостинге с SSH. Ничего не нужно устанавливать, не нужен root, не нужен специфический стек.

Если у вас другой подход к бэкапам на shared — интересно услышать в комментариях.