Chuyển tới nội dung chính

15.10 — 9. Kubernetes Intro (Awareness Level)

Tóm tắt

Kubernetes giải quyết bài toán chạy container trên nhiều máy: tự đặt pod lên node còn chỗ, tự thay pod chết, tự cân bằng tải, tự rolling update. Hai khái niệm quyết định ứng dụng .NET chạy ổn hay không. requests và limits: requests là thứ scheduler dùng để chọn node, limits là trần cứng — vượt bộ nhớ là OOM kill ngay lập tức, còn vượt CPU chỉ bị điều tiết (chậm lại). Ba probe: startupProbe cho ứng dụng khởi động chậm, livenessProbe quyết định restart, readinessProbe quyết định nhận traffic — và liveness kiểm tra database là công thức của restart loop. Nhưng câu hỏi quan trọng nhất là: bạn có thật sự cần nó không? Với một API trên một máy chủ, Kubernetes là chi phí lớn không có lợi ích.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Đọc hiểu một manifest Kubernetes cơ bản.
  • Đặt requests và limits đúng cho ứng dụng .NET.
  • Cấu hình ba probe đúng vai trò.
  • Đảm bảo pod shutdown gọn gàng.
  • Quyết định khi nào Kubernetes là thừa.

Nội dung bài học​

15.10.1 — Khái niệm cốt lõi​

Khái niệmLà gì
PodĐơn vị nhỏ nhất — một hoặc vài container dùng chung mạng và volume
DeploymentQuản lý số bản sao của pod, lo rolling update và rollback
ServiceTên DNS ổn định cộng cân bằng tải nội bộ cho một nhóm pod
IngressĐịnh tuyến HTTP từ ngoài vào Service, thường kèm TLS
ConfigMapCấu hình không nhạy cảm
SecretCấu hình nhạy cảm (mã hoá base64, không phải mã hoá thật)
NamespaceCô lập logic giữa các môi trường hoặc đội

Pod là thứ tạm thời. Nó bị thay bất cứ lúc nào: khi deploy, khi node cần bảo trì, khi scheduler cân bằng lại, khi bạn sửa một biến môi trường. Mọi thiết kế ứng dụng phải giả định điều đó — đúng như bài 15.2.

Secret của Kubernetes chỉ là base64, không mã hoá. Ai đọc được etcd hoặc có quyền get secret đều đọc được nội dung. Production thật cần mã hoá at-rest cộng External Secrets Operator kéo từ Key Vault.

15.10.2 — Manifest cho .NET​

apiVersion: apps/v1
kind: Deployment
metadata:
name: crm-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # KHÔNG giảm số pod khoẻ trong lúc deploy
selector:
matchLabels: { app: crm-api }
template:
metadata:
labels: { app: crm-api }
spec:
terminationGracePeriodSeconds: 60
containers:
- name: crm-api
image: ghcr.io/org/crm-api:sha-abc123
ports:
- containerPort: 8080

resources:
requests: # scheduler dung de chon node
memory: "256Mi"
cpu: "100m"
limits: # TRẦN cứng
memory: "512Mi"
cpu: "1000m"

env:
- name: ASPNETCORE_ENVIRONMENT
value: Production
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef: { name: crm-secrets, key: db-connection }

startupProbe:
httpGet: { path: /health/live, port: 8080 }
failureThreshold: 30
periodSeconds: 5 # cho toi 150 giay de khoi dong

livenessProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
failureThreshold: 3

readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
failureThreshold: 2

15.10.3 — requests và limits​

Đây là phần ảnh hưởng trực tiếp nhất tới ứng dụng .NET.

requests là thứ scheduler dùng: nó tìm node còn ít nhất chừng đó tài nguyên chưa được đặt trước. Đặt quá cao thì pod không được lên; quá thấp thì pod bị đặt lên node đã chật và cạnh tranh tài nguyên.

limits là trần cứng, và hai loại tài nguyên hành xử khác nhau:

Vượt limits
Bộ nhớOOM kill ngay — pod chết, exit code 137
CPUĐiều tiết — tiến trình chỉ chạy chậm lại

Khác biệt này rất quan trọng. Đặt cpu limit thấp không làm chết pod, chỉ làm nó chậm — nhưng chậm theo cách khó chẩn đoán: request timeout, GC mất nhiều thời gian, và metric CPU trông như "chưa đầy 100%".

Với .NET, thêm hai điều:

