第 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,建立叢集
- 安裝(macOS):
brew install kubectl # 跟叢集溝通的 CLI
brew install kind # Kubernetes IN Docker:用 Docker 跑本機叢集
kubectl version --client # 確認裝好
kind version
- 建立一個本機叢集(需要 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 物件都有
apiVersion、kind(物件類型)、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
重點:
Secret用stringData:你寫明文、K8s 自動 base64。(若用data則要自己先 base64,很容易出錯——新手建議用stringData。)PersistentVolumeClaim(PVC):向叢集「要一塊磁碟」。掛到/var/lib/postgresql/data,這樣 Pod 重建時 DB 資料還在。kind 內建儲存能滿足它。Deployment的selector/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
重點:
ConfigMap的DB_HOST: "postgres":app 連叢集內的postgresService。其餘鍵完全沿用你 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 都是 Running、READY 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-secret、DB_HOST=postgres |
Pod 卡在 Pending |
PVC 沒綁定 / 資源不足 | kubectl describe pod -n nlt <pod> 看事件;kind 預設有儲存類別,通常稍等即可 |
port-forward connection refused |
Pod 還沒 ready,或 port 寫錯 | 等 READY 1/1;Service port: 80 → targetPort: 8080,forward 用 8080:80 |
| 多個全新 replica 同時啟動,有 Pod 在全新 DB 上 migration 失敗 | 啟動時跑 migration 在並發下撞 schema_migrations 主鍵 | 先用 replicas: 1 讓 migration 跑一次;正式環境用獨立的 migration Job/initContainer(進階) |
kubectl 指令打到別的叢集 |
context 不對 | kubectl config current-context 應為 kind-nlt;kubectl config use-context kind-nlt |
| Secret 值看起來亂碼 | 用了 data 還貼明文(應 base64) |
新手用 stringData 寫明文,讓 K8s 自動編碼 |
| 改了 manifest 沒生效 | 忘了重新 apply | kubectl apply -f k8s/app.yaml;kubectl rollout status -n nlt deploy/nlt-api |
下一步 → 第 8 章:把 Kubernetes 接到雲端(GKE,managed K8s)
本機 kind 很適合學習,但要真正對外服務,需要一個跑在雲端、有人幫你顧控制平面的叢集。我們會用 Terraform 開一個 GKE(Google Kubernetes Engine) 叢集,把這一章同一套 manifest 套上去,並用 LoadBalancer 取得對外 IP。💸 GKE 會計費,章末第一優先就是 terraform destroy。