Docker 核心概念: Container 不是魔法 是Linux Process + Isolation
DevOps 學習筆記
把 Docker 的核心概念從原理層面搞清楚, 包含 image、layer、container 的關係, multi-stage build, 以及 container 為什麼是 Linux process 而不是魔法
Image 是什麼?
Image 是一個唯讀的樣板, 用來建立 container
比喻:
- Image = 蛋糕模具 (唯讀, 用過不會改變形狀, 可以做出無數個一樣的蛋糕)
- Container = 用模具做出來的蛋糕 (每個都是獨立的實體)
對比 OOP:
|
|
Layer 與不可變性
Image 不是一個整體, 是一層一層疊起來的。Dockerfile 每一行指令建立一個 Layer:
|
|
比喻: Layer 是千層蛋糕的每一層。可以在最上面繼續加層, 但沒辦法抽換中間某一層, 因為上面的層都依賴它
Layer 不可變的正確理解
改 Dockerfile 不是「修改舊 layer」, 而是「產生新 layer」。舊的 layer 還在 Docker cache 裡, 完全沒有被動到
|
|
就像 git commit: 不能修改舊的 commit, 只能加新的 commit。Layer 一旦建立, 內容永遠不變, 由 SHA256 hash 唯一識別
Layer Cache 的實際意義
這就是為什麼 Dockerfile 要分開寫依賴和程式碼:
|
|
go.mod 沒變的話, 套件下載那層直接用 cache, 不重新下載。程式碼改了只重跑最後幾層, 節省大量 build 時間
如果把全部東西放在一層:
|
|
Container 是什麼?
Image 是唯讀的, 但 container 需要可以寫 (例如 log、暫存檔)
Docker 的做法:
|
|
比喻:
- Image = CD 光碟 (唯讀, 不能寫)
- Container = 程式從 CD 跑起來, 旁邊有一本便條紙可以暫時記東西
Container 刪掉, 便條紙就丟了。CD 永遠不變
Multi-stage Build
為什麼需要分 stage?
scratch 是完全空的 image, 裡面沒有 Go 編譯器, 沒辦法在空的環境裡執行 go build
|
|
Builder stage 是施工用的鷹架, 房子蓋完不會留在裡面。golang:alpine 只是 build 過程中的工具, 不進入最終 image
Dockerfile
|
|
幾個細節:
CGO_ENABLED=0: 關閉 C 語言橋接, 產生完全靜態的 binary, 不依賴任何系統 library。Go binary 才能在空的 scratch 上跑起來
COPY go.mod go.sum ./ 的 ./: ./ 和 . 都是指 container 內的當前目錄 (WORKDIR 設定的 /app), 完全等價, 只是習慣寫法不同
COPY --from=builder: 只從 builder stage 複製那一個 binary, 其他東西全部丟掉
Image Size 對比
| Image | 大小 | 內容 |
|---|---|---|
golang:1.25-alpine |
~300MB | Go 工具鏈 + 標準庫 + OS |
go-api (multi-stage) |
~10MB | 只有一個 binary |
.dockerignore
對比 .gitignore, 告訴 Docker build context 排除哪些檔案:
|
|
scratch vs distroless
|
|
scratch: 完全空, 什麼都沒有
distroless: Google 做的, 只包含最低限度必要的東西 (CA 憑證、時區資料、基本使用者資訊), 沒有 shell、curl、apt、任何工具
選擇依據:
| 語言 | 選擇 | 原因 |
|---|---|---|
Go (CGO_ENABLED=0) |
scratch | 靜態 binary, 不需要任何 runtime |
| Java | distroless/java | 需要 JVM, 但不需要 shell |
| Python | distroless/python | 需要 interpreter, 但不需要 shell |
Restart vs Rebuild
Container 被建立的瞬間, 凍結在那個版本的 image 上
就算你之後 pull 了新版 image, 跑中的 container 完全不受影響:
|
|
docker images 有時候會看到:
|
|
舊 image 沒有 tag 了, 但因為 container 依賴它, 不能刪除
Restart: 把 process kill 掉, 重新啟動同一個 container。Binary 是舊的, 改了 .go 沒有效果
Rebuild: 重新執行 Dockerfile, 產生新 image, 再從新 image 建新 container。程式碼改動才真正生效
|
|
Volume: 資料持久化
Container 的可寫層是暫時的, container 刪掉資料就消失。需要持久保存的資料要用 Volume
比喻: Container 是一台電腦, Volume 是插進去的外接硬碟。電腦壞了, 外接硬碟的資料還在。換一台新電腦, 把外接硬碟插進去, 資料全部回來
|
|
Volume 要手動刪才會消失:
|
|
latest tag 的陷阱
latest 不是版本號, 是會移動的標籤。業界實際做法是釘死版本號:
|
|
不同時間 pull、不同機器 pull, 保證拿到完全一樣的版本。升版是刻意的行為, 不應該是意外
交付 Image
| 情境 | 方式 |
|---|---|
| 公司內部部署、CI/CD | Registry (AWS ECR、GitHub Container Registry) |
| 無網路環境 | docker save / docker load |
| Open source | 給 source code, 自己 build |
|
|
Container 的本質: Linux Process + Isolation
Container 就是 Process
docker run做的事, 就是在你的 Linux 上啟動一個 process
不是開一台虛擬機, 不是魔法, 就是一個 process
|
|
你會在 host 的 process 列表裡直接看到 server, 它就跑在你的 Linux 上, 不是在某個「裡面的 OS」
Isolation = Namespace + cgroup
Container 跟普通 process 的差別, 是被兩種 Linux kernel 機制限制住了:
|
|
比喻: 一棟公寓大樓
|
|
住戶不是被送到另一棟樓 (不是 VM), 是在同一棟樓裡被隔開
Namespace: 視線隔離
Namespace 讓 container 內的 process 以為自己活在獨立的環境裡
PID Namespace
|
|
Network Namespace
|
|
每個 container 有自己的 IP、自己的網路介面, 看不到對方的 socket
Mount Namespace
Container 裡的 / 是 image 裡的 /, 不是 host 的 /。在 container 裡 ls / 看到的跟 host 完全不同
cgroup: 資源隔離
就算 process 想搶資源, cgroup 限制了它能用多少:
|
|
就算 container 裡的程式瘋狂跑迴圈, 也只能吃 0.5 顆 CPU, 不影響 host 或其他 container
VM vs Container
| VM | Container | |
|---|---|---|
| 隔離方式 | 獨立 kernel (Hypervisor) | Namespace + cgroup (共用 kernel) |
| 啟動時間 | 幾分鐘 (要開機) | 幾毫秒 (只是跑 process) |
| 大小 | GB 等級 | MB 等級 |
| 隔離強度 | 強 | 較弱 (共用 kernel) |
| 效能損耗 | 較高 | 接近原生 |
|
|
為什麼 scratch 更安全?
Namespace 的隔離不是 100% 完美。如果攻擊者找到 kernel 漏洞進到 container, 他能做什麼取決於 container 裡有什麼工具:
|
|
少一個工具, 就少一個攻擊面。這就是 scratch 和 distroless 比完整 OS image 更安全的核心原因
整體概念圖
|
|
References
- Docker Documentation — Multi-stage builds — multi-stage build 的官方說明與最佳實踐
- Docker Documentation — Building best practices — layer cache、image size 優化等官方建議
- GoogleContainerTools/distroless — distroless image 的來源與設計說明
- Linux man page — namespaces(7) — Linux Namespace 的核心文件
- Linux man page — cgroups(7) — cgroup 的核心文件