1. memory limit điều khiển GC. .NET đọc giới hạn cgroup và đặt heap khoảng 75% của nó (bài 15.2). Đặt 512Mi nghĩa là heap khoảng 384Mi; phần còn lại cho runtime, thread stack và bộ nhớ native.

2. cpu limit ảnh hưởng thread pool. Với cpu: "500m" (nửa core), thread pool của .NET vẫn có thể được cấu hình theo số core của node nếu runtime không đọc đúng quota — kiểm tra bằng cách log Environment.ProcessorCount lúc khởi động.

Mẫu thực dụng:

resources:
requests: { memory: "256Mi", cpu: "100m" }
limits: { memory: "512Mi" } # KHÔNG đặt cpu limit

Bỏ cpu limit là lựa chọn có chủ đích được nhiều đội dùng: pod được dùng CPU rảnh của node khi cần, và requests vẫn đảm bảo phần tối thiểu. Giữ memory limit vì bộ nhớ không chia sẻ được như CPU.

maxUnavailable: 0 trong chiến lược rolling update: pod mới phải Ready trước khi pod cũ bị gỡ, nên không có lúc nào số pod khoẻ giảm xuống.

15.10.4 — Ba probe​

Đã nói ở bài 8.10, nhưng đây là nơi nó thành cấu hình thật:

ProbeThất bại thìKiểm tra gì
startupProbePod bị giếtỨng dụng đã khởi động xong chưa
livenessProbeRestart containerChỉ bản thân tiến trình
readinessProbeRút khỏi ServicePhụ thuộc bắt buộc

Lỗi nghiêm trọng nhất: liveness kiểm tra database. Database chậm 30 giây → liveness thất bại ở mọi pod → Kubernetes restart tất cả → pod mới đồng loạt mở kết nối → database càng quá tải → restart loop.

startupProbe giải quyết vấn đề "ứng dụng khởi động chậm": khi nó còn chạy, liveness và readiness bị tạm dừng. Không có nó, bạn buộc phải đặt initialDelaySeconds lớn cho liveness — và như vậy suốt vòng đời pod, mọi sự cố thật đều được phát hiện chậm hơn.

15.10.5 — Shutdown gọn gàng​

terminationGracePeriodSeconds: 60

Khi pod bị xoá, Kubernetes làm song song hai việc:

  1. Gửi SIGTERM cho container.
  2. Gỡ pod khỏi endpoint của Service.

Vì chúng song song, có một khoảng ngắn pod đã nhận SIGTERM nhưng vẫn nhận traffic — và request đó thất bại.

lifecycle:
preStop:
exec:
command: ["sleep", "5"] # chờ kube-proxy kịp cập nhật

preStop chạy trước SIGTERM, cho hệ thống mạng thời gian ngừng gửi traffic tới pod này. Đây là mẹo được dùng rộng rãi và giải quyết đúng lớp lỗi "lác đác 502 mỗi lần deploy".

Và chuỗi timeout phải nhất quán:

preStop (5s) + ShutdownTimeout cua ung dung (25s) < terminationGracePeriodSeconds (60s)

Nếu ShutdownTimeout lớn hơn terminationGracePeriodSeconds, Kubernetes gửi SIGKILL trước khi ứng dụng dọn xong — và mọi cấu hình shutdown gọn gàng trở thành vô nghĩa (bài 15.3).

Nhớ cả ENTRYPOINT dạng exec, nếu không SIGTERM không bao giờ tới dotnet.

15.10.6 — Khi nào Kubernetes là thừa​

Tình huốngNên
Một API, một máy chủDocker Compose
Vài dịch vụ, một máy chủDocker Compose
Cần scale tự động, không muốn vận hànhContainer Apps, Cloud Run
Nhiều service, nhiều node, có đội vận hànhKubernetes
Cần service mesh, operator tuỳ biếnKubernetes

Kubernetes có chi phí học và vận hành rất lớn: cụm phải được nâng cấp, chứng chỉ phải xoay, CNI và storage class phải hiểu, và khi có sự cố thì bề mặt chẩn đoán rộng hơn nhiều.

Nhiều đội chọn Kubernetes cho một API duy nhất và dành hàng tháng vận hành hạ tầng thay vì xây tính năng. Nếu Container Apps hay App Service đáp ứng được, chúng cho 90% lợi ích với 10% chi phí.

Dấu hiệu bạn thật sự cần: nhiều service triển khai độc lập, nhiều node, yêu cầu scale tự động theo tín hiệu tuỳ biến, và có người chịu trách nhiệm vận hành cụm.

