容器化技術 ch03 Image 分層的秘密:疊透明片與寫時複製
下一章→
CH 03 核心原理

Image 分層的秘密:疊透明片與寫時複製

為什麼 pull 有好幾條進度條OverlayFS 聯合掛載Copy-on-WriteImage 與 Container 的真正關係

上一章解決了「容器是什麼」,這一章解決「容器的檔案系統是怎麼來的」。

這是 Docker 設計裡最漂亮的一段。理解分層之後,一堆看似無關的現象會突然全部串起來:為什麼 pull 有好幾條進度條、為什麼第二次 pull 快得像作弊、為什麼容器啟動只要幾毫秒、為什麼容器刪掉資料就消失、為什麼 Dockerfile 改一行就要重建一大半、以及一個很多人不知道的資安地雷——你在映像裡刪掉的密碼檔,其實還在裡面。

你一定看過這個畫面:

$ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
a2abf6c4d29d: Pull complete
a9edb18cadd1: Pull complete
589b7251471a: Pull complete
186b1aaa4aa6: Pull complete
b4df32aa5a72: Pull complete
a0bcbecc962e: Pull complete
Digest: sha256:0d17b565c37b...
Status: Downloaded newer image for nginx:latest

六條進度條、六個奇怪的 ID。映像不是一個檔案,它是好幾個「層(layer)」組成的。

再看一個更有意思的現象

接著你 pull 另一個也是基於 Debian 的映像:

$ docker pull redis
a2abf6c4d29d: Already exists      ← 這層跳過了!
7c3f1b4c9e88: Pull complete
...

Already exists——因為 nginx 和 redis 都建立在同一個 Debian 基底上,那一層你已經有了,不用再下載,而且兩個映像共用同一份。

⭐ 這帶來三個立刻可感受到的好處:
① 省頻寬:只下載你沒有的層。
② 省磁碟:十個 Java 應用映像共用同一份 JDK 層,磁碟上只有一份。
③ 更新快:你的應用改一行程式重新建置,只有最上面那層變了,推送/拉取都只傳那一層(幾 MB),不是整個 300 MB 映像。

接下來三張卡,把「層」到底是什麼、怎麼疊起來、怎麼被修改,一路拆到底。

定義

一層(layer)= 相對於下一層,這一步「新增、修改、刪除了哪些檔案」的差異包(diff)。它被打包壓縮,並用內容算出一個 SHA-256 雜湊值當作身分證。

層是怎麼產生的?Dockerfile 的每個指令一層

FROM eclipse-temurin:17-jre     ← 基底(本身也是好幾層)
RUN apt-get install -y curl     ← 第 N 層:多了 curl 相關檔案
COPY app.jar /app/app.jar       ← 第 N+1 層:多了一個 app.jar
CMD ["java", "-jar", "/app/app.jar"]  ← 只是設定,不佔實體層
  • 第 N 層裡面不是整個作業系統,只有「安裝 curl 這件事新增/改動的那些檔案」。
  • 第 N+1 層裡面只有 app.jar 這一個檔案。

關鍵性質:層是不可變的(immutable)

層一旦建立就永遠不會被修改,因為它的身分證(雜湊值)是用內容算出來的——改內容就變成另一層了。這帶來兩個重要推論:

  • 可安全共用:既然沒人能改它,A 映像和 B 映像共用同一層就完全安全。
  • 可驗證:拉下來的層算一次雜湊就知道有沒有被竄改或損毀。
💡 疊透明片類比:想像動畫師的做法——一張背景圖(基底),上面疊一張只畫了角色的透明片,再疊一張只畫了對白框的透明片。從上往下看是一張完整的畫面,實際上是好幾張各自只有一部分內容的片子疊出來的。映像的層就是這些透明片:每張只記錄「這一步多了什麼」,疊在一起才是完整的檔案系統。
🚨 先埋一個伏筆:既然層不可變、而且每層都留著——那如果你在第 3 層 COPY 了一個含密碼的設定檔,第 5 層再 RUN rm 把它刪掉,那個密碼檔真的消失了嗎?答案在本章最後一張卡,這是很多人踩過的資安地雷。

層各自獨立存在磁碟上,但容器裡的程式需要看到一個正常、完整的檔案系統。負責把它們疊起來的技術叫做聯合檔案系統(Union File System),Docker 現在預設用的實作是 Linux 核心內建的 OverlayFS。

