HOME Python

Maildir のバックアップ


1. 初めに

紫藤の自宅サーバでは、メールを Maildir 形式で保存しています。

Maildir 形式はメッセージを別々のファイルとして保存する電子メール格納方式で、そのファイル名はシステムで一意であることが保障されています。 この性質を利用して、効率的にメールをバックアップするスクリプトを書いてみました。

スクリプトの仕様は以下の通りです。

  1. メールは圧縮してバックアップすること。
  2. 新着メールのみを追加でバックアップしていくこと。(Maildir 内を移動したメッセージについては、path のみを更新し、コンテンツは変更しない。)
  3. バックアップファイルには、Maildir の情報すべてが保持されていること。
Maildir 中のファイルを手動で書き換えることは通常行いません。従って、ファイル名と内容は対応しているといえます。 メールファイル名はシステムごとに一意なので、辞書のキーになることができます。 そこで、
  1. メールファイル名 => メールファイルパス の辞書 (DP)。
  2. メールファイル名 => メールのコンテンツ の辞書 (DC)。コンテンツは圧縮して保持する。
の2つの辞書でメールをバックアップすることを考えます。 Maildir の内容が変化するのは次の 3 つの場合は考えられます。
  1. 新着メールがある
  2. メールを別のフォルダーに移動する
  3. メールを削除する
1 の場合、DP と DC の両方にデータを挿入します。
2 の場合は DP のみ更新します。
3 の場合は何も行いません。 削除したメールが backup に残っていても全く問題はありませんし、むしろ、誤って削除したメールを復活できるという利点があります。

2. 実装

2.1. データベースの選択

辞書をストレージに保持する方法はいくつか考えられます。
  1. gdbm を使う。
  2. pickle を使って python の辞書をストレージにダンプする。
  3. sqliteを使う。
pickle を使う方法は、バックアップ作業のたびに全てのバックアップデータをメモリーにロードする必要があるので、現実的でありません。
sqlite と gdbm は一長一短ですが、RDBM は必要ないので、key-value 型の gdbm にしました。 gdbm は、処理速度、メモリー使用量で有すぐれており、Linux 上で動作する多くのアプリケーションで採用されています。 (wikipedia参照。)

2.2. バックアップ用ソースコード

[code 1] にバックアップ用ソースコードを示します。 MailBackup がバックアップを取るクラスで、 Maildir を渡り歩いて、前回のバックアップから変更があったメールファイルについて、 path と contents をそれぞれ /var/backup_mail/$USER/mail_path.db, /var/backup_mail/$USER/mail_contents.db に保存していきます。

[code 1] backup_mail.py