Vài lệnh cần biết khi làm việc với cụm có sẵn:

kubectl get pods -n production
kubectl describe pod crm-api-xyz -n production # xem su kien, ly do restart
kubectl logs crm-api-xyz -n production -f
kubectl logs crm-api-xyz -n production --previous # log cua container DA CHET
kubectl rollout status deployment/crm-api -n production
kubectl rollout undo deployment/crm-api -n production

--previous là lệnh quan trọng nhất khi chẩn đoán crash loop — nó cho bạn log của lần chạy đã chết, thứ duy nhất giải thích vì sao.

Và kubectl describe pod cho biết pod bị OOMKilled, bị Evicted, hay probe nào thất bại — thường trả lời câu hỏi ngay lập tức.

15.10.7 — Rà lại code của bạn​

Danh sách rà soát Kubernetes

  • •Đã cân nhắc Container Apps hoặc Compose trước khi chọn Kubernetes.
  • •requests đặt theo mức tiêu thụ thật, không đoán.
  • •Có memory limit; đã cân nhắc bỏ cpu limit.
  • •Biết memory vượt limit là OOM kill còn CPU chỉ bị điều tiết.
  • •livenessProbe KHÔNG kiểm tra database hay dịch vụ ngoài.
  • •Có startupProbe cho ứng dụng khởi động chậm.
  • •maxUnavailable: 0 để không giảm số pod khoẻ khi deploy.
  • •Có preStop sleep để tránh lỗi lác đác khi deploy.
  • •ShutdownTimeout nhỏ hơn terminationGracePeriodSeconds.
  • •ENTRYPOINT dạng exec để SIGTERM tới được dotnet.
  • •Secret Kubernetes có mã hoá at-rest hoặc lấy từ Key Vault.

Bài tập áp dụng​

Bài 1 — OOM kill trên Kubernetes​

Đặt memory limit thấp hơn nhu cầu thật, tạo tải, rồi chạy kubectl describe pod và tìm OOMKilled. Xem kubectl logs --previous.

Tiêu chí hoàn thành: bạn giải thích được khác biệt giữa requests và limits, và nêu được hậu quả của ba cách đặt chúng.

Gợi ý và lời giải — Bài 1

Gợi ý. Một cái dùng để lập lịch, cái kia dùng để thực thi. Cái nào là cái nào?

Lời giải:

resources:
requests: { memory: "128Mi", cpu: "100m" }
limits: { memory: "256Mi", cpu: "500m" }
hey -n 50000 -c 100 http://crm-api/bao-cao
kubectl get pods -w
NAME                READY   STATUS      RESTARTS   AGE
crm-api-7d4f-x8k2 1/1 Running 0 4m
crm-api-7d4f-x8k2 0/1 OOMKilled 0 4m12s
crm-api-7d4f-x8k2 1/1 Running 1 4m30s
crm-api-7d4f-x8k2 0/1 OOMKilled 1 8m45s
crm-api-7d4f-x8k2 0/1 CrashLoopBackOff 2 9m
kubectl describe pod crm-api-7d4f-x8k2 | grep -A8 "Last State"
Last State:     Terminated
Reason: OOMKilled
Exit Code: 137
Started: Fri, 25 Sep 2026 08:14:22 +0700
Finished: Fri, 25 Sep 2026 08:18:34 +0700
kubectl logs crm-api-7d4f-x8k2 --previous | tail -20

--previous là cờ quan trọng nhất ở đây: nó lấy log của container đã chết. Không có nó, kubectl logs chỉ cho bạn log của container vừa khởi động lại — tức là không có gì hữu ích.

Khác biệt giữa requests và limits:

requestslimits
Dùng đểLập lịch — chọn nodeThực thi — cgroup
Nghĩa"Pod cần ít nhất chừng này""Pod không được vượt chừng này"
Vượt CPUĐược, nếu node còn rảnhBị throttle
Vượt bộ nhớĐược, nếu node còn rảnhBị OOMKilled
Ảnh hưởng chi phíCó — chiếm chỗ trên nodeKhông trực tiếp

Hai điều quan trọng nhất:

1. requests quyết định pod được xếp vào node nào. Scheduler cộng requests của mọi pod trên một node và không xếp thêm nếu vượt dung lượng. Nó không quan tâm tới limits.

2. CPU và bộ nhớ bị thực thi theo hai cách hoàn toàn khác nhau:

Vượt CPU limit:     bị THROTTLE — chậm lại, nhưng sống
Vượt memory limit: bị GIẾT ngay — không có cảnh báo, không có suy giảm dần

