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 composetrê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);
preStopdạngsleepnative 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+HTTPRoutethuộ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.
Đâ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ần | Vai trò |
|---|---|
| API server | Cử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 |
| etcd | Kho lưu trạng thái. Nguồn sự thật của toàn cluster |
| Scheduler | Quyết định pod nào chạy trên node nào |
| Controller manager | Chứa các vòng lặp điều hoà (deployment, replicaset, node...) |
| kubelet | Chạ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ượng | Là gì | Khi nào dùng |
|---|---|---|
| Pod | Đơn vị nhỏ nhất: 1+ container chia sẻ mạng và volume | Gần như không bao giờ tạo trực tiếp |
| Deployment | Quản lý N bản sao của pod, hỗ trợ rolling update | Mặc định cho app không trạng thái |
| StatefulSet | Như Deployment nhưng pod có danh tính và ổ đĩa cố định | DB, 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 pod | Luôn cần |
| Gateway + HTTPRoute (Gateway API) | Định tuyến HTTP từ ngoài vào, TLS | Cử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 |
| ConfigMap | Cấu hình không nhạy cảm | Biến môi trường, file config |
| Secret | Dữ liệu nhạy cảm (chỉ base64, không mã hoá!) | Mật khẩu, API key |
| Job / CronJob | Chạy tới khi hoàn tất / theo lịch | Migration, 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#
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:
- Profile Restricted của Pod Security Standards đòi:
allowPrivilegeEscalation: false, chạy non-root (runAsNonRoot: true,runAsUserkhác 0),seccompProfilelàRuntimeDefaulthoặcLocalhost, và drop capability (Pod Security Standards, đọc 2026-10-05).readOnlyRootFilesystemkhô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ộtemptyDircho thư mục ghi tạm như/tmp. runAsUserphải là số. Imagenodekhai báoUSER node(tên, không phải số): khirunAsNonRoot: 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). ĐặtrunAsUser: 1000(usernodethường là uid 1000; kiểm bằngdocker run --rm <image> id, chưa chạy) hoặc dùngUSER 1000trong Dockerfile.- Ép ở cấp namespace bằng nhãn Pod Security Admission:
pod-security.kubernetes.io/enforce: restricted(kèmwarnvà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):
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:
- 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
kindmặ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ồikubectl exec ... -- wget. - Namespace chọn theo tên qua nhãn
kubernetes.io/metadata.namedo control plane gắn sẵn (cùng trang trên). Tên namespace của gateway (gateway-systemở đây) và nhãnk8s-app: kube-dnscủa DNS phụ thuộc controller và bản cài cluster của bạn: kiểm lại bằngkubectl get ns --show-labelsvà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-denyhoạt động; trang NetworkPolicy không nói riêng về DNS. Database ngoài cluster (RDS) cần ruleipBlockthay chopodSelector.
Đã 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).
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:
| Probe | Câu hỏi | Fail 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:
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:
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ự:
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.
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 đủ:
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):
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ượt | Hậu quả |
|---|---|
| Memory limit | Container bị OOMKilled ngay lập tức. Restart |
| CPU limit | Bị 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 container | heap_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=2048trong limit 512Mi, cấp phát liên tụcBuffer→ 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:
Đừ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ện | Bị giết |
|---|---|---|
Guaranteed | requests == limits cho mọi container | Cuối cùng |
Burstable | có requests, limits khác | Giữa |
BestEffort | không đặt gì | Đầu tiên |
Đừng bao giờ để pod production ở BestEffort.
8. Cấu hình và secret#
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:
- Bật encryption at rest cho etcd (cấu hình cluster).
- External Secrets Operator — đồng bộ từ AWS Secrets Manager / Vault vào cluster.
- Sealed Secrets — mã hoá secret để commit an toàn vào git.
- 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#
Đ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):
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:
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#
Bảng chẩn đoán nhanh:
| Trạng thái | Nguyên nhân thường gặp |
|---|---|
ImagePullBackOff | Sai tên image/tag, thiếu imagePullSecrets |
CrashLoopBackOff | App chết lúc boot. logs --previous là nơi có câu trả lời |
Pending | Khô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 memory | Khô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ãi | Có finalizer, hoặc grace period dài, hoặc app không xử lý SIGTERM |
502 khi deploy | Thiế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.
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.
-
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ìnhextraPortMappingsđể có cổng vào từ máy bạn). Cài CRD Gateway API Standard v1.6 theostandard-install.yamlcủa repokubernetes-sigs/gateway-apirồi cài một controller (ví dụ Envoy Gateway, theo hướng dẫn của nó). Sau đó tạoGateway+HTTPRouteở mục 4b, đổigatewayClassNamethà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ớilocalhost:8080khô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àHTTPRoutebỏhostnames(nếu giữhostnames, k6 phải gửi headerHost: api.example.comvà listener phải khai cùng hostname). Code tham chiếu, chưa chạy:textReadyĐể
localhost:8080vào được Gateway,kindcầnextraPortMappings(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ó. -
Manifest cho:
api(Deployment 3 replica, kèmGateway+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ớiAPP_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. -
Đủ 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
NotReadymà không bị restart.Đáp án
Tắt Postgres rồi xem pod.
bashReadyMong đợi: cột READY của các pod api chuyển
1/1→0/1sau khoảngperiodSeconds × 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ớiready: false, nên slice không rỗng; kiểmkubectl get endpointslices -l kubernetes.io/service-name=api -o jsonpath='{.items[*].endpoints[*].conditions.ready}'phải rafalse false false. Bật lại Postgres thì READY trở về1/1không cần restart. -
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átOOMKilled.Đáp án
Phần khác với manifest mục 4 (đã qua kiểm schema):
textReadyTạo pod thử với
limits.memory: 128Michạy script cấp phátBufferliên tục (không dùng--max-old-space-sizelớn).bashReadyMong đợ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 memorythì đó là V8 chạm trần heap, không phải OOMKilled. -
preStop+terminationGracePeriodSecondskhớ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,
preStop5 s, timer ép thoát trong app 10 s (hằng10_000của mẫu GĐ09 mục 18) →5 + 8 < 30và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 đặtsetTimeout(() => 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ảng140_000(lớn hơn job 120 s, nhỏ hơn grace 150, đúng quy tắc: timer ép thoát < grace − preStop).Deployment
workervớiterminationGracePeriodSeconds: 150nằm trong khối Khung và mã dùng chung. -
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 -
Migration chạy bằng
Job; job dọn dẹp hàng đêm bằngCronJob— 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: Forbidchặn chồng lịch, lịch sử Job xem được bằngkubectl. BullMQupsertJobScheduler(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
JobvàCronJobnằm trong khối Khung và mã dùng chung. -
HPA theo CPU + PodDisruptionBudget
minAvailable: 2.Đáp án
HPA và PDB: giữ nguyên mục 9. Lưu ý
minReplicas: 3vớiminAvailable: 2: drain được từng node một. -
Bài kiểm tra quan trọng nhất: chạy
k6với tải liên tục, đồng thờikubectl rollout restart deploy/api. Chứng minh 0 request lỗi. Nếu có lỗi 502, sửapreStop/readiness cho tới khi sạch.Đáp án
Vào cluster qua cổng của Gateway (
localhost:8080nhờextraPortMappingsvà listener HTTP ở đáp án yêu cầu 1), không quakubectl port-forward:typescriptReadybashReadyMong đợi: k6 báo
http_req_failed ... 0.00%và exit 0;rollout statuskết thúcsuccessfully rolled out. Pod cũ ở trạng tháiTerminatingkhoả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ặcconnection refusedở k6 thì sai ở một trong ba chỗ: thiếupreStop, 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
:latesthoặc pull chậm: pod mớiContainerCreatinglâu, trông như rollout lỗi. Dùng tag cố định vàkind load docker-imageđể nạp image. gatewayClassNamegiữ nguyên giá trị placeholder: Gateway ở trạng tháiProgrammed: 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
sleeptrong 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.
- Đo bằng
-
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ơ đồ
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:
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 podkhô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 đổireplicas. 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ó
selectorkhớptemplate.labels,imagecó tag cố định,resources, ba probe,preStop,terminationGracePeriodSeconds, Secret quasecretKeyRef. Chạykubectl apply --dry-run=server -f file.yaml(cần cụm) hoặc kiểm schema tĩnh bằngkubeconform/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 1thì 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 (CrashLoopBackOffdù app đúng).startupProbevớifailureThreshold: 30, periodSeconds: 2cho 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
preStopsleepĐáp án
Terminating (đồng hồ grace chạy, gỡ khỏi EndpointSlice song song và lan dần) →
preStopsleep (vẫn phục vụ) → SIGTERM → app drain rồi thoát → SIGKILL nếu hết grace.preStopcầ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ỗ;limitslà 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ằngcontainer_cpu_cfs_throttled_seconds_total. Xem mục 7. -
Đặt
--max-old-space-sizecó 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.reasonlà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 secretshoặ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: 2vớiselectorkhớp nhãn pod. Tự kiểm:kubectl get pdb apicóALLOWED DISRUPTIONS≥ 1 khi đủ 3 pod;kubectl drainsẽ 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, đọclogs --previous.ImagePullBackOff: sai tên/tag hoặc thiếuimagePullSecrets.Pending: không node đủrequestshoặc thiếu PV (describe pod, mục Events).OOMKilled: RSS vượt limit. Xem bảng ở mục 10. -
Biết
logs --previousvàdescribe podlà hai lệnh đầu tiên khi sự cốĐáp án
describe podcho Events (lập lịch, pull image, probe fail, OOM);logs --previouslà 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 applybằ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 applytay 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ốtkubectl rollout restart, exit 0. Đo qua cổng Gateway, không quaport-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.