
DevOps 學習筆記

承接前兩篇 (K8s 核心架構、Service/HPA/Debug), 這篇深入 K8s 故障場景的實際行為, 加上 ConfigMap、Secret、Namespace 的用法 目標是能預測和診斷問題, 而不只是會 apply YAML

> 環境: k3s v1.34 single node cluster + go-api (Go HTTP server, scratch image)

## OOMKilled — Container 被 Kernel 殺掉

### 什麼是 OOMKilled

Container 的記憶體用量超過 `limits.memory` 設定的上限, Linux kernel 的 OOM killer 直接把 process 殺掉

不是 K8s 殺的, 是 **kernel** 殺的 K8s 只是觀察到 container 死了, 紀錄原因

### 實驗

在 go-api 加一個 `/oom` endpoint, 瘋狂配置記憶體:

```go
func OOM(w http.ResponseWriter, r *http.Request) {
    log.Println("oom endpoint hit, allocating memory until killed")
    var data [][]byte
    for {
        block := make([]byte, 10*1024*1024) // 10MB per iteration
        data = append(data, block)
        log.Printf("allocated %d MB", len(data)*10)
    }
}
```

Deployment 設定 memory limit 為 64Mi:

```yaml
resources:
  requests:
    memory: 32Mi
  limits:
    memory: 64Mi
```

用 `curl` 打 `/oom`, 連線直接斷掉 — container 被殺了, 所以 connection 中斷

### 觀察結果

```bash
kubectl describe pod <pod-name>
```

```
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
```

| 欄位 | 值 | 意義 |
|------|-----|------|
| Reason | OOMKilled | 超過 memory limit, 被 OOM killer 終止 |
| Exit Code | 137 | 128 + 9 = SIGKILL, 不是程式自己退出, 是被外力殺掉的 |
| RESTARTS | 增加 | K8s 自動重啟了 container |

重啟後因為 `/oom` 沒有再被打, Pod 穩定下來 但如果 container **啟動就 OOM** (比如 init 時就吃太多記憶體), 就會不斷重啟, 最終進入 CrashLoopBackOff

### OOMKilled vs CrashLoopBackOff

這兩個常常搞混, 但它們不是同一個層級:

- **OOMKilled** 是**原因** — 記憶體超標被殺
- **CrashLoopBackOff** 是**狀態** — container 反覆 crash, K8s 拉長重啟間隔 (指數退避: 10s → 20s → 40s → ... 最多 5 分鐘)

一個 container 可以因為 OOMKilled 而進入 CrashLoopBackOff, 也可以因為其他原因 (程式碼 bug、設定錯誤) 進入 CrashLoopBackOff

## requests vs limits — 兩個完全不同的東西

```yaml
resources:
  requests:
    memory: 32Mi    # used by Scheduler
  limits:
    memory: 64Mi    # runtime hard cap
```

### requests — 「我至少需要這麼多」

Scheduler 用 requests 決定 Pod 放到哪個 Node 如果 Node 剩餘可分配資源 < Pod 的 requests, Scheduler 就不會把 Pod 排到那個 Node, Pod 狀態會是 **Pending**

### limits — 「最多只能用這麼多」

Container 實際執行時的硬上限 超過就被 OOM kill

### 關鍵差異

| | requests | limits |
|--|----------|--------|
| 誰看 | Scheduler | Kernel (cgroup) |
| 什麼時候看 | 排程時 | 執行時 |
| 超過會怎樣 | Pod Pending (排不上去) | OOMKilled (被殺掉) |

### 實驗: ResourceQuota 模擬資源不足

因為本機 k3s node 有 39GB 記憶體, 用小小的 requests 根本排不滿 改用 ResourceQuota 在 namespace 層級限制:

```yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: small-quota
  namespace: resource-test
spec:
  hard:
    requests.memory: 100Mi
    limits.memory: 200Mi
```

Deploy 4 個 Pod, 各 requests 32Mi (4 × 32Mi = 128Mi > quota 100Mi):

```
Warning  FailedCreate  replicaset/go-api-hungry-xxx
  Error creating: pods "..." is forbidden:
  exceeded quota: small-quota,
  requested: requests.memory=32Mi,
  used: requests.memory=96Mi,
  limited: requests.memory=100Mi
```

3 個 Pod 成功, 第 4 個被擋 注意這裡和真正的 Node 資源不足不同:

