Kubernetes 故障模擬: OOMKilled、Probe、ConfigMap 與 Namespace

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, 瘋狂配置記憶體:

1
2
3
4
5
6
7
8
9
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:

1
2
3
4
5
resources:
  requests:
    memory: 32Mi
  limits:
    memory: 64Mi

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

觀察結果

1
kubectl describe pod <pod-name>
1
2
3
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 — 兩個完全不同的東西

1
2
3
4
5
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 層級限制:

1
2
3
4
5
6
7
8
9
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):

1
2
3
4
5
6
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 名稱:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:

1
kubectl get pods -l app=go-api-probe-test
1
2
3
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 認定沒準備好

1
kubectl describe pod <pod-name>
1
2
Conditions:
  Ready       False

檢查 EndpointSlice:

1
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:

1
kubectl get pods -l app=go-api-liveness-test -w
1
2
3
4
5
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 裡:

1
2
3
4
5
6
7
apiVersion: v1
kind: ConfigMap
metadata:
  name: go-api-config
data:
  APP_ENV: "staging"
  LOG_LEVEL: "debug"

Secret — 敏感資料

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

1
2
3
4
5
6
7
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 讀得到的格式

方式一 — 環境變數:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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):

1
2
3
4
5
6
7
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 讀取:

1
kubectl logs -l app=go-api-config-test
1
2026/04/02 23:17:11 env=staging log_level=debug db_configured=true

三個值都成功注入

K8s Secret 安全嗎

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

1
2
kubectl get secret go-api-secret -o yaml
# base64 encoded, decode = plaintext

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

1
2
3
4
5
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 是資源的邏輯分群

用途

1
2
3
4
5
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:

1
2
3
4
5
# 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
1
{"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

0%