Khác biệt này là lý do lời khuyên về hai loại tài nguyên lại ngược nhau.

Ba cách đặt và hậu quả:

Cách A — requests = limits (QoS Guaranteed):

resources:
requests: { memory: "512Mi", cpu: "500m" }
limits: { memory: "512Mi", cpu: "500m" }
Ưu:  Pod được ưu tiên CAO NHẤT khi node thiếu bộ nhớ — bị đuổi SAU CÙNG
Hiệu năng ổn định, dễ đoán
Dễ tính dung lượng cụm
Nhược: Lãng phí — tài nguyên được giữ chỗ kể cả khi không dùng

Cách B — requests < limits (QoS Burstable):

resources:
requests: { memory: "256Mi", cpu: "100m" }
limits: { memory: "512Mi", cpu: "1000m" }
Ưu:  Tiết kiệm — nhiều pod trên cùng node, dùng tài nguyên rảnh khi cần
Nhược: Hiệu năng không đoán được — phụ thuộc vào hàng xóm trên node
Bị đuổi trước pod Guaranteed khi node thiếu bộ nhớ
Có thể OOM khi nhiều pod cùng burst

Cách C — không đặt gì (QoS BestEffort):

Ưu:  không có
Nhược: bị đuổi ĐẦU TIÊN khi node thiếu tài nguyên
một pod rò rỉ bộ nhớ có thể làm sập CẢ NODE
scheduler không có thông tin để xếp pod hợp lý

Đừng bao giờ dùng cách C ở production. Chặn nó bằng LimitRange:

apiVersion: v1
kind: LimitRange
metadata: { name: bat-buoc-resources }
spec:
limits:
- type: Container
default: { memory: "512Mi", cpu: "500m" }
defaultRequest: { memory: "256Mi", cpu: "100m" }
max: { memory: "2Gi", cpu: "2000m" }

Khuyến nghị thực tế — và đây là điểm phản trực giác nhất:

resources:
requests:
memory: "512Mi" # bằng mức dùng thật ở p95
cpu: "200m" # bằng mức dùng thật trung bình
limits:
memory: "512Mi" # BẰNG requests — bộ nhớ không co giãn được
# KHÔNG đặt cpu limit

Vì sao memory limit nên bằng requests: bộ nhớ là tài nguyên không nén được. Một pod dùng 600 Mi thì nó thật sự cần 600 Mi — không có cách nào cho nó "chậm lại" để dùng ít hơn. Cho phép burst bộ nhớ chỉ trì hoãn cái chết và khiến nó xảy ra vào lúc khó đoán hơn.

Vì sao không nên đặt cpu limit — đây là điều gây tranh cãi nhất, và lý do đáng biết:

CFS throttling của Linux hoạt động theo chu kỳ 100 ms. Một pod có cpu: 500m được dùng 50 ms CPU mỗi chu kỳ. Khi dùng hết, nó bị đóng băng hoàn toàn cho tới chu kỳ sau:

Chu kỳ 100 ms, limit 500m:
0–50 ms: chạy
50–100 ms: BỊ ĐÓNG BĂNG, kể cả khi CPU của node đang rảnh

Với .NET, hiệu ứng này tệ hơn bình thường vì runtime là đa luồng: bốn luồng cùng chạy sẽ tiêu hết ngân sách trong 12,5 ms và cả tiến trình bị đóng băng 87,5 ms còn lại. Kết quả là p99 tăng vọt trong khi CPU trung bình nhìn vẫn thấp.

kubectl exec crm-api-7d4f -- cat /sys/fs/cgroup/cpu.stat
nr_periods 84210
nr_throttled 18942 <- bị throttle 22,5% số chu kỳ
throttled_usec 412840000 <- tổng 412 giây bị đóng băng

Nếu nr_throttled / nr_periods vượt vài phần trăm, cpu limit đang làm hại bạn.

Bỏ cpu limit và giữ cpu requests: pod vẫn được bảo đảm phần của mình khi node bận, và dùng được CPU rảnh khi node rỗi.

Và với .NET, phải cấu hình cả GC:

env:
- name: DOTNET_gcServer
value: "0" # Workstation GC cho container nhỏ
- name: DOTNET_GCHeapHardLimitPercent
value: "0x4B" # 75% dạng hex

Lý do đầy đủ ở bài 15.12.

Xác định con số đúng — đo, đừng đoán:

kubectl top pods --containers
# Quan sát nhiều ngày qua Prometheus
kubectl exec -it prometheus-0 -- promtool query instant http://localhost:9090 \
'quantile_over_time(0.95, container_memory_working_set_bytes{pod=~"crm-api.*"}[7d])'

Quy tắc đặt:

memory requests = p95 của working set trong 7 ngày × 1,3
memory limits = memory requests
cpu requests = trung bình của cpu usage trong 7 ngày
cpu limits = không đặt

Hệ số 1,3 là biên cho tải cao điểm và cho việc dữ liệu lớn dần. Xem lại con số mỗi quý — chúng lỗi thời theo sự phát triển của dữ liệu.


Bài 2 — Liveness probe gây restart loop​

Cấu hình livenessProbe gọi endpoint kiểm tra database, dừng database, và quan sát mọi pod restart liên tục. Chuyển sang readiness và kiểm chứng.

Tiêu chí hoàn thành: bạn nêu được vai trò của ba probe và thứ mỗi cái được phép kiểm tra, và giải thích được vì sao nhầm lẫn giữa chúng biến sự cố nhỏ thành sự cố lớn.

Gợi ý và lời giải — Bài 2

Gợi ý. Liveness thất bại thì Kubernetes làm gì? Readiness thất bại thì sao?

Lời giải — cấu hình gây lỗi:

livenessProbe:
httpGet: { path: /health/ready, port: 8080 } # endpoint này kiểm tra database
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
kubectl scale statefulset postgres --replicas=0      # mô phỏng sự cố database
kubectl get pods -w
crm-api-7d4f-x8k2   1/1   Running             0     10m
crm-api-7d4f-x8k2 0/1 Running 1 10m40s
crm-api-7d4f-x8k2 0/1 Running 2 11m20s
crm-api-7d4f-x8k2 0/1 CrashLoopBackOff 3 12m
crm-api-7d4f-p2m9 0/1 CrashLoopBackOff 3 12m
crm-api-7d4f-k9n4 0/1 CrashLoopBackOff 3 12m

Mọi pod vào CrashLoopBackOff. Và khi database quay lại:

kubectl scale statefulset postgres --replicas=1
crm-api-7d4f-x8k2   0/1   CrashLoopBackOff    5     18m

Pod không tự hồi phục ngay, vì CrashLoopBackOff có độ trễ tăng dần: 10 s, 20 s, 40 s, 80 s, 160 s, tối đa 5 phút. Sau vài lần restart, bạn phải chờ tới 5 phút cho mỗi lần thử — dù database đã khoẻ từ lâu.

Sự cố database 30 giây trở thành sự cố ứng dụng 5 phút.

Bản sửa — tách ba endpoint:

builder.Services.AddHealthChecks()
.AddDbContextCheck<CrmDbContext>("database", tags: ["ready"])
.AddRedis(redisConn, "redis", failureStatus: HealthStatus.Degraded, tags: ["ready"]);

var app = builder.Build();

// Liveness — KHÔNG kiểm tra gì cả
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });

// Readiness — kiểm tra dependency bắt buộc
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
});

// Startup — như readiness, nhưng dành cho giai đoạn khởi động
app.MapHealthChecks("/health/startup", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
});
startupProbe:
httpGet: { path: /health/startup, port: 8080 }
failureThreshold: 30
periodSeconds: 5 # cho tới 150 giây để khởi động

livenessProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 3

readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
failureThreshold: 2
timeoutSeconds: 3
kubectl scale statefulset postgres --replicas=0
kubectl get pods
crm-api-7d4f-x8k2   0/1   Running   0   15m       <- KHÔNG restart, chỉ NotReady
crm-api-7d4f-p2m9 0/1 Running 0 15m
kubectl scale statefulset postgres --replicas=1
crm-api-7d4f-x8k2   1/1   Running   0   16m       <- tự hồi phục trong vài giây

Vai trò của ba probe:

ProbeCâu hỏiThất bại thìĐược kiểm tra
startup"Khởi động xong chưa?"Tắt hai probe kia, chờ tiếpDependency bắt buộc
liveness"Tiến trình còn hoạt động không?"RESTART containerChỉ chính tiến trình
readiness"Nhận traffic được không?"Rút khỏi ServiceDependency bắt buộc

Nguyên tắc phân chia:

liveness  -> chỉ thứ mà RESTART sửa được
readiness -> thứ có thể TỰ KHỎI mà không cần restart

Áp dụng:

Vấn đềRestart có sửa được?Probe
Deadlock trong tiến trìnhCóliveness
Rò rỉ bộ nhớ đã ở mức nguy hiểmCóliveness
Vòng lặp vô hạn nuốt CPUCóliveness
Database chếtKhôngreadiness
Redis chếtKhôngreadiness (Degraded)
Đang khởi độngKhôngstartup
Đang làm nóng cacheKhôngreadiness
Connection pool tạm cạnKhôngreadiness

Cột giữa là câu hỏi duy nhất bạn cần trả lời. Nếu restart không sửa được vấn đề, thì để nó trong liveness chỉ tạo ra restart vô ích.

Vì sao nhầm lẫn biến sự cố nhỏ thành sự cố lớn — ba cơ chế khuếch đại:

1. Restart làm database quá tải nặng hơn. Mỗi pod mới mở lại toàn bộ connection pool và chạy lại các truy vấn khởi động:

Database chậm vì tải cao
-> liveness timeout -> pod restart
-> pod mới mở 20 kết nối mới, chạy truy vấn khởi động
-> database càng tải
-> nhiều pod hơn restart
-> vòng lặp không tự thoát

2. Mất hết khả năng chẩn đoán. Pod restart nghĩa là log trong bộ nhớ mất, metric bị reset, và bạn phải dùng --previous cho từng pod để xem chuyện gì đã xảy ra. Đúng lúc cần thông tin nhất thì thông tin bị xoá.

3. CrashLoopBackOff kéo dài sự cố sau khi nguyên nhân đã hết. Như đã thấy ở trên: độ trễ tăng dần tới 5 phút, nên hệ thống không hồi phục cùng lúc với database.

Năm chi tiết cấu hình hay sai:

1. timeoutSeconds mặc định là 1 giây — quá ngắn cho một ứng dụng đang chịu tải:

timeoutSeconds: 3

2. failureThreshold của liveness phải cao hơn của readiness. Readiness nên phản ứng nhanh (rút traffic là hành động rẻ), liveness nên phản ứng chậm (restart là hành động đắt):

readinessProbe: { failureThreshold: 2 }     # rút traffic sau 10 s
livenessProbe: { failureThreshold: 3 } # restart sau 30 s

3. Dùng startupProbe thay vì initialDelaySeconds lớn. initialDelaySeconds: 120 nghĩa là pod luôn chờ 120 giây, kể cả khi nó sẵn sàng sau 5 giây. startupProbe kiểm tra liên tục và chuyển sang hai probe kia ngay khi pass.

4. Endpoint liveness phải cực nhẹ. Nếu nó chạm database, bạn quay về vấn đề ban đầu:

app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });

Predicate = _ => false nghĩa là không chạy health check nào — chỉ cần ASP.NET Core trả lời được là tiến trình còn sống. Đó chính xác là điều liveness cần biết.

5. Redis chết phải là Degraded, không phải Unhealthy — như đã phân tích ở bài 14.3. Degraded trả HTTP 200, nên readiness probe pass và pod vẫn nhận traffic.

Kiểm chứng — thử cả ba tình huống:

# 1. Database chết -> NotReady, KHÔNG restart
kubectl scale statefulset postgres --replicas=0
kubectl get pods # mong đợi: 0/1 Running, RESTARTS = 0

# 2. Redis chết -> vẫn Ready
kubectl scale deployment redis --replicas=0
kubectl get pods # mong đợi: 1/1 Running

# 3. Tiến trình treo -> restart
kubectl exec crm-api-7d4f -- kill -STOP 1
kubectl get pods # mong đợi: restart sau khoảng 30 s

Ba thử nghiệm này nên nằm trong checklist trước khi đưa một dịch vụ lên production — chúng mất năm phút và bắt được đúng loại cấu hình sai gây thiệt hại lớn nhất.


Bài 3 — Lỗi khi rolling update​

Chạy tải liên tục trong lúc kubectl rollout restart, đếm số request thất bại có và không có preStop sleep 5.

Tiêu chí hoàn thành: bạn giải thích được vì sao có khoảng trống giữa lúc pod bị xoá và lúc nó ngừng nhận traffic, và nêu được ba điều kiện để dừng mượt.

Gợi ý và lời giải — Bài 3

Gợi ý. Khi pod bị xoá, hai việc xảy ra song song. Việc nào nhanh hơn?

Lời giải — không có preStop:

hey -z 60s -c 50 http://crm-api/leads &
kubectl rollout restart deployment/crm-api
wait
Status code distribution:
[200] 28412 responses
[502] 47 responses
[504] 12 responses