OverlayFS 的四個角色

  • lowerdir(下層):唯讀的那些層,可以有很多個,由下往上疊。← 就是映像的各層
  • upperdir(上層):唯一可寫的那一層。← 容器啟動時新增的
  • workdir:內部運作用的暫存區。
  • merged(合併後的視圖):疊完的成果,這才是容器裡看到的 /。

疊起來的規則(只有兩條)

  1. 上層覆蓋下層:同一個路徑的檔案,如果上下層都有,看到的是最上層那個版本。(就像疊透明片,上面畫的蓋住下面的)
  2. 各層互補:某路徑只有某一層有,那就直接看得到它。
容器看到的 /  (merged)
  ↑ 疊合
[可寫層]      /app/config.yml(改過的版本)← 蓋住下面那個
[映像層 3]    /app/app.jar
[映像層 2]    /usr/bin/curl
[映像層 1]    /bin, /etc, /usr, /lib …(Debian 基底)
💡 上一章講的 pivot_root,換過去的那個目錄就是這裡的 merged。兩章在這裡接起來了:ch02 說「換掉容器的根目錄」,ch03 說「那個根目錄是好幾層疊出來的」。

實際看一眼

在 Linux 宿主機上執行 mount | grep overlay,會看到類似這樣的東西(有簡化):

overlay on /var/lib/docker/overlay2/abc.../merged type overlay
  (lowerdir=/var/lib/docker/overlay2/l/A:/var/lib/docker/overlay2/l/B,
   upperdir=/var/lib/docker/overlay2/abc.../diff,
   workdir=/var/lib/docker/overlay2/abc.../work)

沒有魔法,就是一個掛載參數。容器的檔案系統,是宿主機上一個 overlay 掛載點而已。

問題來了:映像的層是唯讀的,但容器裡的程式明明可以寫檔案。怎麼辦到的?

答案:容器啟動時,在最上面加一層薄薄的「可寫層」

這層一開始是空的。之後所有的寫入都發生在這裡,下面的映像層永遠不動。

三種操作的處理方式

  1. 讀取一個檔案:從上往下找,找到第一個就用。不複製任何東西,直接讀映像層的原檔。
  2. 修改一個檔案(重點!):
    • 檔案在下面的唯讀層,不能直接改。
    • 於是 OverlayFS 先把整個檔案複製到可寫層,再讓你改那份複本。這個動作就叫 Copy-on-Write(寫時複製,CoW)——不寫就不複製,寫了才複製。
    • 之後讀這個路徑,因為上層覆蓋下層,你讀到的就是改過的複本。
  3. 刪除一個檔案:下層的檔案刪不掉,於是在可寫層放一個特殊的標記檔(whiteout),意思是「這個路徑當作不存在」。查找時遇到 whiteout 就停下來回報找不到。下層那個檔案其實還好端端地躺在那裡。

由 CoW 直接推導出的四個現象(全部串起來了)

  • 為什麼容器啟動只要幾毫秒?因為完全不複製映像內容,只是加一個空的可寫層再做一次 overlay 掛載。
  • 為什麼同一個映像可以開 50 個容器不佔 50 倍空間?50 個容器共用同一份唯讀層,各自只有自己那層薄薄的可寫層。
  • 為什麼容器刪掉,裡面寫的資料就沒了?因為資料全在可寫層,而可寫層是屬於這個容器的——容器被 docker rm 掉,可寫層跟著刪除。映像層完全沒動過,所以重新開一個容器就是全新的乾淨狀態。(要保住資料,就要用 volume 把資料寫到容器外面,見 ch06。)
  • 為什麼資料庫容器不該把資料放在可寫層?CoW 有效能代價:第一次修改一個 10 GB 的資料檔,得先把整個檔案複製上來。資料庫這種頻繁隨機寫入的工作負載,走 overlay 可寫層又慢又浪費空間。所以你 compose 檔裡那行 mssql-data:/var/opt/mssql 不只是為了保存資料,也是為了效能。
⭐ 一句話總結:映像是唯讀的共用資產,容器是「映像 + 一層屬於自己的可寫層」。這一句解釋了本章開頭所有的現象。

現在可以精確定義這兩個天天在用、卻常被混為一談的詞了。

