UUID照合で誤挿入防止!Pythonでラズパイのフォルダをzipバックアップし、月初アーカイブとInfluxDB記録も実現する

InfluxDB

Raspberry Piは突然のSDカード故障やファイル破損が起きやすい環境です。大切なスマートホームのプログラムや設定ファイルを失わないために、定期的なバックアップは欠かせません。
本記事では、Pythonを使って指定フォルダをzip圧縮してUSB SDカードにバックアップする仕組みを解説します。単純なコピーではなく、3重の安全チェック(パス確認・マウント確認・UUID照合)で誤ったデバイスへの書き込みを防止し、月初バックアップの永久保持InfluxDBへの実行記録も備えています。

なぜこの機能を作ったのか?

スマートホームの中核となるRaspberry Piには、各種自動化プログラム・設定ファイル・ログなど、長期間かけて積み上げてきた大切なデータが入っています。
「電源断によるSDカードの突然死」「誤操作でのファイル削除」といったリスクは常にあります。クラウドへのバックアップも考えましたが、自宅内で完結する手軽さと確実性を優先し、物理的なUSB接続のSDカードへのバックアップを自動化することにしました。

ラズパイが突然壊れても、すぐに復元できる安心感が欲しい!

実現したいこと

  • 指定フォルダをzip圧縮してUSB接続のSDカードにバックアップする
  • バックアップ先のパス確認・マウント確認・UUID照合の3重チェックで誤書き込みを防ぐ
  • バックアップファイルは30日分を保持し、古いものは自動削除する
  • 各月の1日のバックアップは削除せず永久保持する(月初アーカイブ)
  • バックアップ実行結果(ファイルサイズ・空き容量・ファイル数)をInfluxDBに記録する
  • シェルスクリプト経由でcronやsystemdタイマーから自動実行できる

この記事でわかること

  • Pythonの zipfile モジュールを使ったフォルダのzip圧縮バックアップの方法
  • mountpointfindmntlsblk を使ったUSBデバイスの安全確認方法
  • UUID照合による「意図したSDカードかどうか」の確認方法
  • 30日保持+月初ファイルは永久保持するローテーション方式の実装方法
  • バックアップ結果をInfluxDB v2に記録する方法

必要な準備と用意するもの

ハードウェア
  • Raspberry Pi
  • USB接続のSDカードリーダー + SDカード(バックアップ先)
ソフトウェア/サービス
  • Python 3(標準ライブラリのみ使用:os / shutil / zipfile / datetime / subprocess)
  • util-linux(findmntmountpoint コマンド、通常はデフォルトインストール済み)
  • InfluxDB v2(バックアップ記録用・任意)

完成イメージ

cronやsystemdタイマーで定期実行されると、以下の流れでバックアップが行われます。

① SDカードがマウントされているか・UUIDが一致するか安全確認
② 指定フォルダ(例:smarthomeプロジェクトフォルダ)全体をzip圧縮
③ バックアップ先に YYYY-MM-DD_HH-MM-SS.zip という名前で保存
④ 30日より古いファイルを自動削除(ただし各月1日分は保持)
⑤ バックアップサイズ・空き容量・ファイル数をInfluxDBに記録

月日が経つと、バックアップフォルダには「直近30日分の日次バックアップ」と「各月1日の月初アーカイブ」が残ります。

システムの仕組み

3重の安全チェック

間違ったデバイスに大量のファイルを書き込んでしまう事故を防ぐため、バックアップ実行前に3段階の確認を行います。

  • ① パス存在確認os.path.exists() でバックアップ先ディレクトリが存在するか確認
  • ② マウント確認mountpoint -q でUSBがマウントされているか確認
  • ③ UUID照合findmnt + lsblk でデバイスのUUIDを取得し、設定値と一致するか確認

UUID照合で誤USB防止
パスが存在してマウントされていても、別のUSBデバイスが同じパスにマウントされている場合があります。UUIDでデバイスを特定することで「バックアップ専用SDカード以外には書き込まない」ことを保証できます。

月初アーカイブ保持の仕組み

通常は30日以上前のバックアップファイルを自動削除しますが、各月1日(月初)のバックアップファイルは削除しません。これにより、「直近1ヶ月は毎日」「それ以前は月単位」で遡れるローテーション戦略が自然に実現されます。

ファイル名が YYYY-MM-DD_HH-MM-SS.zip 形式のため、先頭10文字 (YYYY-MM-DD) をパースして日付を判定しています。月初かどうかは file_date.day == 1 で判定します。

コマンドライン引数による柔軟な設定

バックアップ元フォルダとバックアップ先フォルダ名をコマンドライン引数で受け取るため、1つのPythonスクリプトで複数の対象に使い回せます。シェルスクリプトに引数を並べるだけで対象を追加できます。

実装のポイント

zipfile.ZIP_DEFLATEDによる圧縮

