前言
某天收到了 Raspberry Pi 的磁碟 image 過來,檔案有 61GB = =
筆者只是想看看裡面到底放了哪些設定檔、開機腳本、還有塞了什麼程式進去。
聽起來很單純吧?「掛起來看一下不就好了。」
結果一到 macOS 上就卡住了 —— Mac 原生掛不動 ext4。而 Raspberry Pi 的 root filesystem 偏偏就是 ext4。
於是這一篇就誕生了!我們要做的,是寫一支 bash script,讓筆者可以像登入到 Pi 裡面一樣,用 shell 自由地逛整個檔案系統,而且全程唯讀,絕對不弄髒這顆收到的原始 image。
這篇會從「先搞清楚 image 是什麼」開始,講到三個方案的取捨、最後選定的 Docker 做法,以及過程中踩到的兩個很有代表性的坑。
先搞清楚:這個 image 到底是什麼
在動手之前,先用 file 看清楚它的長相:
1file demo_image輸出大概長這樣:
1DOS/MBR boot sector;
2 partition 1 : ID=0xc ... startsector 8192, 1048576 sectors
3 partition 2 : ID=0x83 ... startsector 1056768, 119824384 sectors翻譯一下:
- Partition 1(ID=0x0c):FAT32 的 boot 分割區,約 512MB。這個 macOS 可以原生掛。
- Partition 2(ID=0x83):Linux ext4 的 root filesystem,約 57GB。這個才是重點,而 macOS 掛不動。
所以真正的難題只有一個:怎麼在 Mac 上讀到那個 ext4 分割區。
為什麼 macOS 直接掛不動?三個方案的取捨
macOS 的核心沒有內建 ext4 的讀取支援。筆者想到三條路:
- 方案 A — Docker 唯讀 loop mount + 互動 shell:開一個 Linux 容器,用真正的 Linux kernel 把分割區掛起來,再進去用 bash 逛。
- 方案 B — macFUSE + fuse-ext2:裝套件後直接把 ext4 掛到 macOS 的目錄,連 Finder 都能看。
- 方案 C — debugfs(e2fsprogs):完全不掛載,用工具直接讀 ext4。
核心做法:用 Docker 把 image 唯讀掛起來
整個流程其實很好懂,先看一張序列圖:
在容器裡,關鍵只有兩步:先用 losetup 把 image 變成一個 loop 區塊裝置並偵測分割區,再把第二個分割區(rootfs)唯讀掛起來。
1# 自動偵測分割區,不寫死 offset
2LOOP=$(losetup -fP --read-only --show /image.img)
3
4# 把 ext4 rootfs(partition 2)唯讀掛到 /rootfs
5mount -o ro "${LOOP}p2" /rootfs
6
7# 進去逛
8cd /rootfs
9exec bash -i這裡刻意用 losetup -fP 自動偵測分割區,而不是把「offset = 1056768 × 512」這種魔術數字寫死 —— 換一顆 image 也能用。
唯讀,而且是「三層」唯讀
因為這是收到的原始資料,筆者最在意的一件事就是:絕對不能改到它。所以唯讀不是掛一層就算了,而是疊了三層互相保險:
- 第一層 — bind mount 加
:ro:docker run -v "$IMG:/image.img:ro",容器層級就不給寫。 - 第二層 —
losetup --read-only:loop 區塊裝置本身唯讀。 - 第三層 —
mount -o ro:檔案系統唯讀;萬一 journal 不乾淨,再退一步用-o ro,noload跳過 replay,避免任何寫入嘗試。
驗證也很直接,進去之後 touch 一下就知道:
1pi-image:/rootfs# touch test
2touch: cannot touch 'test': Read-only file system看到 Read-only file system 就安心了 —— 原始 image 動不了。至於想留下的檔案,可以另外開一個 _export/ 目錄用 rw 掛進去,cp 出來就好,唯一可寫的地方僅此一處。
第一個坑:容器裡沒有 udev,partx 建不出裝置節點
理論很美好,實際一跑就翻車。losetup -fP 明明成功了,${LOOP}p2 卻不存在。
等一下…… 分割區明明偵測到了,裝置節點怎麼憑空消失?
追下去才發現容器跑在一個 LinuxKit 的輕量 VM 裡,而這個 VM 沒有跑 udev。losetup -fP 只在 kernel 裡登記了分割區,卻沒有人幫忙在 /dev 底下建出 /dev/loopNpN 這些節點。
解法是自己動手:從 /proc/partitions 讀出 major / minor,再用 mknod 把缺的節點補回來。
1# udev 沒跑時,partx 建不出節點 → 從 /proc/partitions 補建
2for _p in 1 2; do
3 _dev="${LOOP}p${_p}"
4 if [ ! -e "$_dev" ]; then
5 _name="${LOOP#/dev/}p${_p}"
6 _maj=$(awk -v n="$_name" '$4==n{print $1}' /proc/partitions)
7 _min=$(awk -v n="$_name" '$4==n{print $2}' /proc/partitions)
8 [ -n "$_maj" ] && mknod "$_dev" b "$_maj" "$_min" 2>/dev/null || true
9 fi
10donemknod 出來的節點是可寫的,會不會開了一條後門?其實不會。唯讀是由 loop 驅動在 kernel 層強制的 —— 只要 loop device 是用 --read-only 建的,就算你的裝置節點權限可寫,實際 I/O 依然是唯讀。節點只是個入口,擋讀寫的是背後的 loop 裝置。第二個坑:loop device 會「洩漏」
以為大功告成,結果在收尾前跑最後一次測試,又炸了:
1losetup: /image.img: failed to set up loop device: No such file or directory
2losetup: device node /dev/loop5 (7:5) is lost. You may use mknod(1) to recover it.奇怪的是,前面測了好幾次都好好的,怎麼突然壞掉?進去 VM 一看 losetup -a,真相大白:loop0 到 loop4 全都還附掛著,背後指向的都是早就被刪掉的容器。
原來每跑一次,就漏一顆 loop device。
原因是:loop device 是整個 VM 全域共用的,不像檔案系統掛載那樣被容器命名空間隔離。docker run --rm 只會移除容器,不會幫你把 loop device 卸掉。跑幾次之後,losetup -f 挑到一個號碼(loop5),但那個號碼在無 udev 的 VM 裡連節點都沒有,於是就掛了。
修法是加一個 EXIT trap,不管是正常離開、跑完自我測試、還是中途出錯,離開前都把掛載卸掉、把 loop device losetup -d 掉:
1LOOP=""
2cleanup() {
3 [ -n "$LOOP" ] || return 0
4 cd / 2>/dev/null || true
5 umount -R /rootfs 2>/dev/null || true
6 losetup -d "$LOOP" 2>/dev/null || true
7}
8trap cleanup EXIT這裡有個小細節:互動模式原本用的是 exec bash,但 exec 會取代目前的行程,trap 就永遠不會觸發。所以要把它改成一般呼叫,讓使用者離開 shell 後,控制權回到腳本、trap 才有機會清理:
1# 從 exec bash ... 改成:
2bash --rcfile /root/.browserc -i || true--rm 幫你收拾,因為那些東西根本不在容器的命名空間裡。自己建的,就自己拆。驗收方式也很明確 —— 連續跑三次自我測試,而且事後 losetup -a 必須是空的:
1for i in 1 2 3; do ./browse-pi-image.sh --selftest; done
2docker run --rm --privileged debian:stable-slim losetup -a # 應為空「跑完之後 loop device 歸零」才是這個修正真正該通過的測試,而不只是「這次能跑」。
完整用法
最後成品是一支 browse-pi-image.sh,用起來就三種姿勢:
1# 進入互動瀏覽(像登入 Pi 一樣逛)
2./browse-pi-image.sh
3
4# 非互動自我測試(驗證掛載 / 唯讀 / 匯出)
5./browse-pi-image.sh --selftest
6
7# 指定別的 image
8./browse-pi-image.sh /path/to/other.img進去之後 rootfs 就掛在 /rootfs,提示字元是 pi-image:/rootfs#,ls 一下就能看到 bin boot etc home usr var … 一整套熟悉的 Linux 根目錄。想把檔案帶回 Mac 就 cp /rootfs/… /export/。exit 離開後,容器自動移除、loop device 自動 detach。
小結
回顧一下這趟旅程:
- macOS 掛不動 ext4,但我們可以借一個 Docker 容器的 Linux kernel 來讀,體驗最接近真機。
- 用
losetup -fP自動偵測分割區,不寫死 offset,換 image 也能用。 - 唯讀要疊三層(
:ro/losetup --read-only/mount -o ro),對「收到的原始資料」這種場景特別重要。 - Docker Desktop 的 LinuxKit VM 沒有 udev,要自己
mknod補裝置節點。 - loop device 是 VM 全域共用的,
--rm不會幫你清,得自己用trap在離開時losetup -d,否則會慢慢洩漏。
其中最讓筆者有感的,是最後那個 loop 洩漏的坑 —— 它是在「收尾閘門」跑最後一次測試時才現形的。這也再次提醒我們:測試不是為了證明它會動,而是為了逼出那些平常剛好沒踩到的狀態。
未來如果有機會玩到需要寫入 image、或想改用 macFUSE 直接掛進 Finder 的版本,或許會再寫一篇續集!