Error distribution:
[23] connection reset by peer

82 request thất bại trên một deployment 3 replica.

Vì sao có khoảng trống. Khi pod bị xoá, Kubernetes làm hai việc song song và độc lập:

t=0    Pod bị đánh dấu Terminating

Nhánh A — xoá endpoint Nhánh B — dừng container
───────────────────────── ────────────────────────
t=0 API server xoá pod khỏi Endpoints kubelet gửi SIGTERM ngay
t=0,1 endpoints controller cập nhật ứng dụng bắt đầu dọn dẹp
t=0,3 kube-proxy trên node 1 cập nhật ...
t=0,5 kube-proxy trên node 2 cập nhật ứng dụng đóng listener
t=0,8 kube-proxy trên node 3 cập nhật ...
t=1,2 ingress controller cập nhật tiến trình thoát

Nhánh B nhanh hơn nhánh A. Ứng dụng đóng listener trong khi kube-proxy trên một số node vẫn còn quy tắc định tuyến trỏ tới nó:

t=0,6  Một request đến ingress
-> ingress vẫn nghĩ pod này còn sống (chưa cập nhật)
-> chuyển tới IP của pod
-> pod đã đóng listener
-> connection refused -> HTTP 502

Điểm mấu chốt: việc xoá endpoint là bất đồng bộ và phân tán. Nó phải lan qua API server, endpoints controller, kube-proxy trên mọi node, và ingress controller. Không có cơ chế nào bảo đảm nó hoàn tất trước khi SIGTERM được gửi — vì hai nhánh không biết gì về nhau.

Bản sửa — preStop hook:

lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 30
t=0     Pod bị đánh dấu Terminating
t=0 Nhánh A: bắt đầu xoá endpoint
t=0 Nhánh B: kubelet chạy preStop -> sleep 5
-> SIGTERM CHƯA được gửi
t=0,1..1,2 mọi kube-proxy và ingress cập nhật xong
t=1,2..5 pod vẫn phục vụ bình thường, nhưng KHÔNG còn nhận request mới
t=5 preStop xong -> kubelet gửi SIGTERM
t=5..8 ứng dụng hoàn tất request đang chạy rồi thoát
Status code distribution:
[200] 28471 responses

Không có lỗi nào.

sleep 5 trông như một cách chữa cháy thô sơ, nhưng nó là cách đúng — và đây là điều đáng hiểu rõ. Vấn đề là hai quá trình bất đồng bộ không có điểm đồng bộ. Kubernetes không cung cấp cơ chế nào để chờ "mọi kube-proxy đã cập nhật", vì không có thành phần nào biết được điều đó. preStop là điểm móc duy nhất cho phép bạn trì hoãn SIGTERM, và một khoảng chờ cố định là cách duy nhất để lấp khoảng trống.

Chọn thời lượng:

Cụm nhỏ (dưới 10 node):    3–5 giây
Cụm vừa (10–50 node): 5–10 giây
Cụm lớn, nhiều ingress: 10–15 giây

Đo thật bằng cách xoá một pod và theo dõi:

kubectl get endpoints crm-api -w

Ba điều kiện để dừng mượt — cả ba đều bắt buộc:

1. preStop đủ dài để endpoint được xoá ở mọi nơi.

2. ENTRYPOINT dạng exec, để ứng dụng nhận được SIGTERM:

ENTRYPOINT ["dotnet", "Crm.Api.dll"]

Với dạng shell, SIGTERM bị /bin/sh nuốt và ứng dụng không dọn dẹp gì — chi tiết ở bài 15.2. Và preStop không cứu được điều này: nó chỉ trì hoãn SIGTERM, không làm cho SIGTERM tới được đúng tiến trình.

3. Ứng dụng thật sự chờ request đang chạy hoàn tất:

builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(20));
terminationGracePeriodSeconds: 30
= preStop (5 s) + ShutdownTimeout (20 s) + biên (5 s)

Ba con số phải khớp nhau. Nếu ShutdownTimeout lớn hơn phần còn lại của grace period, SIGKILL sẽ tới khi ứng dụng đang dọn dở — và bạn mất chính phần mà mình đang cố bảo vệ.

Kiểm chứng bằng log:

app.Lifetime.ApplicationStopping.Register(() =>
logger.LogWarning("SIGTERM nhận lúc {Luc:O}, bắt đầu dừng mượt", DateTime.UtcNow));
app.Lifetime.ApplicationStopped.Register(() =>
logger.LogWarning("Đã dừng xong lúc {Luc:O}", DateTime.UtcNow));
08:14:27.102  SIGTERM nhận lúc 2026-09-25T01:14:27Z, bắt đầu dừng mượt
08:14:29.847 Đã dừng xong lúc 2026-09-25T01:14:29Z