Image(映像)= 一疊唯讀層 + 一份設定檔(metadata)
設定檔記錄著:預設要執行什麼指令(CMD/ENTRYPOINT)、以什麼使用者身分執行(USER)、環境變數(ENV)、工作目錄(WORKDIR)、宣告要開的 port。它是靜態的、躺在磁碟上不動的東西。
Container(容器)= Image + 一層可寫層 + 正在執行的 process + 自己的 namespace/cgroup 設定
它是動態的、活著的東西。

三個類比,挑一個記住

  • 程式設計:Image 是 class,Container 是 object(實例)。一個 class 可以 new 出無數個 object,各自有自己的狀態。
  • 音樂:Image 是母帶(不會變),Container 是正在播放的那一次(有播放進度、有音量,播完就沒了)。
  • 烹飪:Image 是食譜+所有備好的食材,Container 是正在爐上煮的那一鍋。

由此推出的實務判斷

  • 「我改了容器裡的東西,怎麼重建後不見了?」→ 你改的是 object 的狀態,不是 class 的定義。要永久生效請改 Dockerfile(ch08)。
  • 「同一個映像的兩個容器會互相影響嗎?」→ 不會。它們共用唯讀層,但各有各的可寫層,寫入互不可見。
  • 「刪映像前要先刪容器」→ 因為容器還引用著那些層。這就是 image is being used by running container 錯誤的來源。
  • docker commit 是什麼?→ 把容器目前的可寫層固化成一個新的映像層。可以用,但不建議——這等於用「手動改物件狀態」的方式產生 class,別人完全不知道你做了什麼。正道是寫進 Dockerfile。

回到前面埋的問題。假設有人寫了這樣的 Dockerfile:

FROM eclipse-temurin:17-jre
COPY secrets.env /tmp/secrets.env      ← 第 3 層:含資料庫密碼
RUN ./setup.sh && rm /tmp/secrets.env  ← 第 4 層:用完就刪掉,安全了吧?
COPY app.jar /app/app.jar

直覺上:檔案刪了,映像裡就沒有了。大錯特錯。

為什麼還在?

  • 層是不可變的、而且每一層都保留在映像裡。第 3 層裡確實有 secrets.env,這件事已成定局。
  • 第 4 層的 rm 只是加了一個 whiteout 標記:「這個路徑當作不存在」。它是遮住,不是抹除。
  • 任何人拿到這個映像,都可以把層一層層拆開來看——docker save 匯出成 tar 檔解開,或用 docker history、dive 這類工具,第 3 層裡的密碼原封不動。
結論:映像的每一層都是公開的歷史紀錄,沒有「刪掉」這回事,只有「後面的層遮住它」。推到公開 registry 上,等於把密碼一起公開。

正確作法(ch10 會完整展開)

  • 秘密絕不寫進映像。密碼、token、金鑰在執行時用環境變數或 secret 機制注入,不在建置時放進去。
  • 建置階段真的需要秘密(例如私有套件庫的認證)→ 用 BuildKit 的 secret mount(RUN --mount=type=secret),它掛載進去但不會留在任何一層。
  • 或用多階段建置(ch10):需要秘密的動作在建置階段做完,最終映像只從那階段複製出乾淨的成品。
  • .dockerignore 擋掉 .env、.git、金鑰檔,避免 COPY . . 時整包送進去(.git 裡藏著歷史上所有曾經 commit 過的秘密)。
⭐ 順帶一提,這個「層是歷史紀錄」的特性也有好的一面:docker history <映像> 可以看到這個映像是怎麼一步步建出來的,是稽核與除錯的好工具。缺點是別人也看得到。
Image vs Container:一張表講清楚
面向Image(映像)Container(容器)
本質一疊唯讀層 + 設定檔Image + 可寫層 + 執行中的 process
狀態靜態,躺在磁碟上不會變動態,活著且會累積狀態
類比class / 母帶 / 食譜+食材object / 播放中 / 爐上那鍋
可否修改不可變(改了就是新映像)可寫,但寫的東西只活在這個容器裡
數量關係一份同一映像可開 N 個,彼此互不影響
刪掉的後果要重新 pull 或 build可寫層一併消失 → 沒放 volume 的資料就沒了
查看指令docker images / docker historydocker ps -a / docker inspect
三種檔案操作在 OverlayFS 裡發生什麼
操作實際發生的事代價推論出的現象
讀取由上往下找,找到就讀(不複製)幾乎為零多個容器共用映像層,省空間
修改先把整個檔案複製到可寫層,再改複本(Copy-on-Write)第一次寫要付複製整檔的成本大檔案/資料庫不該寫在可寫層 → 用 volume
刪除在可寫層放 whiteout 標記,遮住下層檔案低映像裡刪掉的秘密其實還在下層!
分層設計:好處與代價
面向帶來什麼背後原因
傳輸只下載缺少的層(Already exists)層以內容雜湊為身分證,可精確比對
儲存多個映像/容器共用同一層,磁碟只存一份層不可變,共用絕對安全
啟動速度毫秒級啟動不複製映像,只加空的可寫層再掛載
建置速度沒改到的層直接沿用快取(ch08 主題)層以內容決定身分,內容沒變就能重用
代價①寫大檔案第一次很慢Copy-on-Write 要先複製整個檔案
代價②刪不掉歷史:秘密會永久留在層裡刪除只是 whiteout 遮蔽,不是抹除
代價③層太多會變慢、變大每層都有查找與儲存開銷(ch10 教怎麼減層)

