第 7 章:Kubernetes 入門 — 先在本機用 kind 跑起來

第 7 章:Kubernetes 入門 — 先在本機用 kind 跑起來

這一章在做什麼? 我們在自己的電腦用 kind 建一個 Kubernetes 叢集,把 app 和一個 in-cluster 的 PostgreSQL 部署上去,並親眼看到「刪掉一個容器,它自動長回來」的自我修復。做完這章,你會懂 Kubernetes 最核心的幾個物件(Pod / Deployment / Service / ConfigMap / Secret),為第 8 章上雲打底。

為什麼需要 Kubernetes? Cloud Run 幫你跑「單一服務」很省事;但當你要管理很多服務、要它們自我修復、要更細的控制(擴縮、滾動更新、設定、密鑰、網路)時,就需要一個容器編排系統——Kubernetes(常簡稱 K8s)就是業界標準。

💸 本章完全免費:kind 在你本機用 Docker 跑叢集,不碰雲、不花錢。可以放心亂玩。 前置:Docker 要在執行中;app image 能 build(第 2 章)。


步驟 1:先搞懂 K8s 的核心名詞

Kubernetes / 容器編排

一套自動管理「一大堆容器」的系統。你告訴它「我想要的狀態」(例如「我要 2 份這個 app 一直跑著」),它就持續想辦法維持那個狀態——容器掛了自動重啟、要更新時逐步換新、流量大時可擴充。

🔁 與 Terraform 的差別:Terraform 是「建立基礎設施」(一次性 apply);K8s 是「持續運行並維持狀態」(一直在背景盯著、出事就修)。兩者都是「宣告式」——你說要什麼,它去達成。

幾個你會一直看到的物件(先有概念):

物件 白話
Cluster(叢集) 一組機器(節點),你的容器跑在上面,由 K8s 統一管理。
Node(節點) 叢集裡的一台機器(kind 用 Docker 容器模擬)。
Pod K8s 最小部署單位:一個(通常)容器的包裝。Pod 是用過即丟的,會被重建、IP 會變。
Deployment 宣告「我要 N 份某個 Pod 一直跑著」。Pod 掛了它補、要更新它做滾動替換。
Service 替一群會變動的 Pod 提供一個固定的網路名稱與負載平衡入口(因為 Pod 的 IP 會變)。
ConfigMap 存「非機密」設定(key-value),注入成環境變數或檔案。
Secret 像 ConfigMap,但放機密(密碼、token)。預設只是 base64 編碼(非加密)。
Namespace 把資源分組的虛擬隔間(我們用 nlt)。
kubectl 跟叢集溝通的命令列工具("kube control")。

步驟 2:安裝 kubectl 與 kind,建立叢集

  1. 安裝(macOS):
brew install kubectl       # 跟叢集溝通的 CLI
brew install kind          # Kubernetes IN Docker:用 Docker 跑本機叢集
kubectl version --client   # 確認裝好
kind version
  1. 建立一個本機叢集(需要 Docker 在跑):
kind create cluster --name nlt

這會用 Docker 起一個「節點」,並把 kubectl 的當前 context 切到這個叢集。確認:

kubectl cluster-info --context kind-nlt
kubectl get nodes
# 預期:一個 nlt-control-plane 節點,狀態 Ready

步驟 3:把 app image 載進 kind

kind 叢集看不到你本機 Docker 的 image,要先「載進去」。先 build,再 kind load

cd code
docker build -t nlt-api:local .
kind load docker-image nlt-api:local --name nlt
  • kind load docker-image:把本機 image 複製進 kind 節點。這是新手最常忘的一步——忘了它,Pod 會卡在 ImagePullBackOff(找不到 image)。
  • 之後我們在 manifest 用 imagePullPolicy: IfNotPresent,叫 K8s「本機有就用本機的,別去 registry 拉」。

也可以改用第 4 章 GHCR 上的公開 image(ghcr.io/<你>/no-logic-trade:latest),那就不用 kind load。這裡用本機 build 來示範 kind 的典型流程。


步驟 4:寫 K8s manifest

manifest 就是描述「我要哪些 K8s 物件」的 YAML。我們建在 code/k8s/

4-1 k8s/namespace.yaml — 隔間

