SDカードが壊れにくいラズベリーパイ(initramfsとは何か?)

に公開

何の話?

  • ラズベリーパイ(カードサイズのコンピュータ)
  • RAMベースファイルシステムについて
  • OverlayFSについて
  • SquashFSについて
  • 起動シーケンスについて
  • initスクリプトの書き方 (busybox ash)
  • initramfsイメージの作り方

はじめに

Raspberry Piは、安さが取り柄のSBC(シングルボードコンピュータ)です。安さのために部品点数が抑えられており、eMMCなどの記録媒体は搭載されていません。記憶媒体には一般に(micro)SDカードが使用されます。

そのSDカードには、壊れやすいと言う欠点があります。具体的には、以下のようなものです。

  • 書込み回数上限による寿命
     フラッシュメモリは、書込みや消去を行うたびに劣化するので、書込み回数に上限があります。書込み箇所を分散させる機能がありますが、SDカードに搭載されるしょぼいコントローラでは、効果に限りがあります
  • 書込み中の電源遮断によるブロック破損
     フラッシュメモリには、ユーザーデータの記憶領域とは別に、その記憶領域を管理する管理データがあります。その管理データは、保護機構により不意の電源切断から保護されるはずなのですが、安さが売りのSDカードにはしょぼい機構しかなく保護が十分とは言えません。
     特に、SBCをモニタに接続しないヘッドレス運用している場合は、SBCの状態がわからないので不用意に電源を落としてしまい、SDカードがちょくちょく壊れてしまいます。

これらの欠点は、何れも書込みに起因しています。そこで、書込みを禁止することができれば、故障の頻度も低減させることができるでしょう。

本記事は、SDカードへの書込みを禁止しながらシステムを稼働させる方法と、その方法を支える技術を紹介したものです。より具体的には、Linuxを前提としたシステムにおいて、ルートディレクトリをOverlayFSで構築すると共に、そのOverlayFSの上層をtmpfsにより構成したシステムについての話です。

まずは、システムの構築に必要となるtmpfsとOverlayFSについて説明します。

RAMベースファイルシステム

Linuxにて、RAMの上にファイルシステムを構築するには、大きく分けて二つの方法があります。一つはbrdによる方法、もう一つはtmpfsによる方法です。

なお、本記事では、ramdiskを、一般的な用法ではなく、Linuxでの用法に倣い使用します。つまり、ramdiskは、ブロックデバイスを指すものであり、tmpfsはramdiskに含まれません

brd

brd(Block Ramdisk Device)は、もっとも直感的な方法です。brdは、RAM(主記憶)を、HDDなどの二次記憶と同じように、ブロックデバイスとして使用します。そのデバイスは、OSからは/dev/ramXとして認識されます。

brdの場合、他のブロックデバイスと同様にブロックデバイスの上に一般的なファイルシステム(ext4など)が構築されることになります。

brdには、ファイルシステムの使用するページキャッシュが、冗長になってしまうと言う欠点があります。

page cacheとbrdの欠点

ページキャッシュは、ファイルシステムとストレージ(バッキングストア)の間に位置して、バッキングストアに読み書きされるデータをRAM上に一時的に蓄えるためのものです。

このデータの読み書きは、ページと呼ばれる所定の単位(典型的には4kB)で行われます。RAM上に一時保存することで、ページキャッシュは、バッキングストアのアクセスの遅さを緩和するバッファとして機能します。

しかし、brdの場合、バッキングストアもRAMなので、遅さを緩和するバッファは不要です。ページキャッシュの介在は余計であり、むしろオーバーヘッドを招く虞があります。

tmpfs

そこで、考えられたのがtmpfsです(ramfsもありますがほぼ使用されることがないので触れません)。

tmpfsの最も特徴的な点は、ページキャッシュを省略するのではなく、ページキャッシュを積極的に利用する点です。この点を以下に詳述します。

一般に、ページキャッシュは、ファイルシステムに書込み要求があったときに、その書き込みデータを単数または複数のページに分割してRAMに一時的に保存します。それらのページはバッキングストアに書込むべき状態(Dirty)として管理されます。各Dirtyページは、適当なタイミングでバッキングストアに書出されます。この書出し動作はライトバックと呼ばれます。ライトバックされたページはClean状態になり、適当なタイミングで解放されます。この解放動作はページ回収と呼ばれます。

ここで、ライトバックをしないと、どうなるでしょうか?その場合、ファイルシステムに書込まれたデータ(ページ)はRAMに保存され続けるので、RAMの情報はファイルシステムの書込み情報と同じものとなります。つまり、ページキャッシュをストレージと看做すことが可能になります。

この仕組みによりファイルシステムを実装したのが、tmpfsです。tmpfsでは、バッキングストアが省略され、ライトバックが行われません。当然、ライトバックによるページ回収も行われないので、tmpfsは、書込みデータをページキャッシュに保持することができます。

より詳細には、カーネルには、共有メモリ機能を提供するためにページキャッシュ操作を行うShared Memory Virtual Filesystem(shmem)が実装されており、tmpfsは、これを流用すると共に、ファイルシステムとしての操作インターフェイス(mount, read, writeなど)を実装したものです。

tmpfsの特徴

tmpfsの主な特徴は、以下の通りです。

  • 揮発性である
    電源が喪失するとRAM上のデータは全て失われます。
  • サイズ制限可能
    使用するRAMの容量の上限を設定する機能です。デフォルトでは全容量の半分に制限されます。
  • スワップアウト可能
    RAMの空き領域を確保するために、ページの内容をストレージに退避させて、そのページの回収を行う機能です(shmem由来です)。本記事では、この機能は使用しません。

OverlayFS

OverlayFSは、読込み専用のファイルシステムを書込み可能に扱うためのファイルシステムです。より具体的には、読取り専用を前提とした下層ディレクトリと、読み書き可能な上層ディレクトリの二層構造を備え、変更等の差分情報を上層に保存することで、それらを利用者に一体的なディレクトリとして提供します。

OverlayFSの主な挙動は以下の通りです。

読取り

対象がファイルの場合は、上層のファイルが優先し、下層のファイルは上層のファイルがない場合のみ可視になります。つまり、同名の上層のファイルは、不透過であり、下層のファイルを覆い隠します(shadowing)。

対象がディレクトリの場合、下層と上層の内容(ファイル等のリスト)が合併(merge)されます。この場合、同名の上層のディレクトリは、基本的には透過です(後述する削除は例外)

ファイルの変更

上層にファイルが存在する場合は、そのファイルが変更されます。上層にファイルがない場合は、下層のファイルが上層にコピーされ(copy up)、そのコピーが変更されます。変更後は、上層のファイルのみが可視となります。

削除

削除対象のファイルやディレクトリ(ファイル等)が上層にしか存在しない場合は、そのファイル等が削除されます。

一方、削除対象のファイル等が下層にも存在する場合は、その下層のファイル等を掩蔽するために、削除されたことを示すメタデータ(whiteoutやopaque)が上層に作成されます。

削除対象がファイルの場合は、拡張属性(trusted.overlay.whiteout)を有する空ファイルが作成されます(whiteoutと呼ばれます)。このwhiteoutは、上層に存在するのですが、利用者に対しては不可視となっています。また、whiteoutの存在により下層のファイルも不可視となりますが、これは上記の「読取り」で説明した通りです。

削除対象がディレクトリの場合は、既存のディレクトリがあれば拡張属性(trusted.overlay.opaque)が設定され、なければ拡張属性を有する新規ディレクトリが作成されます。これにより、下層の同名ディレクトリだけでなく、そのディレクトリ下のファイル等が全て不可視となります。

本記事では、これらのファイルシステムと、SDカード上の書込み禁止なファイルシステムとを組み合わせることで、OSのルートファイルシステムを構築します。ルートファイルシステム構築は、OSの起動の初期段階で行います。

起動シーケンス

次に、起動の大まかな流れについて説明します。

ラズベリーパイの起動シーケンスは、4(EEPROM搭載)の前後で違います(EEPROMが関係する一番初めの段階が違います)。私が所有しているのは中古品の3B(OTPメモリ書込み済み)なのでこちらを説明します。説明の射程は、2Bv1.2, 3B, および3B+になります。また、initramfsを含む構成を前提とします。

ROMブートローダからGPUファームウェア

まずは、カーネルの起動を目指して、ハードウェアの初期化とカーネルイメージ群のロードが行われます。ラズベリーパイでは、カーネルの起動までのシーケンスはすべてGPUにより実行されます。

