rootfs 與 chroot / pivot_root#
容器看到的不是主機的檔案系統,而是自己的根檔案系統(root filesystem,rootfs) — 那個 ubuntu:22.04 image 裡的 /bin、/etc、/usr。但「換掉根目錄」這件事比想像中棘手:早期的 chroot 並不夠安全,現代容器執行環境改用 pivot_root。這一節拆解「容器如何取得並切換到自己的根」,以及為什麼這一步必須與 mount namespace 搭配。
chroot:把根目錄換掉,以及它的陷阱#
chroot(2) 把一個行程(及其子孫)的根目錄 / 改指到某個子目錄。之後該行程看到的 / 就是那個子目錄,看不到外面。這是最古老的「檔案系統隔離」手段。
# 準備一個迷你 rootfs,把根換進去
sudo chroot /path/to/rootfs /bin/sh但 chroot 從來不是為安全隔離設計的,它有幾個著名陷阱:
/proc、/sys若沒有在新根裡重新掛載,要嘛不存在,要嘛仍指向主機的核心介面- 持有
CAP_SYS_CHROOT的 root 行程可以再chroot一次「逃出」原本的牢籠(經典的 chroot escape) - 它只改了根目錄這個指標,完全沒碰 mount namespace:主機既有的掛載點仍可能可見
- 開著的檔案描述子(fd)、目錄串流可被用來繞過邊界
pivot_root:為什麼容器需要它#
pivot_root(2) 做的事更徹底:它把整個 mount namespace 的根掛載點(root mount)換成新的檔案系統,並把舊根移到一個指定目錄,之後可以把舊根卸載(unmount)掉。
關鍵差異在於 — pivot_root 操作的是掛載樹本身,配合 mount namespace 使用後,舊根可以被徹底 umount,行程再也沒有任何掛載點通往主機檔案系統。chroot 留下的「舊根其實還掛在那」的後門,被 pivot_root 關上了。
chroot vs pivot_root 對照
| 面向 | chroot | pivot_root |
|---|---|---|
| 改變的對象 | 行程的根目錄指標 | mount namespace 的根掛載點 |
| 舊根去向 | 仍掛在原處,可能被繞回 | 移到指定目錄,可被 umount 徹底移除 |
| 安全邊界 | 弱,特權行程可逃逸 | 強,配合 mount namespace 後無路通往主機 |
| 與 namespace 關係 | 不涉及 mount namespace | 必須在獨立 mount namespace 內使用 |
| 容器用途 | 早期、玩具級隔離 | 現代容器執行環境的標準做法 |
結論:chroot 是「假裝換了根」,pivot_root 是「真的把根接到新檔案系統並斷開舊的」。容器需要後者。
容器 rootfs 的準備:從 OverlayFS 到 pivot_root#
容器的根不是憑空來的,而是把 image 的各層用聯合檔案系統疊出來的 merged 視圖(見 ../03-image/04-overlayfs/)。完整流程:
graph TD
A["image 各 layer(read-only)<br/>排成 OverlayFS LowerDir"]
B["容器可寫層<br/>作為 UpperDir"]
C["mount -t overlay<br/>產生 merged 視圖"]
D["進入新的 mount namespace<br/>(unshare CLONE_NEWNS)"]
E["把掛載傳播設為 private<br/>MS_PRIVATE | MS_REC"]
F["pivot_root 到 merged<br/>舊根移到 /old_root"]
G["重新掛載 /proc /sys /dev"]
H["umount /old_root<br/>斷開與主機的最後連結"]
I["exec 容器入口程式"]
A --> C
B --> C
C --> D --> E --> F --> G --> H --> I每一步都不可省:少了 mount namespace,pivot_root 會影響到主機;少了 private 傳播,掛載動作會洩漏出去;少了重新掛載 /proc,容器內看到的會是主機的行程資訊。
容器內 /proc、/sys、/dev 重新掛載#
OverlayFS merged 出來的只是「檔案」,但 /proc 與 /sys 是核心提供的虛擬檔案系統(虛擬介面),必須在新根裡重新掛載,且要掛容器自己的視圖:
# 在新根內掛載容器自己的 procfs(反映容器 PID namespace 的行程)
mount -t proc proc /proc
# sysfs
mount -t sysfs sys /sys
# 最小化的 /dev(通常只給 null、zero、random 等少數裝置)
mount -t tmpfs tmpfs /dev- 容器內的
/proc必須對應到容器的 PID namespace,這樣ps才會只看到容器內的行程、PID 1 才會是 entrypoint - 若直接沿用主機的
/proc,容器內ps會看到主機所有行程,隔離破功 /dev通常用 tmpfs 加上極少量必要裝置節點,避免暴露主機硬體
mount namespace 保護:MS_PRIVATE / MS_REC#
pivot_root 必須在獨立的 mount namespace 裡做,否則會污染主機。但光是進入新 mount namespace 還不夠 — 預設的掛載傳播(mount propagation)可能是 shared,意味著子 namespace 的掛載動作會「傳回」父 namespace。
MS_PRIVATE:把掛載點設為私有,掛載/卸載動作不再向外傳播MS_REC:遞迴套用到整棵子掛載樹- 容器初始化時通常先
mount --make-rprivate /(等於MS_PRIVATE | MS_REC),確保後續所有掛載操作都被關在容器內
進入新的 mount namespace 不會自動切斷傳播關係。若不把根掛載設為 private,容器內的
mount/umount可能影響到主機,這既是正確性問題也是安全問題。mount --make-rprivate /是容器初始化的必要動作。
動手做:unshare + pivot_root 演示#
不靠 Docker,用 unshare 也能完整重現「進入新 mount namespace ➡️ 切換根 ➡️ 重新掛載 /proc」:
# 假設 $ROOTFS 是一個已備好的迷你根檔案系統目錄(含 /bin/sh、/proc、/old_root 等空目錄)
# 進入新的 mount + PID namespace,並以新 namespace 內 fork 出的行程為 PID 1
sudo unshare --mount --pid --fork /bin/bash <<'EOF'
# 1. 把根掛載設為私有,避免污染主機
mount --make-rprivate /
# 2. 把 ROOTFS 變成一個 mount point(pivot_root 要求新根是 mount)
mount --bind "$ROOTFS" "$ROOTFS"
# 3. 準備放舊根的目錄
mkdir -p "$ROOTFS/old_root"
# 4. 切換根:新根 = $ROOTFS,舊根移到 /old_root
cd "$ROOTFS"
pivot_root . old_root
# 5. 重新掛載容器自己的 /proc
mount -t proc proc /proc
# 6. 斷開舊根,從此沒有路徑通往主機
umount -l /old_root
# 7. 驗證:ps 只看得到 namespace 內的行程,根目錄已是新 rootfs
ps -ef
ls /
EOF預期觀察:
ls /顯示的是$ROOTFS的內容,主機的/home、/etc都不見了ps -ef只看到這個 namespace 內的少數行程,且 bash 是 PID 1- 在容器內
mount任何東西都不會出現在主機的mount輸出(拜MS_PRIVATE所賜)
這個實驗把容器執行環境「準備並切換根」的核心動作,完全不依賴 Docker 重現了一遍。
延伸閱讀#
man 2 pivot_root、man 2 chroot、man 7 mount_namespaces- 相關:
../04-namespaces/04-mount-namespace-pitfall/mount namespace 陷阱 - 上一節:
../08-memory-and-oom虛擬記憶體與 OOM Killer - 下一節:
../10-capabilities能力與非 root 運行