Cosnomi
Cosnomi

医療×IT / 医学生 / Web(React, Flask) / 機械学習(画像認識, Keras)

Twitter / GitHub / Keybase

CentOS7のLVMバックアップ作業をPythonで自動化してみた

この記事は、下の 2 つの記事が前提となっておりますので、xfsdump や LVM スナップショットをご存知ない方はぜひ読んでみてください。

https://blog.cosnomi.com/archives/169

https://blog.cosnomi.com/archives/258

さて、前回までの記事でサーバーを停止せずにフルバックアップを取る方法を学びましたが、スナップショット領域を作成したり削除したりといろいろ面倒です。面倒なことは自動化するのが鉄則ですよね。ということで、今回はこのバックアップ作業を自動化したいと思います。

バックアップポリシー

いくらデータ消失が不安だからといって、毎日フルバックアップを行うのは、ストレージや負荷の面から現実的ではありません。xfsdump では差分バックアップを取れますので、私は次のような方針でバックアップを取りたいと思います。

  • 3 月と 9 月の第一火曜日にフルバックアップ
  • 毎週水曜日にはフルバックアップとの差分バックアップ
  • それ以外の日には水曜日のバックアップとの差分バックアップ

なお、日付の判定は複雑になるので Python 側で行い、同じスクリプトを毎日一定の時刻に cron で呼び出すようにします。

コード

コードをいくつかの部分に分けて紹介します。結構前に書いたコードで命名規則が python っぽくないですが、ご容赦ください。なお、コピペで済ませたい人は、最後にまとめてコードと変更すべき箇所を書いているのでそちらをご覧ください。

import

import subprocess
import datetime
import sys
import os

使うので import します。subprocess はシェルを実行するために、datetime は日付判定のために、sys は exit のために、os はファイル関係の操作のためにです。

バックアップレベルの判定

def ShouldFullBackup(dt):
    """
    戻り値
    True: 行う
    False: 行わない
    """
    if dt.weekday()!=1:
        return False
    if dt.day > 7:
        return False
    if dt.month not in (3,9):
        return False
    return True

def ShouldLv1Backup(dt):
    """
    水曜日にはLv1のバックアップを行う
    戻り値
    True: 行う
    False: 行わない
    """
    if dt.weekday()==2:
        return True
    return False

def GetLevel(dt):
    if ShouldFullBackup(dt):
        return 0
    if ShouldLv1Backup(dt):
        return 1
    else:
        return 2

この部分では datetime 型の変数 dt を用いて「フルバックアップを行うか」「Lv1 バックアップを行うか」を判定し、レベルの数字を返しています。フルバックアップは 0 です。

曜日判定を関数にすることで見通しが良くなります。自分好みに変えてみてください。

ファイルの命名

dump ファイルの名前に日付やレベルや場所の情報を入れて分かりやすくします。これらの情報を含めておかないと復元のときに面倒です。

def GetSaveName(dt, media, level):
    return '{0}-{1}-lv-{2}.dump'.format(dt.strftime('%Y-%m-%d-%H-%M-%S'), media, str(level))

各種コマンド

スナップショットを取ったり消したりなどのコマンドを、メソッドにして定義しておきます。

まず定数を宣言しておきます。

snap_path_prefix = '/mnt/snap-'
backup_path = '/mnt/backup1'
backup_dev = '/dev/sdb1'
vg_name = 'cl'

snappathprefix は、スナップショットのマウント先となるパスの前につくやつです。backupdev はバックアップを保存するディスクで、backuppath にマウントされます。vg_name はスナップショットのボリュームグループ名です。

基本的に変える必要がありそうなのは、backupdev(と backuppath)くらいですね。それ以外はそのままで大丈夫だと思います。

def DumpCommand(media_name, level, save_name, snap_path):
    com = ['/sbin/xfsdump', '-M', media_name, '-L', 'backup', '-l', str(level), '-u', '-f', save_name, snap_path, '-p', '300']
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Dump failed' ,'{0} failed:n{1}'.format(com, e.args))
        raise

def ZipCommand(save_name):
    com = ['/bin/pbzip2', '-p3', save_name]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Zip failed' ,'{0} failed:n{1}'.format(com, e.args))
        raise

def MakeSnapshotCommand(snap_name, media_path):
    com = ['/sbin/lvcreate', '-s', '--size=50G', '--name', snap_name, media_path]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Make snapshot failed' ,'{0} Snapshot failed:n{1}'.format(media_path, e.args))
        raise