練習題 點選選項查看解析

0 / 8
01 / 8
docker pull redis 時看到某一層顯示 Already exists。這代表什麼?
A 下載失敗了
B 這一層你本機已經有(例如先前 pull nginx 時下載過的同一個 Debian 基底層),可以直接共用不必重下
C redis 映像損壞
D Docker 快取過期
解析
層以內容的 SHA-256 雜湊作為身分證,只要雜湊相同就是同一層。nginx 和 redis 若建在同一個基底上,那層本機已有就跳過。這同時省了頻寬與磁碟——磁碟上實際只存一份,被多個映像共用。
02 / 8
Dockerfile 裡的一層(layer)實際存放的是什麼?
A 一個完整的作業系統副本
B 相對於下一層的「檔案系統差異包」——這一步新增、修改、刪除了哪些檔案
C 一段可執行的腳本
D 容器執行時的記憶體快照
解析
層是 diff 不是完整快照。RUN apt-get install curl 那層只含安裝 curl 造成的檔案異動;COPY app.jar 那層只含那一個 jar 檔。所以應用層通常只有幾 MB,即使基底映像有 300 MB。也因為是 diff,疊起來才會構成完整的檔案系統。
03 / 8
OverlayFS 疊合多層時,同一個路徑在多層都存在,容器看到的是哪一個?
A 最下層的版本
B 最上層的版本——上層覆蓋下層,就像疊透明片時上面畫的會蓋住下面
C 會報錯衝突
D 隨機挑一個
解析
疊合只有兩條規則:上層覆蓋下層、各層互補。這正是修改檔案能生效的原因——CoW 把改過的複本放在可寫層,因為它在最上面,之後讀到的就是新版本,而下層原檔完全沒動。
04 / 8
同一個映像開 50 個容器,磁碟為什麼不會佔 50 倍空間?
A Docker 使用了高壓縮率演算法
B 50 個容器共用同一份唯讀映像層,各自只多出一層一開始是空的可寫層
C Docker 只允許一個容器實際存在
D 映像會被自動刪除
解析
這就是 Copy-on-Write 的直接效益:啟動容器不複製映像內容,只加一層空的可寫層再做一次 overlay 掛載。所以啟動是毫秒級,而空間只隨各容器實際寫入的量成長。
05 / 8
容器被 docker rm 刪掉後,它執行期間寫入的資料為什麼消失了?
A Docker 主動清空了映像
B 那些資料都寫在「屬於這個容器的可寫層」,容器刪掉時可寫層一併刪除;下層映像層則完全沒被動過
C 資料被移到了另一台機器
D 因為沒有執行儲存指令
解析
映像層唯讀且共用,所有寫入都落在該容器獨有的可寫層。容器一刪,可寫層跟著沒了,所以重新建立的容器永遠是乾淨的初始狀態——這是特性不是 bug。要保留的資料必須寫到容器外的 volume(ch06)。
06 / 8
為什麼資料庫容器一定要把資料目錄掛到 volume,而不是留在可寫層?
A 只是為了方便備份
B 兩個理由:① 可寫層隨容器消滅,資料會不見;② Copy-on-Write 對大檔案的頻繁隨機寫入效能很差,第一次改動就得複製整個檔案
C 因為可寫層有容量上限 100 MB
D 因為資料庫不支援 overlay 檔案系統
解析
持久性與效能兩個原因都成立。你 compose 檔裡那行 mssql-data:/var/opt/mssql 同時解決了這兩件事——volume 繞過 overlay 層直接寫到宿主機檔案系統,既不會隨容器消失,也沒有 CoW 的複製開銷。
07 / 8
Dockerfile 中先 COPY 了含密碼的 secrets.env,後面再 RUN rm 刪掉它。這個映像安全嗎?
A 安全,檔案已經刪除了
B 不安全。層是不可變且全部保留的,rm 只是在上層加了 whiteout 標記遮住它;把映像匯出解開,下層那個密碼檔原封不動
C 只要不推到公開 registry 就安全
D 取決於使用的儲存驅動
解析
映像的層是完整的歷史紀錄,沒有「刪掉」只有「被遮住」。任何人 docker save 匯出成 tar 解開、或用 dive 之類的工具,都能翻出那個檔案。正確作法:秘密在執行時用環境變數或 secret 機制注入;建置階段真的需要就用 BuildKit 的 RUN --mount=type=secret,它不會留在任何一層。另外用 .dockerignore 擋掉 .env 與 .git。
08 / 8
Image 與 Container 的關係,最精確的描述是?
A Image 是壓縮檔,Container 是解壓縮後的結果
B Image 是 class(靜態的唯讀層堆疊 + 設定),Container 是 object(Image + 自己的可寫層 + 執行中的 process)
C 兩者是同一個東西的不同稱呼
D Container 是 Image 的備份
解析
class 與 object 的類比最貼切:一個 class 可以 new 出無數 object,各自有獨立狀態且互不影響。由此可直接推導:改容器內容重建後會消失(改的是物件狀態不是類別定義)、同映像的兩個容器不互相影響(各有可寫層)、刪映像前要先刪容器(容器還引用著那些層)。

