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 對照
面向chrootpivot_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 重現了一遍。

延伸閱讀#