Mở đầu — vì sao bài này quan trọng
Ở những bài trước, chúng ta đã học rất kỹ về cách triển khai: rolling update, blue-green, canary, feature flags. Đó là các kỹ thuật triển khai — trả lời câu hỏi "làm thế nào để đưa phiên bản mới lên production một cách an toàn". Bài 46 này đứng ở một góc khác: chúng ta sẽ nói về backend deployment patterns — tức là đặc tính kiến trúc của chính dịch vụ backend quyết định bạn được phép triển khai theo cách nào.
Nghe qua thì giống nhau, nhưng khác biệt rất lớn. Một dịch vụ REST API không lưu trạng thái (stateless) có thể bị giết và tạo lại bất cứ lúc nào — nên bạn thoải mái rolling update, scale ngang, tự động phục hồi. Nhưng một dịch vụ giữ trạng thái trong bộ nhớ (session in-memory), một worker đang xử lý job dài hàng giờ, hay một database cần ghi tuần tự — những thứ đó không thể "giết bất cứ lúc nào". Nếu bạn áp dụng cùng một pattern triển khai cho tất cả, bạn sẽ mất dữ liệu, đứt session người dùng, hoặc job chạy dở dang.
Tôi từng chứng kiến một đội ở một công ty fintech tại TP.HCM triển khai một dịch vụ xử lý thanh toán y hệt như họ triển khai API stateless: rolling update, pod cũ bị SIGTERM và giết sau 30 giây. Vấn đề là dịch vụ đó đang giữ các giao dịch dở dang trong bộ nhớ. Kết quả: mỗi lần deploy, khoảng 0.3% giao dịch bị treo ở trạng thái "pending" và phải xử lý thủ công. Với 200.000 giao dịch/ngày, đó là 600 giao dịch cần con người can thiệp — chỉ vì chọn sai deployment pattern cho một dịch vụ không phải stateless.
Bài học cốt lõi: bạn phải phân loại đúng bản chất backend của mình trước, rồi mới chọn cách triển khai. Đó là những gì chúng ta sẽ đào sâu ở đây.
Khái niệm cốt lõi
Phân loại backend theo trạng thái (state)
Đây là trục phân loại quan trọng nhất. Mọi backend rơi vào một trong ba nhóm:
1. Stateless service (dịch vụ không trạng thái). Đây là trường hợp dễ nhất và cũng là mục tiêu lý tưởng mà kiến trúc hiện đại luôn hướng tới. Một request đến, dịch vụ xử lý, trả kết quả, và không giữ lại bất cứ thông tin nào về request đó trong bộ nhớ của chính nó. Mọi trạng thái cần lưu đều được đẩy ra ngoài: database, Redis, message queue, object storage.
Vì mỗi replica (bản sao) đều giống hệt nhau và không giữ dữ liệu riêng, bạn có thể:
- Giết một replica bất cứ lúc nào mà không mất gì — request đang chạy chỉ cần được drain xong.
- Tạo thêm replica tùy ý để scale ngang (horizontal scaling).
- Dùng rolling update — mặc định của Kubernetes — mà không cần lo lắng.
2. Stateful service (dịch vụ có trạng thái). Dịch vụ này giữ dữ liệu quan trọng ngay trên chính node/pod của nó — thường là trên đĩa (persistent volume). Database (PostgreSQL, MySQL), message broker (Kafka, RabbitMQ), cache có persistence (Redis với AOF), Elasticsearch... đều thuộc nhóm này. Mỗi instance có danh tính riêng (identity), thường có thứ tự (ordered), và không thể hoán đổi tự do cho nhau. Trong Kubernetes, chúng dùng StatefulSet chứ không phải Deployment.
3. Sticky/quasi-stateful service. Đây là vùng xám nguy hiểm nhất — dịch vụ có vẻ stateless nhưng thực ra giữ trạng thái nhẹ trong bộ nhớ: session người dùng in-memory, WebSocket connection đang mở, một job worker đang chạy dở. Chính nhóm này gây ra hầu hết các sự cố deploy, vì đội ngũ nhầm tưởng nó stateless và triển khai bất cẩn.
Vì sao stateless là "mẫu số vàng"
Khi bạn thiết kế một backend stateless đúng chuẩn, gần như mọi vấn đề triển khai biến mất:
- Rolling update mặc định của K8s hoạt động hoàn hảo. Kubernetes lần lượt tạo pod mới, chờ nó khỏe (readiness probe pass), rồi mới giết pod cũ. Vì mọi pod tương đương, người dùng không bao giờ nhận ra.
- Autoscaling dễ dàng. Với
HorizontalPodAutoscaler(HPA), Kubernetes tự thêm/bớt replica dựa trên CPU, memory, hoặc metric tùy chỉnh (số request/giây, độ dài queue). Vì thêm một replica không cần "khởi tạo trạng thái", việc scale gần như tức thời. - Self-healing miễn phí. Pod chết? K8s tạo lại. Node hỏng? Pod được lên lịch sang node khác. Không mất dữ liệu.
Cơ chế graceful shutdown — trái tim của deploy an toàn
Ngay cả với stateless service, có một chi tiết bắt buộc phải làm đúng: graceful shutdown (tắt có trật tự). Khi Kubernetes muốn giết một pod, nó gửi tín hiệu SIGTERM, chờ một khoảng gọi là terminationGracePeriodSeconds (mặc định 30 giây), rồi mới gửi SIGKILL để giết cứng.
Trong khoảng graceful đó, ứng dụng của bạn cần:
- Ngừng nhận request mới (pod bị gỡ khỏi Service endpoint).
- Xử lý xong các request đang dang dở (drain).
- Đóng kết nối database, flush log, rồi thoát.
SIGTERM và cứ chạy đến khi bị SIGKILL, mọi request đang xử lý sẽ bị cắt đứt đột ngột — người dùng nhận lỗi 502/504. Đây là lỗi tôi thấy nhiều nhất ở các đội mới lên Kubernetes.Readiness vs Liveness probe
Hai loại probe này quyết định vòng đời deploy:
- Readiness probe: "Pod này đã sẵn sàng nhận traffic chưa?" Nếu fail, K8s gỡ pod khỏi load balancer nhưng không giết nó. Rolling update dựa vào readiness để biết khi nào an toàn giết pod cũ.
- Liveness probe: "Pod này còn sống không?" Nếu fail liên tục, K8s giết và tạo lại pod.
Tình huống thực tế
Tình huống 1 — Tiki: API danh mục sản phẩm stateless scale trong ngày sale
Bối cảnh: Một đội backend làm việc trên dịch vụ catalog (danh mục sản phẩm) của một sàn thương mại điện tử lớn tại Việt Nam, tương tự Tiki. Dịch vụ này trả về thông tin sản phẩm, giá, tồn kho. Ngày thường chạy 12 replica, nhưng vào giờ flash sale 12h trưa lượng truy cập tăng gấp 8 lần.
Cách làm: Vì dịch vụ được thiết kế stateless đúng chuẩn — mọi dữ liệu nằm ở PostgreSQL và Redis, pod không giữ gì — đội cấu hình HorizontalPodAutoscaler với ngưỡng CPU 60% và một custom metric là số request/giây. Khi sale bắt đầu, HPA tự động scale từ 12 lên 90 pod trong khoảng 90 giây. Sau sale, số pod tự co lại. Trong lúc đó, họ vẫn deploy phiên bản mới bằng rolling update bình thường mà không gián đoạn.
Diễn giải: Chính vì tính stateless, việc thêm 78 pod không cần bất kỳ "khởi tạo trạng thái" nào — pod mới lên là phục vụ ngay sau khi readiness probe pass (khoảng 8 giây). Nếu dịch vụ giữ cache tồn kho trong bộ nhớ pod, mỗi pod mới sẽ phải warm-up cache vài phút, và trong flash sale thì vài phút đó là mất doanh thu.
Bài học: Stateless không phải may mắn, đó là thiết kế. Đội đã cố ý đẩy mọi trạng thái ra ngoài để đổi lấy khả năng scale gần như tức thời — thứ tối quan trọng cho e-commerce Việt Nam nơi flash sale là mô hình kinh doanh cốt lõi.
Tình huống 2 — Grab-style: Worker xử lý job dài không thể rolling update ngây thơ
Bối cảnh: Một đội tại một công ty gọi xe/giao đồ ăn ở Đông Nam Á có một dịch vụ worker đọc job từ queue để tính toán đối soát tài xế cuối ngày. Mỗi job chạy trung bình 4–6 phút, có job tới 20 phút. Ban đầu họ triển khai worker này như một Deployment stateless bình thường với graceful period 30 giây.
Vấn đề: Mỗi lần deploy, SIGTERM đến, worker vẫn đang chạy job 15 phút. Sau 30 giây, SIGKILL giết cứng. Job dở dang bị mất, message quay lại queue và bị xử lý lại từ đầu bởi pod mới — gây tính toán trùng và số liệu đối soát sai lệch.
Cách khắc phục: Đội nhận ra đây là dịch vụ quasi-stateful (giữ trạng thái job trong bộ nhớ). Họ đã:
- Tăng
terminationGracePeriodSecondslên 1200 giây (20 phút) để cho phép job đang chạy hoàn tất. - Trong handler
SIGTERM: ngừng nhận (ack) job mới từ queue, nhưng tiếp tục xử lý job hiện tại đến khi xong rồi mới thoát. - Làm job idempotent (chạy lại nhiều lần cho cùng kết quả) để phòng trường hợp buộc phải giết.
Tình huống 3 — Ứng dụng chat: Sticky session và WebSocket khi deploy
Bối cảnh: Một startup SaaS ở Hà Nội xây dựng nền tảng chăm sóc khách hàng, backend duy trì hàng nghìn kết nối WebSocket mở liên tục (real-time chat). Mỗi kết nối gắn với một pod cụ thể — đây là trạng thái sống trong bộ nhớ pod.
Vấn đề khi deploy: Rolling update giết pod cũ → tất cả WebSocket trên pod đó bị đứt. Client thấy "mất kết nối", phải reconnect. Nếu deploy giữa giờ cao điểm, hàng nghìn người dùng bị văng cùng lúc.
Cách xử lý: Đội áp dụng vài kỹ thuật kết hợp:
- Graceful drain dài: Khi
SIGTERM, pod ngừng nhận kết nối mới nhưng giữ kết nối cũ thêm 10 phút, cho phép client tự nhiên đóng hoặc chuyển dần. - Client reconnect thông minh: Frontend tự động reconnect với backoff, tự khôi phục trạng thái từ server — nên một lần đứt kết nối chỉ gây gián đoạn dưới 1 giây, gần như vô hình.
- Đẩy trạng thái ra ngoài: Danh sách phòng chat, tin nhắn chưa đọc... được lưu ở Redis, không phải trong pod. Nhờ đó pod mới có thể phục vụ ngay client vừa reconnect.
Hướng dẫn từng bước
Đây là quy trình tôi khuyên bạn áp dụng mỗi khi chuẩn bị triển khai một backend service mới:
Bước 1 — Phân loại dịch vụ. Trả lời thẳng: Dịch vụ này có giữ trạng thái gì trong bộ nhớ hoặc trên đĩa không? Nếu không → stateless, dùng Deployment + rolling update + HPA. Nếu có dữ liệu trên đĩa → stateful, dùng StatefulSet. Nếu giữ trạng thái nhẹ trong bộ nhớ (session, WebSocket, job) → quasi-stateful, cần xử lý đặc biệt.
Bước 2 — Với stateless: cấu hình rolling update và HPA. Ví dụ tối giản cho Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
spec:
replicas: 12
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # tạo thêm tối đa 25% pod mới cùng lúc
maxUnavailable: 0 # không cho phép giảm dưới số replica mong muốn
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: api
image: registry.example.com/catalog-api:v2.3.1
readinessProbe:
httpGet: { path: /healthz/ready, port: 8080 }
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz/live, port: 8080 }
periodSeconds: 10
Kèm HPA:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: catalog-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: catalog-api
minReplicas: 12
maxReplicas: 100
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 60 }
maxUnavailable: 0 đảm bảo trong lúc deploy luôn đủ pod phục vụ — quan trọng cho dịch vụ nhạy tải.
Bước 3 — Cài graceful shutdown trong code. Bắt SIGTERM, ngừng nhận request/job mới, drain phần đang chạy, đóng kết nối, rồi thoát. Ví dụ Node.js:
process.on('SIGTERM', async () => {
server.close(); // ngừng nhận HTTP request mới
await drainInFlightRequests(); // chờ request đang chạy xong
await db.close(); // đóng pool kết nối
process.exit(0);
});
Bước 4 — Kiểm tra probe khớp với vòng đời thật. Readiness chỉ pass khi dịch vụ thực sự kết nối được DB/dependency. Đừng để readiness trả 200 ngay khi process khởi động nhưng chưa kết nối được DB.
Bước 5 — Với quasi-stateful: điều chỉnh graceful period và idempotency. Kéo dài terminationGracePeriodSeconds cho hợp với job dài nhất, làm mọi tác vụ idempotent, và đẩy trạng thái ra Redis/DB tối đa có thể.
Bước 6 — Kiểm thử deploy dưới tải. Chạy load test trong lúc rolling update để đảm bảo không có request nào lỗi. Đây là bước hầu hết đội bỏ qua và trả giá trên production.
Lỗi thường gặp & mẹo
Lỗi 1 — Coi mọi backend là stateless. Đây là lỗi gốc rễ. Session in-memory, WebSocket, job worker, cache local... đều là trạng thái. Mẹo: hỏi "nếu tôi giết ngẫu nhiên một pod ngay bây giờ, có gì mất không?" Nếu câu trả lời là "có", nó không stateless.
Lỗi 2 — Không xử lý SIGTERM. Ứng dụng chạy đến khi bị SIGKILL, cắt đứt request giữa chừng. Mẹo: luôn cài handler SIGTERM, và test bằng cách kubectl delete pod rồi quan sát log xem có drain sạch không.
Lỗi 3 — terminationGracePeriodSeconds quá ngắn cho tác vụ dài. Mặc định 30 giây giết cả job đang chạy 10 phút. Mẹo: đặt graceful period ≥ thời gian tác vụ dài nhất, hoặc thiết kế để tách job ra khỏi vòng đời pod (checkpoint, resume).
Lỗi 4 — maxUnavailable mặc định làm giảm năng lực giữa deploy. Với dịch vụ nhạy tải, đặt maxUnavailable: 0 và maxSurge dương để luôn đủ pod.
Lỗi 5 — Readiness probe "dối trá". Probe trả 200 trước khi dịch vụ thực sự sẵn sàng → traffic đổ vào pod chưa kết nối DB → lỗi hàng loạt lúc deploy. Mẹo: readiness phải kiểm tra dependency thật sự.
Lỗi 6 — Dùng local disk trong stateless service. Ghi file tạm lên đĩa pod rồi mong nó còn ở request sau — sai. Pod là ephemeral. Mẹo: mọi file cần tồn tại lâu phải đẩy lên object storage (S3, MinIO).
Mẹo vàng: Khi có thể, hãy ép backend về stateless bằng cách đẩy trạng thái ra ngoài (Redis cho session, DB cho dữ liệu, queue cho job, object storage cho file). Mỗi phần trạng thái bạn gỡ khỏi pod là một lớp phức tạp deploy bạn xóa bỏ vĩnh viễn.
Bài tập thực hành
- Phân loại thực tế. Liệt kê 5 dịch vụ backend trong dự án hiện tại (hoặc một dự án bạn biết). Với mỗi cái, phân loại: stateless / stateful / quasi-stateful. Ghi rõ trạng thái nào nằm ở đâu.
- Cài graceful shutdown. Lấy một API nhỏ (Node/Go/Python), thêm handler
SIGTERMđể drain request. Chạy trong container, gửiSIGTERMbằngkubectl delete podhoặcdocker stop, và xác nhận qua log rằng request đang chạy được hoàn tất trước khi process thoát.
- Rolling update không downtime. Deploy dịch vụ trên trên Kubernetes với
maxUnavailable: 0. Trong lúc chạykubectl rollout restart, dùng một script gửi request liên tục (ví dụheyhoặcwrk) và đo tỉ lệ lỗi. Mục tiêu: 0% lỗi.
- Biến quasi-stateful thành gần-stateless. Cho một dịch vụ giữ session in-memory, hãy đề xuất và (nếu được) triển khai phương án đẩy session sang Redis. So sánh hành vi khi deploy trước và sau thay đổi.
- Thử nghiệm HPA. Cấu hình HPA cho một dịch vụ stateless, dùng load test tạo tải, và quan sát số pod tự tăng/giảm. Ghi lại thời gian từ lúc tải tăng đến khi pod mới sẵn sàng phục vụ.
Tóm tắt
- Kỹ thuật triển khai (rolling, blue-green, canary) chỉ dùng được nếu bản chất backend cho phép — vì vậy phải phân loại dịch vụ trước.
- Ba nhóm chính: stateless (dễ nhất, mục tiêu vàng), stateful (dữ liệu trên đĩa, dùng StatefulSet), và quasi-stateful (trạng thái nhẹ trong bộ nhớ — vùng xám nguy hiểm nhất).
- Stateless service có thể bị giết bất cứ lúc nào, dùng rolling update mặc định của K8s, scale ngang dễ dàng bằng HPA, và tự phục hồi. Đây là lý do triết lý 12-Factor luôn khuyên đẩy trạng thái ra ngoài.
- Graceful shutdown (xử lý
SIGTERM, drain, đóng kết nối) là bắt buộc kể cả với stateless — thiếu nó, mỗi lần deploy là một lần cắt đứt request. - Quasi-stateful service (WebSocket, session in-memory, job dài) cần graceful period dài hơn, idempotency, và đẩy trạng thái ra Redis/DB để ứng xử gần như stateless khi deploy.
- Các sự cố deploy backend hầu hết không đến từ công cụ, mà đến từ phân loại sai bản chất dịch vụ. Hãy luôn hỏi: "Nếu tôi giết pod này ngay bây giờ, có gì mất không?"