RaspberryPiイメージを縮小してバックアップするプログラムを作ってみました

使用していない領域を切り詰めてバックアップするプログラムです。ブロックやセクタの計算はおまかせで。
2020.08.24

1 はじめに

CX事業本部の平内(SIN)です。

RaspberryPiは、Liteのような小さなイメージでも、初回起動時にSDカードの容量いっぱいまで広げられるので、使用中のイメージの複製やバックアップをしようとすると、カードのサイズに応じて大きなサイズを相手する事になってします。

使用していない領域を切り詰めて小さくバックアップする事は可能ですが、ブロックの縮小や、パーティションの変更がやや厄介です。 今回は、これを軽易に行えるようにプログラムしてみました。

動画は、これを利用している様子です。16GのSDカードに入ったイメージが2Gでバックアップされます。

2 構成

使用しているのは、RaspberryPi 3Bで、OSは、今年5月の最新版(Raspberry Pi OS (32-bit) with desktop and recommended software)です。

$ cat /proc/cpuinfo  | grep Revision
Revision	: a32082

$ lsb_release -a
No LSB modules are available.
Distributor ID:	Raspbian
Description:	Raspbian GNU/Linux 10 (buster)
Release:	10
Codename:	buster

$ uname -a
Linux raspberrypi 5.4.51-v7+ #1327 SMP Thu Jul 23 10:58:46 BST 2020 armv7l GNU/Linux

コピー元となるSDカードとコピー先となるドライブをUSBドライブに接続して使用する構成になっています。

3 作業内容

作業している内容は、概ね以下のとおりです。

  • 元イメージを必要最小ブロックまで縮小
  • 元イメージのパーティションを縮小
  • ddによるイメージのコピー
  • 元イメージのパーティションを拡大
  • 元イメージのブロック数を拡大

詳しくは、下記で紹介させて頂いております。

4 コード

プログラムの本体です。

途中、コピー元のイメージのブロックサイズやパーティションの変更が行われますが、使用する環境によっては、誤動作する可能性もあります。その場合、手動での復旧が必要になります。申し訳ありませんが、利用は、自己責任とさせて下さい。

MinimumBackup.py

import logging
import datetime
import os
import subprocess
from subprocess import PIPE

logLevel = "DEBUG"
# logLevel = "INFO"
logger = logging.getLogger("MinimumBackup")
logger.setLevel(logLevel)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(asctime)s] [%(process)d] [%(name)s] [%(levelname)s] %(message)s"))
logger.addHandler(handler)

def exec(cmd):
    logger.debug("> {}".format(cmd))
    proc = subprocess.run(cmd, shell=True, stdout=PIPE, stderr=PIPE, text=True)
    return proc.stdout

def getDevice():
    logger.info("df コマンドにより/dev/sd*を検出し、入力(RasPiイメージ)と出力(USBドライブ)を検出する")
    src = None
    dst = None
    result = exec("df -H")
    for line in result.split('\n'):
        if('Filesystem' in line):
            logger.debug(line)
        if('/dev/sd' in line):
            logger.debug(line)
            tmp = line.split()
            stat = {
                "dev": tmp[0][0:9],
                "size": tmp[1],
                "used": tmp[2],
                "avail": tmp[3],
                "path": tmp[5]
            }
            if('/media/pi/rootfs' in line):
                src = stat
            else:
                if(('/media/pi/boot' in line) == False):
                    dst = stat
    return (src, dst)


def getMinimumBlock(dev):
    logger.info("resize2fsにより、必要最小サイズを確認する")
    result = exec("sudo resize2fs -P {}".format(dev))
    logger.debug(result.replace('\n',''))
    return int(result.split()[6])

def resize(dev, newBlocks):
    logger.info("resize2fsにより サイズを変更する")
    os.system("sudo umount {}".format(dev))
    os.system("sudo e2fsck -f {}".format(dev))
    if(newBlocks!=None):
        os.system("sudo resize2fs -p {} {}".format(dev, newBlocks))
    else:
        os.system("sudo resize2fs -p {}".format(dev))

