На сервере стоит Mysql, Apache, nginx… во общем простой стандартный набор, там же хостятся с два десятка клиентских сайтов.
Каждый день делается резервная копия всех баз и файлов доменнов средствами приметного скрипта #!bin/bash
Я решил использовать Python 3… Вот непосредственно и сам код:
#!/usr/bin/env python3
import subprocess
import datetime
import optparse
import zipfile
import os
import ftplib
class ReturnCode(Exception):
pass
class NotExist(Exception):
pass
class RequiredOpts(Exception):
pass
class BackupUtils:
__current_date = str(datetime.datetime.now().strftime('%d_%m_%Y'))
def __init__(self):
self.ftp = None
def to_zip(self, file, filename=__current_date + '.zip', append_to_file=False):
"""
:param file: file or folder for added to archive
:param filename: output archive filename
:param append_to_file: if False, will be create new file, True for append in exist file
:type append_to_file: False
:type filename: str
:type file: str
:return True
"""
param_zip = 'a' if append_to_file else 'w'
try:
with zipfile.ZipFile(filename, param_zip) as zip_file:
if os.path.isfile(file):
zip_file.write(file)
else:
self.add_folder_to_zip(zip_file, file)
return True
except IOError as error:
print('Cannot create zip file, error: {}'.format(error))
return False
def add_folder_to_zip(self, zip_file, folder):
"""
:type folder: str
:type zip_file: file
"""
for file in os.listdir(folder):
full_path = os.path.join(folder, file)
if os.path.isfile(full_path):
zip_file.write(full_path)
elif os.path.isdir(full_path):
self.add_folder_to_zip(zip_file, full_path)
def run_backup(self, mysql_user, mysql_pw, db):
"""
:type db: str
:type mysql_pw: str
:type mysql_user: str
:return string - dump filename
"""
try:
dump = 'dump_' + db + '_' + self.__current_date + '.sql'
# return dump
p = subprocess.Popen(
'mysqldump -u' + mysql_user + ' -p' + mysql_pw + ' --databases ' + db + ' > ' + dump,
shell=True)
# Wait for completion
p.communicate()
# Check for errors
if p.returncode != 0:
raise ReturnCode
print('Backup done for', db)
return dump
except:
print('Backup failed for ', db)
def parse_options(self):
parser = optparse.OptionParser(usage=""" %prog -u USERNAME -p PASSWORD -d DATABASE -D /path/for/domain/ -f BACKUP_FILE_NAME
Required Username, Password, Database name and path for Domain folder
If you want copy backup to remote ftp, use options:
%prog -u USERNAME -p PASSWORD -d DATABASE -D /path/for/domain/ -f BACKUP_FILE_NAME --ftp-host HOST --ftp-user USERNAME --ftp-password PASSWORD --ftp-folder FOLDER
If you want delete archives from ftp, add options: --ftp-delete-old --ftp-delete-day N (not required, 3 days default)
""", conflict_handler="resolve")
parser.add_option("-u", "--username", dest="username",
help=("Username of database "
"[default: %default]"))
parser.add_option("-p", "--password", dest="password",
help=("Password of database "
"[default: %default]"))
parser.add_option("-d", "--database", dest="database",
help=("Database name "
"[default: %default]"))
parser.add_option("-D", "--domain", dest="domain",
help=("Domain folder for backup "
"[default: %default]"))
parser.add_option("-f", "--filename", dest="filename",
help=("Backup file name "
"[default: %default]"))
parser.add_option("--ftp-host", dest="host",
help=("Ftp host "
"[default: %default]"))
parser.add_option("--ftp-user", dest="ftpuser",
help=("Ftp username "
"[default: %default]"))
parser.add_option("--ftp-password", dest="ftppassword",
help=("Ftp password "
"[default: %default]"))
parser.add_option("--ftp-folder", dest="folder",
help=("Ftp upload folder "
"[default: %default]"))
parser.add_option("--ftp-delete-old", dest="ftpdelete", action='store_true',
help=("Delete files from ftp older 3 days "
"[default: %default]"))
parser.add_option("--ftp-delete-day", dest="ftpdeleteday", type='int',
help=("Delete files from ftp older N days "
"[default: %default]"))
parser.set_defaults(username='root', filename=self.__current_date + '.zip', folder='.', ftpdelete=False,
ftpdeleteday=3)
return parser.parse_args()
def ftp_connect(self, host, username, password):
"""
:param host: remote host name
:param username: username for remote host
:param password: password for remote host
:type host: str
:type username: str
:type password: str
:return object self.ftp
"""
try:
self.ftp = ftplib.FTP(host=host, user=username, passwd=password)
return self.ftp
except ftplib.error_perm as error:
print('Is there something wrong: {}'.format(error))
except:
print('Cannot connected to ftp: ', host)
return False
def ftp_disconnect(self):
"""
:return: True
"""
try:
self.ftp.close()
self.ftp = None
return True
except:
return False
def upload_file_to_ftp(self, filename, folder='.'):
"""
:param filename: upload file name
:param folder: special folder - / default
:type filename: str
:type folder: str
:return True
"""
try:
self.ftp.cwd(folder)
self.ftp.dir()
with open(filename, 'rb') as f:
self.ftp.storbinary('STOR %s' % filename, f)
return True
except ftplib.all_errors as error:
print('Is there something wrong: {}'.format(error))
return False
def remove_old_files_from_ftp(self, folder='.', day=3):
"""
:param folder: special folder - / default
:param day: count of day
:type folder: str
:type day: int
:return True
"""
try:
self.ftp.cwd(folder)
facts = self.ftp.mlsd()
i = 0
for fact in facts:
modify = fact[1]['modify'][:8]
if (int(datetime.datetime.now().strftime('%Y%m%d')) - int(modify)) > int(day):
# if we cannot change directory - is file
try:
self.ftp.cwd(fact[0])
except:
self.ftp.delete(fact[0])
i += 1
print('Deleted {} files'.format(str(i)))
return True
except ftplib.all_errors as error:
print('Is there something wrong: {}'.format(error))
return False
except TypeError:
print('Day is not number, use 1 or 2,3,n')
return False
Создал простой класс с несколькими методами:
to_zip(self, file, filename=__current_date + '.zip', append_to_file=False)
Метод принимает файл или папку и создает архив с именем ТЕКУЩАЯДАТА.zip или с вашим именем, если передать append_to_file=True, файлы будут добавлены в существующий архив
run_backup(self, mysql_user, mysql_pw, db)
Делаем резервную копию базы данных, использую линуксовскую утилиту mysqldump, метод принимает ИМЯ ПОЛЬЗОВАТЕЛЯ, ПАРОЛЬ, НАЗВАНИЕ БАЗЫ
parse_options(self)
Парсим переданные опции, об этом в примере ниже…
ftp_connect(self, host, username, password)
Открываем FTP соединение, метод принимает ХОСТ, ИМЯ ПОЛЬЗОВАТЕЛЯ, ПАРОЛЬ от FTP сервера
ftp_disconnect(self)
Не понятный метод с не ясным названием )
upload_file_to_ftp(self, filename, folder='.')
Метод принимает ИМЯ ФАЙЛА и опционально ПАПКУ, как раз в нее и копируется ФАЙЛ
remove_old_files_from_ftp(self, folder='.', day=3)
Удаляет все файлы старше N дней с указанной папки, метод принимает соответственно ПАПКУ и ДНИ
А теперь пример того как этот класс использую я:
def main():
backup_utils = BackupUtils()
opts, args = backup_utils.parse_options()
# required Username, password, database name and path for domain folder
try:
if opts.username is None or opts.password is None or opts.database is None or opts.domain is None:
raise RequiredOpts
except RequiredOpts:
print('Use -h or --help option')
exit()
# create sql dump
backup_database = backup_utils.run_backup(opts.username, opts.password, opts.database)
# dump archive filename
dump_archive = 'dump_' + opts.filename if '.zip' in opts.filename else 'dump_' + opts.filename + '.zip'
if backup_database:
# add sql dump to zip "dump_filename.zip"
backup_utils.to_zip(backup_database, dump_archive)
# remove sql dump
os.remove(backup_database)
# find domain name in path - site.com
try:
i = opts.domain.index('.')
if opts.domain[:-1] != '/': opts.domain += '/'
left = opts.domain.rindex('/', 0, i)
right = opts.domain.index('/', i)
domain = opts.domain[left + 1:right]
except:
domain = ''
# backup file name
backup_archive = 'backup_' + domain + '_' + opts.filename if '.zip' in opts.filename else 'backup_' + domain + '_' + opts.filename + '.zip'
# check if path exist
try:
if not os.path.isdir(opts.domain) and not os.path.isfile(opts.domain):
raise NotExist
except NotExist:
print('{} No such file or directory'.format(opts.domain))
exit()
# create domain folder archive
backup_utils.to_zip(opts.domain, backup_archive)
if os.path.isfile(dump_archive):
# add dump archive to domain archive
backup_utils.to_zip(dump_archive, backup_archive, True)
# remove dump zip file
os.remove(dump_archive)
# upload backup to ftp
if opts.host and opts.ftpuser and opts.ftppassword and backup_utils.ftp_connect(opts.host, opts.ftpuser,
opts.ftppassword) is not None:
backup_utils.upload_file_to_ftp(backup_archive, folder=opts.folder)
backup_utils.ftp_disconnect()
# remove local backup archive
os.remove(backup_archive)
# delete files from ftp older N days
if opts.ftpdelete and backup_utils.ftp_connect(opts.host, opts.ftpuser,
opts.ftppassword) is not None:
backup_utils.remove_old_files_from_ftp(folder=opts.folder, day=opts.ftpdeleteday)
backup_utils.ftp_disconnect()
if __name__ == "__main__":
main()
И напоследок в cron добавляем команду:
backup.py -p PASSWORD FOR DB -d NAME FO DB -D /PATH/FOR/WEB/SITE.COM/HTML/ --ftp-host FTP HOST NAME --ftp-user FTP USER --ftp-password FTP PASSWORD --ftp-delete-old --ftp-delete-day DAYS --ftp-folder FTP FOLDER
Все! Каждый день создается резервная копия базы и файлов проекта и копируется на ftp, и что бы не переполнять ftp сервер все копии старше 3х дней удаляются.
Комментарии (14)
Kwent
11.08.2017 19:09Вычитывайте, пожалуйста, статью перед публикацией, текста на полстраницы и все равно неприятно читать:
во общем
Не понятный метод с не ясным
и что бы не переполнять
и т.д.
broken-ufa
11.08.2017 20:05А с каких пор это нормально — светить пароли от БД и FTP в кронтабе?
xelaxela Автор
11.08.2017 20:06Какие будут предложения? где хранить? можно в принципе с файла читать параметры.
g0rd1as
11.08.2017 20:52Прочитал статью и возникло 2 вопроса:
1. Зачем вам в начале скрипта аж три класса, которые ничего не делают?
2. Почему по FTP? Этот протокол же небезопасный! Не лучше ли использовать SFTP вместо него?xelaxela Автор
11.08.2017 21:141. Начал учится 2 недели назад, по классическому учебнику Марк Саммерфилд «Программирование на Python 3»,
это классический подход возбуждать собственные исключения, если верить Саммерфилду ))
2. Хорошее замечание, оказывается есть pysftp, судя по документации еще и проще чем ftplibnad_oby
14.08.2017 21:31Вот FTP это конечно зло и пароли тоже не комильфо.
Желательно перейти на SSH и аутентификацию по ключу.
Еще при неполадках неплохо письмо слать.
import paramiko
import scp
import smtplib
Я с серверов подобной связкой логи прямо в juputer notebook качаю.
Для быстрого поиска аномалий.
omgiafs
11.08.2017 21:06Backup-manager.
Бэкап чего угодно (файлы/mysql/pgsql), шифрование, инкрементные бэкапы, авторотация, логи, хеш-суммы, аплоад через/в scp, ssh-gpg, ftp, rsync, Amazon S3…
Ну а на Питоне можно что-нибудь другое потренировать, менее критичное, нежели работу с резервными копиями.
Хотя дело ваше.
d1m
11.08.2017 23:48+1Про MySQL. Зависит от сложности и размера БД, но я бы посоветовал обратить внимание на следующее:
- В приведенном варианте восстановить БД отдельно взятого клиента так просто не получится. Лучше бекапить каждую БД отдельно.
- Если у кого-то были процедуры, то их в бекапе не окажется.
- mysqldump делает дамп в текстовом виде. Если БД большая, то это и долго, и много места занимает. Потому лучше вывод mysqldump сразу передавать в gzip, минуя промежуточный этап записи на диск.
- Можно легко поймать ситуацию когда целостность данных в дампе нарушится. Потому хорошо добавить ключ
--single-transaction
. - Посмотреть percona xtrabackup. В этом случае получится делать бекап всех/некоторых БД быстро и не мешая пользователям, и с гарантированой целостностью.
xelaxela Автор
13.08.2017 11:11В приведенном варианте восстановить БД отдельно взятого клиента так просто не получится.
Скрипт делает бекап каждой базы отдельно.
Если у кого-то были процедуры, то их в бекапе не окажется.
Наверное Вы правы )
минуя промежуточный этап записи на диск
Тоже верно!
Потому хорошо добавить ключ --single-transaction
За ключ спасибо )
sadon
13.08.2017 11:11Скажите мне зачем для такого базового набора функционала Python?
Есть же bash и logrotate. Больше инструментов для этой задачи не нужно.
antirek
14.08.2017 07:27Пару месяцев назад с коллегой реализовали похожий велосипед https://github.com/antirek/backuper ))
— бэкап для mysql, pgsql, mongodb
— для каждой бд указывается свой конфиг-файл (очень удобно — добавил/удалил)
— бэкап копируется на ftp
— отправляет уведомление на емейл
TFStudio
Мне кажется не очень рационально так хранить. Всякое бывает в жизни. Лучше разделять на месячные копии, недельные и суточные.