001:   #!/usr/bin/python3
002:   
003:   '''
004:   Maildir のバックアップを取るスクリプト
005:   Maildir 形式では、ファイル名が一意に決まることを利用して、
006:   ファイル名をキーとしてメールファイルのストレージ上での場所と内容を
007:   別々の辞書として保持する。
008:   
009:   辞書の実装として gdbm を用いる。
010:   メールの内容は内容は zlib で圧縮する。
011:   '''
012:   
013:   import os, os.path, zlib, re
014:   
015:   #dbm.gnu がインストールされている必要がある。
016:   import dbm.gnu as dbm
017:   
018:   # 圧縮レベル
019:   CLEVEL=3
020:   
021:   # このサイズを超えると flush する
022:   BUF_SIZE=0x100000 # 1 M
023:   
024:   # バックアップを取らないメールのリストを書いたファイル。HOME 直下に置いておく
025:   EXCLUDE='.exclude4mailbackup'
026:   
027:   
028:   def walk(f, d0='.', excludes=('.Junk',)):
029:       '''ディレクトリを渡り歩いて、ファイルを処理する。'''
030:       def _iter(d):
031:           for e in os.listdir(d):
032:               if e in excludes: # 除外リストにある場合は処理しない
033:                   continue
034:               p=os.path.join(d,e)
035:               if os.path.isdir(p): # ディレクトリの場合は再帰的に処理を続ける
036:                   _iter(p)
037:               elif os.path.isfile(p): # ファイルの場合は関数 f を適用する。
038:                   f(p)
039:                   
040:       _iter(d0)
041:   
042:   
043:   def get_contents(_path):
044:       '''メールの内容を圧縮する'''
045:       with open(_path, 'rb') as f:
046:           return zlib.compress(f.read(), CLEVEL)
047:   
048:   
049:   RE_U=re.compile(r'[,:]')
050:   def get_ukey(p):
051:       '''メールのファイル名から一意な文字列を取り出す'''
052:       return RE_U.split(os.path.basename(p),1)[0].encode('ascii')
053:   
054:   
055:   def get_status(db,k,v):
056:       '''path のdb の状態'''
057:       return 'insert' if k not in db \
058:         else 'update' if db[k]!=v \
059:         else 'nothing'
060:   
061:       
062:   def get_exclude(d):
063:       '''除外するメールのリストを取得する'''
064:       fname=os.path.join(d, EXCLUDE)
065:       if os.path.exists(fname):
066:           with open(fname) as f:
067:               return tuple(line.strip() for line in f if line[0]!='#')
068:       else:
069:           return ('.Junk',)
070:                         
071:   def is_mail(p):
072:       '''メールファイルかどうか判定する'''
073:       d=os.path.dirname(p)
074:       return any(d.endswith(x) for x in ('cur', 'new', 'tmp'))
075:   
076:   
077:   
078:   class MailBackup:
079:       '''メールのバックアップを取るクラス'''
080:   
081:       BACKUP_ROOT='/var/backup_mail'
082:       
083:       def __init__(self, usr):
084:           self.db_path=dbm.open(os.path.join(self.BACKUP_ROOT, usr, 'mail_path.db'), 'cf')
085:           self.db_contents=dbm.open(os.path.join(self.BACKUP_ROOT, usr, 'mail_contents.db'), 'cf')
086:           self.counter=dict(path=0,contents=0)
087:   
088:       def __call__(self, p):
089:           if is_mail(p):
090:               # メールの path と contents を db に保存する
091:               bp=p.encode('ascii')
092:               ukey=get_ukey(p)
093:               status=get_status(self.db_path, ukey, bp)
094:               if status in ('insert', 'update'):
095:                   self.update_path(ukey, bp)
096:                   if status == 'insert':
097:                       self.add_contents(ukey, get_contents(p))
098:       
099:       def update_path(self, k, p):
100:           '''path を更新する'''
101:           self.db_path[k]=p
102:           self.db_flush('path', len(p))
103:           
104:       def add_contents(self, k, c):
105:           '''内容を追加する'''
106:           self.db_contents[k]=c
107:           self.db_flush('contents', len(c))
108:           
109:       def close(self):
110:           for dbname in ('db_path', 'db_contents'):
111:               db=getattr(self, dbname)
112:               db.sync()
113:               db.close()
114:       
115:       def db_flush(self, k, n):
116:       '''一定量を超えたらストレージに flush する'''
117:       if self.counter[k]+n > BUF_SIZE: 
118:           getattr(self, 'db_'+k).sync()
119:       self.counter[k]=0
120:           
121:        
122:   if __name__=='__main__':
123:       home, user = [os.getenv(x) for x in ('HOME', 'USER')]
124:       os.chdir(os.path.join(home, 'Maildir'))
125:       mail_backup=MailBackup(user)
126:       walk(mail_backup, '.', get_exclude(home))
127:       mail_backup.close()
128:       print ('backup mail for {} done.'.format(user))

2.3. cron 用スクリプト

[code 2] に示すようなスクリプトを使って、家族のメールのバックアップを毎晩取ります。 SCRIPT には実際の python スクリプトへのフルパスを、WE にはバックアップを取るユーザをスペースで区切って書きます。