def getBlock(dev):
    logger.info("fdiskにより、パーティション情報を取得する")
    result = exec('sudo fdisk -l {}'.format(dev[0:8]))
    for line in result.split('\n'):
        if(line.startswith('Device')):
            logger.debug(line)
        if(line.startswith('/dev/sd')):
            logger.debug(line)
            tmp = line.split()
            if(tmp[0] == dev):
                stat = {
                    "start":  int(tmp[1]),
                    "end": int(tmp[2]),
                    "sectors": int(tmp[3]),
                    "size": tmp[4]
                }
                logger.debug(stat)
    return stat

def question(msg):
    while True:
        data = input("{} [y/n]:".format(msg)).lower()
        if data in ['y', 'ye', 'yes']:
            return True
        elif data in ['n', 'no']:
            return False

def changePermition(dev, start, end):
    logger.info("パーミッションを変更します {} start:{} end:{}".format(dev, start, end))
    line = 'p\nd\n2\nn\n\n\n' # 既存のパーティションを削除し、新しく作成する
    line += str(start) # 開始ブロック
    line += '\n'
    line += str(end) # 終了ブロック
    line += '\n'
    line += 'N\np\nw\nq\n' # 確認(表示)と書き込み

    d = dev[0:8]
    os.system("echo \"{}\" | sudo fdisk /dev/sda".format(line))
    
def backup(srcDev, dstDev, fileName, count):
    cmd = "sudo dd bs=1048576 if={} of={}/{} count={}".format(srcDev[0:8], dstDev, fileName, count)
    logger.debug("?:{}".format(cmd))
    os.system(cmd)

def main():

    logger.critical("*****************************")
    logger.critical("       MinimumBackup")
    logger.critical("*****************************")

    # 入出力デバイスの取得
    (src, dst) = getDevice()
    if(src == None):
        logger.error("入力のRasPiのイメージディスクが見つかりません")
        exit()
    if(dst == None):
        logger.error("出力用のUSBドライブが見つかりません")
        exit()
    logger.info("入力(RasPI)    {} size: {} used: {} {}".format(src["dev"], src["size"], src["used"], src["path"]))
    logger.info("出力(USBDrive) {} size: {} avail: {} {}".format(dst["dev"], dst["size"], dst["avail"], dst["path"]))

    # 必要最小ブロック数の取得
    minimumBlocks = getMinimumBlock(src["dev"])
    logger.info("必要最小サイズ: {:.1f}GB ({:,}) {:,}Blocks".format(minimumBlocks*4/1024/1024, minimumBlocks*4*1024, minimumBlocks))

    # 必要ブロック数 = 必要最小ブロック+余裕(2048)
    requiredBlocks = minimumBlocks + 2048
    logger.info("必要ブロック数:{} - 必要最小ブロック+2048(余裕)".format(requiredBlocks)) 
    # 必要セクタ数 = 必要なブロック数 * 8
    requiredSectors = requiredBlocks * 8 
    logger.info("必要セクタ数:{}".format(requiredSectors)) 
    
    # 現在のパーティション状態を取得
    beforeBlock = getBlock(src["dev"])
    logger.info("変更前の{}の開始ブロック:{} 終了ブロック:{}".format(src["dev"], beforeBlock["start"], beforeBlock["end"]))
    
    afterBlock = {
        "start": beforeBlock["start"],
        "end": beforeBlock["start"] + requiredSectors,
    }
    logger.info("変更後の{}の開始ブロック:{} 終了ブロック:{}".format(src["dev"], afterBlock["start"], afterBlock["end"]))


    # 書き込みが必要なブロック数(Mbyte) = (最終セクタ / 2048 ) + 2(余裕)
    count = int(afterBlock["end"] / 2048) + 2
    logger.info("バックアップするブロック数(Mbyte):{} ({:.1f}G)".format(count, count/1024))

    # 出力ファイル名
    now = datetime.datetime.now()
    fileName = now.strftime('raspi-%Y-%m-%d-%H-%M') + '.img'

    logger.critical("入力:{} {}".format(src["dev"],src["size"]))
    logger.critical("出力:{}/{} {:.1f}G (空き容量 {})".format(dst["path"], fileName, count/1024, dst["avail"]))
    logger.critical("作業中は、一時的にパーティションの変更が行われるので、中断しないで下さい")
    logger.critical("バックアップを開始して宜しいですか?[Y/N]")
    if(question("バックアップを開始して宜しいですか?") == False):
        exit()
    logger.info("Backup Start.")

    try:
        # ブロックサイズ変更    
        resize(src["dev"], requiredBlocks)
        # パーティション変更
        changePermition(src["dev"], afterBlock["start"], afterBlock["end"])
        # バックアップ
        logger.critical("バックアップ中です、そのままお待ち下さい")
        backup(src["dev"], dst["path"], fileName, count)
    finally:   
        # パーティション変更(戻す)
        changePermition(src["dev"], beforeBlock["start"], beforeBlock["end"])
        # ブロックサイズ変更(戻す)
        resize(src["dev"], None)

    output = "{}/{}".format(dst["path"], fileName)
    if(os.path.isfile(output) == False):
        logger.error("バックアップに失敗しました")
        exit()

    logger.critical("バックアップが完了しました {}".format(output))