はじめにSoCのROMブートローダが起動されます。ROMブートローダは、SDカードやネットワークコントローラなどの周辺機器およびSoCのSRAMを初期化し、そのSRAMに次段のブートローダ(bootcode.bin)をロードして起動します。ロードされるブートメディアの順は、SDカード、USB、ネットワークになります。

bootcode.binは、SDRAMの初期化を行うと共に、config.txtを限定的に解析して次段のブートローダであるGPUのファームウェア(start*.elf)と設定ファイル(fixup*.dat)を特定します。bootcode.binは、特定したファイルをSDRAMにロードした後、start*.elfを起動します。

start*.elfは、config.txtを解析して、カーネルイメージ、dtbファイルおよびinitramfsイメージをSDRAMにロードします。さらにstart*.elfは、GPUからCPUに制御を渡します。そのCPUがカーネルを起動します。その際、initramfsイメージおよびdtbファイルのアドレスとcmdline.txtのカーネルコマンドラインパラメータがカーネルに渡されます。

なお、ご承知の通り、ラズベリーパイは非公開閉鎖的なSBCなので、以上の説明は正確ではない虞がありますので、ご留意下さい。

カーネル

次は、本番の実行環境を整えるための準備環境の構築を目指します。準備環境とは、具体的には、initramfsによる環境(ルートディレクトリとinitスクリプト)です。誤解を恐れずに単純化するならば、準備環境は/initスクリプトと、それ実行するためのディレクトリ群(/bin, /lib/modules, /dev, /proc, /sys)とからなります。

カーネルは、自己解凍(圧縮されていれば)した後、カーネル環境(メモリや割込み)を初期化します。その初期化の際に、rootfs(普通はtmpfs)によるルートディレクトリが形成されます。そのルートディレクトリの上で、最低限のデバイスファイル(/dev/consoleや/dev/null)の準備とinitramfsイメージの展開(populate)がなされます。これで準備環境が整います。次は、/initがユーザー空間にて実行されます。

/init (initramfsのinit)

initが行う処理は、典型的には、本番のルートディレクトリのマウントと、マウントに必要な前処理です。

マウントに必要な要素

マウントには、以下の3つ要素が必要となります。

  • ファイルシステム
    ディレクトリおよびファイルの実データと管理データとからなります。ファイルシステムには、様々なタイプ(ext4など)があります。
  • デバイスファイル
    ファイルシステムが構築されているブロックデバイスです。例えば、SDカード(/dev/mmcblk0)や、そのパーティション(/dev/mmcblk0p2)などです。
  • マウントポイント
    initramfs環境のディレクトリです。例えば、/mnt/rootなどです。

マウントの前処理は、これら3つの要素を確保するための処理になります。

initの処理の大まかな流れは、擬似ファイルシステムの準備、カーネルモジュールのロード、ルートデバイスの仮マウント、switch_rootになります。

擬似ファイルシステムの準備

ユーザー空間からカーネル空間の情報へのアクセスを容易にするために以下の擬似ファイルシステムを用意します。慣習として必要性に関係なく3つとも用意します。

  • devtmpfs (/dev)
    ブロックデバイスの特定が簡単になります。原理的には手動でmknodすることも不可能ではないので、なくてもinitは動作します。
  • sysfs (/sys)
    mdev(やudev)を使用する場合に必要となります。mdev等を使用しないなら、なくてもinitは動作します。
  • procfs (/proc)
    カーネルコマンドラインパラメータを取得するのに必要です。カーネルにより消尽されるパラメータに依存しないなら、なくてもinitは動作します。

カーネルモジュールのロード

基本的には本番ルートファイルシステムのマウントに関連するモジュール(ファイルシステム、デバイス)とその依存モジュールだけをロードします。なお、関連しないモジュール(例えば、オーディオなど)は、本番のinitで別途ロードされます。

デバイスの仮マウント

本番のルートファイルシステムが構築されたデバイス(ルートデバイス)を、initramfs環境のマウントポイントに、仮マウントします。「仮」としたのは、次のswitch_rootによりマウントポイントがルートディレクトリに付替えられるためです。

switch_root

ルートデバイスのマウントポイントを仮のものからルートディレクトリ(/)に付替えた後、そのディレクトリ階層にて、本番のinitにコンテキストを置換えて実行します(制御が移譲されます)。また、併せてinitramfs(tmpfs)を解放するための浄化処理も行います。

なお、switch_root(MS_MOVE)の代わりに、pivot_rootおよびexecの組合せでも同様の処理を行うことが可能です。

以上により所望のルートファイルシステムを本番のルートディレクトリとして、本番のinit(例えば、SystemV init)による処理が開始されます。

initramfsの構成

次に、initramfsを具体的に構成する方法について説明します。上述したようにinitramfsの主な要素は、initとその実行環境をなすディレクトリ階層です。したがって、initramfsを構成することは、ディレクトリ階層を作成することに他なりません。詳しくは後述するように、ディレクトリ階層は単一のイメージファイルとしてブートローダに供されます。

initの正体

initramfsのinitは、処理が単純なことから、一般にはシェルスクリプトが用いられます。そのシェルスクリプトの処理系には、静的リンク形式の実行ファイルが作りやすいbusyboxのashが用いられます。本記事のinitも、busyboxのシェルスクリプトです。

busyboxとは

busyboxは、既存の様々なユーティリティコマンド(例えばechoなど)の簡易版を一つの実行ファイルに詰め込んだ応用ソフトウェアです。個々のコマンドはアプレットと呼ばれます。既存のコマンドと同じ名前のアプレットは、基本的には同じ働きをしますが、機能やオプションが一部省略されています。

アプレットを呼び出す方法は二つあります。

  • アプレットを第1引数にしてbusyboxを呼出す方法
    busybox <applet> <args>
  • アプレットと同名のリンクを作成して、そのファイル名でbusyboxを呼出す方法
    ln -s busybox <applet>
    <applet> <args>

busyboxの用意

busyboxを用意するには、既存のバイナリーパッケージを利用する方法と、ソースコードからビルドする方法の二つがあります。

既存のバイナリーパッケージを利用する方法

適当なディストリビューションのパッケージから実行ファイルを抜出します。実行ファイルは、対象のラズベリーパイに合ったアーキテクチャ(例えば、arm)のものを選んで下さい。また、実行ファイルは、動的リンク形式であると共有ライブラリのコピーが必要になるため、静的リンク形式のものが好ましいです。

以下の例では、Debianのパッケージ(busybox-static)をダウンロードして、実行ファイルを抜出しています。

curl -LO http://ftp.jp.debian.org/debian/pool/main/b/busybox/busybox-static_1.37.0-6+b3_armhf.deb
ar p busybox-static_1.37.0-6+b3_armhf.deb data.tar.xz | tar -xOJ ./usr/bin/busybox > ./busybox
chmod +x busybox

ソースコードからビルドする方法

