Rolling deploy lên 10 pod, 2 pod mới chạy code mới, 8 pod cũ vẫn chạy code cũ — trong 3 phút transition này, cả hai version song song xử lý request của cùng một user. Nếu schema database không backward compatible, pod cũ sẽ throw exception khi đọc column mà code cũ không biết là gì. Nếu session không share state, user đang login trên pod cũ sẽ bị logout khi request tiếp theo hit pod mới.

Zero-downtime deploy không phải chỉ là “dùng rolling update thay vì recreate”. Đó là cả một quá trình chuẩn bị trước deploy, trong deploy, và sau khi version mới ổn định — mỗi bước đều có thứ có thể sai.


Trước deploy: nền móng phải vững

1. DB migration phải backward compatible

Đây là gotcha phổ biến nhất và hậu quả nặng nhất. Rule đơn giản: trong lúc rolling deploy, cả code cũ và code mới phải đọc/ghi database thành công.

Thêm column mới — an toàn miễn là có default value hoặc nullable, vì code cũ sẽ ignore column này.

Rename column — không bao giờ rename trực tiếp trong một deploy. Code cũ dùng tên cũ, code mới dùng tên mới, hai version sẽ không thể nói chuyện cùng database.

-- SAI: rename trực tiếp sẽ break code cũ ngay lập tức
ALTER TABLE users RENAME COLUMN phone TO phone_number;

-- ĐÚNG: quy trình 3 bước qua nhiều deploy cycle
-- Deploy 1: thêm column mới, code mới ghi cả hai column
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);

-- Code thời điểm này: đọc phone, ghi cả phone VÀ phone_number
-- (backfill job chạy song song để sync data cũ)

-- Deploy 2: code mới chỉ đọc/ghi phone_number, code cũ vẫn dùng phone
-- Hai version có thể song song vì phone vẫn còn

-- Deploy 3 (sau khi deploy 2 stable ≥2 tuần): xóa column cũ
ALTER TABLE users DROP COLUMN phone;

Quy trình này tốn thêm 2 deploy cycle nhưng đảm bảo không bao giờ có downtime do schema mismatch. Thêm column, thêm table, thêm index (không blocking) — đều safe. Đổi type column, xóa column, rename — đều cần quy trình multi-step.

2. Feature flag để rollback logic mà không rollback binary

Binary rollback chậm và rủi ro: phải re-deploy version cũ, có thể mất vài phút, trong lúc đó traffic tiếp tục hit version mới bị lỗi. Feature flag cho phép rollback logic trong vài giây bằng cách flip một config value.

# Ví dụ dùng LaunchDarkly, Flagsmith, hoặc config đơn giản trong Redis
def process_payment(payment_data):
    if feature_flags.is_enabled("new_payment_processor", user_id=payment_data["user_id"]):
        # code mới: dùng payment processor V2
        return payment_processor_v2.charge(payment_data)
    else:
        # code cũ: fallback về V1
        return payment_processor_v1.charge(payment_data)

Nếu V2 có bug sau deploy, flip flag về false — tất cả user ngay lập tức dùng V1, không cần rollback binary. Sau khi fix V2, flip lại.

3. Xóa column/bảng chỉ sau 2+ deploy cycle

Điều này nghe thừa sau điểm 1, nhưng mình muốn nhấn mạnh riêng vì hay bị bỏ qua. Nguyên tắc: chỉ xóa thứ gì bạn chắc chắn 100% không còn code nào đang dùng, và “chắc chắn” nghĩa là đã có ít nhất một full deploy cycle chạy ổn định mà không có reference nào đến column/table đó.

Thêm lint rule hoặc CI check tìm kiếm reference đến tên column sắp bị xóa trong codebase trước khi chạy migration DROP.


Trong deploy: Kubernetes phải biết pod nào sẵn sàng thật sự

4. Readiness probe phải phản ánh application ready thật sự

