Skip to content

HARRY-25 — GĐ15 — Kubernetes & container orchestration

GĐ15 — Kubernetes & container orchestration

Điều kiện vào: đã hoàn thành GĐ14 — biết viết Dockerfile multi-stage, có CI/CD chạy được, đã deploy thật lên PaaS hoặc VPS. Học K8s trước khi biết Docker và trước khi từng deploy thủ công là cách nhanh nhất để thuộc lệnh mà không hiểu gì.

Nói thẳng ngay từ đầu: DA3 và DA4 của bạn không cần Kubernetes. Một docker compose trên VPS hoặc một PaaS phục vụ tốt tới hàng nghìn người dùng. Bạn học K8s ở đây vì hai lý do thực tế: (1) nó xuất hiện trong mô tả công việc và phỏng vấn ở hầu hết công ty tầm trung trở lên; (2) các khái niệm của nó — declarative, reconciliation loop, health probe, resource limit — là cách tư duy đúng về vận hành, kể cả khi bạn không dùng K8s.


1. Vấn đề mà orchestrator giải quyết

Bạn có 12 container chạy trên 4 máy. Câu hỏi phải trả lời hàng ngày:

  • Container chết lúc 3 giờ sáng — ai khởi động lại?
  • Máy số 2 chết — ai chuyển container của nó sang máy khác?
  • Deploy phiên bản mới — làm sao thay dần mà không có downtime?
  • Traffic tăng gấp 5 — ai thêm container, và bớt đi khi hết giờ cao điểm?
  • Container A cần tìm container B — B vừa đổi IP, làm sao A biết?
  • Secret đưa vào container thế nào mà không nằm trong image?

Định nghĩa. Orchestrator là hệ thống trả lời tất cả câu hỏi trên một cách tự động. Kubernetes là orchestrator phổ biến nhất; các lựa chọn khác: Nomad (đơn giản hơn nhiều), ECS/Fargate (AWS, quản lý), Docker Swarm (đơn giản, đang tàn).

Ý tưởng cốt lõi: declarative + reconciliation loop.

Bạn không ra lệnh "khởi động 3 container". Bạn khai báo "trạng thái mong muốn là 3 bản sao đang chạy". Một vòng lặp điều hoà liên tục so trạng thái thực với trạng thái mong muốn và làm những gì cần để thu hẹp khoảng cách.

        ┌──────────────────────────────────┐
        │  Desired state (YAML bạn viết)   │
        └────────────────┬─────────────────┘

                    so sánh liên tục

        ┌────────────────▼─────────────────┐
        │  Actual state (cluster thực tế)  │
        └────────────────┬─────────────────┘

              controller hành động để thu hẹp chênh lệch

Đây là toàn bộ triết lý của K8s. Hiểu một câu này thì mọi thứ còn lại chỉ là chi tiết. Nó cũng giải thích vì sao kubectl delete pod không xoá được pod — controller sẽ tạo lại ngay, vì trạng thái mong muốn vẫn là "3 bản sao".


2. Kiến trúc: control plane và node

Thành phầnVai trò
API serverCửa duy nhất vào cluster. Mọi thứ (kể cả các thành phần khác) nói chuyện qua đây
etcdKho lưu trạng thái. Nguồn sự thật của toàn cluster
SchedulerQuyết định pod nào chạy trên node nào
Controller managerChứa các vòng lặp điều hoà (deployment, replicaset, node...)
kubeletChạy trên mỗi node; nhận lệnh, chạy container, báo cáo sức khoẻ
kube-proxyĐịnh tuyến traffic của Service trên mỗi node

Điều đáng nhớ cho phỏng vấn: K8s không có "master ra lệnh cho worker". Mọi thành phần đều theo dõi API server và tự hành động. Đây là kiến trúc hướng sự kiện, không phải chỉ huy tập trung — và đó là lý do nó chịu lỗi tốt.


3. Các đối tượng cần biết — và chỉ những cái này

K8s có hàng trăm loại tài nguyên. Bạn cần khoảng tám.