| 情境 | 錯誤來源 | Pod 狀態 | Event |
|------|---------|---------|-------|
| ResourceQuota 超過 | API server 直接拒絕 | Pod 不存在 | FailedCreate on ReplicaSet |
| Node 資源不足 | Scheduler 找不到合適 Node | **Pending** | FailedScheduling on Pod |

## Probe — K8s 怎麼知道你的 App 是不是正常的

Container 在跑不代表 app 正常 — 可能 deadlock 了, 可能 database 斷了 K8s 需要一個方法去確認

Probe 就是 K8s **定期打你指定的 endpoint**, 看 HTTP status code:
- 200 → 正常
- 非 200 → 有問題

### 兩種 Probe 的差別

| | Readiness Probe | Liveness Probe |
|--|----------------|----------------|
| 問的問題 | 「你準備好接流量了嗎?」 | 「你還活著嗎?」 |
| 失敗後果 | 從 Service 移除, **不送流量** | **砍掉 container, 重啟** |
| Pod 還在嗎 | 在, 只是不接客 | container 被殺, 重新啟動 |
| 用途 | 啟動中、暫時忙碌、依賴斷了 | 真的卡死了, 需要重啟才能救 |

用餐廳比喻:
- **Readiness** = 「廚房準備好了嗎?」→ 沒好就先不帶客人進來, 但廚房還在
- **Liveness** = 「廚師還有呼吸嗎?」→ 沒有就換一個廚師 (重啟)

### 設定方式

在 container spec 裡用不同的 key 名稱:

```yaml
containers:
  - name: go-api
    image: go-api:0.0.5
    readinessProbe:         # ← this key makes it a readiness probe
      httpGet:
        path: /ready
        port: 8080
      initialDelaySeconds: 2
      periodSeconds: 5
    livenessProbe:          # ← this key makes it a liveness probe
      httpGet:
        path: /alive
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 3
      failureThreshold: 3   # 3 consecutive failures = dead
```

可以同時設兩個, 也可以指向同一個 endpoint Production 裡通常分開: readiness 檢查比較多 (database、cache), liveness 只檢查 app 本身有沒有卡死

### Readiness Probe 實驗

設定 readiness probe 指向不存在的 `/ready` endpoint, probe 每 5 秒打一次都拿到 404:

```bash
kubectl get pods -l app=go-api-probe-test
```

```
NAME                                 READY   STATUS    RESTARTS   AGE
go-api-probe-test-86cd4c5787-7lmrj   0/1     Running   0          39s
go-api-probe-test-86cd4c5787-mtxl2   0/1     Running   0          39s
```

READY 是 `0/1` — container 在跑, 但 K8s 認定沒準備好

```bash
kubectl describe pod <pod-name>
```

```
Conditions:
  Ready       False
```

檢查 EndpointSlice:

```bash
kubectl get endpointslice -l kubernetes.io/service-name=go-api-probe-test
```

EndpointSlice 會列出所有 address, 但每個 address 標記 `ready: false` Service 不會送流量到 `ready: false` 的 Pod

> 注意: K8s v1.33+ 開始 `kubectl get endpoints` 會出 deprecation warning, 改用 `kubectl get endpointslice -l kubernetes.io/service-name=<svc>` 查

### Liveness Probe 實驗

設定 liveness probe 指向不存在的 `/alive` endpoint, 每 3 秒失敗一次, 連續 3 次後 K8s 殺掉 container:

```bash
kubectl get pods -l app=go-api-liveness-test -w
```

```
NAME                                   READY   STATUS             RESTARTS     AGE
go-api-liveness-test-95bb8f89c-7qn85   1/1     Running            3 (8s ago)   45s
go-api-liveness-test-95bb8f89c-7qn85   0/1     CrashLoopBackOff   3 (0s ago)   49s
go-api-liveness-test-95bb8f89c-7qn85   1/1     Running            4 (29s ago)  78s
go-api-liveness-test-95bb8f89c-7qn85   0/1     CrashLoopBackOff   4 (0s ago)   88s
```

和 readiness 完全不同 — RESTARTS 不斷增加, container 被殺了又重啟, 最終進入 CrashLoopBackOff

### 什麼時候用哪個 — 常見錯誤

