15.13 - Ví dụ thực tế nhanh
Tóm tắt: Mỗi lần triển khai, khoảng 40 request đang xử lý bị cắt giữa chừng — người dùng thấy lỗi mạng, và một số giao dịch dở dang. Đội cho rằng "deploy thì phải vậy", nhưng không phải: nguyên nhân là một dấu ngoặc vuông trong Dockerfile. Viết
ENTRYPOINT dotnet Crm.Api.dll(shell form) khiến ứng dụng chạy như tiến trình con của/bin/sh, vàshkhông chuyển tiếp SIGTERM. Ứng dụng không bao giờ biết nó sắp bị dừng, nên nó không kịp làm gì — 30 giây sau, Kubernetes gửi SIGKILL và cắt ngang mọi thứ. Đổi sang exec formENTRYPOINT ["dotnet", "Crm.Api.dll"]là sửa xong một nửa vấn đề; nửa còn lại là ba điều kiện để dừng mượt thật sự.
Mục tiêu bài học
Sau bài này bạn có thể:
- Phân biệt exec form và shell form trong Dockerfile.
- Cấu hình dừng mượt cho ASP.NET Core.
- Đặt
preStopvàterminationGracePeriodSecondsđúng. - Kiểm chứng rằng không còn request bị cắt.
Nội dung bài học
15.13.1 — Sự cố
# Dockerfile — shell form
ENTRYPOINT dotnet Crm.Api.dllDocker chay: /bin/sh -c "dotnet Crm.Api.dll"
Tien trinh 1 (PID 1): /bin/sh
Tien trinh con: dotnet
Kubernetes gửi SIGTERM -> PID 1 (tức /bin/sh)
/bin/sh KHÔNG chuyển tiếp tín hiệu cho tiến trình con
-> dotnet KHÔNG BIẾT gì
-> sau 30 giay: SIGKILL -> cat ngang moi request# Sua — exec form
ENTRYPOINT ["dotnet", "Crm.Api.dll"]Docker chay: dotnet Crm.Api.dll (truc tiep)
Tiến trình 1 (PID 1): dotnet <- nhận SIGTERM
Kubernetes gửi SIGTERM -> dotnet nhận được
-> ASP.NET Core bắt đầu dừng mượtMột cặp dấu ngoặc vuông. Đây là lỗi rất phổ biến vì shell form ngắn hơn và trông tự nhiên hơn.
Cách kiểm tra nhanh:
docker exec <container> ps aux
# Neu thay "/bin/sh -c dotnet ..." o PID 1 -> dang dung shell form15.13.2 — Điều kiện 2: ứng dụng phải xử lý SIGTERM
Nhận được tín hiệu là chưa đủ — ứng dụng phải làm gì đó với nó.
ASP.NET Core xử lý sẵn: khi nhận SIGTERM, nó ngừng nhận request mới và chờ request đang chạy hoàn thành. Nhưng thời gian chờ mặc định chỉ 5 giây:
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});Với BackgroundService, bạn phải tự xử lý:
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // ct bi huy khi shutdown
{
try
{
await ProcessOneBatchAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogInformation("Đang dừng, hoàn tất lô hiện tại");
break; // thoat vong lap TU TE
}
}
}Nếu BackgroundService bỏ qua CancellationToken, nó chạy tới khi bị SIGKILL — và một lô đang xử lý dở có thể để lại dữ liệu nửa vời.
15.13.3 — Điều kiện 3: load balancer phải ngừng gửi request trước
Đây là phần tinh vi nhất, và thiếu nó thì hai điều kiện trên vẫn chưa đủ:
t=0 Kubernetes gửi SIGTERM cho pod
t=0 Kubernetes BẮT ĐẦU gỡ pod khỏi Service endpoint
-> nhưng việc này mất vài giây để lan tới mọi node
t=1 Pod đã ngừng nhận request mới (do SIGTERM)
t=2 Một node CHƯA cập nhật vẫn gửi request tới pod này
-> Ket noi bi tu choiKhoảng thời gian giữa "pod ngừng nhận" và "mọi node biết điều đó" là nơi request bị mất.
lifecycle:
preStop:
exec:
command: ["sleep", "15"] # CHỜ trước khi bắt đầu dừngpreStop chạy trước khi Kubernetes gửi SIGTERM. Trong 15 giây đó, pod vẫn phục vụ bình thường, còn Kubernetes đã bắt đầu gỡ nó khỏi endpoint — nên khi SIGTERM tới, không còn request mới nào được gửi tới.
Nghe phản trực giác — cố tình chờ để dừng nhanh hơn — nhưng đó chính là điều làm cho việc dừng không mất request nào.
15.13.4 — Tổng hợp cấu hình
spec:
terminationGracePeriodSeconds: 60 # tổng thời gian cho phép
containers:
- name: crm-api
lifecycle:
preStop:
exec:
command: ["sleep", "15"] # (1) chờ load balancer cập nhật
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 2Đồng hồ khi dừng pod:
t=0 Kubernetes bắt đầu gỡ pod khỏi Service endpoint
t=0 preStop chay: sleep 15 <- pod VAN phuc vu
t=15 preStop xong -> gửi SIGTERM
t=15 ASP.NET Core ngừng nhận request mới, chờ request đang chạy
t=~20 Request cuối cùng hoàn thành -> tiến trình thoát
(neu chua xong toi t=60 -> SIGKILL)Quan hệ giữa các con số phải đúng:
terminationGracePeriodSeconds > preStop + ShutdownTimeout
60 > 15 + 30Sai quan hệ này là nguyên nhân phổ biến khiến cấu hình "trông đúng" nhưng vẫn mất request: nếu terminationGracePeriodSeconds là 30 và preStop là 15, ứng dụng chỉ còn 15 giây để hoàn thành mọi việc.
15.13.5 — Kiểm chứng
# Chay tai lien tuc
hey -z 60s -c 10 https://api.company.com/api/leads &
# Trien khai trong luc dang co tai
kubectl rollout restart deployment/crm-api
# Xem kết quả
waitStatus code distribution:
[200] 14203 responses <- TẤT CẢ thành côngNếu thấy [502], [503] hoặc lỗi kết nối, một trong ba điều kiện chưa đúng.
Cách xác định điều kiện nào thiếu:
# Kiểm tra exec form
kubectl exec <pod> -- ps aux | head -3
# Kiểm tra pod nhận được SIGTERM
kubectl logs <pod> --previous | grep -i "shutting down\|application stopping"Nếu log trước khi pod dừng không có dòng nào về việc dừng, ứng dụng không nhận được SIGTERM — tức vẫn đang dùng shell form hoặc có một lớp shell ở giữa.
15.13.6 — Hai cái bẫy khác
Bẫy 1 — script khởi động bọc ngoài:
# VAN co van de — script la PID 1
ENTRYPOINT ["/app/start.sh"]#!/bin/bash
# start.sh
dotnet Crm.Api.dll # dotnet là tiến trình CON -> không nhận SIGTERM#!/bin/bash
# Sửa — exec THAY THẾ tiến trình shell
exec dotnet Crm.Api.dll # dotnet TRO THANH PID 1Từ khoá exec thay thế tiến trình shell bằng tiến trình mới, giữ nguyên PID — nên dotnet trở thành PID 1 và nhận tín hiệu trực tiếp.
Bẫy 2 — kết nối database chưa đóng:
// Đảm bảo DbContext và connection được dispose khi shutdown
builder.Services.AddDbContext<AppDbContext>(...); // scoped -> tu dispose theo request
// Voi ket noi giu lau, dang ky IHostApplicationLifetime
lifetime.ApplicationStopping.Register(() =>
{
_logger.LogInformation("Đang đóng kết nối");
_connection.Close();
});ApplicationStopping chạy trước khi host dừng hẳn — đúng chỗ để đóng kết nối broker, huỷ đăng ký khỏi service discovery, hoặc đẩy nốt dữ liệu đang đệm.
15.13.7 — Rà lại code của bạn
Danh sách rà soát dừng mượt
- ENTRYPOINT dùng exec form với dấu ngoặc vuông.
- Nếu có script khởi động, nó dùng exec để thay thế tiến trình.
- Đã xác nhận PID 1 là dotnet, không phải /bin/sh.
- ShutdownTimeout đặt đủ dài cho request chậm nhất.
- BackgroundService tôn trọng CancellationToken.
- Có preStop sleep để load balancer kịp cập nhật.
- terminationGracePeriodSeconds lớn hơn preStop cộng ShutdownTimeout.
- Đã kiểm chứng bằng cách deploy trong lúc có tải.
- Kết nối dài hạn được đóng trong ApplicationStopping.
Bài tập áp dụng
Bài 1 — Shell form và exec form
Dựng container với shell form rồi exec form, gửi SIGTERM và xem log ứng dụng có phản ứng không.
Tiêu chí hoàn thành: bạn đo được thời gian dừng ở mỗi trường hợp, và nêu được vì sao mất request là hệ quả trực tiếp.
Gợi ý và lời giải — Bài 1
Gợi ý. Tiến trình không nhận được SIGTERM thì nó biết mình sắp chết không?
Lời giải — thiết lập:
app.Lifetime.ApplicationStopping.Register(() =>
logger.LogWarning("SIGTERM nhận lúc {Luc:O}", DateTime.UtcNow));
app.Lifetime.ApplicationStopped.Register(() =>
logger.LogWarning("Đã dừng xong lúc {Luc:O}", DateTime.UtcNow));
app.MapGet("/cham", async (CancellationToken ct) =>
{
await Task.Delay(3000, ct);
return Results.Ok(new { xong = true });
});Shell form:
ENTRYPOINT dotnet Crm.Api.dlldocker run -d --name test -p 8080:8080 crm-api:shell
curl http://localhost:8080/cham &
sleep 0.5
time docker stop testreal 0m10.284sdocker logs test | tail -3
curl: (52) Empty reply from serverinfo: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.Không có dòng SIGTERM nhận lúc. Request /cham bị cắt giữa chừng.
Exec form:
ENTRYPOINT ["dotnet", "Crm.Api.dll"]docker run -d --name test -p 8080:8080 crm-api:exec
curl http://localhost:8080/cham &
sleep 0.5
time docker stop testreal 0m2.847swarn: Crm.Api[0] SIGTERM nhận lúc 2026-09-25T01:14:27.102Z
info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
warn: Crm.Api[0] Đã dừng xong lúc 2026-09-25T01:14:29.847Z{"xong": true}Request hoàn tất. Container dừng sau 2,8 giây — đúng bằng thời gian còn lại của request đang chạy.
| Shell form | Exec form | |
|---|---|---|
| PID 1 | /bin/sh | dotnet |
Nhận SIGTERM | Không | Có |
ApplicationStopping chạy | Không | Có |
| Thời gian dừng | 10,3 s (chờ hết grace period) | 2,8 s |
| Request đang chạy | Bị cắt | Hoàn tất |
| Cách kết thúc | SIGKILL | Thoát bình thường, mã 0 |
Vì sao mất request là hệ quả trực tiếp — chuỗi nhân quả có bốn mắt xích:
1. /bin/sh là PID 1 và nhận SIGTERM
2. sh ở chế độ không tương tác KHÔNG chuyển tiếp tín hiệu cho tiến trình con
3. dotnet không bao giờ biết có yêu cầu dừng
-> không kích hoạt ApplicationStopping
-> KHÔNG ngừng nhận request mới
-> KHÔNG chờ request đang chạy
-> vẫn xử lý bình thường như không có gì xảy ra
4. Sau 10 giây, Docker gửi SIGKILL
-> nhân xoá tiến trình NGAY LẬP TỨC
-> mọi request đang bay bị cắt giữa chừngMắt xích 3 là chỗ quyết định. Ứng dụng không "từ chối dừng" — nó không biết cần dừng. Từ góc nhìn của nó, mọi thứ bình thường cho tới khoảnh khắc nó ngừng tồn tại.
Và hậu quả không chỉ là request bị cắt:
- Transaction database không commit cũng không rollback rõ ràng
-> khoá được giữ cho tới khi database phát hiện kết nối chết
- Message đang xử lý không được ack -> broker gửi lại cho consumer khác
- Kết nối HTTP outbound bị đứt giữa chừng -> phía kia chờ timeout
- Dữ liệu trong buffer chưa flush -> mất log của chính khoảnh khắc đóDòng cuối là lý do sự cố này khó chẩn đoán: bằng chứng về nó bị mất cùng với nó.
Nhân lên theo quy mô:
Deployment 10 pod, rolling update:
10 pod × 10 giây chờ vô ích = 100 giây thêm cho mỗi lần deploy
10 pod × số request đang bay bị cắt
Với 50 request/giây mỗi pod và 3 giây xử lý trung bình:
khoảng 150 request đang bay mỗi pod -> 1.500 request thất bại mỗi lần deployDeploy 3 lần một ngày là 4.500 request lỗi mỗi ngày, và chúng rải đều trên mọi người dùng — nên báo cáo lỗi đến dưới dạng "thỉnh thoảng bị lỗi", không ai nối được với thời điểm deploy.
Ba cách viết đúng:
# 1. Exec form — mặc định
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
# 2. Cần shell (biến môi trường) — dùng exec tường minh
ENTRYPOINT ["/bin/sh", "-c", "exec dotnet Crm.Api.dll --urls http://+:$PORT"]
# 3. Script khởi động — exec ở dòng cuối
ENTRYPOINT ["/app/entrypoint.sh"]#!/bin/sh
set -e
dotnet Crm.Api.dll --migrate-only
exec dotnet Crm.Api.dll # exec — thiếu nó là quay lại vấn đề ban đầuTừ khoá exec khiến shell thay thế chính nó bằng tiến trình mới, giữ nguyên PID 1.
Kiểm chứng nhanh nhất — xem PID 1:
docker exec crm-api ps -o pid,comm PID COMMAND
1 dotnet <- ĐÚNG PID COMMAND
1 sh <- SAI
7 dotnetĐưa vào CI để không bao giờ lọt qua:
#!/usr/bin/env bash
docker run -d --name kiem-tra-pid "$IMAGE" > /dev/null
sleep 3
PID1=$(docker exec kiem-tra-pid ps -o comm= -p 1 2>/dev/null | tr -d ' ')
docker rm -f kiem-tra-pid > /dev/null
if [ "$PID1" != "dotnet" ]; then
echo "PID 1 là '$PID1', không phải 'dotnet' — ENTRYPOINT đang dùng shell form"
exit 1
fiKiểm tra này mất ba giây và bắt được một lỗi làm hỏng mọi lần deploy — tỷ lệ chi phí trên lợi ích tốt nhất trong toàn bộ bài này.
Bài 2 — Kiểm tra PID 1
Chạy ps aux trong container của bạn và xác nhận tiến trình nào là PID 1.
Tiêu chí hoàn thành: bạn nêu được ba trách nhiệm của PID 1 trên Linux, và biết khi nào cần một init thật.
Gợi ý và lời giải — Bài 2
Gợi ý. PID 1 trên một hệ thống Linux bình thường là systemd hoặc init. Nó làm gì?
Lời giải:
docker exec crm-api ps auxUSER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
app 1 2.1 1.7 4821304 140192 ? Ssl 08:14 0:12 dotnet Crm.Api.dll
app 42 0.0 0.0 7376 2884 ? Rs 09:02 0:00 ps auxVới image chiseled không có ps:
docker exec crm-api cat /proc/1/cmdline | tr '\0' ' '
docker inspect crm-api --format '{{.Config.Entrypoint}}'Ba trách nhiệm của PID 1 trên Linux:
1. Nhận và xử lý tín hiệu. PID 1 là tiến trình mà nhân gửi tín hiệu tới khi có yêu cầu dừng. Và nó có một tính chất đặc biệt:
Tiến trình thường: SIGTERM không có handler -> nhân dùng hành vi MẶC ĐỊNH = kết thúc
PID 1: SIGTERM không có handler -> nhân BỎ QUA tín hiệuĐây là cơ chế bảo vệ để không ai vô tình giết init và làm sập hệ thống. Nhưng trong container, nó nghĩa là: nếu PID 1 không xử lý SIGTERM, tín hiệu bị bỏ qua hoàn toàn và container chỉ dừng khi bị SIGKILL.
May mắn là .NET có xử lý SIGTERM, nên vấn đề chỉ xuất hiện khi dotnet không phải PID 1.
2. Thu hoạch tiến trình mồ côi (reaping). Khi một tiến trình kết thúc, nó để lại một mục trong bảng tiến trình cho tới khi cha gọi wait() để đọc mã thoát. Nếu cha đã chết, tiến trình mồ côi được gán lại cho PID 1, và PID 1 có trách nhiệm thu hoạch chúng.
dotnet (PID 1)
└─ ffmpeg (PID 7) — kết thúc
└─ trở thành zombie, chờ PID 1 gọi wait()
└─ dotnet KHÔNG làm điều đó -> zombie tồn tại mãidocker exec crm-api ps aux | grep -c defunct1847Mỗi zombie chiếm một mục trong bảng tiến trình. Bảng đầy thì không tiến trình mới nào tạo được trong container:
System.ComponentModel.Win32Exception: Resource temporarily unavailable
at System.Diagnostics.Process.Start()3. Là gốc của cây tiến trình. Mọi tiến trình trong namespace PID đều là con cháu của PID 1. Khi PID 1 thoát, nhân giết mọi tiến trình còn lại trong namespace và container kết thúc.
Khi nào cần một init thật:
CẦN: ứng dụng sinh tiến trình con
(ffmpeg, chromium/puppeteer, imagemagick, wkhtmltopdf, pandoc,
lệnh shell qua Process.Start, script bên ngoài)
KHÔNG CẦN: API ASP.NET Core thuần tuý, không gọi Process.StartVới một API thông thường, dotnet làm PID 1 là hoàn toàn ổn: nó xử lý SIGTERM đúng, và không có tiến trình con nào để thu hoạch.
Cách thêm init:
# tini — nhỏ, chuyên cho container
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--", "dotnet", "Crm.Api.dll"]# Hoặc để Docker cung cấp — không cần sửa image
docker run --init crm-api# Kubernetes — không có --init, phải dùng tini trong image
# hoặc shareProcessNamespace với một container init riêngdocker exec crm-api ps -o pid,comm PID COMMAND
1 tini
7 dotnettini xử lý hai việc: chuyển tiếp mọi tín hiệu xuống tiến trình con, và gọi wait() cho mọi tiến trình mồ côi. Nó nhỏ (khoảng 20 KB) và không có chi phí đáng kể.
Xử lý tiến trình con đúng cách trong C# — thường tốt hơn là thêm init:
using var proc = new Process
{
StartInfo = new ProcessStartInfo("ffmpeg", args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
},
EnableRaisingEvents = true,
};
proc.Start();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromMinutes(5));
try
{
await proc.WaitForExitAsync(cts.Token); // gọi wait() -> không có zombie
}
catch (OperationCanceledException)
{
proc.Kill(entireProcessTree: true); // giết cả cây con
await proc.WaitForExitAsync(CancellationToken.None);
throw;
}Ba chi tiết quan trọng:
WaitForExitAsyncgọiwait(), nên không tạo zombie.entireProcessTree: truegiết cả cháu —ffmpegcó thể tự sinh tiến trình con.WaitForExitAsyncsauKill—Killgửi tín hiệu rồi trả về ngay, không chờ tiến trình thật sự chết.
Nếu mọi chỗ gọi Process đều làm đúng ba điều này, bạn không cần init. Nhưng init là mạng lưới an toàn cho những chỗ làm sai — và trong một codebase lớn, sẽ có những chỗ như vậy.
Giám sát zombie:
public class GiamSatZombieService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!OperatingSystem.IsLinux()) return;
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
{
var soZombie = Directory.GetDirectories("/proc")
.Where(d => int.TryParse(Path.GetFileName(d), out _))
.Count(d =>
{
try
{
var stat = File.ReadAllText(Path.Combine(d, "stat"));
return stat.Split(')').Last().TrimStart().StartsWith('Z');
}
catch { return false; }
});
if (soZombie > 10)
_logger.LogWarning("Có {SoZombie} tiến trình zombie", soZombie);
}
}
}Con số này nên luôn gần 0. Nếu nó tăng đều, có một chỗ gọi Process.Start mà không WaitForExit — và bạn có vài giờ trước khi bảng tiến trình đầy.
Bài 3 — Deploy dưới tải
Chạy tải liên tục rồi rollout restart, đếm số request thất bại trước và sau khi thêm preStop.
Tiêu chí hoàn thành: bạn đo được con số ở bốn cấu hình, và nêu được thứ tự áp dụng từng biện pháp.
Gợi ý và lời giải — Bài 3
Gợi ý. Có ba điều kiện để dừng mượt. Thiếu một điều kiện thì hai điều kiện kia có cứu được không?
Lời giải — bốn cấu hình, đo bằng cùng một phép thử:
do_test() {
hey -z 90s -c 50 -q 20 http://crm-api/leads > "$1.txt" &
sleep 15
kubectl rollout restart deployment/crm-api
kubectl rollout status deployment/crm-api --timeout=120s
wait
echo "=== $1 ==="
grep -A6 "Status code distribution" "$1.txt"
}Cấu hình A — shell form, không preStop:
ENTRYPOINT dotnet Crm.Api.dll[200] 41203 responses
[502] 187 responses
[504] 64 responses251 lỗi.
Cấu hình B — exec form, không preStop:
ENTRYPOINT ["dotnet", "Crm.Api.dll"][200] 42718 responses
[502] 58 responses58 lỗi. Tốt hơn nhiều, nhưng vẫn còn — vì endpoint chưa được xoá xong khi ứng dụng đã đóng listener.
Cấu hình C — shell form, có preStop:
lifecycle:
preStop:
exec: { command: ["/bin/sh", "-c", "sleep 5"] }[200] 41876 responses
[504] 142 responses142 lỗi. preStop sửa được vấn đề endpoint (không còn 502), nhưng không sửa được việc ứng dụng bị SIGKILL sau grace period — nên request đang bay vẫn bị cắt, và chúng hiện ra dưới dạng 504.
Cấu hình D — exec form, có preStop, ShutdownTimeout khớp:
ENTRYPOINT ["dotnet", "Crm.Api.dll"]lifecycle:
preStop:
exec: { command: ["/bin/sh", "-c", "sleep 5"] }
terminationGracePeriodSeconds: 30builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(20));[200] 43102 responses0 lỗi.
Bảng tổng hợp:
| Cấu hình | Exec form | preStop | Lỗi | Nguyên nhân còn lại |
|---|---|---|---|---|
| A | Không | Không | 251 | Cả hai vấn đề |
| B | Có | Không | 58 | Endpoint chưa xoá xong |
| C | Không | Có | 142 | Ứng dụng bị SIGKILL |
| D | Có | Có | 0 | — |
Điều quan trọng nhất từ bảng này: C không tốt hơn B bao nhiêu. Hai biện pháp sửa hai vấn đề khác nhau và độc lập, nên có một cái không bù được cho cái kia:
Exec form sửa: ứng dụng nhận được SIGTERM và dọn dẹp
-> không sửa: request đến trong lúc endpoint chưa xoá xong
preStop sửa: endpoint được xoá trước khi SIGTERM được gửi
-> không sửa: ứng dụng không xử lý SIGTERM thì vẫn bị cắtĐây là lý do phải có cả ba điều kiện, không phải chọn một.
Thứ tự áp dụng:
1. ENTRYPOINT exec form -> miễn phí, sửa 77% số lỗi
2. preStop sleep 5 -> một dòng YAML, sửa phần còn lại
3. ShutdownTimeout khớp grace -> một dòng C#, đảm bảo không bị cắt giữa chừng
4. maxUnavailable: 0 -> giữ dung lượng trong lúc deploy
5. PodDisruptionBudget -> bảo vệ khi bảo trì nodeBốn bước đầu mất khoảng 10 phút và loại bỏ hoàn toàn lỗi khi deploy. Đây có lẽ là tỷ lệ lợi ích trên công sức tốt nhất trong toàn bộ chủ đề vận hành.
Ba con số phải khớp nhau:
terminationGracePeriodSeconds (30)
= preStop sleep (5)
+ ShutdownTimeout (20)
+ biên an toàn (5)Kiểm tra bằng cách đọc log:
08:14:22.000 Pod nhận lệnh xoá
08:14:22.001 preStop bắt đầu: sleep 5
08:14:27.010 SIGTERM nhận lúc 2026-09-25T01:14:27Z
08:14:29.847 Đã dừng xong lúc 2026-09-25T01:14:29Z
-> tổng 7,8 s, còn dư 22,2 s trong grace periodNếu bạn không thấy dòng SIGTERM nhận lúc, điều kiện 1 chưa thoả. Nếu bạn thấy nó nhưng không thấy Đã dừng xong, điều kiện 3 chưa thoả — SIGKILL đã tới trước khi ứng dụng dọn xong.
Bốn cấu hình bổ sung cho deploy hoàn toàn không mất request:
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # pod mới Ready TRƯỚC khi pod cũ bị xoá
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
lifecycle:
preStop:
exec: { command: ["/bin/sh", "-c", "sleep 5"] }
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
failureThreshold: 2
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: crm-api }
spec:
minAvailable: 2
selector:
matchLabels: { app: crm-api }Và đưa phép đo vào pipeline, để không bao giờ hồi quy:
- name: Kiểm tra deploy không mất request
run: |
hey -z 90s -c 50 -q 20 "https://$STAGING_URL/leads" > kq.txt &
sleep 15
kubectl rollout restart deployment/crm-api -n staging
kubectl rollout status deployment/crm-api -n staging --timeout=180s
wait
LOI=$(grep -oP '\[(?!200)\d{3}\]\s+\K\d+' kq.txt | paste -sd+ | bc)
LOI=${LOI:-0}
echo "Số request thất bại: $LOI"
[ "$LOI" -eq 0 ] || { echo "Deploy làm mất request"; exit 1; }Bước này chạy trên staging trước khi promote lên production. Nó bắt được mọi hồi quy — ai đó đổi ENTRYPOINT, ai đó bỏ preStop, ai đó tăng thời gian dọn dẹp vượt quá grace period — và nó bắt được trước khi người dùng thật bị ảnh hưởng.
Tự kiểm tra
Vì sao shell form trong ENTRYPOINT gây mất request?
Vì Docker chạy ứng dụng qua /bin/sh, nên sh là PID 1 và nhận SIGTERM, còn nó không chuyển tiếp tín hiệu cho tiến trình con. Ứng dụng không biết mình sắp bị dừng, và 30 giây sau SIGKILL cắt ngang mọi thứ.
Làm sao kiểm tra container đang dùng form nào?
Chạy ps aux trong container và xem PID 1 là gì. Nếu thấy /bin/sh -c thì đang dùng shell form; nếu thấy dotnet thì đang dùng exec form.
Vì sao cần preStop sleep dù ứng dụng đã xử lý SIGTERM đúng?
Vì việc gỡ pod khỏi Service endpoint mất vài giây để lan tới mọi node. Trong khoảng đó, node chưa cập nhật vẫn gửi request tới pod đã ngừng nhận. preStop cho load balancer thời gian cập nhật trong khi pod vẫn phục vụ bình thường.
Quan hệ giữa các con số thời gian phải thế nào?
terminationGracePeriodSeconds phải lớn hơn tổng của preStop và ShutdownTimeout. Sai quan hệ này khiến cấu hình trông đúng nhưng ứng dụng không đủ thời gian hoàn thành request đang chạy.
Vì sao script khởi động cần từ khoá exec?
Vì không có nó, dotnet là tiến trình con của script và không nhận được SIGTERM. Từ khoá exec thay thế tiến trình shell bằng tiến trình mới và giữ nguyên PID, nên dotnet trở thành PID 1.
ApplicationStopping dùng để làm gì?
Nó chạy trước khi host dừng hẳn, đúng chỗ để đóng kết nối broker, huỷ đăng ký khỏi service discovery, hoặc đẩy nốt dữ liệu đang đệm trong bộ nhớ.
Kết luận
Ba điều đáng nhớ nhất:
- Exec form với dấu ngoặc vuông — shell form nuốt SIGTERM.
preStop sleepcho load balancer kịp cập nhật — cố tình chờ để không mất request.terminationGracePeriodSecondsphải lớn hơnpreStopcộngShutdownTimeout.
Tham khảo
- Docker build best practices
- Xây dựng container cho .NET
- Quản lý tài nguyên container trong Kubernetes
- Health checks trong ASP.NET Core
Điều hướng
- Bài trước: 15.12 — Mini case study
- Bài tiếp theo: 15.14 — Review and Assessment
- Về module: Trang mục lục