Docker 核心概念: Container 不是魔法 是Linux Process + Isolation

DevOps 學習筆記

把 Docker 的核心概念從原理層面搞清楚, 包含 image、layer、container 的關係, multi-stage build, 以及 container 為什麼是 Linux process 而不是魔法

Image 是什麼?

Image 是一個唯讀的樣板, 用來建立 container

比喻:

  • Image = 蛋糕模具 (唯讀, 用過不會改變形狀, 可以做出無數個一樣的蛋糕)
  • Container = 用模具做出來的蛋糕 (每個都是獨立的實體)

對比 OOP:

1
2
Image     = Class definition
Container = instance created by new

Layer 與不可變性

Image 不是一個整體, 是一層一層疊起來的。Dockerfile 每一行指令建立一個 Layer:

1
2
3
4
5
6
FROM golang:1.25-alpine    ← Layer 1: base OS + Go toolchain
WORKDIR /app               ← Layer 2: create working directory
COPY go.mod go.sum ./      ← Layer 3: copy dependency definitions
RUN go mod download        ← Layer 4: download packages
COPY . .                   ← Layer 5: copy source code
RUN go build -o server ... ← Layer 6: compile

比喻: Layer 是千層蛋糕的每一層。可以在最上面繼續加層, 但沒辦法抽換中間某一層, 因為上面的層都依賴它

Layer 不可變的正確理解

改 Dockerfile 不是「修改舊 layer」, 而是「產生新 layer」。舊的 layer 還在 Docker cache 裡, 完全沒有被動到

1
2
3
4
5
6
7
First build:
  FROM alpine  → Layer A (hash: abc123) ← created
  RUN go build → Layer B (hash: def456) ← created

Code changed, second build:
  FROM alpine  → Layer A (hash: abc123) ← cache hit, reused
  RUN go build → Layer C (hash: ghi789) ← new layer, Layer B still exists but unused

就像 git commit: 不能修改舊的 commit, 只能加新的 commit。Layer 一旦建立, 內容永遠不變, 由 SHA256 hash 唯一識別

Layer Cache 的實際意義

這就是為什麼 Dockerfile 要分開寫依賴和程式碼:

1
2
3
4
COPY go.mod go.sum ./   ← copy only these two files first
RUN go mod download     ← download packages (this layer gets cached)
COPY . .                ← then copy source code
RUN go build ...

go.mod 沒變的話, 套件下載那層直接用 cache, 不重新下載。程式碼改了只重跑最後幾層, 節省大量 build 時間

如果把全部東西放在一層:

1
2
COPY . .                ← any code change invalidates this layer
RUN go mod download     ← re-downloads packages every time

Container 是什麼?

Image 是唯讀的, 但 container 需要可以寫 (例如 log、暫存檔)

Docker 的做法:

1
2
3
4
5
Image (all read-only layers)
     +
thin writable layer (belongs to this container only)
     =
Container

比喻:

  • Image = CD 光碟 (唯讀, 不能寫)
  • Container = 程式從 CD 跑起來, 旁邊有一本便條紙可以暫時記東西

Container 刪掉, 便條紙就丟了。CD 永遠不變

Multi-stage Build

為什麼需要分 stage?

scratch 是完全空的 image, 裡面沒有 Go 編譯器, 沒辦法在空的環境裡執行 go build

1
2
golang:alpine  → has compiler, compiles .go into binary (builder stage)
scratch        → empty, only holds the compiled binary (final stage)

Builder stage 是施工用的鷹架, 房子蓋完不會留在裡面。golang:alpine 只是 build 過程中的工具, 不進入最終 image

Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
FROM golang:1.25-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server/

FROM scratch

COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

幾個細節:

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 排除哪些檔案:

1
2
3
.git
*.md
server

scratch vs distroless

1
2
Full OS (ubuntu)  →  distroless  →  scratch (empty)
     risky            middle          smallest

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 完全不受影響:

1
2
3
4
pull latest (= 11.0) → docker run → container A frozen at 11.0

pull latest (= 12.0) → new image arrives
                       container A still runs 11.0, completely unaffected

docker images 有時候會看到:

1
2
3
REPOSITORY    TAG       IMAGE ID
mssql         latest    abc123     ← new 12.0
<none>        <none>    def456     ← old 11.0, still used by a container

舊 image 沒有 tag 了, 但因為 container 依賴它, 不能刪除

Restart: 把 process kill 掉, 重新啟動同一個 container。Binary 是舊的, 改了 .go 沒有效果

Rebuild: 重新執行 Dockerfile, 產生新 image, 再從新 image 建新 container。程式碼改動才真正生效

1
2
3
4
5
# correct update flow after code changes
docker build -t go-api .
docker stop old-container
docker rm old-container
docker run -p 8080:8080 go-api

Volume: 資料持久化

Container 的可寫層是暫時的, container 刪掉資料就消失。需要持久保存的資料要用 Volume