Mặc định nhiều người setup readiness probe return HTTP 200 từ một endpoint đơn giản — endpoint đó có thể return 200 ngay khi process start, trong khi application vẫn đang warm up: đang connect database, load cache, initialize connection pool.

# KHÔNG ĐỦ: chỉ kiểm tra HTTP server đang chạy
readinessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
# Endpoint /ready phải kiểm tra thật sự
from fastapi import FastAPI, HTTPException
import asyncpg
import redis.asyncio as aioredis

app = FastAPI()

@app.get("/ready")
async def readiness_check():
    checks = {}
    overall_healthy = True

    # Kiểm tra database connection
    try:
        async with app.state.db_pool.acquire() as conn:
            await conn.fetchval("SELECT 1")
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {str(e)}"
        overall_healthy = False

    # Kiểm tra Redis connection
    try:
        await app.state.redis.ping()
        checks["redis"] = "ok"
    except Exception as e:
        checks["redis"] = f"error: {str(e)}"
        overall_healthy = False

    # Kiểm tra background workers đã init chưa
    if not app.state.workers_initialized:
        checks["workers"] = "not_ready"
        overall_healthy = False
    else:
        checks["workers"] = "ok"

    if not overall_healthy:
        raise HTTPException(status_code=503, detail=checks)

    return {"status": "ready", "checks": checks}

Kubernetes sẽ không route traffic đến pod nào chưa pass readiness probe — nhưng chỉ khi probe của bạn kiểm tra đúng thứ.

5. Liveness và readiness probe KHÔNG dùng cùng endpoint

Hiểu nôm na: readiness probe kiểm tra “pod có sẵn sàng nhận traffic không?”, còn liveness probe kiểm tra “pod có còn sống và không bị deadlock không?” — hai câu hỏi khác nhau, cần hai câu trả lời khác nhau.

Nếu dùng chung endpoint: khi database tạm thời chậm, readiness probe fail (đúng — pod không nên nhận traffic), nhưng liveness probe cũng fail → Kubernetes restart pod → pod mới start → cũng kết nối database chậm → cũng fail liveness → restart liên tục → CrashLoopBackOff trong lúc database chỉ đang slow query tạm thời.

# ĐÚNG: hai probe, hai endpoint, hai mục đích khác nhau
livenessProbe:
  httpGet:
    path: /healthz # chỉ kiểm tra process còn sống, không kiểm tra external deps
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 15
  failureThreshold: 3 # fail 3 lần liên tiếp mới restart — tránh restart do blip

readinessProbe:
  httpGet:
    path: /ready # kiểm tra DB, Redis, dependencies
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10
  failureThreshold: 3 # fail 3 lần mới remove khỏi load balancer
@app.get("/healthz")
async def liveness_check():
    # Chỉ kiểm tra process không bị deadlock
    # Không kiểm tra external dependencies
    return {"status": "alive"}

6. minReadySeconds: đủ thời gian để probe ổn định

Kubernetes mặc định là 0 — pod pass readiness probe một lần là ngay lập tức được đưa vào load balancer và deploy tiếp sang pod tiếp theo. Nhưng application có thể pass probe lần đầu rồi fail lần tiếp theo (VD: connection pool chưa warm up hết, slow startup JVM).

spec:
  minReadySeconds: 30 # chờ 30 giây pod ổn định trước khi move sang pod tiếp
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1 # cho phép tối đa 1 pod extra trong lúc rolling
      maxUnavailable: 0 # không bao giờ giảm số pod available — đây là key để zero-downtime

maxUnavailable: 0 kết hợp với maxSurge: 1 nghĩa là Kubernetes sẽ tạo pod mới trước khi terminate pod cũ. Tổng số pod có thể là 11 (10 cũ + 1 mới) trong lúc deploy, nhưng không bao giờ ít hơn 10.

7. PodDisruptionBudget: đảm bảo không bao giờ tất cả pod down cùng lúc

