Bài 06
Bài 06 — Lưu trữ & StatefulSet: chạy database trên K8s
Suốt mấy bài qua tôi nhắc đi nhắc lại một câu, mỗi lần lại hứa "để bài sau": "Database phải dùng StatefulSet, không phải Deployment." Bài 03 cảnh báo, capstone nhắc lại, pitfall ghi hẳn ra. Giờ trả nợ.
Bài này hai phần gắn chặt:
- Lưu trữ bền — đào sâu PV / PVC / StorageClass, access mode, reclaim policy. Capstone mới chỉ lướt; ở đây ta hiểu cơ chế.
- StatefulSet — controller cho app có trạng thái: cho mỗi Pod một danh tính cố định và một ổ đĩa riêng, để bạn chạy được database, message queue, cluster lưu trữ... mà không làm hỏng dữ liệu.
Hiểu xong bài này, bạn trả lời được câu hỏi production thật: "Có nên chạy DB trên K8s không, và nếu có thì làm sao cho đúng?"
Vấn đề: vì sao Deployment không chạy được DB
Nhớ Bài 03: Deployment coi các Pod là bản sao giống hệt, thay thế lẫn nhau, vô danh. Với web stateless thì tuyệt. Nhưng đem tư duy đó cho database:
Deployment "mysql" replicas: 3, cùng 1 PVC:
Pod-A ─┐
Pod-B ─┼──► cùng ghi vào 1 volume (ReadWriteOnce)
Pod-C ─┘
→ tranh nhau ghi → DATA CORRUPT 💥
(hoặc 2 Pod còn lại kẹt vì RWO chỉ cho 1 node mount)
Mà DB cluster thật cần:
• mỗi node 1 ổ RIÊNG (không chung)
• danh tính ỔN ĐỊNH (primary là primary, replica nào ra replica đó)
• thứ tự khởi động (primary lên trước, replica join sau)
→ Deployment KHÔNG cho cái nào trong 3 thứ này.Đây chính là pitfall "scale stateful như stateless" (Bài 03) mổ xẻ kỹ. App có trạng thái cần thứ Deployment không có: danh tính + ổ riêng + thứ tự. Đó là việc của StatefulSet. Nhưng trước hết phải nắm chắc tầng lưu trữ.
Lưu trữ bền — PV, PVC, StorageClass
Ở capstone bạn đã gặp khái niệm. Giờ hệ thống lại cho rõ ba mảnh và cách chúng ăn khớp:
StorageClass "loại ổ" + ai cấp (provisioner). VD: nfs, local-path, ssd-cloud
│ (dynamic provisioning)
▼
PV (PersistentVolume) ổ đĩa THẬT trong cluster (do provisioner tạo)
▲ bind 1-1
│
PVC (PersistentVolumeClaim) "đơn đặt hàng" của bạn: tôi cần 10Gi, RWO
▲ mount
│
Pod- PVC = bạn yêu cầu ("cần 10Gi, ghi được"). Bạn chỉ viết cái này.
- PV = ổ thật đáp ứng yêu cầu. Với dynamic provisioning (capstone đã bật), StorageClass tự tạo PV khớp PVC — bạn không phải dựng PV tay.
- StorageClass = định nghĩa "loại ổ" và provisioner tạo ra nó (NFS, local-path, EBS...).
Access Modes — ai được mount, kiểu gì
RWO (ReadWriteOnce) 1 NODE mount đọc-ghi ← phổ biến nhất (DB, ổ riêng)
ROX (ReadOnlyMany) nhiều node mount chỉ ĐỌC
RWX (ReadWriteMany) nhiều node mount đọc-GHI ← cần NFS/CephFS, không phải
ổ block nào cũng hỗ trợ⚠️ Đây là gốc rễ vì sao Deployment + nhiều replica + 1 PVC RWO bị kẹt: RWO chỉ cho 1 node mount đọc-ghi. Pod thứ 2 ở node khác không mount được → Pending mãi. Muốn nhiều Pod cùng ghi 1 volume thật sự thì cần RWX (vd NFS) — nhưng với DB thì cách đúng không phải share volume, mà là mỗi Pod một ổ riêng (StatefulSet, xem dưới).
Reclaim Policy — xóa PVC thì dữ liệu đi đâu
# trên StorageClass hoặc PV
reclaimPolicy: Retain # giữ PV + dữ liệu khi PVC bị xóa (an toàn — khuyên cho DB)
# reclaimPolicy: Delete # xóa luôn PV + dữ liệu khi xóa PVC (tiện nhưng nguy hiểm)⚠️ Nhiều StorageClass mặc định Delete. Xóa PVC của database = mất sạch dữ liệu ngay lập tức. Với dữ liệu quan trọng, đặt Retain để xóa PVC không cuốn theo ổ.
kubectl get sc # các StorageClass có sẵn (* = default)
kubectl get pv # các ổ thật
kubectl get pvc -n myapp # các "đơn đặt hàng" + trạng thái Bound/Pending💡 PVC
Pendingmãi (Bài 02 nhắc) thường vì: không có StorageClass mặc định, hoặc xin access mode mà ổ không hỗ trợ (vd RWX trên ổ block), hoặc hết dung lượng.kubectl describe pvcchỉ rõ.
StatefulSet — controller cho app có trạng thái
StatefulSet quản Pod như Deployment, nhưng cho thêm ba thứ then chốt mà app có trạng thái cần:
1. DANH TÍNH ỔN ĐỊNH Pod tên cố định: mysql-0, mysql-1, mysql-2
(không phải tên ngẫu nhiên như Deployment)
→ mỗi Pod có DNS riêng, restart vẫn giữ tên.
2. Ổ ĐĨA RIÊNG volumeClaimTemplates → mỗi Pod tự có 1 PVC riêng:
mysql-0 ↔ data-mysql-0, mysql-1 ↔ data-mysql-1
→ Pod chết tạo lại, GẮN LẠI ĐÚNG ổ cũ của nó.
3. THỨ TỰ Tạo/scale tuần tự: mysql-0 Ready rồi mới tới mysql-1.
Xóa thì ngược: mysql-2 trước, mysql-0 sau cùng.
→ primary lên trước, replica join sau.Cần một Headless Service
Nhớ Headless Service (clusterIP: None) ở Bài 04? Đây là nơi nó dùng tới. StatefulSet cần một headless service để cấp DNS riêng cho từng Pod:
mysql-0.mysql.myapp.svc.cluster.local
mysql-1.mysql.myapp.svc.cluster.local
→ gọi ĐÍCH DANH từng replica (primary/replica), không phải "một Pod bất kỳ".YAML — StatefulSet + Headless Service
# Headless Service — cấp DNS cho từng Pod
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: myapp
spec:
clusterIP: None # ← headless (Bài 04)
selector: { app: mysql }
ports: [{ port: 3306 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: myapp
spec:
serviceName: mysql # trỏ tới headless service ở trên
replicas: 3
selector:
matchLabels: { app: mysql }
template:
metadata:
labels: { app: mysql }
spec:
containers:
- name: mysql
image: mysql:8.0
ports: [{ containerPort: 3306 }]
envFrom:
- secretRef: { name: db-secret } # Secret từ Bài 05
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates: # ← KHÁC Deployment: mỗi Pod tự đẻ 1 PVC
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources:
requests: { storage: 10Gi }
# storageClassName: nfs-client # tùy môi trường→ Để ý volumeClaimTemplates: không phải một PVC dùng chung, mà là khuôn để mỗi Pod đẻ PVC riêng. mysql-0 có data-mysql-0, mysql-1 có data-mysql-1... Xóa Pod mysql-1, StatefulSet tạo lại đúng mysql-1 và gắn lại đúng ổ data-mysql-1 — dữ liệu còn nguyên.
Deployment StatefulSet
────────── ───────────
tên Pod ngẫu nhiên tên cố định mysql-0, mysql-1...
web-7d9f-x2k (có thứ tự, đoán được)
1 PVC dùng chung (nếu mỗi Pod 1 PVC riêng (volumeClaimTemplates)
có) → tranh ghi
tạo/xóa song song tạo/xóa tuần tự, có thứ tự
Pod = vô danh, thay Pod = có danh tính, KHÔNG thay thế tùy tiện
thế tự doVậy có nên chạy DB trên K8s không?
Câu hỏi thật mà mọi team gặp. Trả lời thẳng:
Nên cân nhắc KHÔNG tự chạy DB trên K8s khi:
• Có managed DB (RDS, Cloud SQL...) → để nhà cung cấp lo backup, HA, vá lỗi.
Thường rẻ hơn và an toàn hơn tự vận hành DB trên cluster.
Nếu BẮT BUỘC chạy DB trên K8s (on-prem, không có managed):
• DÙNG OPERATOR, đừng tự viết StatefulSet trần.
Operator (vd: Zalando Postgres Operator, MySQL Operator, Percona) tự lo
replication, failover, backup, restore — những thứ StatefulSet KHÔNG tự làm.
• StatefulSet chỉ cho bạn danh tính + ổ + thứ tự. Nó KHÔNG biết cách
"bầu primary mới khi primary chết" — đó là việc của operator hoặc app.💡 Quan trọng: StatefulSet ≠ database tự động HA. Nó là nền để chạy app có trạng thái, không phải giải pháp DB hoàn chỉnh. Production thật: managed DB > operator > StatefulSet trần. StatefulSet trần hợp để học và cho app stateful đơn giản.
App có trạng thái khác cũng dùng StatefulSet: Kafka, Elasticsearch, Redis cluster, Zookeeper, MinIO... — đều cần danh tính + ổ ổn định.
🚀 Lab — StatefulSet & ổ đĩa bền
Dùng cluster kind (có sẵn StorageClass standard mặc định).
1. Tạo StatefulSet đơn giản
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Service
metadata: { name: web }
spec:
clusterIP: None
selector: { app: web }
ports: [{ port: 80 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: web }
spec:
serviceName: web
replicas: 3
selector: { matchLabels: { app: web } }
template:
metadata: { labels: { app: web } }
spec:
containers:
- name: nginx
image: nginx:1.25
volumeMounts: [{ name: data, mountPath: /usr/share/nginx/html }]
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 1Gi } }
EOF2. Quan sát danh tính + thứ tự + PVC riêng
kubectl get pods --watch
# web-0 Running TRƯỚC → rồi web-1 → rồi web-2 (tuần tự, không song song!)
kubectl get pvc
# data-web-0, data-web-1, data-web-2 ← MỖI Pod 1 PVC riêng3. Chứng minh ổ "dính" với Pod
# Ghi dấu riêng vào ổ của web-1
kubectl exec web-1 -- sh -c 'echo "toi la web-1" > /usr/share/nginx/html/id.txt'
# Xóa web-1 — StatefulSet tạo lại ĐÚNG web-1, gắn lại ĐÚNG ổ cũ
kubectl delete pod web-1
kubectl get pods --watch # web-1 (không phải tên khác) mọc lại
kubectl exec web-1 -- cat /usr/share/nginx/html/id.txt
# "toi la web-1" ← dữ liệu CÒN NGUYÊN, vì gắn lại đúng PVC data-web-1 ✨4. DNS từng Pod (headless service)
kubectl run dns --rm -it --image=busybox -- sh
# nslookup web-0.web → ra IP của riêng web-0
# nslookup web → ra cả 3 IP (headless trả mọi Pod)
# exit5. Dọn (chú ý PVC không tự xóa!)
kubectl delete statefulset web && kubectl delete svc web
kubectl get pvc # data-web-0/1/2 VẪN CÒN — phải xóa tay:
kubectl delete pvc data-web-0 data-web-1 data-web-2→ Khoảnh khắc "à há" ở bước 3 và 5: xóa Pod, dữ liệu vẫn sống vì PVC gắn theo danh tính Pod. Và xóa StatefulSet không xóa PVC — K8s cố tình giữ dữ liệu để bạn khỏi mất oan. Đây đúng là thứ DB cần mà Deployment không có.
Câu chuyện thực tế
Đây là phần sâu của cú vấp tôi kể lướt ở capstone. Khi migrate stack lên K8s, tôi copy file webapp-deployment.yaml để làm nhanh manifest cho MySQL — và quên sửa replicas: 2 xuống 1, lại để nguyên kind: Deployment. Apply. Hai Pod MySQL cùng mount một PVC ReadWriteOnce.
Kết quả: Pod thứ hai kẹt ContainerCreating mãi (RWO không cho node thứ hai mount), còn ứng dụng thì lúc kết nối được lúc không. Tệ hơn, trong một lần tôi đổi PVC sang một StorageClass ReadWriteMany (NFS) để "cho hai Pod cùng ghi được" — thì cả hai Pod ghi đè lên cùng file dữ liệu MySQL, và InnoDB hỏng. Phải restore từ backup. Mất một buổi sáng và một bài học nhớ đời.
Cái sai gốc không phải kỹ thuật mount — mà là tư duy: tôi coi database như một web stateless, "cứ thêm bản là HA". Database không như thế. Hai instance MySQL không tự biết chia việc; chúng phải được cấu hình replication (primary–replica), mỗi đứa một ổ riêng, primary lên trước. Đúng những gì StatefulSet sinh ra để làm.
Tôi viết lại bằng StatefulSet 1 replica + volumeClaimTemplates (1 ổ riêng), reclaim policy Retain. Khi cần HA thật sự về sau, chúng tôi chuyển sang một MySQL operator lo replication và failover — chứ không tự đẻ thêm replica bằng tay nữa.
Bài học:
Database không phải web stateless. "Thêm replica" với web là HA; với DB chưa cấu hình replication là hỏng dữ liệu. Đừng mang tư duy Deployment sang DB.
StatefulSet cho danh tính + ổ riêng + thứ tự — ba thứ DB cần mà Deployment không có.
volumeClaimTemplateslà điểm mấu chốt: mỗi Pod một ổ.RWO chỉ 1 node mount. Hai Pod tranh 1 PVC RWO → kẹt. "Chữa" bằng RWX để cùng ghi 1 file DB → corrupt. Cả hai đều sai; cách đúng là mỗi Pod một ổ.
Reclaim
Retaincho dữ liệu quý.Deletemặc định khiến xóa PVC là mất sạch. Database phảiRetain.StatefulSet ≠ DB tự HA. Production thật: managed DB tốt nhất; bắt buộc tự chạy thì dùng operator, đừng tự viết logic failover trên StatefulSet trần.
Pitfalls
Dùng Deployment cho DB. Pod vô danh, dễ tranh ổ chung. DB phải StatefulSet (hoặc operator).
Nhiều replica + 1 PVC ReadWriteOnce → Pod thứ hai kẹt
Pending/ContainerCreating. RWO chỉ 1 node mount.Dùng RWX cho DB để "cùng ghi" → nhiều instance ghi đè cùng file → corrupt. Mỗi DB instance phải có ổ riêng.
Reclaim policy
Deletecho dữ liệu quan trọng → xóa PVC là mất sạch. ĐặtRetain.Tưởng xóa StatefulSet sẽ xóa PVC. Không — PVC từ
volumeClaimTemplatescố tình ở lại để giữ dữ liệu. Phải xóa PVC bằng tay (cẩn thận!).Lưu dữ liệu DB vào
emptyDir(Bài 05/capstone) → Pod tái tạo là mất sạch. emptyDir sống theo Pod, không bền.Quên headless service cho StatefulSet → Pod không có DNS riêng,
serviceNametrỏ vào hư không.Local Path storage cho StatefulSet đa node → ổ nằm ở node cũ, Pod dời node là mất dữ liệu. Production dùng NFS/Ceph/cloud-disk.
PVC
Pendingmà không soi.kubectl describe pvcchỉ rõ: thiếu StorageClass mặc định / access mode không hỗ trợ / hết dung lượng.Tự viết logic HA/failover trên StatefulSet trần. Việc đó cực khó làm đúng — dùng operator chuyên cho từng loại DB.
Scale StatefulSet xuống rồi tưởng PVC tự dọn. Scale
mysqltừ 3→1 giữ lạidata-mysql-1/2(lại để bảo toàn dữ liệu). Dọn tay nếu chắc chắn không cần.
Tóm tắt
- App stateless → Deployment; app có trạng thái (DB, Kafka, ES...) → StatefulSet.
- Tầng lưu trữ: PVC (bạn đặt hàng) ↔ PV (ổ thật) ↔ StorageClass (loại ổ + provisioner, dynamic provisioning tự tạo PV).
- Access mode: RWO (1 node đọc-ghi — phổ biến), ROX (nhiều node đọc), RWX (nhiều node ghi — cần NFS/CephFS).
- Reclaim policy:
Retain(giữ dữ liệu khi xóa PVC — cho DB) vsDelete(xóa luôn — nguy hiểm). - StatefulSet cho 3 thứ: danh tính cố định (
mysql-0,1,2), ổ riêng mỗi Pod (volumeClaimTemplates), thứ tự tạo/xóa. Cần Headless Service (clusterIP: None) để cấp DNS từng Pod. - Xóa Pod StatefulSet → gắn lại đúng ổ cũ; xóa StatefulSet không xóa PVC (giữ dữ liệu).
- DB trên K8s: managed DB > operator > StatefulSet trần. StatefulSet ≠ DB tự HA.
Bài sau (Bài 07 — Job, CronJob & workload đặc biệt) rời thế giới "app chạy mãi" để sang nhóm việc chạy rồi xong: Job (chạy một lần tới khi hoàn tất — migration, batch), CronJob (chạy theo lịch — backup mỗi đêm), và DaemonSet (đúng một Pod trên mỗi node — log/monitoring agent). Cùng một cluster, nhưng vòng đời rất khác Deployment.
Nếu hôm nay bạn xóa một Pod StatefulSet và thấy nó hồi sinh kèm đúng ổ dữ liệu cũ — bạn đã hiểu vì sao database cần StatefulSet chứ không phải Deployment.
— Minh Hưng