GĐ18 — Kubernetes & container orchestration

Điều kiện vào: đã hoàn thành GĐ15 — 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.

Kiểm chứng ngày 2026-10-05: Kubernetes 1.37 là bản hiện hành (1.34 hết hỗ trợ 2026-10-27); preStop dạng sleep native stable từ 1.34; ingress-nginx đã retire (hết bảo trì 2026-03, repo archived) nên cửa vào dùng Gateway API v1.6 (Gateway + HTTPRoute thuộc kênh Standard); Node 24 tự nhận giới hạn memory của cgroup (đã đo trong container, xem mục 7).


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.

textReady
        ┌──────────────────────────────────┐        │  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
Gateway + HTTPRoute (Gateway API)Định tuyến HTTP từ ngoài vào, TLSCửa vào của cluster. Ingress API vẫn GA, không bị xoá nhưng không phát triển thêm; ingress-nginx đã retire
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#

textReady
apiVersion: apps/v1kind: Deploymentmetadata:  name: apispec:  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Đ10      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:              sleep: { seconds: 5 }                       # native, cần K8s >= 1.34; xem mục 6---apiVersion: v1kind: Servicemetadata: { 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).

Pod chạy với quyền tối thiểu: securityContext, RBAC, NetworkPolicy#

Manifest ở trên chạy được nhưng chưa khoá gì: allowPrivilegeEscalation mặc định là true (Security context, đọc 2026-10-05) và mọi pod mặc định được mount token gọi API của cluster. Thêm các trường sau vào spec.template.spec:

textReady
serviceAccountName: api               # ServiceAccount riêng, không dùng "default"automountServiceAccountToken: false   # API Node không gọi Kubernetes APIsecurityContext:  runAsNonRoot: true  runAsUser: 1000                     # SỐ: xem giải thích bên dưới  seccompProfile: { type: RuntimeDefault }containers:  - name: api    securityContext:      allowPrivilegeEscalation: false      readOnlyRootFilesystem: true      capabilities: { drop: ["ALL"] }    volumeMounts:      - { name: tmp, mountPath: /tmp }volumes:  - { name: tmp, emptyDir: {} }
  • Profile Restricted của Pod Security Standards đòi: allowPrivilegeEscalation: false, chạy non-root (runAsNonRoot: true, runAsUser khác 0), seccompProfile là RuntimeDefault hoặc Localhost, và drop capability (Pod Security Standards, đọc 2026-10-05). readOnlyRootFilesystem không nằm trong các control đó (trang này không có nó); đây là lớp hardening thêm, và vì root filesystem chỉ đọc nên app cần một emptyDir cho thư mục ghi tạm như /tmp.
  • runAsUser phải là số. Image node khai báo USER node (tên, không phải số): khi runAsNonRoot: true, kubelet không kiểm được tên đó có phải root không và từ chối khởi động pod (Security context). Đặt runAsUser: 1000 (user node thường là uid 1000; kiểm bằng docker run --rm <image> id, chưa chạy) hoặc dùng USER 1000 trong Dockerfile.
  • Ép ở cấp namespace bằng nhãn Pod Security Admission: pod-security.kubernetes.io/enforce: restricted (kèm warn và audit để thử trước khi chặn thật) (hướng dẫn gắn nhãn, đọc 2026-10-05).

Khi app thật sự cần đọc API của cluster, cấp đúng một quyền hẹp bằng Role và RoleBinding gắn vào ServiceAccount (lúc đó đổi lại automountServiceAccountToken: true):

textReady
apiVersion: v1kind: ServiceAccountmetadata: { name: api, namespace: prod }automountServiceAccountToken: false---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: { name: api-config-reader, namespace: prod }rules:  - apiGroups: [""]    resources: ["configmaps"]    resourceNames: ["api-config"]      # chỉ đúng ConfigMap này    verbs: ["get", "watch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: { name: api-config-reader, namespace: prod }subjects:  - { kind: ServiceAccount, name: api, namespace: prod }roleRef:  apiGroup: rbac.authorization.k8s.io  kind: Role  name: api-config-reader

Kiểm bằng kubectl auth can-i get configmaps/api-config --as=system:serviceaccount:prod:api -n prod (chưa chạy, cần cluster). Tắt tự mount token qua automountServiceAccountToken: false theo hướng dẫn ServiceAccount.

NetworkPolicy mặc định cho phép mọi pod nói chuyện với nhau. Chặn hết rồi mở đúng đường cần dùng:

textReady
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: { name: default-deny, namespace: prod }spec:  podSelector: {}                      # mọi pod trong namespace  policyTypes: [Ingress, Egress]---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: { name: api-allow, namespace: prod }spec:  podSelector: { matchLabels: { app: api } }  policyTypes: [Ingress, Egress]  ingress:    - from:        - namespaceSelector:            matchLabels: { kubernetes.io/metadata.name: gateway-system }      ports: [{ port: 3000 }]  egress:    - to:                              # DNS: nếu thiếu, pod không phân giải được tên        - namespaceSelector:            matchLabels: { kubernetes.io/metadata.name: kube-system }          podSelector:            matchLabels: { k8s-app: kube-dns }      ports:        - { protocol: UDP, port: 53 }        - { protocol: TCP, port: 53 }    - to:        - podSelector: { matchLabels: { app: postgres } }      ports: [{ port: 5432 }]
  • Cần CNI có thực thi NetworkPolicy: tạo NetworkPolicy khi plugin mạng không hỗ trợ thì không có tác dụng gì (Network Policies, đọc 2026-10-05). Cluster kind mặc định có thực thi hay không: chưa xác minh; kiểm bằng cách thử chặn một kết nối rồi kubectl exec ... -- wget.
  • Namespace chọn theo tên qua nhãn kubernetes.io/metadata.name do control plane gắn sẵn (cùng trang trên). Tên namespace của gateway (gateway-system ở đây) và nhãn k8s-app: kube-dns của DNS phụ thuộc controller và bản cài cluster của bạn: kiểm lại bằng kubectl get ns --show-labels và kubectl -n kube-system get pods --show-labels.
  • Vì sao phải mở DNS: egress bị chặn hết thì cả truy vấn DNS tới kube-dns cũng bị chặn. Đây là suy luận từ cách default-deny hoạt động; trang NetworkPolicy không nói riêng về DNS. Database ngoài cluster (RDS) cần rule ipBlock thay cho podSelector.