PDB là lớp bảo vệ cho cả voluntary disruptions: node drain khi upgrade cluster, eviction khi node hết resource. Không có PDB, Kubernetes có thể drain tất cả node cùng lúc và application của bạn đi xuống hoàn toàn.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
spec:
  minAvailable: "60%" # luôn phải có ít nhất 60% pods chạy
  selector:
    matchLabels:
      app: api-service

Hoặc dùng maxUnavailable thay vì minAvailable:

spec:
  maxUnavailable: 2 # tối đa 2 pods có thể down cùng lúc (cho cluster 10 pods = 20%)

Sau khi version mới start: xử lý graceful shutdown đúng cách

8. Graceful shutdown: nhận SIGTERM, dừng nhận request mới, drain request cũ

Khi Kubernetes muốn terminate một pod, nó gửi SIGTERM và chờ terminationGracePeriodSeconds giây. Nếu process không tự exit trong thời gian này, Kubernetes gửi SIGKILL — tất cả request đang xử lý bị cắt đứt ngay lập tức, không có cleanup.

import signal
import asyncio
from contextlib import asynccontextmanager

# Flag để track graceful shutdown state
shutdown_event = asyncio.Event()
active_requests = 0

async def graceful_shutdown(sig, loop):
    """Nhận SIGTERM: stop accept new, drain existing, exit"""
    print(f"Nhận signal {sig.name}, bắt đầu graceful shutdown...")

    # Bước 1: báo với health check rằng pod không còn ready nhận traffic mới
    # /ready sẽ return 503, Kubernetes sẽ remove pod khỏi load balancer
    shutdown_event.set()

    # Bước 2: chờ active requests hoàn thành (tối đa 30 giây)
    deadline = asyncio.get_event_loop().time() + 30
    while active_requests > 0:
        remaining = deadline - asyncio.get_event_loop().time()
        if remaining <= 0:
            print(f"Timeout graceful shutdown: còn {active_requests} requests đang xử lý")
            break
        await asyncio.sleep(0.5)

    print("Graceful shutdown hoàn thành, đang exit...")
    loop.stop()

@app.get("/ready")
async def readiness_check():
    # Fail readiness ngay khi nhận shutdown signal
    if shutdown_event.is_set():
        raise HTTPException(status_code=503, detail="shutting_down")
    # ... các checks khác

9. terminationGracePeriodSeconds phải đủ dài

Mặc định Kubernetes là 30 giây. Nếu p99 request duration của bạn là 25 giây (VD: long-running report generation), 30 giây không đủ buffer.

spec:
  terminationGracePeriodSeconds: 90 # >= p99 request duration + 30s buffer
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            # sleep nhỏ để đảm bảo load balancer deregister xong
            # trước khi container bắt đầu refuse connections
            command: ["/bin/sh", "-c", "sleep 5"]

preStop hook quan trọng vì có một race condition nhỏ: Kubernetes gửi SIGTERM đến container và đồng thời cập nhật Endpoints object để remove pod khỏi Service — nhưng kube-proxy trên các node khác cần vài giây để sync. Trong thời gian đó, traffic mới vẫn có thể route đến pod đang shutdown. sleep 5 trong preStop tạo một khoảng trễ nhỏ giúp kube-proxy sync xong trước khi container bắt đầu refuse connections.

10. Connection draining tại load balancer

Nếu dùng external load balancer (ALB, NLB trên AWS), cần config connection draining (hay còn gọi là deregistration delay). ALB mặc định là 300 giây — nghĩa là khi instance deregister, ALB chờ tối đa 300 giây cho existing connections hoàn thành trước khi force close.

Với Kubernetes và in-cluster load balancing, việc này được xử lý qua preStop hook và readiness probe như đã mô tả. Nhưng nếu có ingress controller như nginx-ingress, cần config upstream-keepalive-timeoutproxy-connect-timeout phù hợp để nginx không close connection đến pod đang drain quá sớm.


State management: đừng để state mắc kẹt trong một pod

11. Session state: stateless hoặc shared store

In-memory session là anti-pattern cho horizontal scaling. Khi pod chạy code mới start, nó không biết gì về sessions đang lưu trong pod cũ — user phải login lại.