Nếu bạn không thấy hai dòng này khi pod bị xoá, điều kiện 2 đang không được thoả mãn.

Bốn cấu hình khác để rolling update không mất request:

1. maxUnavailable: 0 — không bao giờ giảm số pod sẵn sàng:

strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0

Pod mới phải Ready trước khi pod cũ bị xoá. Chậm hơn, nhưng không có khoảnh khắc nào dung lượng giảm xuống dưới mức bình thường.

2. PodDisruptionBudget — bảo vệ khi node được bảo trì:

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

kubectl drain sẽ tôn trọng giới hạn này và không đuổi quá nhiều pod cùng lúc.

3. topologySpreadConstraints — trải pod ra nhiều node, để một node chết không mang theo mọi replica:

topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: { app: crm-api }

4. Retry ở phía client hoặc service mesh — lớp cuối cho những lỗi không tránh được:

# Istio
retries:
attempts: 3
perTryTimeout: 2s
retryOn: connect-failure,refused-stream,503

Chỉ retry trên lỗi kết nối, không retry trên lỗi ứng dụng — và chỉ cho request idempotent, vì như đã thấy ở bài 14.9, retry một POST không idempotent có thể tạo hai đơn hàng.

Và một cách đo dứt khoát, đáng chạy trước mỗi lần thay đổi cấu hình triển khai:

hey -z 120s -c 50 -q 20 http://crm-api/leads > truoc.txt &
sleep 10
kubectl rollout restart deployment/crm-api
kubectl rollout status deployment/crm-api
wait
grep -A5 "Status code distribution" truoc.txt

Nếu có bất kỳ mã nào khác 200, một trong ba điều kiện chưa được thoả mãn — và log ApplicationStopping sẽ cho bạn biết là điều kiện nào.

Tự kiểm tra​

Câu hỏi thường gặp

requests và limits khác nhau thế nào?

requests là thứ scheduler dùng để chọn node còn đủ tài nguyên chưa đặt trước, còn limits là trần cứng khi chạy. Đặt requests quá cao thì pod không được lên, quá thấp thì pod bị đặt lên node đã chật và cạnh tranh tài nguyên.

Vượt limit bộ nhớ và vượt limit CPU khác nhau ra sao?

Vượt bộ nhớ là OOM kill ngay lập tức, pod chết với exit code 137. Vượt CPU chỉ bị điều tiết nên tiến trình chạy chậm lại, và điều đó khó chẩn đoán hơn vì request timeout trong khi metric CPU trông như chưa đầy.

Vì sao nhiều đội bỏ cpu limit?

Vì CPU chia sẻ được: bỏ limit cho pod dùng CPU rảnh của node khi cần, trong khi requests vẫn đảm bảo phần tối thiểu. Memory limit thì vẫn giữ vì bộ nhớ không chia sẻ được theo cách đó.

livenessProbe kiểm tra database gây ra gì?

Database chậm làm liveness thất bại ở mọi pod, Kubernetes restart tất cả, pod mới đồng loạt mở kết nối khiến database càng quá tải, và vòng lặp tự nuôi chính nó. Liveness chỉ nên kiểm tra bản thân tiến trình; phụ thuộc bên ngoài thuộc về readiness.

preStop sleep giải quyết vấn đề gì?

Kubernetes gửi SIGTERM và gỡ pod khỏi Service song song, nên có khoảng ngắn pod đã nhận SIGTERM nhưng vẫn nhận traffic và request đó thất bại. preStop chạy trước SIGTERM, cho hệ thống mạng thời gian ngừng gửi traffic tới pod này.

Lệnh nào quan trọng nhất khi chẩn đoán crash loop?

kubectl logs với cờ previous, vì nó cho log của container đã chết chứ không phải container vừa khởi động lại. Kèm theo kubectl describe pod để biết pod bị OOMKilled, bị Evicted, hay probe nào thất bại.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Vượt bộ nhớ là chết ngay; vượt CPU chỉ chậm lại. Hai loại limit rất khác nhau.
  2. livenessProbe không được chạm database. Đó là công thức của restart loop.
  3. Kubernetes cho một API là chi phí lớn không có lợi ích. Cân nhắc Container Apps trước.

Tham khảo​

Điều hướng​