Đã chạy kubernetes-validate --strict (schema Kubernetes 1.36) cho cả bộ manifest này ghép thành một Deployment đầy đủ cùng ServiceAccount, NetworkPolicy, Role và RoleBinding: không lỗi; thử đổi runAsUser: 1000 thành chuỗi thì validator báo sai kiểu, nên phép kiểm có tác dụng. Chưa chạy trên cluster, nên hành vi thật (app chịu được readOnlyRootFilesystem, NetworkPolicy được thực thi) chưa kiểm.

4b. Cửa vào cluster: Gateway API, không phải ingress-nginx#

ingress-nginx đã retire: thông báo 2025-11-11, hết bảo trì từ 2026-03 và repo đã archived, nên không còn bản vá bảo mật. Ingress API chưa bị xoá khỏi Kubernetes (vẫn GA) nhưng không được phát triển thêm; Kubernetes khuyến nghị chuyển sang Gateway API. Bạn cần hai thứ: bộ CRD của Gateway API (kênh Standard, v1.6 có GatewayClass, Gateway, HTTPRoute) và một controller hiện thực nó (Envoy Gateway là một ví dụ; danh sách đầy đủ ở gateway-api.sigs.k8s.io/implementations).

textReady
apiVersion: gateway.networking.k8s.io/v1kind: Gatewaymetadata: { name: public }spec:  gatewayClassName: envoy            # PLACEHOLDER: tên GatewayClass do controller bạn cài tạo ra  listeners:    - name: https      protocol: HTTPS      port: 443      hostname: api.example.com      tls:        mode: Terminate        certificateRefs:          - name: api-tls            # Secret kubernetes.io/tls tạo sẵn (hoặc do cert-manager cấp)      allowedRoutes:        namespaces: { from: Same }---apiVersion: gateway.networking.k8s.io/v1kind: HTTPRoutemetadata: { name: api }spec:  parentRefs:    - name: public  hostnames: ["api.example.com"]  rules:    - matches:        - path: { type: PathPrefix, value: / }      backendRefs:        - name: api                  # Service ở mục 4          port: 80

Gateway thuộc về người vận hành cluster (cổng, TLS), HTTPRoute thuộc về team app (đường dẫn nào vào Service nào): hai vai trò tách riêng, đó là cải tiến chính so với Ingress một-file-cho-tất-cả.

Chứng chỉ TLS. cert-manager cấp được cert cho Gateway qua annotation cert-manager.io/cluster-issuer, nhưng cần bật hỗ trợ Gateway API (Helm: config.gatewayAPI.enabled=true), cài CRD của Gateway API trước khi cert-manager khởi động (hoặc restart nó), và với ACME HTTP-01 thì Gateway phải có listener HTTP cổng 80 (nếu không, dùng DNS-01). Phần cert-manager này mới đối chiếu tài liệu cert-manager, chưa chạy thử.

Mức đã kiểm của hai manifest trên: kubectl apply --dry-run=server trên cluster k3s 1.37 đã cài CRD Gateway API v1.6.2 (Standard) chấp nhận cả hai; sai tên trường (hostname thay cho hostnames) bị từ chối. Chưa cài controller nên chưa kiểm traffic thật đi qua.


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:

typescriptReady
// SAI — trong livenessapp.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:

typescriptReady
// 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ếtapp.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.

Nuance: readiness có nên kiểm DB không? Mẫu trên có, nhưng đó là một đánh đổi chứ không phải luật. Khi DB dùng chung chết, mọi pod cùng fail readiness, Service hết endpoint và client nhận 503 từ gateway thay vì lỗi từ app; với người dùng thì gần như cùng một kết quả, còn pod không bị restart. Giá trị thật của việc kiểm DB là rút một pod riêng lẻ mất kết nối (pool hỏng, lỗi DNS cục bộ) trong khi các pod khác vẫn khoẻ. Rủi ro là check nặng hoặc timeout dài gây flapping, và khi DB chỉ chậm tạm thời, cả đàn bị rút cùng lúc nên hệ thống đi từ chậm sang chết hẳn. Cách cân bằng: check có timeout ngắn (nhỏ hơn timeoutSeconds của probe), failureThreshold lớn hơn 1, và với dependency chỉ phục vụ một tính năng phụ thì đừng đưa vào readiness mà trả lỗi riêng cho tính năng đó. Phần nền ở GĐ15 mục 12.

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á, việc gỡ khỏi Service và việc tắt container chạy SONG SONG, không tuần tự:

