Skip to content

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à sh khô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 form ENTRYPOINT ["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 preStop và 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
# Dockerfile — shell form
ENTRYPOINT dotnet Crm.Api.dll
Docker 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
dockerfile
# 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ượt

Mộ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:

bash
docker exec <container> ps aux
# Neu thay "/bin/sh -c dotnet ..." o PID 1 -> dang dung shell form

15.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:

csharp
builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});

Với BackgroundService, bạn phải tự xử lý:

csharp
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 choi

Khoả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.

yaml
lifecycle:
  preStop:
    exec:
      command: ["sleep", "15"]      # CHỜ trước khi bắt đầu dừng

preStop 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 ​

yaml
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   +       30

Sai 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 ​

bash
# 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ả
wait
Status code distribution:
  [200]  14203 responses      <- TẤT CẢ thành công

Nế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:

bash
# 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:

dockerfile
# VAN co van de — script la PID 1
ENTRYPOINT ["/app/start.sh"]
bash
#!/bin/bash
# start.sh
dotnet Crm.Api.dll        # dotnet là tiến trình CON -> không nhận SIGTERM
bash
#!/bin/bash
# Sửa — exec THAY THẾ tiến trình shell
exec dotnet Crm.Api.dll   # dotnet TRO THANH PID 1

Từ 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:

csharp
// Đả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:

csharp
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:

dockerfile
ENTRYPOINT dotnet Crm.Api.dll
bash
docker run -d --name test -p 8080:8080 crm-api:shell
curl http://localhost:8080/cham &
sleep 0.5
time docker stop test
text
real    0m10.284s
bash
docker logs test | tail -3
curl: (52) Empty reply from server
text
info: 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:

dockerfile
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
bash
docker run -d --name test -p 8080:8080 crm-api:exec
curl http://localhost:8080/cham &
sleep 0.5
time docker stop test
text
real    0m2.847s
text
warn: 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
json
{"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 formExec form
PID 1/bin/shdotnet
Nhận SIGTERMKhôngCó
ApplicationStopping chạyKhôngCó
Thời gian dừng10,3 s (chờ hết grace period)2,8 s
Request đang chạyBị cắtHoàn tất
Cách kết thúcSIGKILLThoá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:

text
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ừng

Mắ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:

text
- 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ô:

text
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 deploy

Deploy 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:

dockerfile
# 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"]
bash
#!/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 đầu

Từ 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:

bash
docker exec crm-api ps -o pid,comm
text
  PID COMMAND
    1 dotnet          <- ĐÚNG
text
  PID COMMAND
    1 sh              <- SAI
    7 dotnet

Đưa vào CI để không bao giờ lọt qua:

bash
#!/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
fi

Kiể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:

bash
docker exec crm-api ps aux
text
USER   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 aux

Với image chiseled không có ps:

bash
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:

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

text
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ãi
bash
docker exec crm-api ps aux | grep -c defunct
text
1847

Mỗ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:

text
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:

text
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.Start

Vớ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:

dockerfile
# 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"]
bash
# Hoặc để Docker cung cấp — không cần sửa image
docker run --init crm-api
yaml
# Kubernetes — không có --init, phải dùng tini trong image
# hoặc shareProcessNamespace với một container init riêng
bash
docker exec crm-api ps -o pid,comm
text
  PID COMMAND
    1 tini
    7 dotnet

tini 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:

csharp
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:

  1. WaitForExitAsync gọi wait(), nên không tạo zombie.
  2. entireProcessTree: true giết cả cháu — ffmpeg có thể tự sinh tiến trình con.
  3. WaitForExitAsync sau Kill — Kill gử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:

csharp
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ử:

bash
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:

dockerfile
ENTRYPOINT dotnet Crm.Api.dll
text
[200] 41203 responses
[502] 187 responses
[504] 64 responses

251 lỗi.

Cấu hình B — exec form, không preStop:

dockerfile
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
text
[200] 42718 responses
[502] 58 responses

58 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:

yaml
lifecycle:
  preStop:
    exec: { command: ["/bin/sh", "-c", "sleep 5"] }
text
[200] 41876 responses
[504] 142 responses

142 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:

dockerfile
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
yaml
lifecycle:
  preStop:
    exec: { command: ["/bin/sh", "-c", "sleep 5"] }
terminationGracePeriodSeconds: 30
csharp
builder.Services.Configure<HostOptions>(o =>
    o.ShutdownTimeout = TimeSpan.FromSeconds(20));
text
[200] 43102 responses

0 lỗi.

Bảng tổng hợp:

Cấu hìnhExec formpreStopLỗiNguyên nhân còn lại
AKhôngKhông251Cả hai vấn đề
BCóKhông58Endpoint chưa xoá xong
CKhôngCó142Ứng dụng bị SIGKILL
DCó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:

text
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:

text
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ì node

Bố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:

text
terminationGracePeriodSeconds (30)
    = preStop sleep (5)
    + ShutdownTimeout (20)
    + biên an toàn (5)

Kiểm tra bằng cách đọc log:

text
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 period

Nế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:

yaml
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:

yaml
- 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:

  1. Exec form với dấu ngoặc vuông — shell form nuốt SIGTERM.
  2. preStop sleep cho load balancer kịp cập nhật — cố tình chờ để không mất request.
  3. terminationGracePeriodSeconds phải lớn hơn preStop cộng ShutdownTimeout.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.