Docker 實作觀察: Layer Cache、Restart 行為、Image 選擇與資源限制

DevOps 學習筆記

Docker 基礎概念的實作觀察紀錄

把 Docker 的幾個核心行為實際跑過一遍, 包含 layer cache 命中邏輯、container restart 與 rebuild 的差異、image 大小比較, 以及 cgroup 資源限制的驗證方式

Legacy Builder vs BuildKit

現代 Docker 使用 BuildKit 作為 build engine, 舊版的 legacy builder 已被標記為 deprecated

1
2
3
4
5
# legacy (deprecated)
docker build -t go-api .

# modern approach
docker buildx build -t go-api .

重要: 兩個 builder 的 cache 是分開存放的, 互不共用; 從 legacy 切換到 BuildKit 後, 第一次 build 不會有任何 cache 可以用

BuildKit 的輸出格式比舊版更清楚, 每一步都會標示是否命中 cache:

1
2
=> CACHED [builder 2/6] WORKDIR /app          0.0s   ← cache hit
=> [builder 6/6] RUN go build ...             4.5s   ← actually executed

Layer Cache 行為

為什麼 Dockerfile 的順序很重要

Docker 的 cache 機制是: 某一層的內容只要有變動, 那一層以下的所有層都會失效, 必須重新執行

這就是為什麼要把「不常變動的東西」放前面:

1
2
3
4
5
# correct order
COPY go.mod go.sum ./      ← go.mod rarely changes, easy cache hit
RUN go mod download        ← stays CACHED as long as go.mod is unchanged
COPY . .                   ← source code changes often, everything after re-runs
RUN go build ...
1
2
3
# wrong order
COPY . .                   ← any code change invalidates this layer
RUN go mod download        ← re-downloads packages every time, wastes minutes

三個實驗的觀察結果

實驗一: 第一次 build

所有層都實際執行, 沒有任何 CACHED, build 時間最長

實驗二: 不改任何東西, 直接再 build

所有層都 CACHED, build 時間從數秒縮短到不到 1 秒, 這就是 cache 的價值

實驗三: 只改程式碼, 不動 go.mod

1
2
3
4
5
=> CACHED [builder 1/6] FROM golang:1.25-alpine      CACHED
=> CACHED [builder 3/6] COPY go.mod go.sum ./        CACHED
=> CACHED [builder 4/6] RUN go mod download           CACHED (packages not re-downloaded)
=> [builder 5/6] COPY . .                             re-run (source code changed)
=> [builder 6/6] RUN go build ...                     re-run

go mod download 命中 cache 代表套件沒有重新下載, 這是分開 COPY 的核心原因

Container Restart 行為

Restart 不等於 Rebuild

這是很容易誤解的一個概念, 實驗步驟:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 1. run with existing image
docker run -d -p 8080:8080 --name test-restart go-api

# 2. check current response
curl http://localhost:8080/healthz
# {"status":"ok","version":"0.0.1"}

# 3. change version to "0.0.2" in source code
# 4. only restart, no rebuild
docker restart test-restart
curl http://localhost:8080/healthz
# {"status":"ok","version":"0.0.1"}  ← still 0.0.1, unchanged

結論: restart 只是把同一個 process 停掉再重新啟動, container 裡跑的 binary 還是 image build 當時編譯進去的版本

程式碼的修改要生效, 必須走完整的流程:

1
2
3
4
docker buildx build -t go-api .    # rebuild image
docker stop test-restart
docker rm test-restart
docker run -d -p 8080:8080 --name test-restart go-api

為什麼這樣設計?

Container 啟動的瞬間就凍結在那個 image 版本上了, Image 之後怎麼更新, 跑中的 container 都不受影響

這是刻意的設計, 讓 production 環境的更新是可控的, 不會在你不知情的情況下自動改變

Image 大小比較: scratch vs distroless

實際數字

1
2
3
4
docker images | grep go-api

go-api:distroless    22MB
go-api:scratch       15MB

差距約 7MB

distroless 的層級

