K8s 7 — Lưu trữ (PV/PVC) & cấu hình (ConfigMap/Secret)
Vấn đề: container thì "phù du", dữ liệu thì phải bền
Ở bài Workloads chúng ta đã thấy Pod là đơn vị dùng-một-lần: nó bị xoá, bị dời sang node khác, bị thay bằng bản mới trong mỗi lần rolling update. Điều đó tuyệt vời cho các service stateless (không giữ trạng thái) — web API, worker xử lý message. Nhưng nó là thảm hoạ nếu container đó đang giữ dữ liệu quan trọng.
Ổ đĩa mặc định của một container sống và chết cùng container: container Postgres bị thay bằng bản mới thì toàn bộ file trong lớp ghi (writable layer) của nó bay sạch. Đó là hành vi đúng với thiết kế của image, nhưng nó buộc ta phải trả lời hai câu hỏi tách biệt:
- Lưu trữ (storage): làm sao để dữ liệu sống lâu hơn vòng đời của Pod, và theo Pod khi nó được lập lịch lại?
- Cấu hình (configuration): làm sao để tách cấu hình khỏi image — cùng một image chạy được ở dev, staging, production chỉ bằng cách đổi biến, mà không phải build lại?
Bài này trả lời cả hai. Nửa đầu về Volume / PV / PVC / StorageClass, nửa sau về ConfigMap / Secret.
Volume: mở rộng vòng đời lưu trữ ra ngoài container
Trong Kubernetes, Volume là một thư mục được gắn (mount) vào một hay nhiều container trong Pod. Điểm mấu chốt: vòng đời của Volume gắn với Pod, không phải với container. Vì thế nếu một container trong Pod crash và khởi động lại, dữ liệu trong Volume vẫn còn — vì Pod chưa chết.
Có hàng chục loại Volume, nhưng để hiểu bản chất chỉ cần phân biệt hai nhóm:
Volume phù du (ephemeral) — sống và chết cùng Pod. Loại tiêu biểu là emptyDir: một thư mục rỗng được tạo khi Pod được gán vào node, và bị xoá vĩnh viễn khi Pod bị xoá khỏi node. Dùng nó cho: bộ nhớ đệm (cache), không gian scratch, hoặc chia sẻ file giữa hai container trong cùng một Pod (ví dụ một sidecar ghi log, container chính đọc). Đừng bao giờ để dữ liệu bạn cần giữ vào emptyDir — nó không hề bền.
Volume bền (persistent) — dữ liệu sống lâu hơn Pod, nằm trên một hệ thống lưu trữ bên ngoài (đĩa mạng, cloud disk, NFS...). Đây là thứ ta cần cho cơ sở dữ liệu. Nhưng nếu để Pod tham chiếu trực tiếp đến "đĩa EBS số xyz", manifest sẽ bị dính chặt vào hạ tầng — không portable giữa các cluster. Kubernetes giải quyết bằng cách tách cung và cầu ra hai đối tượng riêng: PV và PVC.
PV & PVC: tách biệt cung và cầu
Đây là ý tưởng trung tâm của lưu trữ bền trong Kubernetes, và nó phản ánh đúng cách hai vai trò làm việc thật:
-
PersistentVolume (PV) — một tài nguyên lưu trữ trong cluster, giống như một Node là tài nguyên tính toán. PV mô tả cung: "có một khối lưu trữ 20Gi, nằm trên đĩa này, hỗ trợ mode kia". PV do quản trị viên (hoặc hệ thống cấp phát động, xem bên dưới) tạo ra, và tồn tại độc lập với bất kỳ Pod nào.
-
PersistentVolumeClaim (PVC) — một yêu cầu lưu trữ của ứng dụng. PVC mô tả cầu: "tôi cần 20Gi, mode ReadWriteOnce". PVC do lập trình viên/người deploy app tạo ra. Pod không tham chiếu đến PV trực tiếp; Pod chỉ tham chiếu đến PVC.
Kubernetes chạy một vòng điều khiển để ghép (bind) PVC với một PV thoả yêu cầu (đủ dung lượng, đúng access mode, đúng StorageClass). Khi ghép xong, quan hệ là một-một và độc quyền — một PV chỉ phục vụ một PVC. Nếu không có PV nào phù hợp (ví dụ PVC xin 100Gi mà chỉ có PV 50Gi), PVC sẽ ở trạng thái Pending chờ mãi cho đến khi có PV thoả.
Lợi ích của việc tách này: app chỉ biết "tôi cần một khối lưu trữ" (PVC), còn chuyện nó nằm trên EBS, Ceph hay NFS là việc của tầng hạ tầng (PV). Cùng một manifest PVC chạy được trên mọi cluster biết cách cấp PV.
Access modes
PVC khai báo cách nó muốn dùng volume, và PV phải hỗ trợ mode đó. Bốn mode (theo tài liệu chính thức của Kubernetes):
| Viết tắt | Tên đầy đủ | Ý nghĩa |
|---|---|---|
| RWO | ReadWriteOnce | Đọc-ghi bởi một node duy nhất (nhiều Pod trên cùng node đó vẫn dùng được) |
| ROX | ReadOnlyMany | Chỉ-đọc bởi nhiều node |
| RWX | ReadWriteMany | Đọc-ghi bởi nhiều node cùng lúc |
| RWOP | ReadWriteOncePod | Đọc-ghi bởi đúng một Pod duy nhất |
Điểm hay nhầm: ReadWriteOnce giới hạn theo node, không phải theo Pod. Phần lớn block storage của cloud (EBS, GCE PD) chỉ hỗ trợ RWO — một đĩa chỉ gắn vào một máy tại một thời điểm. Muốn RWX thật sự (nhiều node cùng ghi) bạn cần loại lưu trữ chia sẻ như NFS hay CephFS. Đây là lý do bạn không thể ngây thơ scale một Deployment dùng đĩa RWO lên nhiều node và mong chúng cùng ghi một volume.
StorageClass: cấp phát động (dynamic provisioning)
Tạo PV thủ công cho từng ứng dụng là việc cực nhọc và không mở rộng được. StorageClass giải quyết bằng cấp phát động: thay vì quản trị viên tạo sẵn PV, ta khai báo một "lớp lưu trữ" mô tả cách tạo PV, rồi để hệ thống tự tạo PV đúng lúc có PVC yêu cầu.
Cơ chế: mỗi StorageClass trỏ tới một provisioner (một driver, thường theo chuẩn CSI — Container Storage Interface). Khi một PVC tham chiếu StorageClass đó, provisioner gọi API của hệ lưu trữ (ví dụ tạo một EBS volume mới), rồi tạo PV tương ứng và bind vào PVC — tất cả tự động, không còn PV tạo tay.
StorageClass còn quy định reclaim policy — điều gì xảy ra với PV (và khối lưu trữ thật) khi PVC bị xoá:
Delete— xoá luôn cả PV lẫn khối lưu trữ vật lý. Đây là mặc định cho PV cấp phát động. Tiện, nhưng nguy hiểm: xoá nhầm PVC của database là mất dữ liệu thật.Retain— giữ lại PV và dữ liệu sau khi PVC bị xoá; PV chuyển sang trạng tháiReleasedvà không tự động cho PVC khác dùng. Quản trị viên phải dọn dẹp thủ công. An toàn hơn cho dữ liệu quan trọng.Recycle— (đã deprecated, chỉ nhắc để nhận diện; dùng cấp phát động thay thế).
Cluster thường có một StorageClass mặc định. PVC nào không ghi rõ storageClassName sẽ dùng lớp mặc định này.
Sơ đồ luồng lưu trữ
Ví dụ: PVC + ConfigMap + Secret + mount vào Pod
Manifest dưới đây gom đủ các mảnh (một PVC bền, một ConfigMap, một Secret, và một Pod nạp cả ba). Trong sandbox của trang này chỉ có PostgreSQL, không có cluster Kubernetes, nên các đoạn yaml/bash là minh hoạ để đọc hiểu, không chạy được ở đây.
# (minh hoạ) — pvc.yaml: yêu cầu 5Gi lưu trữ bền
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pg-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard # dùng StorageClass mặc định của cluster
resources:
requests:
storage: 5Gi
---
# (minh hoạ) — cấu hình phi bí mật
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: "production"
LOG_LEVEL: "info"
app.properties: | # cả một file cấu hình
feature.newUI=true
cache.ttlSeconds=300
---
# (minh hoạ) — dữ liệu nhạy cảm
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
stringData: # stringData: Kubernetes tự base64-encode giúp
POSTGRES_PASSWORD: "s3cr3t-please-rotate"
# (minh hoạ) — pod.yaml: nạp cả ba vào một Pod
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
containers:
- name: app
image: myregistry/app:1.4.0
env:
- name: APP_ENV # 1 khoá từ ConfigMap → biến môi trường
valueFrom:
configMapKeyRef: { name: app-config, key: APP_ENV }
- name: POSTGRES_PASSWORD # 1 khoá từ Secret → biến môi trường
valueFrom:
secretKeyRef: { name: db-secret, key: POSTGRES_PASSWORD }
volumeMounts:
- name: data # PVC mount làm thư mục dữ liệu
mountPath: /var/lib/app
- name: config-vol # ConfigMap mount thành file
mountPath: /etc/app
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: pg-data # Pod trỏ tới PVC, KHÔNG trỏ tới PV
- name: config-vol
configMap:
name: app-config
Chú ý: Pod tham chiếu PVC (claimName: pg-data), không hề biết PV nằm ở đâu — đúng tinh thần tách cung/cầu.
Lưu trữ cho StatefulSet: mỗi Pod một PVC ổn định
Với Deployment, mọi Pod giống hệt nhau và dùng chung một PVC — hợp lý cho stateless. Nhưng database phân tán (Postgres replica, Kafka, Elasticsearch) cần điều ngược lại: mỗi bản sao có kho dữ liệu riêng, ổn định, gắn với danh tính của nó. Bạn không muốn kafka-0 và kafka-1 cùng ghi vào một đĩa.
Đây là lý do StatefulSet (đã giới thiệu ở bài Workloads) có cơ chế lưu trữ riêng: volumeClaimTemplates. Thay vì một PVC cố định, bạn đưa cho StatefulSet một khuôn PVC. Với mỗi Pod nó tạo ra (db-0, db-1, db-2...), Kubernetes tự sinh một PVC riêng theo khuôn đó (data-db-0, data-db-1...).
Điều làm nó khác biệt: các PVC này dính chặt vào danh tính Pod và không bị xoá khi Pod bị xoá hay dời sang node khác. Khi db-0 được lập lịch lại, nó bám lại đúng PVC data-db-0 cũ — dữ liệu của nó không lẫn với ai. Đó chính là "lưu trữ bền theo từng bản sao" mà các hệ stateful đòi hỏi.
# (minh hoạ) — StatefulSet với volumeClaimTemplates
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: pg }
spec:
serviceName: pg
replicas: 3
selector: { matchLabels: { app: pg } }
template:
metadata: { labels: { app: pg } }
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # mỗi Pod được cấp 1 PVC riêng
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
resources: { requests: { storage: 10Gi } }
ConfigMap & Secret: tách cấu hình khỏi image
Nguyên tắc vàng: image chỉ chứa code, không chứa cấu hình môi trường. Cùng một image app:1.4.0 phải chạy được ở dev và production, chỉ khác ở giá trị cấu hình được nạp từ ngoài vào lúc chạy. Kubernetes cung cấp hai đối tượng cho việc này.
ConfigMap — cho cấu hình phi bí mật: tên host, mức log, URL dịch vụ, feature flag, hay cả một file cấu hình nguyên vẹn. Dữ liệu lưu dạng plaintext trong etcd.
Secret — cho dữ liệu nhạy cảm: mật khẩu database, API token, TLS key, chứng chỉ. Về cấu trúc gần giống ConfigMap, nhưng Kubernetes đối xử với Secret cẩn thận hơn (ví dụ không tự in ra giá trị khi kubectl describe).
Cả hai đều nạp vào Pod theo hai cách (đã thấy trong ví dụ trên):
- Biến môi trường (env) — bơm từng khoá thành biến. Tiện, nhưng biến môi trường có thể lộ qua
/proc, qua log, và không tự cập nhật khi ConfigMap đổi (phải restart Pod). - Volume (file) — mount thành các file trong một thư mục. Với ConfigMap/Secret mount kiểu volume, khi bạn cập nhật đối tượng thì file trong Pod sẽ tự động được cập nhật (sau một khoảng trễ), không cần restart — hữu ích cho cấu hình đọc lại nóng.
Cảnh báo quan trọng về Secret: base64 KHÔNG phải mã hoá
Đây là hiểu lầm nguy hiểm nhất. Giá trị trong Secret được lưu dưới dạng base64, mà base64 chỉ là mã hoá biểu diễn (encoding), không phải mã hoá bảo mật (encryption). Ai cầm được chuỗi base64 đều giải ngược ra plaintext trong một giây. Mặc định, Secret nằm trong etcd ở dạng gần như plaintext. Để Secret thật sự an toàn, cần thêm các lớp bảo vệ:
- Encryption at rest: bật mã hoá dữ liệu Secret trong etcd (cấu hình ở API server, có thể dùng KMS). Không bật thì ai đọc được etcd là đọc được toàn bộ secret.
- RBAC chặt: giới hạn ai (user/ServiceAccount) được
get/listSecret. Quyền đọc Secret gần như tương đương quyền dùng những gì Secret bảo vệ.
Với hạ tầng nghiêm túc, xu hướng phổ biến là không lưu secret thô trong Git/cluster mà dùng công cụ chuyên dụng:
- External Secrets / Vault (HashiCorp Vault, AWS Secrets Manager...) — secret sống ở kho ngoài, được đồng bộ vào cluster khi cần; cluster không phải là nguồn sự thật.
- Sealed Secrets — mã hoá secret thành dạng "đã niêm phong" (SealedSecret) an toàn để commit vào Git; chỉ controller trong cluster mới giải mã được — hợp với quy trình GitOps.
Immutable ConfigMap / Secret
Bạn có thể đặt trường immutable: true cho ConfigMap/Secret. Khi đó nội dung không sửa được nữa (muốn đổi phải tạo đối tượng mới và cập nhật tham chiếu). Lợi ích: (1) chống sửa nhầm gây sự cố lan rộng; (2) giảm tải cho API server — Kubernetes không phải theo dõi (watch) các đối tượng này để đẩy cập nhật, tốt cho cluster có rất nhiều ConfigMap/Secret.
Use case thực tế
Chạy PostgreSQL trên K8s với PVC. Postgres là stateful điển hình, nên: dùng StatefulSet với volumeClaimTemplates để mỗi instance có PVC riêng bền; đặt StorageClass với reclaimPolicy: Retain cho dữ liệu database (tránh mất dữ liệu khi lỡ tay xoá PVC); mount PVC vào /var/lib/postgresql/data; nạp POSTGRES_PASSWORD từ một Secret (không hard-code trong manifest), và các tham số tinh chỉnh (max_connections, shared_buffers) qua ConfigMap mount thành file. Kết quả: Pod Postgres crash → khởi động lại, dữ liệu còn nguyên trên PV; node chết → Pod dời sang node khác và bám lại đúng PVC cũ (miễn là loại lưu trữ cho phép). Lưu ý thực tế: chạy database tự quản trên K8s là việc không tầm thường (backup, failover, nâng cấp) — nhiều team chọn database dịch vụ (managed) thay vì tự vận hành; sẽ bàn kỹ hơn ở bài Production & vận hành.
Nạp cấu hình app qua ConfigMap/Secret. Một web API 12-factor: URL các dịch vụ nội bộ và feature flag để trong ConfigMap (mount file /etc/app/config.yaml để có thể reload nóng); DB password, JWT signing key, API token của bên thứ ba để trong Secret; app tham chiếu service backend qua tên DNS ổn định của Service — thế là cùng một image đi từ dev tới production chỉ bằng cách đổi ConfigMap/Secret theo namespace, không build lại image lần nào.
Ghi nhớ
emptyDirlà phù du — chết cùng Pod. Muốn dữ liệu bền phải qua PV.- Tách cung/cầu: PV = tài nguyên lưu trữ (do admin/hệ thống); PVC = yêu cầu của app; Pod trỏ tới PVC, không trỏ tới PV.
- Access mode theo node, không theo Pod:
ReadWriteOnce= một node. Cần nhiều node cùng ghi thì phải có lưu trữ RWX (NFS/Ceph...). - StorageClass = cấp phát động: provisioner tự tạo PV khi có PVC. Để ý reclaim policy: mặc định
Delete(tiện nhưng dễ mất dữ liệu), dùngRetaincho dữ liệu quan trọng. - StatefulSet dùng
volumeClaimTemplates: mỗi Pod một PVC riêng, ổn định, bám theo danh tính — đúng cho DB/Kafka. - Secret ≠ mã hoá: base64 chỉ là encoding. Bắt buộc bật encryption at rest + siết RBAC; cân nhắc Vault / External Secrets / Sealed Secrets.
- Nạp cấu hình 2 cách: env (đơn giản, không tự cập nhật) hoặc volume (tự cập nhật file, hợp reload nóng).
immutable: truecho ConfigMap/Secret: chống sửa nhầm và giảm tải API server.
Đọc thêm (chính thức): tài liệu Kubernetes về Persistent Volumes, Storage Classes, ConfigMap và Secret.
Trong series này: K8s kiến trúc · Workloads · Services & mạng · Production & vận hành.
Bài viết liên quan
Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.
Container vs máy ảo, image/layer, Dockerfile, volume, network, Docker Compose và best practices.
Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.
Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.