一個常見的錯誤: **用 liveness probe 檢查 database 連線** 如果 database 掛了, liveness 失敗 → K8s 重啟你的 app → app 起來後 database 還是掛著 → 又 liveness 失敗 → 無限重啟

正確做法: **database 連線放 readiness probe** database 掛了, readiness 失敗, Pod 只是暫時不接流量, 等 database 恢復就自動回來, 不需要重啟

## ConfigMap 與 Secret

### 解決什麼問題

App 需要設定值 (環境、log level、DB 密碼) 如果寫死在 code 裡, 改設定就要重新 build image, 不同環境 (dev/staging/prod) 要不同 image

ConfigMap 和 Secret 把設定從 image 抽出來, 同一個 image 在不同環境只換設定

### ConfigMap — 非敏感設定

一個 key-value 的設定檔, 存在 K8s 裡:

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: go-api-config
data:
  APP_ENV: "staging"
  LOG_LEVEL: "debug"
```

### Secret — 敏感資料

和 ConfigMap 幾乎一樣, 但用來放密碼、API key:

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: go-api-secret
type: Opaque
stringData:
  DB_PASSWORD: "my-secret-password-123"
```

`type: Opaque` 是 Secret 的類型, 表示「一般用途的 secret」 90% 的情況用 Opaque

其他內建類型:

| type | 用途 |
|------|------|
| Opaque | 通用, 自己定義 key/value (最常用) |
| kubernetes.io/tls | TLS 憑證 (必須有 tls.crt 和 tls.key) |
| kubernetes.io/dockerconfigjson | Docker registry 登入帳密 |

### 注入 — 把值傳進 Container

Container 本身只認兩種東西: **環境變數**和**檔案** 需要把 ConfigMap / Secret 的值變成 container 讀得到的格式

方式一 — 環境變數:

```yaml
containers:
  - name: go-api
    envFrom:
      - configMapRef:
          name: go-api-config    # all keys become env vars
    env:
      - name: DB_PASSWORD
        valueFrom:
          secretKeyRef:
            name: go-api-secret
            key: DB_PASSWORD     # pick a specific key from Secret
```

`envFrom` 把整個 ConfigMap 攤開, `valueFrom` 從 Secret 挑單一個 key

方式二 — 掛成檔案 (volume mount):

```yaml
volumes:
  - name: config
    configMap:
      name: go-api-config
volumeMounts:
  - name: config
    mountPath: /etc/config
```

Container 裡會有 `/etc/config/APP_ENV` 這個檔案, 內容是 `staging` 適合設定檔比較大的情況 (例如 nginx.conf)

### 實驗結果

Go code 用 `os.Getenv` 讀取:

```bash
kubectl logs -l app=go-api-config-test
```

```
2026/04/02 23:17:11 env=staging log_level=debug db_configured=true
```

三個值都成功注入

### K8s Secret 安全嗎

K8s Secret 預設**只是 base64 編碼, 不是加密** 任何有 kubectl 權限的人都能:

```bash
kubectl get secret go-api-secret -o yaml
# base64 encoded, decode = plaintext
```

Production 裡不會直接把密碼寫在 Secret YAML 然後 commit 到 git 實際做法:

```
External Secret Manager (GCP Secret Manager / Vault / AWS Secrets Manager)
         ↓ (synced by tooling, not manual)
    K8s Secret object
         ↓ (envFrom / valueFrom)
    Container env vars
```

中間同步的「工具」通常是:
- **External Secrets Operator** — 裝在 K8s 裡, 定期從外部 secret manager 同步到 K8s Secret
- **CI/CD pipeline** — deploy 時用 `kubectl create secret` 建出來
- **Terraform** — 用 `kubernetes_secret` resource 建出來

K8s Secret 是外部 secret manager 和 container 之間的**橋樑** — container 不會直接呼叫 GCP Secret Manager API, 它只從 K8s Secret 讀環境變數

如果用 GCP Cloud Run (不是 K8s), 則不需要這層橋樑 — Cloud Run 可以直接從 GCP Secret Manager 注入環境變數

## Namespace — Cluster 裡的隔離區域

### 什麼是 Namespace

想像一個 cluster 是一棟辦公大樓:

- **Namespace = 樓層** — 每個樓層有自己的房間 (Pod)、服務 (Service)、設定 (ConfigMap)
- 不同樓層可以有一樣名字的房間, 互不衝突
- 但大樓的電梯 (Node)、停車場 (Storage) 是共用的
- 預設**樓層之間的走廊是通的** (網路互通)