textReady
Pod bị xoá → Terminating   (đồng hồ terminationGracePeriodSeconds bắt đầu chạy)    │    ├─► control plane gỡ pod khỏi EndpointSlice    │     └─► lan tới kube-proxy / controller cổng vào    │         (vài trăm ms đến vài giây)    │    └─► kubelet chạy preStop (sleep) ─► gửi SIGTERM ─► app drain          ─► (hết grace period) SIGKILL

Trong lúc việc gỡ endpoint còn đang lan, traffic mới vẫn có thể được gửi tới pod. Nếu không có preStop, SIGTERM tới ngay và app đóng server ngay → client nhận 502/connection refused. (Theo tài liệu Kubernetes: kubelet chạy preStop rồi mới gửi TERM; cùng lúc đó control plane đánh giá việc gỡ pod khỏi EndpointSlice.)

Cách chữa: preStop sleep.

textReady
lifecycle:  preStop:    sleep: { seconds: 5 }    # kubelet tự chờ; trễ SIGTERM để việc gỡ endpoint lan xong

Pod vẫn phục vụ bình thường trong 5 giây đó, trong khi nó đã được gỡ khỏi các 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.

Dạng sleep native (stable từ 1.34) do kubelet thực thi, không cần binary sleep trong image. Dạng cũ exec: { command: ["sleep", "5"] } chạy trong container: với image không có shell/sleep (distroless, scratch) hook thất bại. Đã thử trên k3s 1.37 với image registry.k8s.io/pause: dạng exec ghi sự kiện FailedPreStopHook (không tạo được độ trễ nào), dạng sleep native chạy bình thường.

Trình tự đầy đủ:

textReady
1. Pod bị đánh dấu Terminating  → đồng hồ grace bắt đầu; gỡ khỏi                                  EndpointSlice (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ạy4. App thoát                    → pod xong5. SIGKILL khi hết terminationGracePeriodSeconds                                → cắt phăng, mất dữ liệu dở dang

Phần code Node ở bước 3 (đợi server.close() thật sự xong, đóng worker và DB sau khi HTTP đã drain, timer ép thoát, cờ shuttingDown) đã có một mẫu duy nhất ở GĐ09 mục 18; không chép lại và không bọc thêm handler SIGTERM thứ hai ở đây. Handler của GĐ09 mục 18 bật shuttingDown rồi gọi server.close() trong cùng một tick, nên probe tới sau SIGTERM không còn thấy 503: cổng đã đóng, probe bị từ chối kết nối (đã đo trên Node 24.21: curl trả mã 000). Kết quả vẫn là fail readiness nên vô hại; cờ chỉ trả được 503 nếu app chủ động chờ trước khi đóng server (phương án thay cho preStop, GĐ09 mục 18 quy tắc 6). GĐ18 chỉ thêm preStop ở manifest. Worker dùng worker.close() theo GĐ10. Đã chạy mẫu GĐ09 mục 18 trên Node 24.21 với preStop giả (chờ 1 s rồi mới gửi SIGTERM trong lúc một request /slow 1,5 s đang chạy): request vẫn hoàn tất (done, curl exit 0), queue và DB đóng sau khi HTTP drain, process thoát exit 0. Chưa chạy end-to-end trên cụm thật.

Chỉ chờ lan endpoint ở một nơi. Dùng preStop sleep (khuyến nghị, không đụng code app) hoặc để app tự chờ vài giây sau SIGTERM, không làm cả hai: hai khoảng chờ cộng dồn vào cùng một grace period, deploy chậm gấp đôi mà không an toàn hơn.

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

textReady
preStop + request/job dài nhất  <  terminationGracePeriodSecondstimer ép thoát trong app        <  terminationGracePeriodSeconds - preStop

terminationGracePeriodSeconds tính gộp cả preStop lẫn thời gian container dừng. Đã đo trên k3s 1.37: pod có grace 12 s và preStop sleep 5 s nhận SIGTERM sau khoảng 5 s rồi bị xoá sau khoảng 12,6 s kể từ lúc xoá (không phải 17 s). Mặc định terminationGracePeriodSeconds là 30. 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 — memory limit và heap của V8 là hai con số khác nhau. Node hiện hành có nhận giới hạn cgroup của container (đã đo Node 24.21 trên cgroup v2; chưa đo bản cũ hơn): chạy node:24-alpine và đọc v8.getHeapStatistics().heap_size_limit:

Memory limit của containerheap_size_limit mặc định
không đặt (máy ảo 7,6 GiB)~2240 MB
1 GiB~560 MB
512 MiB~259 MB
256 MiB~259 MB

(process.constrainedMemory() trả đúng giá trị limit.) Vậy "V8 tưởng mình có 8GB" là sai. Hai rủi ro thật còn lại:

  • Heap mặc định với limit nhỏ có thể lớn hơn cả limit (256 MiB ở trên: heap ~259 MB). Khi đó heap có thể phình vượt limit trước khi V8 chạm trần của chính nó, và kernel giết container trước.
  • Một container còn có bộ nhớ ngoài heap (Buffer, native module, thread stack, code). RSS = heap + phần ngoài heap, và RSS mới là thứ bị so với limit. Đã thử: --max-old-space-size=2048 trong limit 512Mi, cấp phát liên tục Buffer → container bị OOMKilled (exit 137) dù heap không chạm trần.

Vì thế nên đặt trần heap có chủ đích, chừa chỗ cho phần ngoài heap, và đo RSS thực tế thay vì đoán:

textReady
env:  - name: NODE_OPTIONS    value: "--max-old-space-size=384"     # ~75% của limit 512Mi; đo thực: heap_size_limit ~387 MB

Đừng nâng --max-old-space-size lên gần hoặc vượt limit để "hết OOM": nó chỉ đẩy lỗi từ V8 sang kernel (mục 10).

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#

textReady
apiVersion: v1kind: ConfigMapmetadata: { 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#

textReady
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: { 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Đ21 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):

textReady
apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: { 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.

Rải pod ra nhiều node và zone — PDB chỉ bảo vệ khi bảo trì có chủ ý; còn khi một node hay một zone chết, bạn cần các pod đã nằm ở chỗ khác từ trước. topologySpreadConstraints bảo scheduler giữ số pod giữa các miền chênh nhau không quá maxSkew:

textReady
# trong spec.template.spec của DeploymenttopologySpreadConstraints:  - maxSkew: 1    topologyKey: topology.kubernetes.io/zone    whenUnsatisfiable: ScheduleAnyway    labelSelector: { matchLabels: { app: api } }

whenUnsatisfiable: DoNotSchedule là ràng buộc cứng (pod không xếp được thì Pending); ScheduleAnyway chỉ ưu tiên rải đều và vẫn xếp pod khi không đạt (Pod Topology Spread Constraints, đọc 2026-10-05). Cluster kind một node không có nhiều zone, nên ở lab dùng ScheduleAnyway (hoặc kubernetes.io/hostname làm khoá); dùng DoNotSchedule trên cluster không đủ miền sẽ làm pod kẹt Pending. Manifest này đã qua kubernetes-validate --strict (xem mục 4); chưa kiểm hành vi xếp pod trên cluster thật.


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

bashReady
kubectl get pods -w                       # theo dõi trạng thái thay đổikubectl describe pod <name>               # LỆNH ĐẦU TIÊN khi có sự cố — xem Events ở cuốikubectl logs <pod> -f --tail=100kubectl logs <pod> --previous             # log của lần chạy TRƯỚC khi crashkubectl exec -it <pod> -- shkubectl port-forward svc/api 3000:80      # debug local không cần exposekubectl rollout status  deploy/apikubectl rollout undo    deploy/api        # rollback ngay lập tứckubectl 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
OOMKilled (exit 137)Kernel giết vì RSS vượt memory limit. Xem RSS so với limit (kubectl top pod, process.memoryUsage(): rss, heapUsed, external, arrayBuffers); đừng tăng --max-old-space-size, nó có thể làm tệ hơn. Tăng limit hoặc tìm chỗ rò bộ nhớ
Error, log có JavaScript heap out of memoryKhông phải OOMKilled. V8 chạm trần heap và tự dừng (đo thực: exit 139 trên image này; đừng dựa vào số exit code). Dùng --heapsnapshot-near-heap-limit=N để lấy snapshot trước khi chết, rồi tìm leak; chỉ nâng trần heap khi dữ liệu thật sự cần
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.

Kustomize: một base, mỗi môi trường một overlay. Cấu trúc thư mục: base/ chứa deployment.yaml, service.yaml và kustomization.yaml liệt kê chúng; overlays/staging/ chỉ chứa phần khác biệt.

textReady
# overlays/staging/kustomization.yamlapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: stagingresources:  - ../../baseimages:  - name: registry.example.com/api    newTag: sha-9f8e7d6               # CI đổi dòng này theo commitpatches:  - target: { kind: Deployment, name: api }    patch: |-      - op: replace        path: /spec/replicas        value: 1

Xem kết quả mà không cần cluster bằng kubectl kustomize overlays/staging, áp bằng kubectl apply -k overlays/staging (Kustomize trong tài liệu Kubernetes, đọc 2026-10-05). Đã chạy kubectl kustomize (Kustomize 5.8.1) cho đúng cấu trúc này: đầu ra có namespace: staging trên cả Deployment và Service, replicas: 1 thay cho 3, image đổi sang tag sha-9f8e7d6. Chưa chạy kubectl apply -k vì không có cluster.


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 CRD Gateway API (standard-install.yaml, v1.6) và một controller hiện thực Gateway API (mục 4b). Không dùng ingress-nginx: nó đã retire.

    Đáp án

    kind create cluster (cấu hình extraPortMappings để có cổng vào từ máy bạn). Cài CRD Gateway API Standard v1.6 theo standard-install.yaml của repo kubernetes-sigs/gateway-api rồi cài một controller (ví dụ Envoy Gateway, theo hướng dẫn của nó). Sau đó tạo Gateway + HTTPRoute ở mục 4b, đổi gatewayClassName thành tên GatewayClass do controller tạo.

    Bản ở mục 4b chỉ có listener HTTPS 443 với hostname: api.example.com, nên một request HTTP tới localhost:8080 không khớp listener nào. Cho lab, dùng bản rút gọn: listener HTTP cổng 80 không có hostname, và HTTPRoute bỏ hostnames (nếu giữ hostnames, k6 phải gửi header Host: api.example.com và listener phải khai cùng hostname). Code tham chiếu, chưa chạy:

    textReady
    apiVersion: gateway.networking.k8s.io/v1kind: Gatewaymetadata: { name: public }spec:  gatewayClassName: envoy            # PLACEHOLDER: tên do controller của bạn tạo  listeners:    - name: http      protocol: HTTP      port: 80      allowedRoutes:        namespaces: { from: Same }---apiVersion: gateway.networking.k8s.io/v1kind: HTTPRoutemetadata: { name: api }spec:  parentRefs:    - name: public  rules:    - backendRefs:        - name: api          port: 80

    Để localhost:8080 vào được Gateway, kind cần extraPortMappings (containerPort: <NodePort> → hostPort: 8080), và Service mà controller tạo cho Gateway phải là NodePort với đúng cổng đó. Cách ép controller dùng NodePort cố định tuỳ từng controller (chưa xác minh với Envoy Gateway); xem tài liệu của nó.

  2. Manifest cho: api (Deployment 3 replica, kèm Gateway + HTTPRoute), worker (Deployment riêng), postgres + redis (StatefulSet hoặc chạy ngoài cluster — ghi lý do lựa chọn vào README).

    Đáp án

    Postgres và Redis: lab dùng StatefulSet cho gọn; ghi vào README lý do "production sẽ dùng managed service" (mục 3 và "Câu hỏi mở"). Redis cho cache và Redis cho queue là hai instance riêng (quy ước GĐ09/GĐ10).

    api: Deployment ở mục 4 với APP_ENV, phần khác biệt nằm ở yêu cầu 4. worker, Job, CronJob: xem YAML trong khối Khung và mã dùng chung.

  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 NotReady mà không bị restart.

    Đáp án

    Tắt Postgres rồi xem pod.

    bashReady
    kubectl scale statefulset postgres --replicas=0kubectl get pods -l app=api -w

    Mong đợi: cột READY của các pod api chuyển 1/1 → 0/1 sau khoảng periodSeconds × failureThreshold = 10 s; cột RESTARTS không tăng (liveness không chạm DB). Pod chưa ready vẫn nằm trong EndpointSlice nhưng với ready: false, nên slice không rỗng; kiểm kubectl get endpointslices -l kubernetes.io/service-name=api -o jsonpath='{.items[*].endpoints[*].conditions.ready}' phải ra false false false. Bật lại Postgres thì READY trở về 1/1 không cần 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.

    Đáp án

    Phần khác với manifest mục 4 (đã qua kiểm schema):

    textReady
    # api: chỉ giữ memory limit, đặt requests = limits cho memory, bỏ CPU limit (mục 7)env:  - { name: APP_ENV, value: staging }  - { name: NODE_OPTIONS, value: "--max-old-space-size=384" }   # ~75% của 512Miresources:  requests: { cpu: "100m", memory: "512Mi" }  limits: { memory: "512Mi" }

    Tạo pod thử với limits.memory: 128Mi chạy script cấp phát Buffer liên tục (không dùng --max-old-space-size lớn).

    bashReady
    kubectl get pod oom-test -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'

    Mong đợi: reason: OOMKilled, exitCode: 137 (phù hợp phép đo ở mục 7). Nếu thay vào đó log có JavaScript heap out of memory thì đó là V8 chạm trần heap, không phải OOMKilled.

  5. preStop + terminationGracePeriodSeconds khớp với job dài nhất của worker.

    Đáp án

    Tính ba con số (mục 6): API có request dài nhất 8 s, preStop 5 s, timer ép thoát trong app 10 s (hằng 10_000 của mẫu GĐ09 mục 18) → 5 + 8 < 30 và 10 < 30 - 5, nên grace mặc định 30 là đủ; manifest mục 4 đặt 60, dư hơn nhưng cũng thoả. Nếu bạn đổi hằng trong code GĐ09, tính lại hai bất đẳng thức. Worker có job dài nhất 2 phút, không có preStop → terminationGracePeriodSeconds: 150 (job 120 s + dư 30 s cho đóng queue/DB). Grace chỉ có tác dụng nếu timer ép thoát của worker cũng đổi theo: mẫu GĐ10 đặt setTimeout(() => process.exit(1), 20_000), nên job 2 phút vẫn bị cắt ở giây 20. Đổi hằng đó thành khoảng 140_000 (lớn hơn job 120 s, nhỏ hơn grace 150, đúng quy tắc: timer ép thoát < grace − preStop).

    Deployment worker với terminationGracePeriodSeconds: 150 nằm trong khối Khung và mã dùng chung.

  6. ConfigMap + Secret (secret sinh từ SOPS hoặc Sealed Secrets, không commit thô).

    Đáp án

    Secret (mục 6 của đề): sinh khoá age, mã hoá file Secret bằng SOPS, chỉ commit file đã mã hoá. Cú pháp theo tài liệu SOPS, chưa chạy:

    bashReady
    age-keygen -o age.key                                   # age.key KHÔNG commitsops --encrypt --age <public-key> --encrypted-regex '^(data|stringData)$' \  api-secrets.yaml > api-secrets.enc.yamlexport SOPS_AGE_KEY_FILE=age.key                        # SOPS tìm khoá giải mã ở đâysops --decrypt api-secrets.enc.yaml | kubectl apply -f -
  7. Migration chạy bằng Job; job dọn dẹp hàng đêm bằng CronJob — so sánh với BullMQ scheduler ở GĐ10 và chọn một, ghi lý do.

    Đáp án

    Migration chạy bằng Job trước khi rollout api (ArgoCD PreSync hoặc bước CI riêng). Cleanup hằng đêm: chọn CronJob cho lab vì nó không cần worker đang chạy, concurrencyPolicy: Forbid chặn chồng lịch, lịch sử Job xem được bằng kubectl. BullMQ upsertJobScheduler (GĐ10) hợp hơn khi việc định kỳ cần retry/backoff/quan sát cùng hàng đợi sẵn có. Chọn một và ghi lý do vào README, đừng chạy cả hai.

    Manifest Job và CronJob nằm trong khối Khung và mã dùng chung.

  8. HPA theo CPU + PodDisruptionBudget minAvailable: 2.

    Đáp án

    HPA và PDB: giữ nguyên mục 9. Lưu ý minReplicas: 3 với minAvailable: 2: drain được từng node một.

  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.

    Đáp án

    Vào cluster qua cổng của Gateway (localhost:8080 nhờ extraPortMappings và listener HTTP ở đáp án yêu cầu 1), không qua kubectl port-forward:

    typescriptReady
    // load.jsimport http from 'k6/http'import { check } from 'k6'export const options = {  vus: 20,  duration: '90s',  thresholds: { http_req_failed: ['rate==0'] },   // 1 lỗi cũng làm k6 thoát khác 0}export default function () {  const res = http.get('http://localhost:8080/todos')   // thay bằng route thật của DA3  check(res, { 'status 200': (r) => r.status === 200 })}
    bashReady
    k6 run load.js &            # terminal 1sleep 20kubectl rollout restart deploy/api && kubectl rollout status deploy/api   # terminal 2wait

    Mong đợi: k6 báo http_req_failed ... 0.00% và exit 0; rollout status kết thúc successfully rolled out. Pod cũ ở trạng thái Terminating khoảng 5 s (preStop) cộng thời gian drain trước khi biến mất. Nếu thấy lỗi 502 hoặc connection refused ở k6 thì sai ở một trong ba chỗ: thiếu preStop, readiness không chuyển fail, hoặc timer ép thoát trong app quá nhỏ.

    Lỗi hay gặp

    • Đo bằng kubectl port-forward svc/api: nó gắn với một pod cụ thể, pod đó bị xoá thì phép đo đứt dù hệ thống đúng. Dùng cổng của Gateway hoặc NodePort.
    • Chạy k6 trong lúc image còn :latest hoặc pull chậm: pod mới ContainerCreating lâu, trông như rollout lỗi. Dùng tag cố định và kind load docker-image để nạp image.
    • gatewayClassName giữ nguyên giá trị placeholder: Gateway ở trạng thái Programmed: False, không có traffic. kubectl get gatewayclass để lấy đúng tên.
    • Sai thứ tự: rollout api trước khi Job migrate xong. Chạy migration trước, theo GĐ15.
    • Thêm sleep trong app và preStop: hai khoảng chờ cộng dồn (mục 6).
    • Đặt grace 30 cho worker có job 2 phút: job bị cắt mỗi lần deploy.
  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.

    Đáp án

    Đây là phán đoán của bạn, đáp án mẫu chỉ để so: "So với docker-compose, K8s thêm self-healing, rollout không downtime có kiểm chứng và HPA; đổi lại thêm CRD Gateway, controller, secret tooling, và một lớp lỗi mới (probe, grace, quota). Với DA3 một VPS hoặc PaaS, tôi chọn compose/PaaS; chỉ chuyển khi cần nhiều node hoặc rollout tự động dưới tải." Đáp án khác vẫn đúng nếu có lý do dựa trên quy mô thật.

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ệ.

Khung và mã dùng chung

Tự làm trước, rồi mới mở. Mức bằng chứng: manifest bên dưới đã qua kiểm schema tĩnh bằng kubernetes-validate 1.36 (--strict; Deployment, Service, ConfigMap, HPA, PDB, Job, CronJob), và một manifest cố ý sai (slep thay cho sleep) bị từ chối. Tham chiếu, chưa chạy trên cụm thật: mọi lệnh kubectl, kind, k6 và các kết quả ở "Kết quả mong đợi" là suy ra từ code và từ các phép đo đã ghi ở mục 6 và 7, không phải quan sát mới. Gateway API: manifest ở mục 4b đã có ghi chú mức kiểm riêng.

Sơ đồ

textReady
  k6 (máy bạn) ──► localhost:8080 ──► Gateway (controller) ──► Service api                    (kind extraPortMappings)                      │                                                    ┌─────────────┼─────────┐                                                    ▼             ▼         ▼                                                  api-0         api-1     api-2                                                    │ (readiness: DB + Redis)                          ┌─────────────────────────┴───────┐                          ▼                                 ▼                   StatefulSet postgres              StatefulSet redis   worker (Deployment riêng) ──► redis      Job migrate ──► postgres   CronJob nightly-cleanup   ──► postgres

Manifest worker, Job và CronJob (đã qua kiểm schema trước khi thêm ttlSecondsAfterFinished; hai dòng này chưa kiểm lại), dùng ở các yêu cầu 2, 5 và 7:

textReady
# worker + migration + cleanupapiVersion: apps/v1kind: Deploymentmetadata: { name: worker }spec:  replicas: 2  selector:    matchLabels: { app: worker }  template:    metadata:      labels: { app: worker }    spec:      terminationGracePeriodSeconds: 150      # > job dài nhất 120 s; timer ép thoát GĐ10 đổi thành ~140_000 ms      containers:        - name: worker          image: registry.example.com/da3-api:sha-a1b2c3d          command: ["node", "dist/worker.js"]          envFrom:            - configMapRef: { name: api-config }          resources:            requests: { cpu: "100m", memory: "256Mi" }            limits: { memory: "256Mi" }---apiVersion: batch/v1kind: Jobmetadata: { name: migrate-sha-a1b2c3d }       # tên theo image: mỗi bản phát hành một Job mớispec:  backoffLimit: 2  ttlSecondsAfterFinished: 86400                # tự dọn Job đã xong sau 1 ngày  template:    spec:      restartPolicy: Never      containers:        - name: migrate          image: registry.example.com/da3-api:sha-a1b2c3d          command: ["node", "dist/migrate.js"]---apiVersion: batch/v1kind: CronJobmetadata: { name: nightly-cleanup }spec:  schedule: "0 2 * * *"  timeZone: "Etc/UTC"  concurrencyPolicy: Forbid  jobTemplate:    spec:      ttlSecondsAfterFinished: 86400      template:        spec:          restartPolicy: OnFailure          containers:            - name: cleanup              image: registry.example.com/da3-api:sha-a1b2c3d              command: ["node", "dist/cleanup.js"]

Done khi#

  • Giải thích declarative + reconciliation loop trong một câu; hiểu vì sao xoá pod không "xoá" được

    Đáp án

    Tham chiếu từ nội dung bài và tài liệu kubernetes.io, chưa chạy lại trên cụm. Bạn khai báo trạng thái mong muốn; controller liên tục so với trạng thái thực và hành động để thu hẹp chênh lệch. kubectl delete pod không "xoá" được vì ReplicaSet vẫn muốn N bản sao nên tạo pod mới. Muốn bớt pod phải đổi replicas. Sai thường gặp: coi YAML là chuỗi lệnh chạy một lần. Xem GĐ18 mục 1.

  • Kể được vai trò của API server, etcd, scheduler, kubelet

    Đáp án

    API server là cửa duy nhất, mọi thành phần khác nói chuyện qua nó. etcd lưu trạng thái (nguồn sự thật). Scheduler chọn node cho pod chưa có node. kubelet chạy container trên node và báo sức khoẻ. Không có "master ra lệnh": các thành phần theo dõi API server. Xem mục 2.

  • Phân biệt Pod / Deployment / Service / Gateway + HTTPRoute (và biết ingress-nginx đã retire); biết vì sao không tạo Pod trực tiếp

    Đáp án

    Pod là đơn vị lập lịch, chết thì không ai thay. Deployment giữ N bản sao và rolling update nên là mặc định. Service cho địa chỉ ổn định + cân bằng tải. Gateway (cổng, TLS, người vận hành sở hữu) + HTTPRoute (đường dẫn vào Service, team app sở hữu). ingress-nginx đã retire (Ingress API vẫn GA nhưng không phát triển thêm). Xem mục 3 và 4b.

  • Viết được manifest Deployment + Service đầy đủ cho app Node

    Đáp án

    Tự kiểm: có selector khớp template.labels, image có tag cố định, resources, ba probe, preStop, terminationGracePeriodSeconds, Secret qua secretKeyRef. Chạy kubectl apply --dry-run=server -f file.yaml (cần cụm) hoặc kiểm schema tĩnh bằng kubeconform/kubernetes-validate; kết quả phải là không lỗi. Xem mục 4.

  • 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

    Đáp án

    startup: boot xong chưa (tạm hoãn hai probe kia). liveness: restart có cứu được không. readiness: nhận traffic được chưa (fail thì rút khỏi Service, không restart). Liveness mà SELECT 1 thì DB chậm làm mọi pod fail, bị restart đồng loạt, cùng mở lại connection, DB càng chết: sự cố DB thành sự cố toàn hệ thống. Xem mục 5.

  • Biết dùng startup probe cho app boot chậm

    Đáp án

    App boot 40 s mà liveness periodSeconds 10 × failureThreshold 3 = 30 s thì bị giết mãi (CrashLoopBackOff dù app đúng). startupProbe với failureThreshold: 30, periodSeconds: 2 cho tối đa 60 s boot, rồi liveness mới bắt đầu. Xem mục 5.

  • Mô tả được trình tự shutdown 5 bước và vì sao cần preStop sleep

    Đáp án

    Terminating (đồng hồ grace chạy, gỡ khỏi EndpointSlice song song và lan dần) → preStop sleep (vẫn phục vụ) → SIGTERM → app drain rồi thoát → SIGKILL nếu hết grace. preStop cần vì traffic mới có thể vẫn tới pod trong lúc việc gỡ endpoint lan; không có nó, app đóng ngay và client nhận 502. Chỉ chờ ở một nơi (preStop hoặc app), không cả hai. Code Node ở GĐ09 mục 18. Xem mục 6.

  • Đảm bảo preStop + job dài nhất < terminationGracePeriodSeconds

    Đáp án

    Ví dụ preStop 5 s, request dài nhất 8 s thì grace 30 (mặc định) là đủ; worker có job 2 phút thì grace tối thiểu khoảng 150, và timer ép thoát của worker (GĐ10) phải đổi từ 20 s lên khoảng 140 s. Grace tính gộp preStop và thời gian dừng, và timer ép thoát trong app phải nhỏ hơn grace - preStop. Tự kiểm: kubectl get deploy api -o jsonpath='{.spec.template.spec.terminationGracePeriodSeconds}'. Xem mục 6.

  • Phân biệt requests vs limits; biết OOMKilled (memory) vs throttle (CPU)

    Đáp án

    requests để scheduler dành chỗ; limits là trần lúc chạy. Vượt memory limit: OOMKilled ngay (exit 137, restart). Vượt CPU limit: bị throttle, chậm âm thầm, p99 tăng; đo bằng container_cpu_cfs_throttled_seconds_total. Xem mục 7.

  • Đặt --max-old-space-size có chủ đích theo memory limit, chừa chỗ cho bộ nhớ ngoài heap; phân biệt được OOMKilled với heap OOM của V8

    Đáp án

    Node 24 có nhận cgroup limit (đã đo ở mục 7) nhưng RSS = heap + phần ngoài heap (Buffer, native, thread stack) và RSS mới bị so với limit; đặt trần heap khoảng 75% limit (384 với 512Mi). OOMKilled: kernel giết do RSS vượt limit, lastState.terminated.reason là OOMKilled. Heap OOM của V8: log có JavaScript heap out of memory, không phải OOMKilled. Sai thường gặp: nâng trần heap lên gần limit để "hết OOM". Xem mục 7 và 10.

  • Biết Secret của K8s chỉ là base64 và nêu được 2 cách xử lý đúng

    Đáp án

    Ai có get secrets hoặc đọc được etcd là đọc được. Hai trong bốn cách đúng: encryption at rest cho etcd; External Secrets Operator (đồng bộ từ Secrets Manager/Vault); Sealed Secrets; SOPS. Không commit secret thô, RBAC chặt. Xem mục 8.

  • Biết HPA đòi hỏi app stateless; biết vì sao CPU thường là metric sai

    Đáp án

    Session trong RAM, rate limit cục bộ, WebSocket không adapter sẽ hỏng khi thêm pod (GĐ21 mục 3). API I/O-bound có thể quá tải ở 30% CPU vì chờ DB; metric hợp hơn: độ sâu queue, concurrency, p95 (KEDA, prometheus-adapter). Xem mục 9.

  • Có PodDisruptionBudget cho service quan trọng

    Đáp án

    minAvailable: 2 với selector khớp nhãn pod. Tự kiểm: kubectl get pdb api có ALLOWED DISRUPTIONS ≥ 1 khi đủ 3 pod; kubectl drain sẽ chờ thay vì hạ cùng lúc. Xem mục 9.

  • Chẩn đoán được CrashLoopBackOff, ImagePullBackOff, Pending, OOMKilled

    Đáp án

    CrashLoopBackOff: app chết lúc boot, đọc logs --previous. ImagePullBackOff: sai tên/tag hoặc thiếu imagePullSecrets. Pending: không node đủ requests hoặc thiếu PV (describe pod, mục Events). OOMKilled: RSS vượt limit. Xem bảng ở mục 10.

  • Biết logs --previous và describe pod là hai lệnh đầu tiên khi sự cố

    Đáp án

    describe pod cho Events (lập lịch, pull image, probe fail, OOM); logs --previous là log của lần chạy trước khi crash, vì lần hiện tại mới khởi động thường còn trống. Xem mục 10.

  • Hiểu GitOps và vì sao kubectl apply bằng tay là phản mẫu ở production

    Đáp án

    Repo git là nguồn sự thật, ArgoCD/Flux đồng bộ cluster: mọi thay đổi có review và lịch sử, rollback là git revert. kubectl apply tay làm cluster lệch khỏi repo (drift), không ai biết ai đổi gì. Xem mục 10.

  • Đã chạy rollout dưới tải k6 với 0 request lỗi

    Đáp án

    Bằng chứng phải có: k6 với http_req_failed: ['rate==0'] chạy suốt kubectl rollout restart, exit 0. Đo qua cổng Gateway, không qua port-forward. Lời giải ở bài tập mục 11.

  • Trả lời được: dự án của tôi có cần K8s không, vì sao?

    Đáp án

    Đáp án mẫu: hầu hết DA3/DA4 không cần; một VPS với compose hoặc PaaS phục vụ tốt. Cân nhắc K8s khi cần nhiều node, rollout tự động có kiểm chứng, hoặc đội đã có nền tảng. "Vì nó chuyên nghiệp hơn" không phải lý do. Xem phần đầu bài và "Câu hỏi mở".


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Đ21 mục resilience) rẻ hơn nhiều. Linkerd nhẹ hơn Istio đáng kể nếu bắt buộc phải có.

    Hướng trả lời hiện tại

    Hướng trả lời hiện tại (chưa chốt): chưa dùng khi dưới khoảng 10 service; mTLS có thể xin từ nền tảng (cloud/ambient mode) thay vì tự vận hành mesh đầy đủ. Cần đo lại chi phí vận hành thực tế của đội trước khi quyết.

  • 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.

    Hướng trả lời hiện tại

    Hướng trả lời hiện tại (chưa chốt): managed service cho production; operator (CloudNativePG...) chỉ khi đội có người vận hành DB và cần kiểm soát hơn. Lab dùng StatefulSet vì mục tiêu học, không phải khuyến nghị production.

  • Khi nào rời PaaS sang K8s? Không có con số chung, nhưng có một quy tắc kiểm được, cùng quy tắc với GĐ15 mục Bước tiếp theo: chỉ cân nhắc khi có ít nhất hai trong ba điều (nhiều hơn 4 đến 5 service triển khai độc lập; nhiều môi trường hay vùng cần cấu hình giống hệt; công ty đã dùng K8s và bạn cần làm việc trong đó). Tín hiệu thực dụng thêm: 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.

    Hướng trả lời hiện tại

    Hướng trả lời hiện tại (chưa chốt): áp quy tắc "ít nhất hai trong ba điều" của GĐ15, thêm tín hiệu chi phí và yêu cầu kiểm soát mạng/tuân thủ, kèm một bài thử nhỏ (chạy một service phụ trên K8s) trước khi chuyển hết.

  • 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. Với người học theo lộ trình này, GĐ15 mục Bước tiếp theo xếp Terraform sau lab AWS ở GĐ16 và trước bài lab K8s này: dùng nó để mô tả lại VPC/EC2/RDS đã dựng tay trước, rồi mới lên cluster local.

    Hướng trả lời hiện tại

    Hướng trả lời hiện tại (chưa chốt): Terraform cho tầng nền, manifest cho ứng dụng, hai tầng có state riêng; chưa có bài lab trong giai đoạn này.