以下のアプレットを組み込んで下さい。

  • Coreutils
    echo, mkdir, seq, sleep, test, test as [
  • Editors
    sed
  • Finding Utilities
    grep
  • Linux Module Utilities
    modprobe
  • Linux System Utilities
    blkid, mount, mountpoint, switch_root, umount
  • Networking Utilities
    ip, ip address, ip link, ntpd, udhcpc
  • Shells
    ash

また、以下のビルドオプションを有効にして下さい。

make menuconfig
Settings  --->
 [*] Include busybox applet
 [*]   Support --install [-s] to install applet links at runtime
 [*] Build static binary (no shared libs)
Linux System Utilities  --->
 [*] Support loopback mounts
 [*]   Create new loopback devices if needed
Networking Utilities  --->
 (/bin/udhcpc.default.script) Absolute path to config script

ディレクトリ階層の準備

initramfsのルートディレクトリとなる階層を準備します。管理のし易さを考えると、少なくとも、実行ファイルを配置する/binおよび/sbinと, カーネルモジュールを配置する/libの三つは作成したほうがいいでしょう。

本記事のinitは、実行時に必要なディレクトリを適宜作成するので、事前に作成が必要なのは/binと/sbinと/libだけです。

具体的な作業としては、適当なディレクトリINITRAMFS_ROOT(例えば、~/initramfs)を定め、その下にbin, sbin, およびlibを作成します。

initの配置

後述するinitを${INITRAMFS_ROOT}/に配置します。最も重要なファイルなのですが、記事構成の都合上作成方法は後ろに回しています。

busyboxの配置

上述したbusyobxを、${INITRAMFS_ROOT}/binに配置します。なお、本記事では、busybox --installにより動的にリンクを作成します。echoやmountなどのbusyboxに対するリンクの事前作成は不要です。

modulesの配置

カーネルモジュールを、${INITRAMFS_ROOT}/lib/modules/<kernel_release>/に配置します。配置するのは、上述した通り「本番ルートファイルシステムのマウントに関連するモジュール」だけです。存在するすべてのモジュールを配置する必要はありません。

モジュールのコピー元は、既存のシステムまたは、独自ビルドしたカーネルになります。

  • 既存のシステムからコピーする場合
    /lib/modules/<kernel_release>から必要な.koファイルをコピーして下さい。
    <kernel_release>は、uname -rで求めることができます。
  • 独自ビルドしたカーネルからコピーする場合
    ${KBUILD_OUTPUT}から.koファイルをコピーして下さい。KBUILD_OUTPUTを設定していない場合は、カーネルソースツリーの根(<kernel_src>)からコピーして下さい。
    <kernel_release>は、make -C <kernel_src> -s kernelreleaseで求めることができます。

以下の説明では、コピー元のディレクトリをMOD_ROOT(/lib/mdoules/<kernel_release>または、${KBUILD_OUTPUT}もしくはカーネルソースツリーの根)として表すので適宜読替えて下さい。

何れの場合も、必要な.koファイルだけでなく、それが依存する.koファイルもコピーして下さい。また、コピー元の.koが圧縮されている場合は、復元したものをコピーして下さい。

このコピー作業は手動では面倒くさいので、例えば、以下のようなシェル関数を作成するとよいでしょう。このcopy_modules関数は、必要なカーネルモジュールの名前を引数に取って、一連の.koファイルを再帰的にコピーします。

copy_modules.sh
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
	echo "${BASH_SOURCE[0]} must be sourced, not executed." >&2
	exit 1
fi

MOD_EXT_REGEX='\.(ko|ko\.(gz|xz|zst))$'
mbasename() {
	echo "$(basename "$1" | sed -E "s/${MOD_EXT_REGEX}//")"
}

copy_and_decompress() {
	local mod_file="$1"
	local dst_dir="$2"
	local mod_base="$(mbasename "${mod_file}")"
	local dst_file="${dst_dir}/${mod_base}.ko"

	case "${mod_file##*.}" in
		xz)  xz -dc "${mod_file}" > "${dst_file}" ;;
		gz)  gunzip -c "${mod_file}" > "${dst_file}" ;;
		zst) zstd -d -c "${mod_file}" > "${dst_file}" ;;
		ko)  cp "${mod_file}" "${dst_file}" ;;
		*)
			echo "Error: Unknown module extension or file: ${mod_file}. Skipping." >&2
			return 1
			;;
	esac

	if [ $? -ne 0 ]; then
		echo "Error: Failed to decompress ${mod_file}" >&2
		rm -f "${dst_file}"
		return 1
	fi
}

copy_module_recursive() {
	local dst_root="$1"
	local src_root="$2"
	local mod_file="$3"

	local rel_path="${mod_file#${MOD_ROOT}/}"
	local dst_dir="${dst_root}/$(dirname "${rel_path}")"
	local mod_base="$(mbasename "${mod_file}")"

	[ -e "${dst_dir}/${mod_base}.ko" ] && return

	mkdir -p "${dst_dir}"
	copy_and_decompress "${mod_file}" "${dst_dir}" || return 1

	local deps=$(modinfo -b "${MOD_ROOT}" -F depends "${mod_file}" 2>/dev/null | tr ',' ' ')
	copy_module_list "${dst_root}" "${src_root}" ${deps}
}

copy_module_list() {
	local dst_root="$1"
	local src_root="$2"
	shift 2

	local mod
	for mod in "$@"; do
		local mod_file=$(find "${src_root}" -regextype posix-extended -regex ".*/${mod}${MOD_EXT_REGEX}" | head -n1)
		[ -z "${mod_file}" ] && continue
		copy_module_recursive "${dst_root}" "${src_root}" "${mod_file}"
	done
}

copy_modules() {
	[ "${MOD_ROOT}" ] || { echo "MOD_ROOT not set" >&2; return 1; }
	[ "${INITRAMFS_ROOT}" ] || { echo "INITRAMFS_ROOT not set" >&2; return 1; }
	[ "${KERNEL_RELEASE}" ] || { echo "KERNEL_RELEASE not set" >&2; return 1; }

	local src_root="${MOD_ROOT}"
	local dst_root="${INITRAMFS_ROOT}/lib/modules/${KERNEL_RELEASE}"
	mkdir -p "${dst_root}"

	copy_module_list "${dst_root}" "${src_root}" "$@"
}

変数の説明

  • INITRAMFS_ROOT
    作業の起点となるディレクトリです。
  • MOD_ROOT
    コピー元のディレクトリです。
    • 既存のシステムからコピーする場合
      /lib/modules/<kernel_release>
    • 独自ビルドしたカーネルからコピーする場合
      ${KBUILD_OUTPUT}もしくは<kernel_src>

使用例(対象モジュールはloopとoverlay)

  • 稼働中の既存のシステムからコピーする場合
. copy_modules.sh
MOD_ROOT="/lib/moudles/$(uname -r)" KERNEL_RELEASE="$(basename "${MOD_ROOT}")" INITRAMFS_ROOT=<適当なディレクトリ> copy_modules loop overlay
  • 独自ビルドしたカーネルからコピーする場合
    <kernel_src>は、カーネルソースツリーの根です。
. copy_modules.sh
MOD_ROOT=$KBUILD_OUTPUTもしくは<kernel_src> KERNEL_RELEASE=$(make -C <kernel_src> -s kernelrelease)" INITRAMFS_ROOT=<適当なディレクトリ> copy_modules loop overlay

modules.depの作成

上述の依存関係の解決は、コピーのときだけでなく、initの実行中におけるロードのときにも必要になります。

modprobeコマンドならば、依存関係の解決と依存モジュールのロードを自動で行えますが、そのためには、予め依存情報が記述されたファイル(modules.dep)が必要になります。そのmodules.depの作成は、depmodコマンドにより行います。

具体的な手順は以下の通りです。

組込み機能に関する三つのファイルmodules.order, modules.builtin, modules.builtin.modinfoを${MOD_ROOT}から${INITRAMFS_ROOT}/lib/modules/<kernel_release>にコピーします。

depmodを実行します。

depmod -b ${INITRAMFS_ROOT} <kernel_release>

initramfsイメージの作成

以上のディレクトリ階層は、ブートローダおよびカーネルが取扱えるように、単一のファイルにまとめられます。ファイルは、cpio形式のアーカイブでなければなく、また圧縮することも可能です(一般には圧縮されます)。圧縮の展開はカーネルにより行われるので圧縮方式はカーネルが対応する方式(CONFIG_RD_XXX=y)でなければなりません。

以下の例では、gzipで圧縮されたcpioアーカイブを${OUTPUT}の下にinitramfs.imgのファイル名で書き出します。ファイル名は任意なので、好みに応じて<kernel_release>や圧縮方式に応じた拡張子を足すこともできます。

cd ${INITRAMFS_ROOT}
find . -print0 | cpio --null -ov --format=newc | gzip -9 > "${OUTPUT}/initramfs.img"

initの書き方(基本編)

基本的なinitの書き方を説明します。ここでは、ext4など普通のファイルシステムを読取り専用でマウントし、それを下層としてOverlayFSを構築する例を解説します。初めにスクリプトの全体を掲載し、その後、個別に説明します。

init
#!/bin/busybox sh
export PATH="/sbin:/bin"