main()

5 出力

ログレベルをDEBUGにした場合の出力は、以下のようになります。(各コマンドの出力は省略されています)

$ python3 MinimumBackup.py
[2020-08-23 08:11:37,735] [1124] [MinimumBackup] [CRITICAL] *****************************
[2020-08-23 08:11:37,736] [1124] [MinimumBackup] [CRITICAL]        MinimumBackup
[2020-08-23 08:11:37,736] [1124] [MinimumBackup] [CRITICAL] *****************************
[2020-08-23 08:11:37,737] [1124] [MinimumBackup] [INFO] df コマンドにより/dev/sd*を検出し、入力(RasPiイメージ)と出力(USBドライブ)を検出する
[2020-08-23 08:11:37,737] [1124] [MinimumBackup] [DEBUG] > df -H
[2020-08-23 08:11:37,755] [1124] [MinimumBackup] [DEBUG] Filesystem      Size  Used Avail Use% Mounted on
[2020-08-23 08:11:37,755] [1124] [MinimumBackup] [DEBUG] /dev/sdb1        16G  4.8G   12G  30% /media/pi/MYDISK
[2020-08-23 08:11:37,756] [1124] [MinimumBackup] [DEBUG] /dev/sda2        16G  1.3G   14G   9% /media/pi/rootfs
[2020-08-23 08:11:37,756] [1124] [MinimumBackup] [DEBUG] /dev/sda1       265M   55M  210M  21% /media/pi/boot
[2020-08-23 08:11:37,757] [1124] [MinimumBackup] [INFO] 入力(RasPI)    /dev/sda2 size: 16G used: 1.3G /media/pi/rootfs
[2020-08-23 08:11:37,757] [1124] [MinimumBackup] [INFO] 出力(USBDrive) /dev/sdb1 size: 16G avail: 12G /media/pi/MYDISK
[2020-08-23 08:11:37,758] [1124] [MinimumBackup] [INFO] resize2fsにより、必要最小サイズを確認する
[2020-08-23 08:11:37,758] [1124] [MinimumBackup] [DEBUG] > sudo resize2fs -P /dev/sda2
[2020-08-23 08:11:37,860] [1124] [MinimumBackup] [DEBUG] Estimated minimum size of the filesystem: 428543
[2020-08-23 08:11:37,860] [1124] [MinimumBackup] [INFO] 必要最小サイズ: 1.6GB (1,755,312,128) 428,543Blocks
[2020-08-23 08:11:37,861] [1124] [MinimumBackup] [INFO] 必要ブロック数:430591 - 必要最小ブロック+2048(余裕)
[2020-08-23 08:11:37,861] [1124] [MinimumBackup] [INFO] 必要セクタ数:3444728
[2020-08-23 08:11:37,861] [1124] [MinimumBackup] [INFO] fdiskにより、パーティション情報を取得する
[2020-08-23 08:11:37,861] [1124] [MinimumBackup] [DEBUG] > sudo fdisk -l /dev/sda
[2020-08-23 08:11:38,070] [1124] [MinimumBackup] [DEBUG] Device     Boot  Start      End  Sectors  Size Id Type
[2020-08-23 08:11:38,071] [1124] [MinimumBackup] [DEBUG] /dev/sda1         8192   532479   524288  256M  c W95 FAT32 (LBA)
[2020-08-23 08:11:38,071] [1124] [MinimumBackup] [DEBUG] /dev/sda2       532480 30744575 30212096 14.4G 83 Linux
[2020-08-23 08:11:38,072] [1124] [MinimumBackup] [DEBUG] {'start': 532480, 'end': 30744575, 'sectors': 30212096, 'size': '14.4G'}
[2020-08-23 08:11:38,072] [1124] [MinimumBackup] [INFO] 変更前の/dev/sda2の開始ブロック:532480 終了ブロック:30744575
[2020-08-23 08:11:38,073] [1124] [MinimumBackup] [INFO] 変更後の/dev/sda2の開始ブロック:532480 終了ブロック:3977208
[2020-08-23 08:11:38,073] [1124] [MinimumBackup] [INFO] バックアップするブロック数(Mbyte):1943 (1.9G)
[2020-08-23 08:11:38,074] [1124] [MinimumBackup] [CRITICAL] 入力:/dev/sda2 16G
[2020-08-23 08:11:38,074] [1124] [MinimumBackup] [CRITICAL] 出力:/media/pi/MYDISK/raspi-2020-08-23-08-11.img 1.9G (空き容量 12G)
[2020-08-23 08:11:38,075] [1124] [MinimumBackup] [CRITICAL] 作業中は、一時的にパーティションの変更が行われるので、中断しないで下さい
[2020-08-23 08:11:38,075] [1124] [MinimumBackup] [CRITICAL] バックアップを開始して宜しいですか?[Y/N]
バックアップを開始して宜しいですか? [y/n]:y
[2020-08-23 08:11:43,784] [1124] [MinimumBackup] [INFO] Backup Start.
[2020-08-23 08:11:44,045] [1124] [MinimumBackup] [INFO] resize2fsにより サイズを変更する
[2020-08-23 08:12:05,335] [1124] [MinimumBackup] [INFO] パーミッションを変更します /dev/sda2 start:532480 end:3977208
[2020-08-23 08:12:05,688] [1124] [MinimumBackup] [CRITICAL] バックアップ中です、そのままお待ち下さい
[2020-08-23 08:12:05,689] [1124] [MinimumBackup] [DEBUG] ?:sudo dd bs=1048576 if=/dev/sda of=/media/pi/MYDISK/raspi-2020-08-23-08-11.img count=1943
1943+0 records in
1943+0 records out
2037383168 bytes (2.0 GB, 1.9 GiB) copied, 315.226 s, 6.5 MB/s
[2020-08-23 08:17:21,112] [1124] [MinimumBackup] [INFO] パーミッションを変更します /dev/sda2 start:532480 end:30744575
[2020-08-23 08:17:21,395] [1124] [MinimumBackup] [INFO] resize2fsにより サイズを変更する
[2020-08-23 08:17:43,662] [1124] [MinimumBackup] [CRITICAL] バックアップが完了しました /media/pi/MYDISK/raspi-2020-08-23-08-11.img