點擊卡片翻面查看答案,共 10 張。

QUESTION
一層(layer)到底是什麼?
點擊翻面
ANSWER
相對於下一層的「檔案系統差異包」(diff)
只記錄這一步新增/修改/刪除了哪些檔案
用內容的 SHA-256 當身分證 → 不可變、可共用、可驗證
點擊翻回
QUESTION
分層帶來的三大好處?
點擊翻面
ANSWER
① 傳輸:只下載缺少的層(Already exists)
② 儲存:多映像共用同一層,磁碟只存一份
③ 建置:內容沒變就重用快取(ch08)
點擊翻回
QUESTION
OverlayFS 的四個角色?
點擊翻面
ANSWER
lowerdir:唯讀的映像各層
upperdir:容器唯一的可寫層
workdir:內部暫存
merged:疊合後的視圖 = 容器看到的 /(也就是 pivot_root 的目標)
點擊翻回
QUESTION
OverlayFS 疊合的兩條規則?
點擊翻面
ANSWER
① 上層覆蓋下層(同路徑看最上面那個)
② 各層互補(只有某層有的就直接看到)
類比:疊透明片
點擊翻回
QUESTION
Copy-on-Write 是什麼?
點擊翻面
ANSWER
不寫就不複製,寫了才複製
修改下層唯讀檔案時:先整個複製到可寫層,再改複本
讀取則完全不複製,直接讀映像層
點擊翻回
QUESTION
容器啟動只要幾毫秒的原因?
點擊翻面
ANSWER
完全不複製映像內容
只加一層空的可寫層 + 一次 overlay 掛載
(所以 50 個容器共用同一份映像層)
點擊翻回
QUESTION
為什麼容器刪掉資料就沒了?
點擊翻面
ANSWER
所有寫入都落在「屬於該容器的可寫層」
docker rm → 可寫層一併刪除
映像層完全沒動 → 新容器永遠是乾淨初始狀態
點擊翻回
QUESTION
OverlayFS 怎麼「刪除」下層的檔案?
點擊翻面
ANSWER
放一個 whiteout 標記在可寫層
意思是「這個路徑當作不存在」
是遮住,不是抹除 → 下層原檔還在
點擊翻回
QUESTION
映像裡 RUN rm 掉的密碼檔,還在嗎?
點擊翻面
ANSWER
還在!層不可變且全部保留,rm 只是 whiteout 遮蔽
docker save 解開就能翻出來
正解:執行時注入 / BuildKit secret mount / 多階段建置 / .dockerignore
點擊翻回
QUESTION
Image 與 Container 的精確定義?
點擊翻面
ANSWER
Image = 唯讀層堆疊 + 設定(CMD/ENV/USER/WORKDIR)→ class
Container = Image + 可寫層 + 執行中 process + namespace/cgroup → object
點擊翻回