Pythonの zipfile モジュールで ZIP_DEFLATED を指定することで、zlibによる圧縮が有効になります。os.walk() でフォルダ内を再帰的にたどり、各ファイルを zip に追加します。
zipf.write(file_path, os.path.relpath(file_path, source_folder)) によって、zip内のパスをソースフォルダからの相対パスにすることで、展開時に自然なディレクトリ構造が再現されます。

UUIDの取得方法

UUIDを取得するには2段階の処理が必要です。

  • Step1findmnt -n -o SOURCE --target <マウントポイント> でマウントポイントに対応するデバイス名(例:/dev/sda1)を取得
  • Step2lsblk -no UUID <デバイス名> でデバイスのUUIDを取得

事前準備

SDカードのマウント設定(/etc/fstab)

まずSDカードのUUIDを確認します。

lsblk -f

出力の UUID 列からバックアップ用SDカードのUUIDをメモします。
次に /etc/fstab に自動マウントの設定を追加します。

UUID=xxxx-xxxx  /mnt/usb  vfat  defaults,nofail,uid=1000,gid=1000  0  0

nofail オプションは必ず付けてください。SDカードが挿さっていない状態でも起動がハングアップしないようにするための設定です。

sudo mkdir -p /mnt/usb
sudo mount -a          # fstabを再読み込みしてマウント
mountpoint /mnt/usb    # マウント確認

settings.jsonへの設定追加

{
    "usb_mount_path": "/mnt/usb",
    "backup_dir": "/mnt/usb/backup",
    "backup_usb_uuid": "xxxx-xxxx"
}

backup_usb_uuid には lsblk -f で確認したSDカードのUUIDを設定します。

実装方法

バックアップ本体(raspi_backup.py)

# -*- coding: utf-8 -*-
import os
import sys
import shutil
import zipfile
import subprocess
import json
from datetime import datetime, timedelta

# ===== 設定読み込み =====
with open('/home/<username>/projects/smarthome/config/settings.json') as f:
    settings = json.load(f)

usb_mount_path = settings['usb_mount_path']   # 例: /mnt/usb
backup_dir     = settings['backup_dir']        # 例: /mnt/usb/backup
expected_uuid  = settings.get('backup_usb_uuid')  # 例: xxxx-xxxx

# ===== ① パス存在確認 =====
if not os.path.exists(backup_dir):
    raise RuntimeError(f"バックアップ先が存在しません: {backup_dir}")

# ===== ② マウント確認 =====
result = subprocess.run(["mountpoint", "-q", usb_mount_path])
if result.returncode != 0:
    raise RuntimeError(f"バックアップ先はマウントされていません: {usb_mount_path}")

# ===== ③ UUID照合(誤USB防止) =====
if expected_uuid:
    device = subprocess.check_output(
        ["findmnt", "-n", "-o", "SOURCE", "--target", usb_mount_path]
    ).decode().strip()

    current_uuid = subprocess.check_output(
        ["lsblk", "-no", "UUID", device]
    ).decode().strip()

    if current_uuid != expected_uuid:
        raise RuntimeError(
            f"USB UUIDが一致しません。期待値:{expected_uuid} 実際:{current_uuid}"
        )

# ===== 引数チェック =====
if len(sys.argv) < 3:
    print('引数にバックアップ元フォルダとバックアップ先フォルダ名を指定してください。')
    sys.exit()

source_folder    = sys.argv[1]   # バックアップ対象フォルダのパス
backup_foldername = sys.argv[2]  # バックアップ先のサブフォルダ名
backup_folder    = os.path.join(backup_dir, backup_foldername)
backup_days      = 30

# ===== 日付・パス設定 =====
current_date      = datetime.now()
backup_cutoff_date = current_date - timedelta(days=backup_days)
backup_filename   = current_date.strftime('%Y-%m-%d_%H-%M-%S') + '.zip'
backup_filepath   = os.path.join(backup_folder, backup_filename)

# バックアップフォルダが存在しない場合は作成
if not os.path.exists(backup_folder):
    os.makedirs(backup_folder)