6 コピーされたイメージ

USBメモリに保存されたイメージは、2.04Gでした。また、圧縮すると666MBとなってました。

balenaEtcherで別のSDカードにコピーします。

コピーされたイメージを起動すると、ディスクのサイズが、1.8Gになっています。 これは、縮小されたイメージをコピーしているからです。

pi@raspberrypi:~ $ df -H
Filesystem      Size  Used Avail Use% Mounted on
/dev/root       1.8G  1.3G  368M  78% /
devtmpfs        481M     0  481M   0% /dev
tmpfs           486M     0  486M   0% /dev/shm
tmpfs           486M   13M  474M   3% /run
tmpfs           5.3M  4.1k  5.3M   1% /run/lock
tmpfs           486M     0  486M   0% /sys/fs/cgroup
/dev/mmcblk0p1  265M   55M  210M  21% /boot
tmpfs            98M     0   98M   0% /run/user/1000

もし、SDカードのサイズまで拡張するとしたら、以下の手順が必要です。

  • fdiskでパーティションを広げる
  • resize2fsでブロックを拡張する

(1) fdisk

以下がfdiskでパーティションを拡張している様子です。

縮小されている2つ目のパーティションを一旦削除して、改めて最大サイズで作成しています。

pi@raspberrypi:~ $ sudo fdisk /dev/mmcblk0