emergency_shell() {
	[ ! -d /dev/pts ] && mkdir -p /dev/pts
	mountpoint -q /dev/pts || mount -t devpts devpts /dev/pts
	exec sh
}
parse_cmdline() {
	sed -n "s/.*\b$1=\([^ ]*\).*/\1/p" /proc/cmdline
}
wait_until() {
	local retries="20"
	for i in $(seq 1 ${retries}); do
		"$@" && return 0
		sleep 1
	done
	return 1
}
make_partuuid() {
	local by_partuuid="/dev/disk/by-partuuid"
	local partuuid="$(blkid -s PARTUUID -o value "$1")"
	if [ "$2" = "${partuuid}" ]; then
		ln -sf "$1" "${by_partuuid}/${partuuid}"
		echo "init: Created: ${by_partuuid}/${partuuid}"
		return 0
	fi
	return 1
}
iterate_devices() {
	local cmd="$1"
	local target="$2"
	shift 2
	for itr in "$@"; do
		local dev="/dev/${itr}"
		${cmd} "${dev}" "${target}" && return 0
	done
	return 1
}
_GLOBAL_PREV_BLKS=""
new_devices() {
	local cur_blks=""
	for itr in /sys/class/block/*; do
		cur_blks="$cur_blks $(basename "$itr")"
	done

	local new_blks=""
	for itr in ${cur_blks}; do
		case " $_GLOBAL_PREV_BLKS " in
			*" $itr "*) ;;
			*) new_blks="$new_blks $itr" ;;
		esac
	done

	_GLOBAL_PREV_BLKS="${cur_blks}"

	echo "${new_blks}"
}
detect_device() {
	[ -b "$1" ] && return 0
	iterate_devices make_partuuid "$(basename $1)" $(new_devices)
}

MOUNT="/bin/busybox mount"
ECHO="/bin/busybox echo"
MKDIR="/bin/busybox mkdir"
${MKDIR} -p /proc /sys /dev
${ECHO} "init: Mounting pseudo file systems"
${MOUNT} -t proc proc /proc && ${ECHO} "init: mount /proc"
${MOUNT} -t sysfs none /sys && ${ECHO} "init: mount /sys"
${MOUNT} -t devtmpfs none /dev && ${ECHO} "init: mount /dev"

$ECHO "init: Creating applet links"
/bin/busybox --install /bin

echo "init: Loading modules"
modprobe loop && echo "modprobe loop"
modprobe overlay && echo "modprobe overlay"

BY_PARTUUID="/dev/disk/by-partuuid"
mkdir -p "${BY_PARTUUID}"

echo "init: Creating mount directories"
mkdir -p /mnt/lower /mnt/tmpfs /mnt/root

ROOT_DEV="$(parse_cmdline root | sed "s|^PARTUUID=|${BY_PARTUUID}/|")"
[ -z "${ROOT_DEV}" ] && ROOT_DEV="/dev/mmcblk0p2"
ROOT_FSTYPE="$(parse_cmdline rootfstype)"
[ -z "${ROOT_FSTYPE}" ] && ROOT_FSTYPE="ext4"
echo "init: Using rootfs: ${ROOT_DEV} (${ROOT_FSTYPE})"
wait_until detect_device "${ROOT_DEV}" || emergency_shell
echo "init: Mounting lower layer"
mount -t "${ROOT_FSTYPE}" -o ro "${ROOT_DEV}" /mnt/lower

echo "init: Mounting upper layer (tmpfs)"
mount -t tmpfs -o noswap upper_and_work /mnt/tmpfs
mkdir -p /mnt/tmpfs/upper /mnt/tmpfs/work

echo "init: Mounting overlay filesystem"
mount -t overlay overlay -o lowerdir=/mnt/lower,upperdir=/mnt/tmpfs/upper,workdir=/mnt/tmpfs/work /mnt/root || emergency_shell

umount /proc
umount /sys
umount /dev
exec switch_root /mnt/root /sbin/init

インタプリタ

カーネルにより実行されるインタプリタを絶対パスで指定します。なお、この段階ではカーネルモジュールをロードできないので、必ずCONFIG_BINFMT_SCRIPT=yのカーネルを使って下さい。

また、/sbinと/binにパスを通します。本記事では、独自の基準により/sbinと/binを分けています。具体的には、/sbinは、busyboxのアプレットと重複する虞があるコマンド、/binは、それ以外です。さらに、/sbin下のコマンドがbusyboxのアプレットに優先されるようにPATHを設定しています。

#!/bin/busybox sh
export PATH="/sbin:/bin"

emergency_shell関数

initで問題が発生したときにシェルを立ち上げるための関数です。擬似端末の設定も行います。

emergency_shell() {
	[ ! -d /dev/pts ] && mkdir -p /dev/pts
	mountpoint -q /dev/pts || mount -t devpts devpts /dev/pts
	exec sh
}

parse_cmdline関数

カーネルコマンドラインパラメータの名前を引数に取り、その値を返す関数です。

parse_cmdline() {
	sed -n "s/.*\b$1=\([^ ]*\).*/\1/p" /proc/cmdline
}

wait_until関数

引数の実行結果が成功するまで待機する関数です。1回の試行ごとに1秒待ちます。最大試行回数は20回です。

wait_until() {
	local retries="20"
	for i in $(seq 1 ${retries}); do
		"$@" && return 0
		sleep 1
	done
	return 1
}

デバイスリンク(PARTUUID)の作成関数

ルートデバイスのデバイスリンクを作成するための関数群です。

  • make_partuuid、new_devicesおよびiterate_devices
    新しく検出されたブロックデバイスを調べ、そのブロックデバイスからPARTUUIDのデバイスリンクを作成します。作成するのはルートデバイスに限ります。
  • detect_device
    ルートデバイスの存在を確認します。存在しない場合は、デバイスリンクの作成を試みます。