# code/k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: nlt
  • 每個 K8s 物件都有 apiVersionkind(物件類型)、metadata(名稱等)。這個宣告一個叫 nlt 的 namespace。

4-2 k8s/postgres.yaml — in-cluster 資料庫

# code/k8s/postgres.yaml
apiVersion: v1
kind: Secret
metadata:
  name: postgres-secret
  namespace: nlt
type: Opaque
stringData: # stringData:你寫明文,K8s 會自動幫你 base64
  POSTGRES_USER: nolologic
  POSTGRES_PASSWORD: nolologic_secret
  POSTGRES_DB: nolologic
---
apiVersion: v1
kind: PersistentVolumeClaim # 跟叢集要一塊持久磁碟,讓 DB 資料不隨 Pod 消失
metadata:
  name: postgres-pvc
  namespace: nlt
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: nlt
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          envFrom:
            - secretRef:
                name: postgres-secret # 把 Secret 的 key 全注入成環境變數
          ports:
            - containerPort: 5432
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          readinessProbe: # 沒 ready 之前,Service 不會把流量導過來
            exec:
              command: ["pg_isready", "-U", "nolologic", "-d", "nolologic"]
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: postgres # ★ Service 名 = app 連線用的主機名
  namespace: nlt
spec:
  selector:
    app: postgres # 把流量導到帶 app=postgres 標籤的 Pod
  ports:
    - port: 5432
      targetPort: 5432

重點:

  • SecretstringData:你寫明文、K8s 自動 base64。(若用 data 則要自己先 base64,很容易出錯——新手建議用 stringData。)
  • PersistentVolumeClaim(PVC):向叢集「要一塊磁碟」。掛到 /var/lib/postgresql/data,這樣 Pod 重建時 DB 資料還在。kind 內建儲存能滿足它。
  • Deploymentselector / template.labels:Deployment 靠標籤 app: postgres 認得「哪些 Pod 是我的」。兩處標籤要一致。
  • Service 名叫 postgres:叢集內,app 只要連 postgres:5432 就會被導到這個 DB Pod(這就取代了 docker-compose 裡的服務名連線)。

4-3 k8s/app.yaml — 你的 Go API

# code/k8s/app.yaml
apiVersion: v1
kind: ConfigMap # 非機密設定
metadata:
  name: app-config
  namespace: nlt
data:
  APP_PORT: "8080"
  DB_HOST: "postgres" # ★ 連到上面的 postgres Service
  DB_PORT: "5432"
  DB_NAME: "nolologic"
  DB_USER: "nolologic"
  DB_SSLMODE: "disable"
---
apiVersion: v1
kind: Secret # 機密設定
metadata:
  name: app-secret
  namespace: nlt
type: Opaque
stringData:
  DB_PASSWORD: nolologic_secret # 要和 postgres-secret 的密碼一致
  JWT_SECRET: dev-secret-change-me
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nlt-api
  namespace: nlt
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nlt-api
  template:
    metadata:
      labels:
        app: nlt-api
    spec:
      containers:
        - name: nlt-api
          image: nlt-api:local
          imagePullPolicy: IfNotPresent # ★ 用 kind load 進來的本機 image
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secret
          ports:
            - containerPort: 8080
          readinessProbe: # ready 才接流量
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe: # 掛了就重啟
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
  name: nlt-api
  namespace: nlt
spec:
  selector:
    app: nlt-api
  ports:
    - port: 80 # Service 對外的 port
      targetPort: 8080 # 導到容器的 8080

重點:

  • ConfigMapDB_HOST: "postgres":app 連叢集內的 postgres Service。其餘鍵完全沿用你 app 既有的設定,所以程式不用改。
  • envFrom:把整個 ConfigMap + Secret 的 key 都注入成環境變數(config.go 就讀得到)。
  • imagePullPolicy: IfNotPresent:本機 image 必加,否則 K8s 會去 registry 找 nlt-api:local、找不到而 ImagePullBackOff
  • readinessProbe / livenessProbe:這就是第 2 章說的「健康檢查交給編排器」。readiness 決定「能不能接流量」、liveness 決定「要不要重啟」,兩者都打你的 /health
  • Service:給 app 一個固定入口 nlt-api(叢集內)。等下用 port-forward 從本機連進去。

步驟 5:部署上去

