
DevOps 學習筆記

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

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

## Legacy Builder vs BuildKit

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

```bash
# legacy (deprecated)
docker build -t go-api .

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

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

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

```
=> 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 機制是: **某一層的內容只要有變動, 那一層以下的所有層都會失效**, 必須重新執行

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

```dockerfile
# 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 ...
```

```dockerfile
# 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`**

```
=> 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

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

```bash
# 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 當時編譯進去的版本

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

```bash
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

### 實際數字

```bash
docker images | grep go-api

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

差距約 7MB

### distroless 的層級

```
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/base` 比 `distroless/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 差別

```dockerfile
# 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 資源限制

### 設定限制

```bash
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**

```bash
docker inspect test-cgroup | grep NanoCpus
# "NanoCpus": 500000000
```

NanoCpus 的換算:

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

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

**方法二: docker stats**

```bash
docker stats test-cgroup --no-stream
```

```
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 欄位有值:

```bash
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

- [Docker BuildKit documentation](https://docs.docker.com/build/buildkit/) — BuildKit 的官方說明, 包含與 legacy builder 的差異
- [Docker build caching](https://docs.docker.com/build/cache/) — layer cache 機制的詳細說明
- [Docker resource constraints](https://docs.docker.com/engine/containers/resource_constraints/) — `--cpus`, `--memory` 等資源限制參數的完整說明
- [GoogleContainerTools/distroless](https://github.com/GoogleContainerTools/distroless) — 各種 distroless image 的選擇說明
- [Linux man page — cgroups(7)](https://man7.org/linux/man-pages/man7/cgroups.7.html) — cgroup 的 kernel 層級文件