不是 .NET 那種 code 組織用的 namespace, 也不是 Linux kernel 的 namespace (process 隔離) K8s Namespace 是**資源的邏輯分群**

### 用途

```
cluster
├── namespace: dev        ← development
├── namespace: staging    ← testing
├── namespace: prod       ← production
└── namespace: default    ← default, used when no namespace specified
```

### 什麼隔離, 什麼不隔離

Namespace 各自獨立的:
- Pod、Deployment、Service、ConfigMap、Secret — 名字可以重複, 互不干擾
- ResourceQuota — 可以對不同 namespace 設不同資源配額
- RBAC 權限 — 可以限制某人只能操作某個 namespace (RBAC = Role-Based Access Control, 用角色控制誰能做什麼)

整個 Cluster 共用的:
- Node — 所有 namespace 的 Pod 都跑在同一批機器上
- Storage (PersistentVolume)
- 網路 — **預設跨 namespace 可以互通**, 要擋要另外設 NetworkPolicy

### 跨 Namespace 網路互通實驗

建立 dev 和 staging namespace, 各自部署 go-api 和 Service (名字都叫 go-api, 不衝突)

從 dev 的 Pod 打 staging 的 Service:

```bash
# run a Pod with curl in dev namespace
kubectl run test-curl -n dev --image=curlimages/curl -- sleep 3600

# from dev, call staging service
kubectl exec -n dev test-curl -- curl -s http://go-api.staging.svc.cluster.local/healthz
```

```json
{"status":"ok","version":"0.0.4"}
```

成功拿到回應, 證明**跨 namespace 網路預設是通的**

K8s 內部的 DNS 規則: `<service-name>.<namespace>.svc.cluster.local` 同 namespace 內可以省略, 直接用 service name

> 注意: scratch image 裡面沒有任何工具 (連 wget、curl、sh 都沒有), 所以不能 `kubectl exec` 進去 debug 需要另外跑一個有工具的 Pod 來測

## 必須記住的東西

### 故障診斷對照表

| 現象 | 原因 | 怎麼查 |
|------|------|--------|
| OOMKilled (exit code 137) | 超過 memory limit | `kubectl describe pod` 看 Last State |
| CrashLoopBackOff | Container 反覆 crash | `kubectl logs --previous` 看上次的 log |
| Pending | Node 資源不足或 Scheduler 排不上 | `kubectl describe pod` 看 Events |
| FailedCreate | ResourceQuota 超標 | `kubectl get events` 看 ReplicaSet event |
| READY 0/1 但 Running | Readiness probe 失敗 | `kubectl describe pod` 看 Conditions |
| RESTARTS 不斷增加 | Liveness probe 失敗或 app crash | `kubectl describe pod` 看 probe 設定 |

### Probe 選擇原則

- 檢查 app 本身卡死 → **liveness** (重啟能救)
- 檢查外部依賴 (DB、cache) → **readiness** (重啟沒用, 等依賴恢復就好)
- 不確定的時候 → 先用 **readiness**, 比 liveness 安全

### requests vs limits 一句話

- **requests** = Scheduler 排程用, 「我至少需要這麼多才能被排到 Node 上」
- **limits** = Runtime 上限, 「超過這麼多就被殺」

## References

- [Kubernetes Documentation — Container Resources](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/) — requests 與 limits 的完整說明
- [Kubernetes Documentation — Pod Lifecycle](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/) — Pod 狀態與 restart policy
- [Kubernetes Documentation — Liveness, Readiness and Startup Probes](https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/) — 三種 probe 的設定與差異
- [Kubernetes Documentation — ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/) — ConfigMap 的建立與使用方式
- [Kubernetes Documentation — Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) — Secret 類型、建立、注入方式
- [Kubernetes Documentation — Namespaces](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/) — Namespace 的用途與隔離邊界
- [Kubernetes Documentation — Resource Quotas](https://kubernetes.io/docs/concepts/policy/resource-quotas/) — 用 ResourceQuota 限制 namespace 資源用量
- [Kubernetes Documentation — DNS for Services and Pods](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/) — 跨 namespace 的 DNS 規則
- [Kubernetes Documentation — EndpointSlice](https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/) — EndpointSlice 取代舊版 Endpoints