# KHÔNG DÙNG CHO PRODUCTION: in-memory session
from flask import session
app.secret_key = "..."
# session được lưu trong cookie phía client (ok) hoặc server memory (không ok)

# DÙNG: JWT stateless
import jwt
from datetime import datetime, timedelta

def create_access_token(user_id: int) -> str:
    payload = {
        "sub": str(user_id),
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(hours=1),
    }
    return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")

# HOẶC: server-side session với Redis shared store
from redis import Redis
from flask_session import Session

app.config["SESSION_TYPE"] = "redis"
app.config["SESSION_REDIS"] = Redis(host="redis-cluster", port=6379)
# Session được lưu trong Redis — tất cả pods đọc cùng một store

JWT là stateless và không cần external store, nhưng không thể invalidate trước khi expire (cần thêm blacklist nếu muốn logout). Redis session có thể invalidate bất kỳ lúc nào nhưng cần Redis highly available.

12. Background jobs phải idempotent và không assume exclusive access

Trong 3 phút rolling deploy, có thể có 2 pod cùng chạy job scheduler. Nếu job không idempotent (chạy nhiều lần cho cùng input cho ra kết quả khác nhau), user có thể nhận email 2 lần, payment bị charge 2 lần.

# Không an toàn: assume exclusive access
def send_daily_report():
    users = db.query("SELECT id FROM users WHERE last_report_sent < NOW() - INTERVAL '24 hours'")
    for user in users:
        # Hai instances chạy song song sẽ gửi email 2 lần cho cùng user
        email_service.send_daily_report(user.id)
        db.execute("UPDATE users SET last_report_sent = NOW() WHERE id = ?", user.id)

# An toàn: idempotent với distributed lock và idempotency key
import uuid
from datetime import date

def send_daily_report():
    today = date.today().isoformat()

    # Chỉ một instance có thể chạy job này tại một thời điểm
    with redis_lock(f"daily_report_job:{today}", ttl=3600):
        # Dùng idempotency key để mark email đã gửi TRƯỚC khi gửi
        # (nếu crash sau gửi nhưng trước update DB, lần sau vẫn gửi — chấp nhận được)
        users = db.query("""
            SELECT id FROM users
            WHERE last_report_date < ? AND is_active = true
        """, today)

        for user in users:
            idempotency_key = f"daily_report:{user.id}:{today}"
            if not redis.exists(f"sent:{idempotency_key}"):
                email_service.send_daily_report(user.id, idempotency_key=idempotency_key)
                redis.setex(f"sent:{idempotency_key}", 86400 * 2, "1")
                db.execute("UPDATE users SET last_report_date = ? WHERE id = ?", today, user.id)

Deployment manifest đầy đủ với tất cả best practices

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
spec:
  replicas: 10
  minReadySeconds: 30
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0 # zero-downtime: không bao giờ giảm số pod
  selector:
    matchLabels:
      app: api-service
  template:
    metadata:
      labels:
        app: api-service
    spec:
      terminationGracePeriodSeconds: 90 # đủ cho p99 request + buffer
      containers:
        - name: api
          image: api-service:v2.1.3
          ports:
            - containerPort: 8080
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"] # cho kube-proxy sync
          livenessProbe:
            httpGet:
              path: /healthz # chỉ kiểm tra process còn sống
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /ready # kiểm tra DB, Redis, external deps
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
            failureThreshold: 3
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
spec:
  minAvailable: "60%" # luôn có ít nhất 6/10 pods running
  selector:
    matchLabels:
      app: api-service

12 điểm trên không phải danh sách để đọc một lần rồi quên — đó là checklist bạn nên chạy qua trước mỗi deploy lớn. Phần lớn vấn đề zero-downtime không đến từ Kubernetes config (phần đó tương đối chuẩn), mà đến từ code chưa sẵn sàng: migration không backward compatible, session không shared, job không idempotent. Infrastructure chuẩn không cứu được application code chưa chuẩn.