# ===== zip圧縮してバックアップ =====
with zipfile.ZipFile(backup_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
    for root, dirs, files in os.walk(source_folder):
        for file in files:
            file_path = os.path.join(root, file)
            zipf.write(file_path, os.path.relpath(file_path, source_folder))

# ===== 古いバックアップファイルを削除(月初は保持) =====
for backup_file in os.listdir(backup_folder):
    backup_file_path = os.path.join(backup_folder, backup_file)
    file_date = datetime.strptime(backup_file[:10], '%Y-%m-%d')
    if file_date < backup_cutoff_date and file_date.day != 1:
        os.remove(backup_file_path)

# ===== 実行結果の情報取得 =====
backup_size = os.path.getsize(backup_filepath)
total, used, free = shutil.disk_usage(backup_dir)
file_count = sum(len(files) for _, _, files in os.walk(backup_dir))

print(f'バックアップ完了: {backup_filepath}')
print(f'ファイルサイズ: {backup_size / (1024*1024):.2f} MB')
print(f'空き容量: {free / (1024**3):.2f} GB')
print(f'バックアップファイル総数: {file_count} 個')

実行用シェルスクリプト(raspi_backup.sh)

バックアップ対象が増えた場合は、行を追加するだけで対応できます。

#!/bin/bash
# smarthomeプロジェクトフォルダをバックアップ
/home/<username>/projects/venv/bin/python \
    /home/<username>/projects/smarthome/maintenance/raspi_backup.py \
    "/home/<username>/projects/smarthome" \
    "smarthome"

# 追加対象の例(コメントアウトを外すと有効になる)
#/home/<username>/projects/venv/bin/python \
#    /home/<username>/projects/smarthome/maintenance/raspi_backup.py \
#    "/home/<username>/projects/minecraft" \
#    "minecraft"

cronによる自動実行設定

毎日午前3時にバックアップを実行するcron設定の例です。

crontab -e
# 毎日午前3時にバックアップを実行
0 3 * * * /bin/bash /home/<username>/projects/smarthome/maintenance/raspi_backup.sh >> /home/<username>/projects/smarthome/logs/backup.log 2>&1

コードの解説

UUID照合の処理フロー

device = subprocess.check_output(
    ["findmnt", "-n", "-o", "SOURCE", "--target", usb_mount_path]
).decode().strip()
# 例: /dev/sda1

current_uuid = subprocess.check_output(
    ["lsblk", "-no", "UUID", device]
).decode().strip()
# 例: 699A-BACB

if current_uuid != expected_uuid:
    raise RuntimeError(...)

findmnt でマウントポイントからデバイス名(例:/dev/sda1)を取得し、lsblk でそのデバイスのUUIDを取得します。
settings.json に登録してあるUUIDと一致しない場合は例外を投げてバックアップを中止します。

zip内のパスを相対パスで記録する理由

zipf.write(file_path, os.path.relpath(file_path, source_folder))

第2引数に os.path.relpath(file_path, source_folder) を渡すことで、zipファイル内のパスをバックアップ元フォルダからの相対パスにします。
これを省略するとフルパス(/home/<username>/projects/smarthome/...)でzipに収録されてしまい、展開時に意図しないディレクトリ構造になります。

月初アーカイブ保持の条件

file_date = datetime.strptime(backup_file[:10], '%Y-%m-%d')
if file_date < backup_cutoff_date and file_date.day != 1:
    os.remove(backup_file_path)

削除条件は「30日より古い」かつ「月初(1日)ではない」の両方を満たす場合のみです。
file_date.day != 1 で月初のファイルを保護することにより、各月の1日に作成されたバックアップは半永久的に保持され続けます。

月初バックアップが溜まりすぎる場合は
月初のみを保持する期間に上限を設けたい場合は、file_date.day != 1 の条件に加えて or file_date < current_date - timedelta(days=365) のような上限日数を追加することで、1年以上前の月初バックアップも削除できます。

InfluxDBへの記録内容

  • measurement:backup_log
  • タグ:backup_target(サブフォルダ名)、source_folder(元フォルダ)、backup_folder(先フォルダ)、backup_filename(ファイル名)
  • フィールド:backup_size(バイト)、used_size(バイト)、free_size(バイト)、total_size(バイト)、file_count(個)

動作確認

手動実行で確認

bash /home/<username>/projects/smarthome/maintenance/raspi_backup.sh

実行後、バックアップ先に YYYY-MM-DD_HH-MM-SS.zip ファイルが作成されているか確認します。

ls -lh /mnt/usb/backup/smarthome/

zipファイルの中身を確認

unzip -l /mnt/usb/backup/smarthome/YYYY-MM-DD_HH-MM-SS.zip | head -20

バックアップ対象フォルダの内容が相対パスで収録されているか確認します。

安全チェックの動作確認

意図的にSDカードを抜いた状態で実行すると、マウント確認エラーが発生してバックアップが中断されることを確認してください。

sudo umount /mnt/usb
python3 raspi_backup.py "/home/<username>/projects/smarthome" "smarthome"
# → RuntimeError: バックアップ先はマウントされていません: /mnt/usb

まとめ

本記事では、Pythonを使ってラズパイのフォルダをzip圧縮してSDカードにバックアップする仕組みを解説しました。

  • 3重の安全チェック(パス・マウント・UUID)で誤ったデバイスへの書き込みを確実に防止できる
  • 月初アーカイブ保持file_date.day != 1)により、直近1ヶ月は毎日・それ以前は月次でバックアップが残るローテーションが実現できる
  • zipf.write(file_path, os.path.relpath(...))相対パス記録にすることで、展開時に元のフォルダ構造が自然に再現される
  • シェルスクリプト経由でコマンドライン引数を渡す設計にすることで、複数のバックアップ対象を1スクリプトで管理できる
  • バックアップ結果をInfluxDBに記録することで、Grafanaでディスク残量・ファイル数の推移を可視化でき、SDカードの空き不足を事前に把握できる

cronやsystemdタイマーと組み合わせることで、毎日自動でバックアップが実行される環境が整います。大切なスマートホームのデータを守るために、ぜひ導入を検討してみてください。

タイトルとURLをコピーしました