Pull to refresh

Резервное копирование для standalone *NIX-серверов. Эмулируем TimeMachine

Reading time 7 min
Views 7.1K
Думаю никому из присутствующих не нужно объяснять важность резервного копирования.
Проблема в том, что из десятков готовых решений ни одно толком не удовлетворяет моим требованиям standalone *NIX-сервера на колокейшене.
Чего же хотелось от резервного копирования?
1) ежедневного полного бакапа всех данных. Никаких incremental-бакапов.
2) максимально быстрого восстановления отдельно взятого файла. Архиваторы (tar/gzip/bzip2/rar) отпадают
3) быстрого мониторинга «кто именно залил вчера на сервер 156Гб?!!!»
4) резервные копии хочется хранить максимально долго, насколько хватает свободного места на дисках.
5) хочется не заботиться об ручном удалении старых копий если место на диске всё-таки уже кончилось
Если в двух словах — то мне захотелось реализовать функционал MAC OS TimeMachine на Linux-сервере.
И я начал писать скрипт.
Вы уже, наверное, догадались, что в основе TimeMachine лежит комбинация из cp -al + rsync
На пальцах это выглядит примерно так:
cp -al вчерашний_backup сегодняшний_backup
скопирует хардлинками вчерашние файлы в сегодняшние. Такая копия почти не занимает реального места на диске. Потом запускается
rsync -a --del данные сегодняшний_backup
просканирует различия между копией вчерашнего backup и если найдет различия — удалит хардлинк и запишет новую версию файла; создаст/скопирует новые файлы и директории; удалит несуществующие более файлы. При этом все файлы во вчерашнем backup останутся на своих местах.
Понятно что для того чтобы сделать нормальный рабочий скрипт backup этих двух команд маловато. Я решил написать скрипт в 2 файлах — один стандартный для всех серверов с набором функций, второй — коротенький, индивидуальный для каждого сервера, который и запускается непосредственно через cron.

Итак, вот эти скрипты: (можно скачать отсюда )
backup_functions.sh
#!/bin/sh

export LC_ALL=en_US.utf8
DIR_PATTERN='20..-..-..'
CURR_DATE=`date +%F`
#CURR_DATE=`date +%F_%R`
RESERVE_G=5
BACKUP_MAIN_DIR='/backup'
BACKUP_TMP_DIR='/tmp'
BACKUP_DELTA=$BACKUP_TMP_DIR/backup.delta
BACKUP_ERRORS=$BACKUP_TMP_DIR/backup.err
BACKUP_REPORT=$BACKUP_TMP_DIR/backup.report
BACKUP_LOG_FACILITY='user.notice'
BACKUP_EXPIRES_DAYS=0
VERIFY_BACKUP_MOUNTED='no'
PID_FILE='/var/run/backup.pid'
BACKUP_MYSQL_DIR=$BACKUP_TMP_DIR/mysql_dump
MYSQL_DATA_DIR='/var/lib/mysql'

[ -z "`which rsync`" ] && { echo "RSYNC is not installed! backup will not work!"; exit; }
[ -n "`which ionice`" ] && IONICE_CMD='ionice -c2 -n6'
touch /etc/default/backup_exclude
rm $BACKUP_DELTA $BACKUP_ERRORS $BACKUP_REPORT 1>/dev/null 2>/dev/null