def RemoveSnapshotCommand(snap_name):
    com = ['/sbin/lvremove', '{0}/{1}'.format(vg_name, snap_name), '-y']
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Remove snapshot failed' ,'Remove Snapshot failed:n{}'.format(e.args))
        raise

def MountCommand(source, target):
    com = ['/bin/mount', source, target]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Mount failed' ,'{0} Mount failed:n{1}'.format(source, e.args))
        raise

def MountSnapshotCommand(source, target):
    com = ['/bin/mount', '-t', 'xfs', '-o', 'ro,nouuid', source, target]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Mount snapshot failed' ,'{0} Mount failed:n{1}'.format(source, e.args))
        raise

def UmountCommand(target):
    com = ['/bin/umount', target]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Unmount failed' ,'{0} Umount failed:n{1}'.format(target, e.args))
        raise
    Log('Unmount Success')

def MakeMountDirectoryIfNot(snap_path):
    if not os.path.exists(snap_path):
        os.makedirs(snap_path)
    if not os.path.exists(backup_path):
        os.makedirs(backup_path)
    return

コードを見てもらえれば詳しい説明はいらないかと思いますが、ざっくり解説します。

各メソッドでは、subprocess.run を用いてシェルのコマンドを呼び出しています。snapname とはスナップショットの名前、snappath とはスナップショットへのパスです。

多分、これらのメソッドをどのように呼び出して使っているかを見てみたほうが早いと思うので次に進みます。

バックアップ

if __name__ == '__main__':
    target = [['root', '/dev/mapper/cl-root'], ['home', '/dev/mapper/cl-home']] # ['メディア', '場所']
    dt = datetime.datetime.now()
    MountCommand(backup_dev, backup_path)
    os.chdir(backup_path)
    Log('Backup Mount Success')
    try:
        for t in target:
            try:
                snap_name = 'snap-{}'.format(t[0])
                snap_path = snap_path_prefix + t[0]
                MakeSnapshotCommand(snap_name, t[1])
                MakeMountDirectoryIfNot(snap_path)
                MountSnapshotCommand('/dev/{0}/snap-{1}'.format(vg_name, t[0]), snap_path)
                level = GetLevel(dt)
                save_name = GetSaveName(dt,t[0], level)
                if os.path.exists(save_name):
                    Error('Duplication Error', 'Duplication Error : {}'.format(save_name))
                    raise Exception()
                DumpCommand(t[0], level, save_name, snap_path)
            except subprocess.CalledProcessError:
                raise
            finally:
                try:
                    UmountCommand(snap_path)
                except:
                    pass
                try:
                    RemoveSnapshotCommand(snap_name)
                except:
                    pass
            ZipCommand(save_name)
    except subprocess.CalledProcessError:
        sys.exit()
    finally:
        os.chdir('/')
        UmountCommand(backup_path)

これまで用意してきたメソッドを用いて実際にバックアップの処理をしている部分です。

target は、第 1 要素がバックアップ対象の領域の名前(自由)で、第 2 要素がその領域のパスとなるようなリストのリストになります。ここは、自分の環境に合わせて適宜変更してください。

バックアップファイルを保存するディスクをマウントして、スナップショットを撮ったあと、それをマウントして、xfsdump でダンプをとった後、その dump ファイルを圧縮するという流れです。これをバックアップ対象の領域の数だけ繰り返しています。すべて完了したら、バックアップファイルを保存するディスクを umount します。

まとめると…

下のスクリプトを適当な場所に配置して、cron で適当な時間(深夜か早朝あたり)に呼び出してください。あくまで一例ですが、普段は 2 時間くらいで完了します。(フルバックアップ時はかなり時間かかりますが)

import subprocess
import datetime
import sys
import os

"""
cronからAM4:15に呼ばれる予定
要ルート権限
"""
snap_path_prefix = '/mnt/snap-'
backup_path = '/mnt/backup1'
backup_dev = '/dev/sdb1'
vg_name = 'cl'

def Log(title='Backup(server1)', str='', priority=-1):
    subprocess.call('echo %s' % str, shell=True)

def Error(title, str):
    subprocess.call('echo %s | mail -s "***Snapshot Backup System Error***" -r root guppy' % str, shell=True)

def ShouldFullBackup(dt):
    """
    1,5,9月の最初の火曜日にはフルバックアップを行う
    戻り値
    True: 行う
    False: 行わない
    """
    if dt.weekday()!=1:
        return False
    if dt.day > 7:
        return False
    if dt.month not in (3,9):
        return False
    return True