Đối tượngLà gìKhi nào dùng
PodĐơn vị nhỏ nhất: 1+ container chia sẻ mạng và volumeGần như không bao giờ tạo trực tiếp
DeploymentQuản lý N bản sao của pod, hỗ trợ rolling updateMặc định cho app không trạng thái
StatefulSetNhư Deployment nhưng pod có danh tính và ổ đĩa cố địnhDB, Kafka — nhưng hãy dùng managed service
ServiceĐịa chỉ mạng ổn định + cân bằng tải tới nhóm podLuôn cần
IngressĐịnh tuyến HTTP từ ngoài vào, TLSCửa vào của cluster
ConfigMapCấu hình không nhạy cảmBiến môi trường, file config
SecretDữ liệu nhạy cảm (chỉ base64, không mã hoá!)Mật khẩu, API key
Job / CronJobChạy tới khi hoàn tất / theo lịchMigration, việc định kỳ

Pod là đơn vị lập lịch, không phải container. Nhiều container trong một pod chia sẻ localhost và volume — dùng cho sidecar (log shipper, proxy service mesh), không dùng để nhét app và DB vào chung.


4. Manifest thực tế cho API Node.js

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels: { app: api }
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }   # không bao giờ tụt dưới 3
  template:
    metadata:
      labels: { app: api }
    spec:
      terminationGracePeriodSeconds: 60      # PHẢI > job/request dài nhất → GĐ9
      containers:
        - name: api
          image: registry.example.com/api:sha-a1b2c3d      # KHÔNG dùng :latest
          ports: [{ containerPort: 3000 }]
          env:
            - name: NODE_ENV
              value: production
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef: { name: api-secrets, key: database-url }
          envFrom:
            - configMapRef: { name: api-config }

          resources:
            requests: { cpu: "100m", memory: "256Mi" }   # dùng để lập lịch
            limits:   { cpu: "500m", memory: "512Mi" }   # trần cứng

          startupProbe:                                   # cho app khởi động chậm
            httpGet: { path: /health/live, port: 3000 }
            failureThreshold: 30
            periodSeconds: 2                              # cho tối đa 60s để boot
          livenessProbe:                                  # còn sống không? fail → RESTART
            httpGet: { path: /health/live, port: 3000 }
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:                                 # sẵn sàng nhận traffic chưa?
            httpGet: { path: /health/ready, port: 3000 }  # fail → RÚT khỏi Service
            periodSeconds: 5
            failureThreshold: 2

          lifecycle:
            preStop:
              exec: { command: ["sleep", "5"] }           # xem mục 6
---
apiVersion: v1
kind: Service
metadata: { name: api }
spec:
  selector: { app: api }
  ports: [{ port: 80, targetPort: 3000 }]

Ba thứ trong file này quan trọng hơn tất cả phần còn lại: probe (mục 5), resources (mục 7), và graceful shutdown (mục 6).


5. Health probe — chỗ sai nhiều nhất

Ba probe, ba mục đích hoàn toàn khác nhau:

ProbeCâu hỏiFail thì sao
startup"Boot xong chưa?"Tạm dừng hai probe kia; quá lâu → kill
liveness"Có bị treo không?"Restart container
readiness"Nhận traffic được chưa?"Rút khỏi Service, không restart

Pitfall #1 — liveness probe kiểm tra database. Đây là lỗi kinh điển và hậu quả rất nặng:

ts
// SAI — trong liveness
app.get('/health/live', async (_, res) => {
  await db.query('SELECT 1')      // DB chậm 3s → probe timeout → K8s giết pod
  res.send('ok')
})

DB chậm hoặc quá tải → mọi pod fail liveness → K8s restart toàn bộ → khi lên lại chúng cùng mở connection → DB càng chết → vòng lặp chết chóc. Bạn vừa biến một sự cố DB thành một sự cố toàn hệ thống.

Đúng:

ts
// liveness: CHỈ kiểm tra process còn phản hồi. Không phụ thuộc bên ngoài.
app.get('/health/live', (_, res) => res.send('ok'))

// readiness: kiểm tra phụ thuộc — pod bị rút khỏi LB nhưng KHÔNG bị giết
app.get('/health/ready', async (_, res) => {
  if (shuttingDown) return res.status(503).send('shutting down')
  try {
    await db.$queryRaw`SELECT 1`
    await redis.ping()
    res.send('ok')
  } catch {
    res.status(503).send('not ready')
  }
})

Nguyên tắc: liveness trả lời "restart có cứu được không?". Nếu câu trả lời là "không, vấn đề ở bên ngoài" — thì nó thuộc readiness.

Pitfall #2 — không có startup probe cho app boot chậm. App mất 40 giây để chạy migration và làm nóng cache; liveness với failureThreshold: 3, periodSeconds: 10 sẽ giết nó ở giây thứ 30, mãi mãi. Pod vào vòng CrashLoopBackOff và bạn tưởng app có bug.


