Резервное копирование базы mysql и файлов на удаленный FTP — Python 3

  • Tutorial
Начал изучать волшебный язык Python3 и решил испробовать его в действие на своем маленьком VPS.

На сервере стоит 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
  • +1
    Мне кажется не очень рационально так хранить. Всякое бывает в жизни. Лучше разделять на месячные копии, недельные и суточные.
    • 0
      Вычитывайте, пожалуйста, статью перед публикацией, текста на полстраницы и все равно неприятно читать:
      во общем

      Не понятный метод с не ясным

      и что бы не переполнять

      и т.д.
      • 0
        А с каких пор это нормально — светить пароли от БД и FTP в кронтабе?
        • 0
          Какие будут предложения? где хранить? можно в принципе с файла читать параметры.
          • 0
            ~/.my.cnf с правами 600
            + файл с паролем на ftp с теми же правами
        • 0
          Прочитал статью и возникло 2 вопроса:

          1. Зачем вам в начале скрипта аж три класса, которые ничего не делают?
          2. Почему по FTP? Этот протокол же небезопасный! Не лучше ли использовать SFTP вместо него?
          • 0
            1. Начал учится 2 недели назад, по классическому учебнику Марк Саммерфилд «Программирование на Python 3»,
            это классический подход возбуждать собственные исключения, если верить Саммерфилду ))
            2. Хорошее замечание, оказывается есть pysftp, судя по документации еще и проще чем ftplib
            • 0

              Вот FTP это конечно зло и пароли тоже не комильфо.
              Желательно перейти на SSH и аутентификацию по ключу.
              Еще при неполадках неплохо письмо слать.
              import paramiko
              import scp
              import smtplib
              Я с серверов подобной связкой логи прямо в juputer notebook качаю.
              Для быстрого поиска аномалий.

          • 0
            Backup-manager.
            Бэкап чего угодно (файлы/mysql/pgsql), шифрование, инкрементные бэкапы, авторотация, логи, хеш-суммы, аплоад через/в scp, ssh-gpg, ftp, rsync, Amazon S3…

            Ну а на Питоне можно что-нибудь другое потренировать, менее критичное, нежели работу с резервными копиями.
            Хотя дело ваше.
            • +1
              Настоящий самурай делает бэкапилку сам! Это как меч. Сделаешь плохо…
            • +1

              Про MySQL. Зависит от сложности и размера БД, но я бы посоветовал обратить внимание на следующее:


              • В приведенном варианте восстановить БД отдельно взятого клиента так просто не получится. Лучше бекапить каждую БД отдельно.
              • Если у кого-то были процедуры, то их в бекапе не окажется.
              • mysqldump делает дамп в текстовом виде. Если БД большая, то это и долго, и много места занимает. Потому лучше вывод mysqldump сразу передавать в gzip, минуя промежуточный этап записи на диск.
              • Можно легко поймать ситуацию когда целостность данных в дампе нарушится. Потому хорошо добавить ключ --single-transaction.
              • Посмотреть percona xtrabackup. В этом случае получится делать бекап всех/некоторых БД быстро и не мешая пользователям, и с гарантированой целостностью.
              • 0
                В приведенном варианте восстановить БД отдельно взятого клиента так просто не получится.

                Скрипт делает бекап каждой базы отдельно.
                Если у кого-то были процедуры, то их в бекапе не окажется.

                Наверное Вы правы )
                минуя промежуточный этап записи на диск

                Тоже верно!
                Потому хорошо добавить ключ --single-transaction

                За ключ спасибо )
              • 0
                Скажите мне зачем для такого базового набора функционала Python?
                Есть же bash и logrotate. Больше инструментов для этой задачи не нужно.
                • 0
                  Пару месяцев назад с коллегой реализовали похожий велосипед https://github.com/antirek/backuper ))
                  — бэкап для mysql, pgsql, mongodb
                  — для каждой бд указывается свой конфиг-файл (очень удобно — добавил/удалил)
                  — бэкап копируется на ftp
                  — отправляет уведомление на емейл

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.