def ShouldLv1Backup(dt):
    """
    水曜日にはLv1のバックアップを行う
    戻り値
    True: 行う
    False: 行わない
    """
    if dt.weekday()==2:
        return True
    return False

def GetLevel(dt):
    if ShouldFullBackup(dt):
        return 0
    if ShouldLv1Backup(dt):
        return 1
    else:
        return 2

def GetSaveName(dt, media, level):
    return '{0}-{1}-lv-{2}.dump'.format(dt.strftime('%Y-%m-%d-%H-%M-%S'), media, str(level))

def DumpCommand(media_name, level, save_name, snap_path):
    com = ['/sbin/xfsdump', '-M', media_name, '-L', 'backup', '-l', str(level), '-u', '-f', save_name, snap_path, '-p', '300']
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Dump failed' ,'{0} failed:n{1}'.format(com, e.args))
        raise

def ZipCommand(save_name):
    com = ['/bin/pbzip2', '-p3', save_name]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Zip failed' ,'{0} failed:n{1}'.format(com, e.args))
        raise

def MakeSnapshotCommand(snap_name, media_path):
    com = ['/sbin/lvcreate', '-s', '--size=50G', '--name', snap_name, media_path]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Make snapshot failed' ,'{0} Snapshot failed:n{1}'.format(media_path, e.args))
        raise

def RemoveSnapshotCommand(snap_name):
    com = ['/sbin/lvremove', '{0}/{1}'.format(vg_name, snap_name), '-y']
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Remove snapshot failed' ,'Remove Snapshot failed:n{}'.format(e.args))
        raise

def MountCommand(source, target):
    com = ['/bin/mount', source, target]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Mount failed' ,'{0} Mount failed:n{1}'.format(source, e.args))
        raise

def MountSnapshotCommand(source, target):
    com = ['/bin/mount', '-t', 'xfs', '-o', 'ro,nouuid', source, target]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Mount snapshot failed' ,'{0} Mount failed:n{1}'.format(source, e.args))
        raise

def UmountCommand(target):
    com = ['/bin/umount', target]
    try:
        subprocess.run(com)
    except subprocess.CalledProcessError as e:
        Error('[ERROR]Unmount failed' ,'{0} Umount failed:n{1}'.format(target, e.args))
        raise

def MakeMountDirectoryIfNot(snap_path):
    if not os.path.exists(snap_path):
        os.makedirs(snap_path)
    if not os.path.exists(backup_path):
        os.makedirs(backup_path)
    return

if __name__ == '__main__':
    target = [['root', '/dev/mapper/cl-root'], ['home', '/dev/mapper/cl-home']] # ['メディア', '場所']
    dt = datetime.datetime.now()
    MountCommand(backup_dev, backup_path)
    os.chdir(backup_path)
    try:
        for t in target:
            try:
                snap_name = 'snap-{}'.format(t[0])
                snap_path = snap_path_prefix + t[0]
                MakeSnapshotCommand(snap_name, t[1])
                MakeMountDirectoryIfNot(snap_path)
                MountSnapshotCommand('/dev/{0}/snap-{1}'.format(vg_name, t[0]), snap_path)
                level = GetLevel(dt)
                save_name = GetSaveName(dt,t[0], level)
                if os.path.exists(save_name):
                    Error('Duplication Error', 'Duplication Error : {}'.format(save_name))
                    raise Exception()
                DumpCommand(t[0], level, save_name, snap_path)
            except subprocess.CalledProcessError:
                raise
            finally:
                try:
                    UmountCommand(snap_path)
                except:
                    pass
                try:
                    RemoveSnapshotCommand(snap_name)
                except:
                    pass
            ZipCommand(save_name)
    except subprocess.CalledProcessError:
        sys.exit()
    finally:
        os.chdir('/')
        UmountCommand(backup_path)

こうなります。コピペで済ませたい人も多いので最低限変更するべき箇所をまとめると、

  • backup_dev
  • backup_path
  • vg_name
  • バックアップポリシーの箇所(何曜日にどのレベルのバックアップをするか)
  • target

あたりです。

最後に

お疲れ様でした。

バックアップは重要ですが、毎回マニュアルに沿って同じ作業を繰り返すのはミスを誘発しますし、エンジニアらしくありませんから、面倒な部分は自動化してより本質的な部分に集中できるようにしたいものですね。

(紹介したスクリプトは私の自宅サーバーで使用しているものですが、利用は自己責任でお願いします。)


コメントフォームは設置していませんので、ご意見・ご感想などはTwitter(@cosnomi)などへお願いします。