Welcome to fdisk (util-linux 2.33.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.


Command (m for help): p
Disk /dev/mmcblk0: 14.9 GiB, 15931539456 bytes, 31116288 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x18ed9157

Device         Boot  Start     End Sectors  Size Id Type
/dev/mmcblk0p1        8192  532479  524288  256M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      532480 3977216 3444737  1.7G 83 Linux

Command (m for help): d
Partition number (1,2, default 2): 2

Partition 2 has been deleted.

Command (m for help): p
Disk /dev/mmcblk0: 14.9 GiB, 15931539456 bytes, 31116288 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x18ed9157

Device         Boot Start    End Sectors  Size Id Type
/dev/mmcblk0p1       8192 532479  524288  256M  c W95 FAT32 (LBA)

Command (m for help): n
Partition type
   p   primary (1 primary, 0 extended, 3 free)
   e   extended (container for logical partitions)
Select (default p): p
Partition number (2-4, default 2): 2
First sector (2048-31116287, default 2048): 532480
Last sector, +/-sectors or +/-size{K,M,G,T,P} (532480-31116287, default 31116287):

Created a new partition 2 of type 'Linux' and of size 14.6 GiB.
Partition #2 contains a ext4 signature.

Do you want to remove the signature? [Y]es/[N]o: N

Command (m for help): p

Disk /dev/mmcblk0: 14.9 GiB, 15931539456 bytes, 31116288 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x18ed9157

Device         Boot  Start      End  Sectors  Size Id Type
/dev/mmcblk0p1        8192   532479   524288  256M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      532480 31116287 30583808 14.6G 83 Linux

Command (m for help): w
The partition table has been altered.
Syncing disks.

(2) resize2fs

resize2fsは、パラメータ無しで実行すると、ブロックは、最大サイズまで拡張されます

$ sudo resize2fs /dev/mmcblk0p2
resize2fs 1.44.5 (15-Dec-2018)
Filesystem at /dev/mmcblk0p2 is mounted on /; on-line resizing required
old_desc_blocks = 1, new_desc_blocks = 1
The filesystem on /dev/mmcblk0p2 is now 3822976 (4k) blocks long.

以上の作業で、ディスクサイズは、SDカードの容量である16Gまで拡張されます。

pi@raspberrypi:~ $ df -H
Filesystem      Size  Used Avail Use% Mounted on
/dev/root        16G  1.3G   14G   9% /
devtmpfs        481M     0  481M   0% /dev
tmpfs           486M     0  486M   0% /dev/shm
tmpfs           486M   13M  474M   3% /run
tmpfs           5.3M  4.1k  5.3M   1% /run/lock
tmpfs           486M     0  486M   0% /sys/fs/cgroup
/dev/mmcblk0p1  265M   55M  210M  21% /boot
tmpfs            98M     0   98M   0% /run/user/1000

7 最後に

今回は、縮小したイメージをバックアップする手順をシェル化してみました。 当初、コピー後のイメージを自動で拡張するシェルも組み込んでいたのですが、こちら安定した動作が出来なくて外しました。

もし、既に使用されているイメージで、残りのディスクサイズに、それほど余裕が必要無いのであれば、縮小されたまま利用することも可能です。コード内の下記の部分を編集すると、ディスクの余裕を調整できます。

# 必要ブロック数 = 必要最小ブロック+余裕(2048)
requiredBlocks = minimumBlocks + 2048

これで、ブロックやセクタの計算から開放されるので、仕事が捗りそうです。

※ 最悪、パーティションが壊れて起動できなくなったりした場合、手動で元に戻せるようにするため、作業前にパーティションの状況を確認しておくと安全かも知れません。