verify_backup_mounted() {
    mount -a
    [ -d "$BACKUP_MAIN_DIR" ] || { echo "BACKUP main directory does not exist!"; exit; }
    str=`df "$BACKUP_MAIN_DIR" | tail -1 | grep ' /$'`
    [ "$str" ] && { echo 'BACKUP partition is not mounted!!!!!!!!'; exit; }
    return 0
}
prepare_for_backup() {
    if [ -s "$PID_FILE" ] && [ `cat "$PID_FILE"` -ne $PPID ]
    then
	if [ "`ps ax | awk '{print $1;}' | grep -f \"$PID_FILE\"`" ]
	then
	    echo -n "Previous BACKUP script is still running. PID = "; cat "$PID_FILE"; exit
	else
	    logger -t BACKUP -p $BACKUP_LOG_FACILITY "Previous BACKUP ended unexpectly"
	fi
    fi
    rm "$PID_FILE" 1>/dev/null 2>/dev/null
    echo $PPID > "$PID_FILE"
    old_dir=`pwd`
    cd "$BACKUP_MAIN_DIR" || { echo "BACKUP main directory does not exist!"; exit; }
	VERIFY_BACKUP_MOUNTED=`echo "$VERIFY_BACKUP_MOUNTED" | tr 'A-Z' 'a-z'`
	[ "$VERIFY_BACKUP_MOUNTED" = "yes" ] && verify_backup_mounted
    reserve_k=$(($RESERVE_G * 1048576))
    mkdir -p $BACKUP_TMP_DIR 1>/dev/null 2>/dev/null
    dirs_list=`ls | grep $DIR_PATTERN | sort`
    if [ -n "$dirs_list" ]
    then
	while { free_k=`df -k .|grep -v Filesystem| sed -e "s/.\+ \([0-9]\+\) .\+/\1/"`
		dirs_list=`ls | grep $DIR_PATTERN | sort`
		free_pre=$free_k
		[ $free_pre -lt $reserve_k ] ; }
	do
	    dir_oldest=`echo $dirs_list | tr " " "\n" | head -1`
	    [ -d $dir_oldest ] && { logger -t BACKUP -p $BACKUP_LOG_FACILITY "Deleting old backup in $BACKUP_MAIN_DIR/$dir_oldest" ; rm -rf $dir_oldest; }
	done
    fi
	[ "$VERIFY_BACKUP_MOUNTED" = "yes" ] && verify_backup_mounted
    last_date=`ls | grep $DIR_PATTERN | sort | tail -1`
    if [ -n "$last_date" -a \( "$CURR_DATE" != "$last_date" \) ]
    then
	logger -t BACKUP -p $BACKUP_LOG_FACILITY "Preparing. Copying $BACKUP_MAIN_DIR/$last_date -> $BACKUP_MAIN_DIR/$CURR_DATE"
	mkdir $CURR_DATE 1>/dev/null 2>/dev/null
	$IONICE_CMD cp -al "$last_date"/* $CURR_DATE 1>/dev/null 2>/dev/null
	rm -rf $CURR_DATE/_delta 1>/dev/null 2>/dev/null
    fi
    mkdir $CURR_DATE/_delta 1>/dev/null 2>/dev/null
    if [ $BACKUP_EXPIRES_DAYS -gt 0 ]
    then
	for expired_dir in `find "$BACKUP_MAIN_DIR" -maxdepth 1 -mtime +$BACKUP_EXPIRES_DAYS -type d | grep "$DIR_PATTERN"`
	do
	    logger -t BACKUP -p $BACKUP_LOG_FACILITY "Deleting expired backup $expired_dir" ; rm -rf $expired_dir;
	done
    fi
    cd $old_dir
    return 0
}
make_backup() {
    while [ -n "$1" ]
    do
	[ "$VERIFY_BACKUP_MOUNTED" = "yes" ] && verify_backup_mounted
	src=$1
	full_src=`echo $PWD/$1 | sed -e 's://:/:g'`
	dst=`echo $BACKUP_MAIN_DIR/$CURR_DATE/$src | sed -e "s/\/\w\+$//"`
	mkdir -p $dst 1>/dev/null 2>/dev/null
	logger -t BACKUP -p $BACKUP_LOG_FACILITY "$full_src started"
	$IONICE_CMD rsync -axW8 --del --exclude-from=/etc/default/backup_exclude $src $dst 2>>$BACKUP_ERRORS
	sync
	shift
    done
    return 0
}
make_backup_with_delta() {
    while [ -n "$1" ]
    do
	[ "$VERIFY_BACKUP_MOUNTED" = "yes" ] && verify_backup_mounted
	src=$1
	full_src=`echo $PWD/$1 | sed -e 's://:/:g'`
	dst=`echo $BACKUP_MAIN_DIR/$CURR_DATE/$src | sed -e "s/\/\w\+$//"`
	mkdir -p $dst 1>/dev/null 2>/dev/null
	rm $BACKUP_DELTA 1>/dev/null 2>/dev/null
	logger -t BACKUP -p $BACKUP_LOG_FACILITY "$full_src (with delta) started"
	$IONICE_CMD rsync -axW8i --del $src $dst --exclude-from=/etc/default/backup_exclude 2>>$BACKUP_ERRORS | grep "^>f" | cut -d ' ' -f 2- 1>$BACKUP_DELTA
	old_dir=`pwd`
	cd $BACKUP_MAIN_DIR/$CURR_DATE
	dst=`echo $src | sed -e "s/\w\+$//"`
	xargs -a $BACKUP_DELTA -r -n5 -d '\n' -I '{}' echo $dst{} | xargs -r -n10 -d '\n' cp -ul --parents -t _delta
	rm $BACKUP_DELTA 1>/dev/null 2>/dev/null
	cd $old_dir
	sync
	shift
    done
    return 0
}
send_email_report() {
    if [ -s $BACKUP_ERRORS ]
    then
	logger -t BACKUP -p $BACKUP_LOG_FACILITY "Sending email report"
	echo 'Content-type: text/plain; charset=utf-8' >> $BACKUP_REPORT
	echo 'Content-Transfer-Encoding: 8bit' >> $BACKUP_REPORT
	echo 'From: root@'`hostname --fqdn` >> $BACKUP_REPORT
	echo 'To: root' >> $BACKUP_REPORT
	echo 'Date:' `date` >> $BACKUP_REPORT
	echo -e 'Subject: Cron <root@'`hostname --fqdn`'> BACKUP\n\n' >> $BACKUP_REPORT
	cat $BACKUP_ERRORS >> $BACKUP_REPORT
	cat $BACKUP_REPORT | sendmail root
    fi
    rm $BACKUP_DELTA $BACKUP_ERRORS $BACKUP_REPORT $PID_FILE 1>/dev/null 2>/dev/null
	logger -t BACKUP -p $BACKUP_LOG_FACILITY "Finished"
    return 0
}
make_mysql_backup() {
    MYSQL_DATA_DIR='/var/lib/mysql'
    mkdir $BACKUP_MAIN_DIR/$CURR_DATE/MySQL 1>/dev/null 2>/dev/null
    rm -rf $BACKUP_MAIN_DIR/$CURR_DATE/MySQL/* 1>/dev/null 2>/dev/null
    rm -rf $BACKUP_MYSQL_DIR 1>/dev/null 2>/dev/null
    mkdir -p $BACKUP_MYSQL_DIR 1>/dev/null 2>/dev/null
    cd $MYSQL_DATA_DIR
    logger -t BACKUP -p $BACKUP_LOG_FACILITY "MySQL started"
    for db_dir in `ls -p | grep '/' | tr -d '/'`
    do
	cd $MYSQL_DATA_DIR/$db_dir
	db_name=`echo $db_dir | sed -e 's/@003d/=/g' -e 's/@002d/-/g'`
	logger -t BACKUP -p $BACKUP_LOG_FACILITY "MySQL database '$db_name' started"
	for table in `ls | grep '.frm' | sed -e 's/\.frm//' -e 's/ /:::/g'`
	do
	    table=`echo $table | sed -e 's/:::/ /g' -e 's/@003d/=/g' -e 's/@002d/-/g'`
	    mysqldump $db_name "$table" --skip-lock-tables -Q -u $mysql_user -p$mysql_pass > "$BACKUP_MYSQL_DIR/$table.sql"
	done
	cd $BACKUP_MYSQL_DIR
	tar czf $BACKUP_MAIN_DIR/$CURR_DATE/MySQL/$db_name.tar.gz * 1>/dev/null 2>/dev/null
	rm $BACKUP_MYSQL_DIR/* 1>/dev/null 2>/dev/null
    done
    return 0
}


и backup.sh:
#!/bin/sh

. /usr/local/sbin/backup_functions.sh
BACKUP_EXPIRES_DAYS=365
# резерв 30Гб
RESERVE_G=30
BACKUP_MAIN_DIR='/backup'
VERIFY_BACKUP_MOUNTED='yes'
prepare_for_backup
cd /
make_backup etc boot home root opt srv usr/local
make_backup_with_delta  var/spool var/lib var/www

BACKUP_MAIN_DIR='/backup'
mysql_user='root'
mysql_pass='jndey7hdFdfii7HN6ygdrarUh'
make_mysql_backup

send_email_report

Предполагается что backup_functions.sh размещен в /usr/local/sbin. Если в другой директории — поправьте соотв.строчку в backup.sh
Как видно, скрипт обильно пользуется глобальными переменными. Например
RESERVE_G — резерв (в гигабайтах) который скрипт будет обеспечивать, удаляя самые старые копии, до того как начать сегодняшний backup
BACKUP_EXPIRES_DAYS — через сколько дней удалять старый backup даже если место на диске еще есть. если BACKUP_EXPIRES_DAYS==0 то это не делается.
BACKUP_MAIN_DIR — директория, в которую пишется backup. Внутри создаются папки типа «2011-11-15», куда и пишется backup.
VERIFY_BACKUP_MOUNTED — проверять или не проверять, смонтирован ли отдельный раздел диска под backup. Надо понимать что если выделенный раздел по каким-то причинам размонтировался, то без этой проверки backup ляжет тяжелым грузом на rootfs или другой не подходящую для этого раздел диска. Однако, бывают ситуации когда backup явно и специально пишется именно на rootfs или другой неспециализированный раздел. Тогда нужно указать
VERIFY_BACKUP_MOUNTED=«no»
короткий скрипт backup.sh, который можно уже запускать непосредственно cron-ом, вызывает несколько самодельных функций, а именно:
prepare_for_backup — функция подготавливает всё перед непосредственно копированием. Грубо говоря она делает cp -al + всякие проверки не закончилось ли свободное место на диске.
make_backup (с параметрами) — функция собственно делает копию
make_backup_with_delta (с такими же параметрами) — функция делает копию и дополнительно в директорию 2011-11-15/_delta сливает хардлинками все новые файлы. Заглянув в эту директорию можно легко и быстро узнать кто же залил вчера на сервер 156Гб.
send_email_report — функция отсылает админу e-mail с ошибками (если таковые случились) при резервном копировании.
Помимо резервного копирования файлов еще я написал в довесок функцию backup-а для MySQL.
make_mysql_backup — функция вызывается без параметров, делает потабличный mysqldump (т.е. одна таблица — один .sql-файл), потом все .sql-файлы каждой database упаковывает в db_name.tar.gz

Извините за простыню. Надеюсь кому-то это пригодится.

UPDATE: если вы хотите делать резервные копии чаще чем раз в сутки — в первых строках backup_functions.sh замените строчку
CURR_DATE=`date +%F`
на
CURR_DATE=`date +%F_%R`

Если rsync не установлен — скрипт прерывается с соотв.сообщением.
Если установлен ionice — тогда rsync будет запущен с пониженным io-приоритетом (параметры можно менять в строке
[ -n "`which ionice`" ] && IONICE_CMD='ionice -c2 -n6'
Tags:
Hubs:
+34
Comments 25
Comments Comments 25

Articles