比喻: Container 是一台電腦, Volume 是插進去的外接硬碟。電腦壞了, 外接硬碟的資料還在。換一台新電腦, 把外接硬碟插進去, 資料全部回來

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# create container with volume mounted
docker run --name sql-old \
  -v sql-data:/var/opt/mssql \
  mssql/server:2019

# upgrade version, keep data
docker stop sql-old
docker rm sql-old
docker run --name sql-new \
  -v sql-data:/var/opt/mssql \
  mssql/server:2022

Volume 要手動刪才會消失:

1
2
docker volume ls
docker volume rm sql-data

latest tag 的陷阱

latest 不是版本號, 是會移動的標籤。業界實際做法是釘死版本號:

1
2
3
4
5
# not recommended
docker pull mssql/server:latest

# recommended
docker pull mssql/server:2022-CU14-ubuntu-22.04

不同時間 pull、不同機器 pull, 保證拿到完全一樣的版本。升版是刻意的行為, 不應該是意外

交付 Image

情境 方式
公司內部部署、CI/CD Registry (AWS ECR、GitHub Container Registry)
無網路環境 docker save / docker load
Open source 給 source code, 自己 build
1
2
3
4
5
# export as file
docker save go-api | gzip > go-api.tar.gz

# import on another machine
docker load < go-api.tar.gz

Container 的本質: Linux Process + Isolation

Container 就是 Process

docker run 做的事, 就是在你的 Linux 上啟動一個 process

不是開一台虛擬機, 不是魔法, 就是一個 process

1
2
3
4
docker run -d --name my-api go-api

# visible directly on host
ps aux | grep server

你會在 host 的 process 列表裡直接看到 server, 它就跑在你的 Linux 上, 不是在某個「裡面的 OS」

Isolation = Namespace + cgroup

Container 跟普通 process 的差別, 是被兩種 Linux kernel 機制限制住了:

1
2
3
Isolation
├── cannot see outside    → Namespace
└── cannot use too much   → cgroup

比喻: 一棟公寓大樓

1
2
3
4
entire building          = your Linux host (shared kernel)
each separate room       = each container
walls and door locks     = Namespace (cannot see neighbors)
electricity quota/room   = cgroup (limited resources per container)

住戶不是被送到另一棟樓 (不是 VM), 是在同一棟樓裡被隔開

Namespace: 視線隔離

Namespace 讓 container 內的 process 以為自己活在獨立的環境裡

PID Namespace

1
2
3
4
Host sees:                         Container sees:
PID 1  → systemd                   PID 1  → server (your Go app)
PID 891 → server (Go app)          (cannot see any host processes)
PID 892 → nginx

Network Namespace

1
2
3
Host:          eth0 (192.168.1.100)
Container A:   eth0 (172.17.0.2)  ← isolated virtual network interface
Container B:   eth0 (172.17.0.3)

每個 container 有自己的 IP、自己的網路介面, 看不到對方的 socket

Mount Namespace

Container 裡的 / 是 image 裡的 /, 不是 host 的 /。在 container 裡 ls / 看到的跟 host 完全不同

cgroup: 資源隔離

就算 process 想搶資源, cgroup 限制了它能用多少:

1
docker run --cpus="0.5" --memory="512m" go-api

就算 container 裡的程式瘋狂跑迴圈, 也只能吃 0.5 顆 CPU, 不影響 host 或其他 container

VM vs Container

VM Container
隔離方式 獨立 kernel (Hypervisor) Namespace + cgroup (共用 kernel)
啟動時間 幾分鐘 (要開機) 幾毫秒 (只是跑 process)
大小 GB 等級 MB 等級
隔離強度 較弱 (共用 kernel)
效能損耗 較高 接近原生
1
2
VM:        Hardware → Hypervisor → Guest OS (full Kernel) → Process
Container: Hardware → Host Linux Kernel → Container Runtime → Process (Namespace + cgroup)

為什麼 scratch 更安全?

Namespace 的隔離不是 100% 完美。如果攻擊者找到 kernel 漏洞進到 container, 他能做什麼取決於 container 裡有什麼工具:

1
2
3
4
5
6
7
8
ubuntu image compromised:
  has bash  run arbitrary commands
  has curl  download malicious tools
  has apt   install anything

scratch compromised:
  no shell  cannot run interactive commands
  no tools  almost nothing can be done

少一個工具, 就少一個攻擊面。這就是 scratch 和 distroless 比完整 OS image 更安全的核心原因

整體概念圖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Each Dockerfile instruction
  └── produces a Layer (read-only, hashed, immutable)
      └── multiple Layers stacked = Image (read-only template)
          └── docker run = Image + writable layer = Container (running process)
              ├── Namespace → cannot see outside (PID / Network / Mount)
              └── cgroup   → cannot use too much (CPU / Memory)

Container operations:
  docker stop / start  → restart, binary unchanged, code changes have no effect
  docker build         → rebuild, new image, code changes take effect
  Volume               → persistent data, independent of container lifecycle

References

0%