6. Graceful shutdown trong K8s — chuỗi sự kiện thật

Đây là chỗ Node.js gặp K8s và là nguồn của những lỗi 502 khó hiểu lúc deploy.

Điều ít người biết: khi pod bị xoá, hai việc xảy ra SONG SONG, không tuần tự:

    ├─► kubelet gửi SIGTERM cho container
    └─► endpoint controller gỡ pod khỏi Service

Việc gỡ khỏi Service phải lan tới mọi kube-proxy và ingress controller trên mọi node — mất vài trăm ms tới vài giây. Trong khoảng đó, traffic mới vẫn được gửi tới pod đã nhận SIGTERM. Nếu app đóng server ngay khi nhận SIGTERM → client nhận 502/connection refused.

Cách chữa: preStop sleep.

yaml
lifecycle:
  preStop:
    exec: { command: ["sleep", "5"] }    # trễ SIGTERM để việc gỡ endpoint lan xong

preStop chạy trước SIGTERM. Pod vẫn phục vụ bình thường trong 5 giây đó, trong khi nó đã được gỡ khỏi mọi bảng định tuyến. Đây là một mẹo có vẻ ngớ ngẩn nhưng là thực hành chuẩn được khuyến nghị rộng rãi.

Trình tự đầy đủ và code Node tương ứng:

1. Pod bị đánh dấu Terminating   → gỡ khỏi Service (bất đồng bộ, lan dần)
2. preStop hook chạy             → sleep 5s, vẫn phục vụ traffic đang có
3. SIGTERM tới container         → app ngừng nhận kết nối mới, xử nốt việc đang chạy
4. Chờ terminationGracePeriodSeconds
5. SIGKILL                       → cắt phăng, mất dữ liệu dở dang
ts
let shuttingDown = false
process.on('SIGTERM', async () => {
  shuttingDown = true                  // readiness bắt đầu trả 503
  server.close()                       // ngừng nhận kết nối MỚI, giữ kết nối đang có
  await worker?.close()                // chờ job đang chạy xong → GĐ9
  await db.$disconnect()
  process.exit(0)
})

Ba con số phải khớp (nhắc lại từ GĐ9):

preStop + thời gian request/job dài nhất  <  terminationGracePeriodSeconds

Mặc định terminationGracePeriodSeconds30. Nếu worker của bạn có job chạy 2 phút, mọi lần deploy đều cắt job giữa chừng. Đây là bug người ta debug hàng tuần mà không nghĩ tới.


7. Resources: request, limit, và cái bẫy CPU

requests = lượng tài nguyên scheduler dành trước khi chọn node. limits = trần cứng lúc chạy.

Hai giới hạn hành xử rất khác nhau:

VượtHậu quả
Memory limitContainer bị OOMKilled ngay lập tức. Restart
CPU limitBị throttle — không chết, chỉ chậm đi. Âm thầm và khó phát hiện

Pitfall #3 — CPU limit quá chặt trên Node.js. CPU throttling không hiện trong log, không có lỗi, chỉ có p99 latency tăng vọt một cách bí ẩn. Với Node (một luồng chính + thread pool), limits.cpu: "500m" nghĩa là 0,5 core — mọi burst (JSON lớn, GC, crypto) đều bị bóp. Nhiều đội có kinh nghiệm đặt request CPU nhưng bỏ hẳn CPU limit, chỉ giữ memory limit. Kiểm tra bằng metric container_cpu_cfs_throttled_seconds_total.

Pitfall #4 — Node không thấy memory limit của container. V8 đặt heap mặc định theo RAM của máy, không theo cgroup limit. Container limit 512Mi nhưng V8 tưởng mình có 8GB → không GC kịp → OOMKilled. Cách chữa:

yaml
env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=384"     # ~75% của memory limit 512Mi

QoS class — quyết định pod nào bị giết trước khi node hết RAM:

ClassĐiều kiệnBị giết
Guaranteedrequests == limits cho mọi containerCuối cùng
Burstablecó requests, limits khácGiữa
BestEffortkhông đặt gìĐầu tiên

Đừng bao giờ để pod production ở BestEffort.


8. Cấu hình và secret

yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config }
data:
  LOG_LEVEL: "info"
  REDIS_HOST: "redis.default.svc.cluster.local"