cd code
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/postgres.yaml
kubectl apply -f k8s/app.yaml

kubectl apply -f k8s/ 一次套用整個資料夾也可以,但 namespace 要先存在,所以分開或重跑一次最保險。)

觀察 Pod 啟動:

kubectl get pods -n nlt -w     # -w 持續觀察,Ctrl+C 離開

預期最後看到 postgres 與 nlt-api 都是 RunningREADY 1/1

NAME                       READY   STATUS    RESTARTS   AGE
postgres-xxxx              1/1     Running   0          40s
nlt-api-yyyy               1/1     Running   0          30s

✅ 如何驗證這一章成功

驗證 1:從本機連進去

Service 是叢集內部位址,用 port-forward 打通到本機:

kubectl port-forward -n nlt svc/nlt-api 8080:80

另開終端機:

curl http://localhost:8080/health
# 預期:{"status":"ok"}

(port-forward 那個終端機要保持開著。)

驗證 2:自我修復(K8s 的招牌特性)

刪掉 app 的 Pod,看 Deployment 立刻補一個新的:

kubectl get pods -n nlt
kubectl delete pod -n nlt <你的-nlt-api-Pod 名稱>
kubectl get pods -n nlt
# 預期:舊的 Terminating,同時冒出一個全新的 nlt-api Pod,幾秒後 Running

你沒做任何補救,K8s 自己把「我要 1 份」維持住了——這就是自我修復。

驗證 3:水平擴充

kubectl scale -n nlt deployment/nlt-api --replicas=3
kubectl get pods -n nlt
# 預期:nlt-api 變成 3 個 Pod(migration 已套用過,新 Pod 直接啟動)

看某個 Pod 的日誌:

kubectl logs -n nlt deployment/nlt-api | tail -20
  • [ ] kubectl get pods -n nlt 兩個服務都 Running 1/1
  • [ ] port-forward + curl /health{"status":"ok"}
  • [ ] 刪 Pod 後自動長回新的。
  • [ ] kubectl scale 能把 replicas 變成 3。

收拾(本機,免費,但保持乾淨)

kubectl delete namespace nlt        # 刪掉這次所有資源
# 或整個叢集砍掉:
kind delete cluster --name nlt

🧯 常見錯誤 / 踩雷點

症狀 原因 解法
Pod 卡在 ImagePullBackOff / ErrImagePull 忘了 kind load,或 imagePullPolicy: Always 去 registry 找本機 image kind load docker-image nlt-api:local --name nlt;manifest 用 IfNotPresent
app Pod CrashLoopBackOff 連不上 DB:密碼不符 / DB_HOST 不是 postgres / DB 還沒 ready kubectl logs -n nlt <pod> 看錯誤;確認 app-secret 的密碼=postgres-secretDB_HOST=postgres
Pod 卡在 Pending PVC 沒綁定 / 資源不足 kubectl describe pod -n nlt <pod> 看事件;kind 預設有儲存類別,通常稍等即可
port-forward connection refused Pod 還沒 ready,或 port 寫錯 READY 1/1;Service port: 80targetPort: 8080,forward 用 8080:80
多個全新 replica 同時啟動,有 Pod 在全新 DB 上 migration 失敗 啟動時跑 migration 在並發下撞 schema_migrations 主鍵 先用 replicas: 1 讓 migration 跑一次;正式環境用獨立的 migration Job/initContainer(進階)
kubectl 指令打到別的叢集 context 不對 kubectl config current-context 應為 kind-nltkubectl config use-context kind-nlt
Secret 值看起來亂碼 用了 data 還貼明文(應 base64) 新手用 stringData 寫明文,讓 K8s 自動編碼
改了 manifest 沒生效 忘了重新 apply kubectl apply -f k8s/app.yamlkubectl rollout status -n nlt deploy/nlt-api

下一步 → 第 8 章:把 Kubernetes 接到雲端(GKE,managed K8s) 本機 kind 很適合學習,但要真正對外服務,需要一個跑在雲端、有人幫你顧控制平面的叢集。我們會用 Terraform 開一個 GKE(Google Kubernetes Engine) 叢集,把這一章同一套 manifest 套上去,並用 LoadBalancer 取得對外 IP。💸 GKE 會計費,章末第一優先就是 terraform destroy