Deployment 是 K8s 裡最常用的 Workload,但它不是唯一的。你的 PostgreSQL 不該用 Deployment 跑、你的 node-exporter 不該用 Deployment 跑、你的每日報表產生器也不該用 Deployment 跑。K8s 針對不同用途設計了不同的 Workload 類型,選對了省事,選錯了踩坑。
先講結論
Deployment 處理 80% 的無狀態服務場景。但資料庫要用 StatefulSet(穩定 hostname + 有序部署 + 綁定 PVC)、每個 Node 都要跑一份的 agent 用 DaemonSet、一次性任務用 Job、排程任務用 CronJob。然後不管用哪種 Workload,都要設好 Probe 三兄弟——不然 K8s 不知道你的服務到底活著還是死了。
Probe 三兄弟:liveness / readiness / startup
在講 Workload 類型之前,先搞懂 Probe。因為不管你用哪種 Workload,Probe 設錯都會出事。
三個 Probe 各自的職責
| Probe | 問題 | 失敗後果 |
|---|---|---|
| livenessProbe | 你還活著嗎? | 殺掉 Pod 重建 |
| readinessProbe | 你能接流量嗎? | 從 Service 的 endpoint 移除(不殺 Pod) |
| startupProbe | 你啟動完了嗎? | 啟動期間不跑 liveness/readiness |
最常見的錯誤:liveness probe 打 /health 但 /health 會查 DB。DB 慢了一下,liveness 失敗,K8s 把 Pod 殺掉重啟,所有 Pod 一起重啟,DB 壓力更大,惡性循環。
正確做法:liveness 只檢查 process 還活著(簡單的 HTTP 200 或 TCP port),readiness 才檢查依賴服務。
完整 YAML 範例
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
replicas: 3
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
spec:
containers:
- name: api
image: myapp/api:1.2.0
ports:
- containerPort: 8080
# startup probe:給你 30 次 * 10 秒 = 5 分鐘啟動
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 10
# liveness:只檢查 process 活著
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
# readiness:檢查依賴都 OK
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi三種檢查方式
# 1. HTTP GET — 最常用,適合有 HTTP endpoint 的服務
livenessProbe:
httpGet:
path: /healthz
port: 8080
# 2. TCP Socket — 適合非 HTTP 服務(DB、Redis)
livenessProbe:
tcpSocket:
port: 5432
# 3. Exec — 跑一個指令,exit code 0 = 健康
livenessProbe:
exec:
command:
- pg_isready
- -U
- postgresStatefulSet:有狀態服務的家
Deployment 把 Pod 當成可以隨便替換的工人——殺掉一個再開一個,名字隨機、PVC 不綁定。但資料庫不能這樣搞。
StatefulSet 跟 Deployment 的差異
| 特性 | Deployment | StatefulSet |
|---|---|---|
| Pod 名稱 | 隨機(api-7d4f8b-x9k2z) | 有序(postgres-0, postgres-1) |
| 啟動順序 | 同時啟動 | 按順序(0 → 1 → 2) |
| PVC | 共享或不綁定 | 每個 Pod 有自己的 PVC |
| 刪除行為 | PVC 跟 Pod 一起刪 | PVC 保留(資料不丟) |
| 網路 | 隨機 IP | 穩定的 DNS(postgres-0.postgres-svc) |
PostgreSQL on K8s 範例
apiVersion: v1
kind: Service
metadata:
name: postgres-svc
labels:
app: postgres
spec:
# Headless Service:不分配 ClusterIP,讓每個 Pod 有自己的 DNS
clusterIP: None
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres-svc
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: myapp
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: postgres-secret
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command:
- pg_isready
- -U
- postgres
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- postgres
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
# 每個 Pod 自動建立一個 PVC
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 10GiPod 名稱會是 postgres-0,對應的 PVC 是 postgres-data-postgres-0。就算 Pod 被砍掉重建,PVC 還在,資料不會丟。
實務建議:生產環境的資料庫建議用 Operator(CloudNativePG、Zalando Postgres Operator),不要自己手寫 StatefulSet。Operator 幫你處理 failover、backup、replica 的邏輯,你只需要寫一個 CR(Custom Resource)。
DaemonSet:每個 Node 跑一份
有些服務不是「跑 N 個副本」的概念,而是「每個 Node 上都要有一份」。典型場景:
- node-exporter:收集每個 Node 的 CPU/RAM/Disk 指標
- fluentd / fluent-bit:收集每個 Node 上所有 Pod 的 log
- kube-proxy:K8s 內建的網路組件(本身就是 DaemonSet)
node-exporter DaemonSet YAML
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: monitoring
labels:
app: node-exporter
spec:
selector:
matchLabels:
app: node-exporter
template:
metadata:
labels:
app: node-exporter
spec:
hostNetwork: true
hostPID: true
containers:
- name: node-exporter
image: prom/node-exporter:v1.8.1
args:
- --path.rootfs=/host
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --collector.filesystem.mount-points-exclude
- ^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+)($|/)
ports:
- containerPort: 9100
hostPort: 9100
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: host-root
mountPath: /host
readOnly: true
volumes:
- name: host-root
hostPath:
path: /
tolerations:
# 讓 DaemonSet 也能跑在 master/control-plane node 上
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule注意 tolerations:K8s 的 control-plane node 預設有 taint,一般 Pod 不會被排上去。但 node-exporter 需要跑在所有 Node 上,所以要加 toleration。
Job:跑完就結束的任務
不是所有任務都是「一直跑著」的。資料庫 migration、批次報表、資料清理——這些跑完就該結束。
一次性 Job
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration
spec:
backoffLimit: 3 # 失敗最多重試 3 次
activeDeadlineSeconds: 600 # 最多跑 10 分鐘
template:
spec:
containers:
- name: migrate
image: myapp/api:1.2.0
command: ["python", "manage.py", "migrate"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
restartPolicy: Never # Job 只能用 Never 或 OnFailureCronJob:排程任務
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report
spec:
schedule: "0 2 * * *" # 每天凌晨 2 點
timeZone: "Asia/Taipei"
concurrencyPolicy: Forbid # 上一個還沒跑完就不要再開新的
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800 # 最多跑 30 分鐘
template:
spec:
containers:
- name: report
image: myapp/report-generator:1.0.0
command: ["python", "generate_report.py"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
- name: S3_BUCKET
value: my-reports-bucket
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
restartPolicy: OnFailureCronJob 的 concurrencyPolicy
| 值 | 行為 |
|---|---|
Allow | 預設。可以同時跑多個 Job |
Forbid | 上一個還在跑就跳過這次 |
Replace | 上一個還在跑就砍掉,開新的 |
我的建議:除非你很確定任務可以平行跑,否則用 Forbid。一個報表跑兩份,不是效率加倍,是 DB 壓力加倍。
Workload 選擇指南
| 場景 | 用什麼 | 原因 |
|---|---|---|
| Web API / 微服務 | Deployment | 無狀態,可以隨便殺 |
| 資料庫 / Redis / Kafka | StatefulSet | 需要穩定 hostname 和持久化儲存 |
| 監控 agent / log collector | DaemonSet | 每個 Node 都要有 |
| DB migration / 資料清理 | Job | 跑完就結束 |
| 每日報表 / 定時清理 | CronJob | 排程觸發 |
不確定的時候先用 Deployment。等你發現 Deployment 滿足不了需求(需要穩定 hostname、需要有序部署、需要每個 Node 一份),再換對應的 Workload 類型。
常見踩坑
StatefulSet 縮容很慢:它是有序的,scale down 從最大編號開始一個一個砍,不像 Deployment 可以同時砍多個。
Job 忘記設 activeDeadlineSeconds:某個 bug 讓 Job 卡住永遠跑不完,沒有 deadline 就會一直佔著資源。
CronJob 的 timezone:timeZone 是 K8s 1.27+ 才穩定的功能。舊版本的 schedule 是 UTC,台灣時間要自己換算。
liveness probe 太敏感:periodSeconds: 3 + failureThreshold: 1 = 3 秒沒回應就殺 Pod。GC pause 一下就被殺了。建議 periodSeconds: 15 + failureThreshold: 3 = 45 秒容忍時間。
Deployment 是 K8s 的 “Hello World”,但真正的生產環境不會只有 Deployment。搞懂每種 Workload 的適用場景,才不會拿著鐵鎚看什麼都像釘子。
延伸閱讀
本系列文章
- 本篇:Workloads:Deployment 之外的世界
- 下一篇: DNS →