Secret của K8s CHỈ là base64, KHÔNG phải mã hoá. Ai đọc được etcd hoặc có quyền get secrets là đọc được tất cả. Đây là hiểu lầm nguy hiểm nhất về K8s.

Bốn cách xử lý đúng:

  1. Bật encryption at rest cho etcd (cấu hình cluster).
  2. External Secrets Operator — đồng bộ từ AWS Secrets Manager / Vault vào cluster.
  3. Sealed Secrets — mã hoá secret để commit an toàn vào git.
  4. SOPS + age/KMS — mã hoá file, giải mã lúc deploy.

Không bao giờ commit secret thô vào git, kể cả trong manifest K8s. RBAC phải chặt: hầu hết service account không cần quyền get secrets.


9. Scaling: HPA và những gì nó không làm được

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300     # chống dao động lên xuống liên tục

Điều kiện tiên quyết mà không ai nói: app phải stateless. Nếu bạn lưu session trong RAM, đếm rate limit trong biến cục bộ, hay giữ WebSocket không có adapter Redis — HPA sẽ phá ứng dụng của bạn, không cứu nó. Xem lại GĐ18 mục 3 trước khi bật HPA.

CPU thường là metric sai cho API Node.js. API I/O-bound có thể quá tải hoàn toàn ở 30% CPU (đang chờ DB). Metric đúng thường là queue depth, request concurrency, hoặc p95 latency — cần custom metrics (KEDA hoặc prometheus-adapter). KEDA đặc biệt hợp với worker: scale theo độ sâu hàng đợi BullMQ, kể cả về 0 khi không có job.

PodDisruptionBudget — bảo vệ bạn khi cluster tự bảo trì (nâng cấp node, drain):

yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: api }
spec:
  minAvailable: 2
  selector: { matchLabels: { app: api } }

Không có PDB, một lần nâng cấp node có thể drain đồng thời và làm rớt toàn bộ pod của bạn cùng lúc.


10. Vận hành: những lệnh và tình huống thật

bash
kubectl get pods -w                       # theo dõi trạng thái thay đổi
kubectl describe pod <name>               # LỆNH ĐẦU TIÊN khi có sự cố — xem Events ở cuối
kubectl logs <pod> -f --tail=100
kubectl logs <pod> --previous             # log của lần chạy TRƯỚC khi crash
kubectl exec -it <pod> -- sh
kubectl port-forward svc/api 3000:80      # debug local không cần expose
kubectl rollout status  deploy/api
kubectl rollout undo    deploy/api        # rollback ngay lập tức
kubectl top pods                          # CPU/RAM thực tế
kubectl get events --sort-by=.lastTimestamp

Bảng chẩn đoán nhanh:

Trạng tháiNguyên nhân thường gặp
ImagePullBackOffSai tên image/tag, thiếu imagePullSecrets
CrashLoopBackOffApp chết lúc boot. logs --previous là nơi có câu trả lời
PendingKhông node nào đủ tài nguyên requests, hoặc không có PV
OOMKilledVượt memory limit → xem lại --max-old-space-size
Terminating mãiCó finalizer, hoặc grace period dài, hoặc app không xử lý SIGTERM
502 khi deployThiếu preStop sleep, hoặc readiness sai (mục 6)

Vận hành thật: GitOps, không phải kubectl apply bằng tay. Repo git là nguồn sự thật; ArgoCD hoặc Flux đồng bộ cluster theo repo. Lợi ích cụ thể: mọi thay đổi có review, có lịch sử, rollback = git revert, và không ai sửa production từ máy cá nhân.

Helm để đóng gói và tham số hoá manifest (values-staging.yaml / values-prod.yaml). Kustomize là lựa chọn nhẹ hơn, không template, chỉ overlay. Bắt đầu bằng Kustomize; chuyển sang Helm khi cần phân phối cho người khác dùng.


11. Bài tập — đưa DA3 lên K8s cục bộ

Không tốn tiền cloud. Dùng kind hoặc k3d trên máy bạn.