1
2
3
4
5
6
7
8
9
scratch                   ← completely empty, only your binary
    ↓ +7MB
distroless/static         ← adds CA certs, timezone data, basic user info
    ↓ larger
distroless/base           ← adds glibc (dynamic C library)
    ↓ larger
distroless/cc             ← adds C++ runtime
    ↓ larger
alpine / debian / ubuntu  ← full OS

注意: distroless/basedistroless/static 更大, 不是更小

怎麼選

關鍵問題是: 你的 server 會不會主動對外發 HTTPS 請求?

「對外發請求」的意思是你的 server 去打別人, 例如:

  • 呼叫 Stripe API 收款
  • 呼叫 SendGrid 發 email
  • 呼叫 Slack 送通知
  • 呼叫任何第三方服務

接收外部請求並回 response 不算, 那只是正常的 server 行為, 不需要 CA 憑證

情境 選擇
server 只接收請求, 不對外呼叫 scratch
server 需要對外發 HTTPS 請求 distroless/static
語言需要 runtime (Java, Python) distroless/java / distroless/python

Dockerfile 差別

1
2
3
4
5
6
7
# scratch
FROM scratch
COPY --from=builder /app/server /server

# distroless
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server

只有 base image 不同, 其他完全一樣

cgroup 資源限制

設定限制

1
2
3
4
5
docker run -d -p 8080:8080 \
  --cpus="0.5" \
  --memory="64m" \
  --name test-cgroup \
  go-api:scratch
  • --cpus="0.5": 最多使用 0.5 顆 CPU core
  • --memory="64m": 最多使用 64MB 記憶體

驗證限制有套上

方法一: docker inspect

1
2
docker inspect test-cgroup | grep NanoCpus
# "NanoCpus": 500000000

NanoCpus 的換算:

1
2
1 CPU = 1,000,000,000 nanocores
0.5 CPU = 500,000,000 nanocores

這個數字直接對應 Linux kernel 的 cgroup 設定, 確認這個值就是確認限制真的有套上去

方法二: docker stats

1
docker stats test-cgroup --no-stream
1
2
CONTAINER ID   NAME          CPU %   MEM USAGE / LIMIT   MEM %
2c9d821fbb02   test-cgroup   0.00%   2.84MiB / 64MiB     4.44%
  • CPU %: 目前實際使用的 CPU 百分比 (idle 時接近 0%)
  • MEM USAGE / LIMIT: 目前用了多少記憶體 / 上限是多少
  • MEM %: 記憶體使用率

--no-stream 的作用: docker stats 預設是持續刷新的畫面 (類似 top), 加上 --no-stream 只印一次快照然後退出

cgroup 的實際意義

就算 container 裡的程式跑瘋掉 (無窮迴圈、memory leak), 它也只能用到設定的上限, 不會影響 host 或其他 container

這是 Linux kernel 的 cgroup 機制在背後運作, Docker 只是把設定指令包裝成簡單的參數

port mapping 注意事項

docker run 時一定要確認 PORTS 欄位有值:

1
2
3
4
5
6
7
8
9
docker ps

# correct: has port mapping
PORTS                    NAMES
0.0.0.0:8080->8080/tcp   test-cgroup

# wrong: empty, cannot connect
PORTS     NAMES
          test-cgroup

如果 PORTS 是空的, 代表 -p 8080:8080 沒有套上, 需要停掉重建

整體觀察總結

  1. Layer Cache:

    • Dockerfile 順序決定 cache 效率
    • 不常變動的東西放前面 → 套件下載才能被 cache 住
  2. Container Restart:

    • restart = 重啟同一個 process, binary 不變
    • rebuild = 產生新 image, 程式碼改動才生效
  3. Image 選擇:

    • scratch → Go 靜態 binary, 最小, 不需對外發 HTTPS
    • distroless → 需要 CA 憑證或 runtime
    • alpine/ubuntu → 開發除錯用, 不上 production
  4. cgroup:

    • --cpus / --memory 設定上限
    • docker inspect 看 NanoCpus 確認設定
    • docker stats 看即時資源使用狀況

References

0%