make_partuuid() {
	local by_partuuid="/dev/disk/by-partuuid"
	local partuuid="$(blkid -s PARTUUID -o value "$1")"
	if [ "$2" = "${partuuid}" ]; then
		ln -sf "$1" "${by_partuuid}/${partuuid}"
		echo "init: Created: ${by_partuuid}/${partuuid}"
		return 0
	fi
	return 1
}
iterate_devices() {
	local cmd="$1"
	local target="$2"
	shift 2
	for itr in "$@"; do
		local dev="/dev/${itr}"
		${cmd} "${dev}" "${target}" && return 0
	done
	return 1
}
_GLOBAL_PREV_BLKS=""
new_devices() {
	local cur_blks=""
	for itr in /sys/class/block/*; do
		cur_blks="$cur_blks $(basename "$itr")"
	done

	local new_blks=""
	for itr in ${cur_blks}; do
		case " $_GLOBAL_PREV_BLKS " in
			*" $itr "*) ;;
			*) new_blks="$new_blks $itr" ;;
		esac
	done

	_GLOBAL_PREV_BLKS="${cur_blks}"

	echo "${new_blks}"
}
detect_device() {
	[ -b "$1" ] && return 0
	iterate_devices make_partuuid "$(basename $1)" $(new_devices)
}

擬似ファイルシステムの準備

擬似ファイルシステムをマウントします。アプレットのリンクが作成されていないのでアプレットを第1引数にしてbusyboxを呼び出します。

MOUNT="/bin/busybox mount"
ECHO="/bin/busybox echo"
MKDIR="/bin/busybox mkdir"
${MKDIR} -p /proc /sys /dev
${ECHO} "init: Mounting pseudo file systems"
${MOUNT} -t proc proc /proc && ${ECHO} "init: mount /proc"
${MOUNT} -t sysfs none /sys && ${ECHO} "init: mount /sys"
${MOUNT} -t devtmpfs none /dev && ${ECHO} "init: mount /dev"

アプレットのリンクの作成

アプレットのリンクを/binに作成します。/binにはパスが通っているので、これ以降は、<applet>だけでコマンドを呼出すことができます。

$ECHO "init: Creating applet links"
/bin/busybox --install /bin

カーネルモジュールのロード

カーネルに応じて適当なモジュールをロードして下さい。

echo "init: Loading modules"
modprobe loop && echo "modprobe loop"
modprobe overlay && echo "modprobe overlay"

by-partuuidとマウントポイントの作成

BY_PARTUUID="/dev/disk/by-partuuid"
mkdir -p "${BY_PARTUUID}"
echo "init: Creating mount directories"
mkdir -p /mnt/lower /mnt/tmpfs /mnt/root

下層ディレクトリのマウント

下層ディレクトリに、本番のルートファイルシステムのベースとなるデバイスをマウントします。このデバイスとは、わかりやすく言うなら、OSがインストールされたSDカードのパーティションです。

デバイスは、カーネルコマンドラインパラメータのrootで指定されます。指定には、デバイスファイル名もしくはPARTUUIDを使用できます。指定がない場合は、/dev/mmcblk0p2が使用されます。同様に、ファイルシステムタイプは、rootfstypeで指定されます(ディフォルトはext4)。

マウントオプションには必ず ro(読取り専用) を指定します。

ROOT_DEV="$(parse_cmdline root | sed "s|^PARTUUID=|${BY_PARTUUID}/|")"
[ -z "${ROOT_DEV}" ] && ROOT_DEV="/dev/mmcblk0p2"
ROOT_FSTYPE="$(parse_cmdline rootfstype)"
[ -z "${ROOT_FSTYPE}" ] && ROOT_FSTYPE="ext4"
echo "init: Using rootfs: ${ROOT_DEV} (${ROOT_FSTYPE})"
wait_until detect_device "${ROOT_DEV}" || emergency_shell
echo "init: Mounting lower layer"
mount -t "${ROOT_FSTYPE}" -o ro "${ROOT_DEV}" /mnt/lower

上層ディレクトリの準備

上層ディレクトリおよび作業ディレクトリを準備します。OverlayFSでは、上層ディレクトリと作業ディレクトリとは同じファイルシステム上になければなりません。

そこで、共通のファイルシステム(tmpfs)を/mnt/tmpfsにマウントし、その/mnt/tmpfsの下に、上層ディレクトリ(upper)と作業ディレクトリ(work)を作成しています。また、tmpfsのスワップを無効にしています。

echo "init: Mounting upper layer (tmpfs)"
mount -t tmpfs -o noswap upper_and_work /mnt/tmpfs
mkdir -p /mnt/tmpfs/upper /mnt/tmpfs/work

ルートデバイス(ファイルシステム)の仮マウント

OverlayFSを一時的に/mnt/rootにマウントします。

echo "init: Mounting overlay filesystem"
mount -t overlay overlay -o lowerdir=/mnt/lower,upperdir=/mnt/tmpfs/upper,workdir=/mnt/tmpfs/work /mnt/root || emergency_shell

switch_root

execでswitch_rootを呼出します。第1引数には上述のOverlayFSのマウントポイントを指定します。第2引数にはOverlayFSが付替えられたのパス(つまり、本番のディレクトリ階層のパス)を指定します。

また、busyboxのswitch_rootでは、ファイルシステムがrootfsと異なるディレクトリは削除されず、また付替えも行われないので、手動で擬似ファイルシステムをアンマウントしています。

umount /proc
umount /sys
umount /dev
exec switch_root /mnt/root /sbin/init

Raspberry Pi OSによる実践例

ここからは、Raspberry Pi OSを対象に実際にシステムを構築する一例を説明します。大部分の作業は ホストマシン (x86-64 Linux) 上で行います(ターゲットのラズベリーパイ上でも行えなくはないです)。作業ではLinuxで一般的なコマンドのみを使用します。

OSイメージの書込み

OSのイメージをダウンロードして、SDカードに書込みます。書込みによりSDカードの情報が上書きされるので注意して下さい。

<SDカードのデバイスファイル>は、/devの下のベースデバイスのファイルです。パーティションではありません(例えば/dev/sdXです。/dev/sdX1ではありません)。

curl -LO https://downloads.raspberrypi.com/raspios_lite_armhf/images/raspios_lite_armhf-2025-10-02/2025-10-01-raspios-trixie-armhf-lite.img.xz
xzcat 2025-10-01-raspios-trixie-armhf-lite.img.xz | sudo dd of=<SDカードのデバイスファイル> bs=4M conv=fsync

この例では Liteの32bit版(raspios_lite_armhf) を使用しています。

以下のサイトに一覧がありますので、所望のOSイメージに応じてURLを適宜置換えて下さい。
https://www.raspberrypi.com/software/operating-systems/

RPI_ROOTのマウント

以降の説明では、SDカードのルートパーティションが${RPI_ROOT}に、ブートパーティションが${RPI_ROOT}/boot/firmwareにマウントされていることを前提に説明を行います。

RPI_ROOTのデフォルトは/mntです。環境に合わせて適宜変更して下さい。また、上述のOSイメージでは、ルートパーティションは第2パーティション(ラベルはrootfs)、ブートパーティションは第1パーティション(ラベルはbootfs)です。

export RPI_ROOT="/mnt"
sudo mount <ルートパーティションのデバイスファイル> "${RPI_ROOT}"
sudo mount <ブートパーティションのデバイスファイル> "${RPI_ROOT}/boot/firmware"

initramfs.imgの作成

上述した「initramfsの構成」に基づいてinitramfs.imgを作成します。

便利のために作成に係る一連の作業をシェルスクリプト(make_initramfs.sh)にまとめました。環境に合わせて以下の変数を適宜変更して下さい。

  • RPI_ARCH
    アーキテクチャです。初代ラズベリーパイ(1A, 1B, Zero, およびZero W)はv6, ラズベリーパイ4および5をAArch64で動かす場合はv8, それ以外はv7です。

  • WORKING_DIR
    作業ディレクトリです。作業ディレクトリには、init「initの書き方」)とcopy_modules.sh「modulesの配置」)と後述するcopy_dsos.sh「共有ライブラリの依存解決」)を配置して下さい。

    この作業ディレクトリの下に、initramfsのディレクトリ階層の根となるディレクトリ(initramfs)が作成されます(不要になったら削除してください)。

  • OUTPUT
    イメージファイル(initramfs.img)が作成されるディレクトリです。ディフォルトでは、${WORKING_DIR}になります。

make_initramfs.sh
#!/bin/bash

die() {
	echo "$1" >&2
	exit 1
}

set -euo pipefail
[ "${RPI_ROOT}" ] || die "RPI_ROOT not set"
RPI_ARCH="v7"
[ $(\ls -1d "${RPI_ROOT}/lib/modules/"*"-${RPI_ARCH}" | wc -l) -ne 1 ] && die "Multiple(or No) module directories found"
MOD_ROOT="$(\ls -1d ${RPI_ROOT}/lib/modules/*-${RPI_ARCH})"
KERNEL_RELEASE="$(basename "${MOD_ROOT}")"

WORKING_DIR="$(pwd)"
INITRAMFS_ROOT="${WORKING_DIR}/initramfs"
mkdir -p "${INITRAMFS_ROOT}/"{bin,sbin,lib}

INIT="${INITRAMFS_ROOT}/init"
cp "${WORKING_DIR}/init" "${INIT}" || die "Failed to copy init"
chmod +x "${INIT}"

BUSYBOX="${INITRAMFS_ROOT}/bin/busybox"
BB_DEB="busybox-static_1.37.0-6+b3_armhf.deb"
curl -LO http://ftp.jp.debian.org/debian/pool/main/b/busybox/"${BB_DEB}"
ar p "${BB_DEB}" data.tar.xz | tar -xOJ ./usr/bin/busybox > "${BUSYBOX}"
chmod +x "${BUSYBOX}" || die "Failed to create busybox"

CMDS="/usr/sbin/blkid"
if [ -n "${CMDS}" ]; then
	read FIRST _ <<< "${CMDS}"
	LINKER="$(readelf -p .interp "${RPI_ROOT}${FIRST}" | sed -n 's/.*]  *//p')"
	cp "${RPI_ROOT}${LINKER}" "${INITRAMFS_ROOT}/lib/" || die "Failed to copy linker"
	. "${WORKING_DIR}/copy_dsos.sh" || die "Failed to source copy_dsos.sh"
	for cmd in ${CMDS}; do
		cp "${RPI_ROOT}${cmd}" "${INITRAMFS_ROOT}/sbin/" || die "Failed to copy ${cmd}"
	done
	copy_dsos "${INITRAMFS_ROOT}" "${RPI_ROOT}" ${CMDS} || die "Failed to copy shared libraries"
fi

. "${WORKING_DIR}/copy_modules.sh" || die "Failed to source copy_modules.sh"
copy_modules loop overlay || die "Faied to copy kernel modules"

cp "${MOD_ROOT}/"{modules.order,modules.builtin,modules.builtin.modinfo} "${INITRAMFS_ROOT}/lib/modules/${KERNEL_RELEASE}/"
depmod -b "${INITRAMFS_ROOT}" "${KERNEL_RELEASE}" || die "Failed to generate modules.dep"

OUTPUT="${WORKING_DIR}"
cd "${INITRAMFS_ROOT}"
find . -print0 | cpio --null -ov --format=newc | gzip -9 > "${OUTPUT}/initramfs.img" || die "Failed to create initramfs.img"

共有ライブラリの依存解決

上述したように、Debianのbusybox-staticにはblkidが欠けています。一方、Raspberry Pi OSには、util-linuxのblkidが含まれています。そこで、このblkidを流用することが考えられますが、このblkidは動的にリンクされた実行ファイルです。

動的にリンクされた実行ファイルを実行するためには、その実行ファイルだけでなく、それが依存する共有ライブラリ(と動的リンカ)も必要になります。さらに、共有ライブラリが他の共有ライブラリに依存する場合には、他の共有ライブラリも必要になります。つまり、initramfsのディレクトリ階層(initramfs.img)には、blkidの実行ファイルの他に、依存する共有ライブラリを配置する必要があります。

一般に、どのファイルが必要になるかは、オブジェクトファイル自身(実行ファイルや共有ライブラリ)がメタ情報として持っています。より具合的には、LinuxのオブジェクトファイルであるELFファイルでは、.dynamicセクションのDT_NEEDEDタグに、依存する共有ライブラリが記録されています。

これらのDT_NEEDEDタグを順に辿ることで共有ライブラリの依存関係を解決できます。ただし、本記事のようにターゲット(ラズベリーパイ)と異なるアーキテクチャのホストマシンで作業を行う場合は、通常の動的リンカ(ld-linux-xxx --list)による解決は行えません。そのため、ELFファイルをreadelfなどで直接解析する必要があります。

この作業を手動で行うのは大変に面倒くさいので、本記事では以下の関数(copy_dsos)を用意しました。この関数は、上述のmake_initramfs.sh内で呼出されます。なお、make_initramfs.shでは併せてblkidと動的リンカもコピーしています。

copy_dsos.sh
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
	echo "${BASH_SOURCE[0]} must be sourced, not executed." >&2
	exit 1
fi

get_sonames() {
	readelf -d "$1" 2>/dev/null | awk '/\(NEEDED\)/ {match($0, /\[([^]]+)\]/, a); print a[1]}'
}
get_lib_paths() {
	local tgt_root="${1%/}"
	{
		[ -f "${tgt_root}/etc/ld.so.conf" ] && grep -hEv '^(#|$)' "${tgt_root}/etc/ld.so.conf"
		[ -d "${tgt_root}/etc/ld.so.conf.d" ] && grep -hEv '^(#|$)' "${tgt_root}/etc/ld.so.conf.d/"*.conf 2>/dev/null
		cat <<-EOF
			/usr/lib
			/usr/lib64
			/lib
			/lib64
		EOF
	} |
	{
		local -A assoc
		local itr
		while read -r itr; do
			[ -z "${itr}" ] && continue
			local abs="$tgt_root/${itr#/}"
			[[ -d "${abs}" && ! -L "${abs}" ]] || continue
			local key="$(stat -c "%i:%d" "${abs}")"
			assoc["${key}"]="${abs}"
		done
		for itr in "${assoc[@]}"; do
			echo "${itr}"
		done
	}
}
get_libs() {
	local tgt_root="$1"
	shift

	local sonames=("$@")
	local lib_paths=()
	mapfile -t lib_paths < <(get_lib_paths "${tgt_root}")

	local so
	local dir
	for so in "${sonames[@]}"; do
		for dir in "${lib_paths[@]}"; do
			local abs="${dir}/${so}"
			[ -e "${abs}" ] || continue
			realpath "${abs}"
			break
		done
	done
}
get_dsos() {
	local tgt_root="${1%/}"
	local cmd="$2"
	local -A res
	loop() {
		local obj="$1"
		local sonames="$(get_sonames "${tgt_root}${obj}")"
		local libs=()
		mapfile -t libs < <(get_libs "${tgt_root}" ${sonames})
		local itr
		for itr in "${libs[@]}"; do
			if [[ ! -v res["${itr}"] ]]; then
				res["${itr}"]=1
				loop "${itr#$tgt_root}"
			fi
		done
	}
	loop "${cmd}"
	local itr
	for itr in "${!res[@]}"; do
		echo "${itr}"
	done
}
copy_dsos() {
	local dst_root="$1"
	local src_root="$2"
	shift 2

	pushd "${dst_root}/lib" > /dev/null

	local cmd
	for cmd in $@; do
		local dsos=()
		mapfile -t dsos < <(get_dsos "${src_root}" "${cmd}")
		local obj
		for obj in "${dsos[@]}"; do
			cp "${obj}" "${dst_root}/lib/" || return 1
			local soname="$(readelf -d "${obj}" | awk '/\(SONAME\)/ {match($0, /\[([^]]+)\]/, a); print a[1]}')"
			local base="$(basename "${obj}")"
			[ "${base}" != "${soname}" ] && ln -sf "${base}" "${soname}"
		done
	done

	popd > /dev/null
}

ブートローダの設定

${RPI_ROOT}/boot/firmwareを対象に作業を行います。作業の内容は以下の二つです。

作成したinitramfsイメージ(initramfs.img)を${RPI_ROOT}/boot/firmwareにコピーします。

${RPI_ROOT}/boot/firmware/config.txtにinitramfsイメージを指定する行を加えます。followkernelはイメージをロードする位置についての指示です。

config.txt
--- config.txt.orig
+++ config.txt
@@ -46,3 +46,4 @@
 dtoverlay=dwc2,dr_mode=host
 
 [all]
+#initramfs initramfs.img followkernel

その他のシステム設定

初回起動の前に必要なRaspberry Pi OSの設定を行います。SDカードの内容を変更するにはホストマシンでのスーパーユーザー権限が必要になる点に注意して下さい。

ユーザーの追加

ヘッドレス運用の場合、事前にユーザーを追加しておく必要があります。Raspberry Pi OSの作法では${RPI_ROOT}/boot/firmware/userconf.txtに<ユーザー>:<パスワード>を登録します。

userconf.txt
read -p 'user: ' u && read -p 'password: ' p && printf '%s:%s\n' "$u" "$(openssl passwd -6 "$p")" | sudo tee -a "${RPI_ROOT}/boot/firmware/userconf.txt" > /dev/null

端末の設定

  • シリアルコンソール
    シリアルコンソールを使用する場合は、${RPI_ROOT}/boot/firmware/cmdline.txtのコンソール順をconsole=serial0,115200が後にくるように変更して下さい。
  • SSH
    sshによるリモートログインをする場合には、${RPI_ROOT}/boot/firmwareの下にファイル名sshで空ファイルを作成して下さい。詳しくは、以下を参照下さい。

https://www.raspberrypi.com/documentation/computers/configuration.html#remote-access

rpi-resize-swap-file.serviceのマスク

swaponを、tmpfs上のファイル(/var/swap)に対して実行すると、EINVALで失敗します。そのため、OverlayFSが有効な場合には、rpi-resize-swap-file.serviceが失敗します。動作に支障はありませんが、警告が煩わしいので、サービスを無効にします(zramによるswapサービスも起動しなくなります)。

cd ${RPI_ROOT}/etc/systemd/system
ln -s /dev/null rpi-resize-swap-file.service

初回起動

初回の起動では様々な初期設定が行われます。そのため、ルートファイルシステムは書込み可能でなければなりません。そこで、書込み可能な状態でシステムを起動してください。

初期設定の完了後のシステム設定

ここからの作業は、ラズベリーパイ上で行うことを前提にしています。

fstabの編集

Raspberry Pi OSの標準設定ではSDカードのブートパーティションが書込み可能にマウントされます。そのため、SDカードが破損する危険が残ります。そこで、ブートパーティションが読取り専用にマウントされるように、/etc/fstabを編集します。具体的には、マウントオプションにroを追加します。

fstab
--- fstab.orig
+++ fstab
@@ -1,3 +1,3 @@
 proc            /proc           proc    defaults          0       0
-PARTUUID=d561e349-01  /boot/firmware  vfat    defaults   0       2
+PARTUUID=d561e349-01  /boot/firmware  vfat    defaults,ro   0       2
 PARTUUID=d561e349-02  /               ext4    defaults,noatime  0       1

なお、roをつけて再起動した後に、/boot/firmware下のファイルを編集する必要が生じた場合は、rwで再マウントして下さい。

sudo mount -o remount,rw /boot/firmware

再起動

最後に、/boot/firmware/config.txtのinitramfs指定がコメント状態になっているのを解除して、システムを再起動します。これにより、OverlayFSでシステムが立ち上がります。確認は、例えばmountコマンドで行えます。

mount|grep 'overlay on /'

initの書き方(読取り専用ファイルシステム編)

次に、下層のファイルシステムとして読取り専用のファイルシステムを用いた例を説明します。例えば、上述した「基本編」のシステム(ext4を読取り専用マウントするシステム)の運用が安定したときに、堅牢性を高めるために、本システムに切換えることが想定されます。

SquashFSとEROFS

Linuxのカーネルがサポートする読取り専用のファイルシステムは、組込み用途に限ると、SquashFSとEROFSが一般的です。これらはまた、透過的な圧縮をサポートするファイルシステムでもあります。それぞれの特徴は以下の通りです。

  • SquashFS
    組込み用途での標準的ファイルシステムです。
    多くの採用実績があり、ドライバもユーティリティツールも安定しています。
    固定のブロック(ディフォルトサイズは128KiB)を単位として圧縮を行います。
  • EROFS
    主にAndroidで使用されています。
    圧縮後データを固定サイズ(4KiB)で整列させています。これにより小サイズのファイルに対するアクセス効率の向上を図っています。

ext4の読取り専用マウントに比べて有利な点

読取り専用ファイルシステムは、読取り専用マウントしたext4に比べて以下のような利点があります。

  • 性能
    圧縮されているので二次記憶からの読取り量が減ります。特に、ラズベリーパイの標準的な二次記憶であるSDカードは読取りが早くないため、シーケンシャルなアクセスや大きなファイルに対するアクセスに対して、性能の向上を期待できます。
  • 安心
    ext4の場合はマウントオプションによる積極的な読取り専用の設定が必要でしたが、読取り専用ファイルシステムは設定に拘らず書込み禁止が保証されます。設定ミスによる危険な状態での運用を根本的に防ぐことができます。
  • 簡単
    ディレクトリ階層を一つのファイルに変更不能に閉込めるので、例えば、複数のSBCや複数の版(バックアップコピーなど)を扱う場合に、管理が簡単になります。

本記事では、成熟度からSquashFSを使用します。SquashFSを扱う標準ツールは、squashfs-toolsです。
https://github.com/plougher/squashfs-tools/

init

まずは、initの全文を掲載し、その後、変更した部分について説明します。変更点は、下層(SquashFS)に関連する点とswitch_rootに関連する点です。

#!/bin/busybox sh
export PATH="/sbin:/bin"

emergency_shell() {
	[ ! -d /dev/pts ] && mkdir -p /dev/pts
	mountpoint -q /dev/pts || mount -t devpts devpts /dev/pts
	exec sh
}
parse_cmdline() {
	sed -n "s/.*\b$1=\([^ ]*\).*/\1/p" /proc/cmdline
}
wait_until() {
	local retries="20"
	for i in $(seq 1 ${retries}); do
		"$@" && return 0
		sleep 1
	done
	return 1
}
make_partuuid() {
	local by_partuuid="/dev/disk/by-partuuid"
	local partuuid="$(blkid -s PARTUUID -o value "$1")"
	if [ "$2" = "${partuuid}" ]; then
		ln -sf "$1" "${by_partuuid}/${partuuid}"
		echo "init: Created: ${by_partuuid}/${partuuid}"
		return 0
	fi
	return 1
}
iterate_devices() {
	local cmd="$1"
	local target="$2"
	shift 2
	for itr in "$@"; do
		local dev="/dev/${itr}"
		${cmd} "${dev}" "${target}" && return 0
	done
	return 1
}
_GLOBAL_PREV_BLKS=""
new_devices() {
	local cur_blks=""
	for itr in /sys/class/block/*; do
		cur_blks="$cur_blks $(basename "$itr")"
	done

	local new_blks=""
	for itr in ${cur_blks}; do
		case " $_GLOBAL_PREV_BLKS " in
			*" $itr "*) ;;
			*) new_blks="$new_blks $itr" ;;
		esac
	done

	_GLOBAL_PREV_BLKS="${cur_blks}"

	echo "${new_blks}"
}
detect_device() {
	[ -b "$1" ] && return 0
	iterate_devices make_partuuid "$(basename $1)" $(new_devices)
}

MOUNT="/bin/busybox mount"
ECHO="/bin/busybox echo"
MKDIR="/bin/busybox mkdir"
${MKDIR} -p /proc /sys /dev
${ECHO} "init: Mounting pseudo file systems"
${MOUNT} -t proc proc /proc && ${ECHO} "init: mount /proc"
${MOUNT} -t sysfs none /sys && ${ECHO} "init: mount /sys"
${MOUNT} -t devtmpfs none /dev && ${ECHO} "init: mount /dev"

$ECHO "init: Creating applet links"
/bin/busybox --install /bin

echo "init: Loading modules"
modprobe loop && echo "modprobe loop"
modprobe overlay && echo "modprobe overlay"
modprobe squashfs && echo "modprobe squashfs"

BY_PARTUUID="/dev/disk/by-partuuid"
mkdir -p "${BY_PARTUUID}"

echo "init: Creating mount directories"
mkdir -p /mnt/lower /mnt/tmpfs /mnt/root

ROOT_DEV="$(parse_cmdline root | sed "s|^PARTUUID=|${BY_PARTUUID}/|")"
[ -z "${ROOT_DEV}" ] && ROOT_DEV="/dev/mmcblk0p2"
ROOT_FSTYPE="$(parse_cmdline rootfstype)"
[ -z "${ROOT_FSTYPE}" ] && ROOT_FSTYPE="ext4"
echo "init: Using rootfs: ${rootfs_image} (${ROOT_DEV}:${ROOT_FSTYPE})"
wait_until detect_device "${ROOT_DEV}" || emergency_shell
echo "init: Mounting lower layer"
mkdir -p /mnt/squashfs
mount -t "${ROOT_FSTYPE}" -o ro "${ROOT_DEV}" /mnt/squashfs
mount -t squashfs -o loop "/mnt/squashfs/${rootfs_image}" /mnt/lower

echo "init: Mounting upper layer (tmpfs)"
mount -t tmpfs -o noswap upper_and_work /mnt/tmpfs
mkdir -p /mnt/tmpfs/upper /mnt/tmpfs/work

echo "init: Mounting overlay filesystem"
mount -t overlay overlay -o lowerdir=/mnt/lower,upperdir=/mnt/tmpfs/upper,workdir=/mnt/tmpfs/work /mnt/root || emergency_shell

exec switch_root /mnt/root /sbin/init

変更点

下層(SquashFS)

この実践例の主要な部分であり、SquashFSのイメージファイルを/mnt/lowerにマウントします。具体的には、1.イメージファイルを含むファイルシステム(vfat)が構築されたブロックデバイスのマウント、2.イメージファイルのマウントの二段階で行います。

まずは、ディレクトリ/mnt/squashfsを作成すると共に、それをマウントポイントとしてイメージファイルに係るブロックデバイスをマウントします。ブロックデバイスは、カーネルコマンドラインパラメータのrootにて、PARTUUIDまたはデバイスファイルのいずれかで指定されます。

次に、イメージファイル(/mnt/squashfs/${rootfs_image})を下層ディレクトリ(/mnt/lower)にマウントします。イメージ「ファイル」なのでループバックマウントが必要です(-o loop)。

ここでは、イメージファイルの名前を、parse_cmdline関数は使わずに、環境変数rootfs_imageから取得します。この実装は、カーネルにより消尽されなかったカーネルコマンドラインパラメータは、initに環境変数として供されると言う挙動を利用したものです(parse_cmdline関数を使用することもできます)。詳しくは、man bootparam等を参照して下さい。

また、これらのマウントに必要なカーネルモジュール(squashfs)もロードします(組込みCONFIG_SQUASHFS=yのカーネルの場合は当然不要です)。

--- init.ro_mount_ext4
+++ init
@@ -76,6 +76,7 @@
 echo "init: Loading modules"
 modprobe loop && echo "modprobe loop"
 modprobe overlay && echo "modprobe overlay"
+modprobe squashfs && echo "modprobe squashfs"
 
 BY_PARTUUID="/dev/disk/by-partuuid"
 mkdir -p "${BY_PARTUUID}"
@@ -90,7 +91,9 @@
 echo "init: Using rootfs: ${ROOT_DEV} (${ROOT_FSTYPE})"
 wait_until detect_device "${ROOT_DEV}" || emergency_shell
 echo "init: Mounting lower layer"
-mount -t "${ROOT_FSTYPE}" -o ro "${ROOT_DEV}" /mnt/lower
+mkdir -p /mnt/squashfs
+mount -t "${ROOT_FSTYPE}" -o ro "${ROOT_DEV}" /mnt/squashfs
+mount -t squashfs -o loop "/mnt/squashfs/${rootfs_image}" /mnt/lower
 
 echo "init: Mounting upper layer (tmpfs)"
 mount -t tmpfs -o noswap upper_and_work /mnt/tmpfs

switch_root

switch_rootをbusyboxのアプレットからuitl-linuxのコマンドに切換えました。切換えた理由は、/devに対するアプレットとコマンドとの扱いの違いです。具体的には、アプレットは/devが事前にアンマウントされることを前提として/devを削除するのに対して、コマンドは/devを付替えます。

しかし、下層をSquashFSにより構成する場合、アプレットが前提とする/devのアンマウントが行えません。なぜなら、SquashFSのイメージファイルはマウント状態が維持されるので、そのイメージファイルを含むブロックデバイス(つまりSDカードのパーティション)のマウント状態も維持されるからです。

/devが残ってもメモリ消費への影響は軽微であり問題はないのですが、吝い質なので、/devを再利用するコマンド版(uitl-linux)のswitch_rootに切換えました。

したがって、/devの放置が気にならないなら、この変更点は不要です。ただし、busyboxのswitch_rootを使う場合は、/devのumountに失敗する点に注意してください。

--- init.ro_mount_ext4
+++ init
@@ -99,7 +102,4 @@
 echo "init: Mounting overlay filesystem"
 mount -t overlay overlay -o lowerdir=/mnt/lower,upperdir=/mnt/tmpfs/upper,workdir=/mnt/tmpfs/work /mnt/root || emergency_shell
 
-umount /proc
-umount /sys
-umount /dev
 exec switch_root /mnt/root /sbin/init

SquashFSのイメージファイル(rootfs.squashfs)の作成

以下の説明では、ホストマシン上で作業を行うことを前提とします(つまりオフラインが前提です)。オンライン(稼働中のラズベリーパイ上)で行うためには、スナップショット機能(LVMやZFS)が必要になります。適当にスナップショットを作成してから作業を行って下さい。スナップショット機能がない場合(LVMなしのext4)はホストマシン上で作業を行って下さい。

イメージファイルの作成は、mksquashfsコマンドにより行います。コマンドの引数は以下の通りです。

  • <src_dir>
    対象のファイルシステムのマウントポイントです。対象のファイルシステムは、例えば、既存のシステムのルートファイルシステムです。
    対象のファイルシステムが書込み可能にマウントされていると、データの一貫性が保証されない可能性があります。読込み専用でマウントしてください。
  • <image_file>
    出力先のファイル名です。本記事のファイル名はrootfs.squashfsです。
  • -comp <圧縮形式>
    圧縮形式を指定するためのオプションです。指定可能な形式は、圧縮率が高い順にxz, zstd, lzma, gzip, lzo, lz4になります。順番はデータの内容や圧縮レベルによっても前後します。以下の例ではzstdを指定しています。
  • -noappend
    所謂O_TRUNCの動作です。既存の同名ファイルを破壊的に上書きします。
mksquashfs <src_dir> <image_file> -comp zstd -noappend

圧縮形式の選択

圧縮形式を選択するにあたっては、まず、SquashFSの読取りが、CPU律速(CPU bound)なのかI/O律速(I/O bound)なのかを判断する必要があります。

圧縮ファイルシステム(SquashFS)の読取りは、ストレージから圧縮データを取得するI/O段階と、その圧縮データをCPUで展開するCPU段階との二段階の処理からなります。どちらが律速段階になるかは、ストレージと圧縮率により決まります。

ストレージは、低速なものほどI/O律速になる傾向があります。低速ストレージではI/O処理が遅くなり、CPUがI/Oによりブロックされます。

一方、圧縮率は、高いものほどCPU律速になる傾向があります。圧縮率の高いアルゴリズムは、CPUの負荷が高いのでCPU処理が遅くなります。また、圧縮率が高いほど、ストレージから取得するデータ量が減少するのでI/O処理が速くなり、これによってもCPU処理は相対的に遅くなります。

理想は、I/O律速にもCPU律速にも偏らず均衡が取れている状態です。現実的には、ストレージが先に決まるので、そのストレージの速度に応じて圧縮形式を選択することになります。特に、ラズベリーパイでは、標準ストレージであるSDカードの読取りが遅いので、CPU性能に余裕があれば、圧縮率を高くすることにより、I/O処理の遅さを軽減できる可能性があります。

initramfs.imgの作成

上述の説明と同じ作業です。make_initramfs.shを使回す場合、変更点は以下の通りです。

make_initramfs.sh
--- make_initramfs.sh.orig	
+++ make_initramfs.sh
@@ -26,7 +26,7 @@
 ar p "${BB_DEB}" data.tar.xz | tar -xOJ ./usr/bin/busybox > "${BUSYBOX}"
 chmod +x "${BUSYBOX}" || die "Failed to create busybox"
 
-CMDS="/usr/sbin/blkid"
+CMDS="/usr/sbin/blkid /sbin/switch_root"
 if [ -n "${CMDS}" ]; then
 	read FIRST _ <<< "${CMDS}"
 	LINKER="$(readelf -p .interp "${RPI_ROOT}${FIRST}" | sed -n 's/.*]  *//p')"
@@ -38,7 +38,7 @@
 	copy_dsos "${INITRAMFS_ROOT}" "${RPI_ROOT}" ${CMDS} || die "Failed to copy shared libraries"
 fi
 
-MODS="loop overlay"
+MODS="loop overlay squashfs"
 . "${WORKING_DIR}/copy_modules.sh" || die "Failed to source copy_modules.sh"
 copy_modules ${MODS} || die "Faied to copy kernel modules"

SDカードの準備

パーティション

SDカードは、単一のパーティションのみからなる構成にし、それをブートパーティションにします。そのブートパーティションに、SquashFSのイメージファイル(rootfs.squashfs)を配置します。また、他のファイル(ブートローダ、カーネルなどのイメージ、設定ファイルなど)も配置します。

以下のシェーマは、基本編で例示したRaspbery Pi OSの構成と、本実践例の構成とを対比したものです。

Raspberry Pi OSの構成            本実践例(SquashFS)の構成
+-/dev/sdX1----------+          +-/dev/sdX1---------+
| <kernel_image>     |          | <kernel_image>    |
| initramfs.img      |   ===>   | initramfs.img     |
| cmdline.txt, ...   |          | cmdline.txt, ...  |
+--------------------+    +---> | rootfs.squashfs   |
                          |     +-------------------+
+-/dev/sdX2----------+    |     
| <root_filesystem>  | ---+   
|                    |          
+--------------------+          

カーネルコマンドラインパラメータ

  • rootおよびrootfstype
    rootには、ブートパーティションのPARTUUIDもしくはデバイス名を指定して下さい。PARTUUIDは以下のコマンドで取得できます。
    rootfstypeは、vfatです。
blkid -s PARTUUID -o value <ブートパーティション>
  • rootfs_image
    SquashFSのイメージファイル名です。なお、パラメータ名rootfs_imageは、initにおける環境変数名になるので、initと必ず整合させて下さい。
cmdline.txt
console=serial0,115200 root=PARTUUID=XXXXXXXX-01 rootfstype=vfat rootfs_image=rootfs.squashfs rootwait

確認

losetup -aにより、SquashFSのイメージファイルがバッキングファイルになっていることを確認できます。あまり格好良くないのですが、不可触なので運用上の問題はありません。

mount|grep 'overlay on /'
losetup -a

SquashFSの応用

なお、SquashFSの応用範囲は本実践例だけではありません。SDカード保護と言う本来の趣旨からは外れますが、OverlayFSの上層をSDカード上にext4などで永続的に書込み可能に構成することで、所謂、工場出荷状態(squashfs)とアップデート差分(ext4)と言う構成も可能になります。


むすび

スクリプトについて

本記事のスクリプトは、記事の内容を支持することに主眼をおいた設計となっています。そのため、エラー処理などの記事と関係ない処理については、簡易的あるいは省略しています。

スクリプトは、そのままでも一応は動くはずですが、そのまま使うことを想定していません。これらを雛形として使い易いように編集して下さい。

実のところ、私はGentooを使っているので、これらのスクリプトを使っていません。これらは、本記事のために新規に作成したものです。作成が可能な内容の記事にしたつもりですので、スクラッチでの作成に挑戦してみてください。

今後の展望

本当は 「initの書き方(NFS root編)付ネットワークブート」 も書きたかったのですが、ここまでで既に4万字に達してしまい、文章の管理が大変なので諦めました。

機会があれば 「SDカードが絶対に壊れないラズベリーパイ(ネットワークブートとは?)」 に挑戦してみたいです。

それでは、本記事が、便利を建前にどんどんロックインされていくLinuxシステムの理解の一助となればこれ幸です。
以上

Discussion