Yêu cầu.

  1. Tạo cluster cục bộ (kind create cluster), cài ingress-nginx.
  2. Manifest cho: api (Deployment 3 replica), worker (Deployment riêng), postgres + redis (StatefulSet hoặc chạy ngoài cluster — ghi lý do lựa chọn vào README).
  3. Đủ ba probe với liveness không chạm DB; chứng minh bằng cách tắt Postgres và cho thấy pod chuyển sang NotReadykhông bị restart.
  4. Resources đầy đủ + NODE_OPTIONS=--max-old-space-size; chứng minh OOM được xử lý đúng bằng cách hạ limit và quan sát OOMKilled.
  5. preStop + terminationGracePeriodSeconds khớp với job dài nhất của worker.
  6. ConfigMap + Secret (secret sinh từ SOPS hoặc Sealed Secrets, không commit thô).
  7. Migration chạy bằng Job; job dọn dẹp hàng đêm bằng CronJobso sánh với BullMQ scheduler ở GĐ9 và chọn một, ghi lý do.
  8. HPA theo CPU + PodDisruptionBudget minAvailable: 2.
  9. Bài kiểm tra quan trọng nhất: chạy k6 với tải liên tục, đồng thời kubectl rollout restart deploy/api. Chứng minh 0 request lỗi. Nếu có lỗi 502, sửa preStop/readiness cho tới khi sạch.
  10. Ghi vào README: cái gì đơn giản hơn so với docker-compose, cái gì phức tạp hơn, và bạn sẽ chọn cái nào cho DA3 thật — kèm lý do.

Mục 9 và 10 là sản phẩm giao. Mục 9 chứng minh bạn hiểu vòng đời pod; mục 10 chứng minh bạn có phán đoán chứ không chạy theo công nghệ.


Done khi

  • [ ] Giải thích declarative + reconciliation loop trong một câu; hiểu vì sao xoá pod không "xoá" được
  • [ ] Kể được vai trò của API server, etcd, scheduler, kubelet
  • [ ] Phân biệt Pod / Deployment / Service / Ingress; biết vì sao không tạo Pod trực tiếp
  • [ ] Viết được manifest Deployment + Service đầy đủ cho app Node
  • [ ] Phân biệt ba probe; giải thích vì sao liveness không được kiểm tra DB và hậu quả nếu làm
  • [ ] Biết dùng startup probe cho app boot chậm
  • [ ] Mô tả được trình tự shutdown 5 bước và vì sao cần preStop sleep
  • [ ] Đảm bảo preStop + job dài nhất < terminationGracePeriodSeconds
  • [ ] Phân biệt requests vs limits; biết OOMKilled (memory) vs throttle (CPU)
  • [ ] Đặt --max-old-space-size khớp memory limit; giải thích vì sao cần
  • [ ] Biết Secret của K8s chỉ là base64 và nêu được 2 cách xử lý đúng
  • [ ] Biết HPA đòi hỏi app stateless; biết vì sao CPU thường là metric sai
  • [ ] Có PodDisruptionBudget cho service quan trọng
  • [ ] Chẩn đoán được CrashLoopBackOff, ImagePullBackOff, Pending, OOMKilled
  • [ ] Biết logs --previousdescribe pod là hai lệnh đầu tiên khi sự cố
  • [ ] Hiểu GitOps và vì sao kubectl apply bằng tay là phản mẫu ở production
  • [ ] Đã chạy rollout dưới tải k6 với 0 request lỗi
  • [ ] Trả lời được: dự án của tôi có cần K8s không, vì sao?

Câu hỏi mở / chưa giải quyết

  • Service mesh (Istio, Linkerd) cho mTLS, retry, circuit breaker ở tầng hạ tầng. Chi phí phức tạp rất lớn. Với một hệ dưới ~10 service, thư viện trong app (→ GĐ18 mục resilience) rẻ hơn nhiều. Linkerd nhẹ hơn Istio đáng kể nếu bắt buộc phải có.
  • Database trong K8s? Tài liệu này nghiêng về không — dùng RDS/Cloud SQL/Atlas. Operator (CloudNativePG, Zalando) đã trưởng thành, nhưng vận hành DB có trạng thái trong K8s đòi hỏi kiến thức chuyên sâu về cả hai lĩnh vực.
  • Khi nào rời PaaS sang K8s? Chưa có ngưỡng khách quan. Tín hiệu thực dụng: hoá đơn PaaS vượt lương một kỹ sư vận hành, hoặc bạn cần kiểm soát mạng/tuân thủ mà PaaS không cho. "Vì nó chuyên nghiệp hơn" không phải lý do.
  • Terraform / IaC không nằm trong giai đoạn này. Thứ tự học đề xuất: Terraform cho hạ tầng nền (VPC, cluster, DB) → K8s manifest cho ứng dụng. Hai thứ giải quyết hai tầng khác nhau.

Học bằng cách build. Chứng minh, đừng tin.