su の -l オプションと -c オプションを使って、対象ユーザになってスクリプトを実行します。
[code 2]

001:   #!/bin/sh
002:   
003:   SCRIPT=/full/path/to/backup_mail.py
004:   WE="taro hanako saki mai"
005:   for ME in ${WE}
006:   do
007:     su -l $ME -c $SCRIPT
008:   done

2.4. リストア用スクリプト

リストア用のスクリプトを以下に示します。

[code 3]

001:   #!/usr/bin/python3
002:   
003:   '''backup_mail.py で作られたメールバックアップを復元する。'''
004:   
005:   import os, os.path, zlib, stat
006:   import dbm.gnu as dbm
007:   
008:   
009:   def restore_mail(path, contents):
010:       '''個々のメールのリストア'''
011:       ps=path.decode('ascii')
012:       d,f=os.path.split(ps)
013:       if not os.path.isdir(d):
014:           os.makedirs(d, 0o700)
015:       with open(ps, 'wb') as f:
016:           f.write(zlib.decompress(contents))
017:       os.chmod(ps, stat.S_IRUSR|stat.S_IWUSR)
018:           
019:       
020:   class MailRestore:
021:   
022:       BACKUP_ROOT='/var/backup_mail'
023:       
024:       def __init__(self, usr):
025:           dbname_path=os.path.join(self.BACKUP_ROOT, usr, 'mail_path.db')
026:           dbname_contents=os.path.join(self.BACKUP_ROOT, usr, 'mail_contents.db')
027:           assert all(os.path.exists(p) for p in (dbname_path, dbname_contents))
028:           self.db_path=dbm.open(dbname_path)
029:           self.db_contents=dbm.open(dbname_contents)
030:   
031:       def restore(self):
032:            k=self.db_contents.firstkey()
033:           while k is not None:
034:               if k in self.db_path:
035:                   restore_mail(self.db_path[k], self.db_contents[k])
036:               else:
037:                   print ('Path for {} does not exist'.format(k.decode('ascii')),
038:                          file=sys.stderr)
039:               k=self.db_contents.nextkey(k)
040:               
041:       def close(self):
042:           self.db_path.close()
043:           self.db_contents.close()
044:   
045:   
046:   if __name__=='__main__':
047:       os.chdir(os.path.join(os.getenv('HOME'), 'Maildir'))    
048:       agent=MailRestore(os.getenv('USER'))
049:       agent.restore()
050:       agent.close()

2.5. .exclude の書式

HOME 直下に .exclude4mailbackup というファイルを作って、バックアップから除外するディレクトリを指定することができます。 書式は、以下の例のように1行に1つづつ除外するディレクトリを書いていきます。このファイルがない場合は .Junk がバックアップ対象外 になります。
.Junk
.sale
.ready_to_delete

3. 性能

紫藤の Maildir のサイズは 2.6 Gb で、8 年分のメールがたまっています。 この Maildir のバックアップの速度と圧縮率は以下のようになりました。

3.1. 速度

この Maildir の新規バックアップおよびリストアは 40 min ほどで終了します。
また、1 日分の増分のバックアップは 3 min ほどで終了します。
速度は申し分ありません。

3.2. バックアップのファイルサイズ

バックアップのサイズは mail_path.db が 20 Mb、 mail_contents.db が 960 Mb です。合わせて 1 Gb ほどで、圧縮率は 40% ほどです。個々のメールを別々に圧縮していることを考えればまずまずでしょう。 また、最近はストレージの容量が大きくなったので、圧縮率はそれほどこだわらなくてもよくなっています。

4. 終わりに

以前は毎月のフルバックアップと毎日の差分バックアップでメールのバックアップを作成していたのでとても不便だったのですが、 このスクリプトでバックアップを取るようにしてからとても快適です。

皆さんも機会があったら試してみてください。