DevOps

Triển khai không gián đoạn: thay API thanh toán mà không cắt lệnh nào

Đo bốn cách dừng instance cũ của API thanh toán khi đang có tải, và vì sao phải rút khỏi load balancer, xả request rồi mới dừng.

Mục lục
  1. 1. Hai kiểu hỏng khi một instance dừng
  2. 2. Một instance .NET dừng thế nào
  3. 3. Mô hình: hai instance, một load balancer, 20 lệnh mỗi giây
  4. 4. Kết quả: năm lần mỗi cách
  5. 5. Thứ tự đúng: rút, xả, rồi mới dừng
  6. 6. Trên Kubernetes: preStop, SIGTERM và grace period
  7. 7. Rolling, blue-green và canary
  8. Những chỗ hay hiểu sai
  9. Đọc tiếp
  10. Nguồn

10:02 một sáng thứ Ba, đội vận hành BHPay triển khai bản mới của API thanh toán. Script dừng từng instance cũ bằng kill -9 rồi chạy bản mới, như mọi tuần. Lệnh nạp 2.000.000 đ vào ví 41206 của Lan đang chờ ngân hàng liên kết trả lời thì instance biến mất. Ngân hàng đã trừ tiền, app nhận lỗi và tự gửi lại. Lần triển khai đó cắt 5 lệnh đang chạy và làm 3 lệnh khác lỗi kết nối, CSKH nhận phản ánh "bị trừ tiền nhưng app báo thất bại" tới trưa (số minh họa). Bài này dựng một mô hình chạy cục bộ, đếm lệnh hỏng ở bốn cách dừng instance cũ, rồi đối chiếu với cách Kubernetes dừng một pod.

Đọc nhanh

  • Lệnh hỏng lúc triển khai có hai kiểu. Lỗi kết nối: lệnh chưa tới handler, gửi lại an toàn. Bị cắt giữa chừng: handler đã gọi cổng, không ai biết tiền đã trừ chưa. Kiểu sau mới gây trừ hai lần.
  • Mô hình 2 instance, 20 lệnh mỗi giây, mỗi cách chạy 5 lần. kill cắt 30–35 lệnh mỗi lần triển khai. StopApplication() ngay không cắt lệnh nào nhưng 14–20 lệnh lỗi kết nối, vì Kestrel đóng cổng trước khi load balancer biết. Rút khỏi load balancer, chờ 2 s rồi mới dừng: 0 lỗi ở cả 5 lần.
  • HostOptions.ShutdownTimeout mặc định 30 s trên .NET 10. Đặt 1 s khi lệnh dài tới 3 s: 1–4 lệnh bị cắt dù đã rút trước, và handler của chúng vẫn chạy xong sau khi app nhận lỗi.
  • Trên Kubernetes, SIGTERM và việc gỡ pod khỏi EndpointSlice diễn ra cùng lúc. preStop sleep là bước "chờ load balancer rút", và terminationGracePeriodSeconds tính cả thời gian của nó.

1. Hai kiểu hỏng khi một instance dừng

Một lệnh gửi tới instance đang dừng hỏng theo một trong hai cách. Cách thứ nhất: instance đã đóng cổng nghe, kết nối TCP bị từ chối, handler không chạy. Gửi lại lệnh đó cho instance khác là an toàn. Cách thứ hai: handler đã nhận lệnh và đang chờ ngân hàng hay cổng thanh toán thì kết nối bị đóng. App nhận lỗi, nhưng tiền có thể đã bị trừ. Đây là trường hợp "không biết đã trừ hay chưa" của idempotency key, và là giới hạn mà bài toán hai vị tướng cho thấy không giao thức nào tránh được.

Kiểu Phía server Client .NET thấy Gửi lại
Lỗi kết nối Cổng đã đóng, handler chưa chạy HttpRequestError.ConnectionError An toàn
Bị cắt giữa chừng Handler đã nhận lệnh, kết nối bị reset IOException bên trong HttpRequestException Chỉ với cùng idempotency key hoặc MaLenh

Client không phải lúc nào cũng phân biệt được hai kiểu: lệnh bị reset trước khi handler kịp chạy cũng hiện ra là IOException. Chỉ ConnectionError chắc chắn là server chưa nhận gì.

Load balancer có thể che kiểu thứ nhất, không che được kiểu thứ hai. nginx mặc định chuyển request sang upstream khác khi lỗi lúc mở kết nối (proxy_next_upstream error timeout), nhưng từ bản 1.9.13 không chuyển request POST đã gửi tới upstream, trừ khi bật non_idempotent. Mục tiêu của một lần triển khai vì vậy là 0 lệnh bị cắt, và càng ít lỗi kết nối càng tốt.

2. Một instance .NET dừng thế nào

kill -9 gửi SIGKILL: tiến trình chết ngay, không dòng code nào của app chạy thêm. Process.Kill() của .NET làm đúng việc đó, gửi SIGKILL trên Linux và gọi TerminateProcess trên Windows. SIGTERM đi đường khác. Từ .NET 6, ConsoleLifetime đăng ký bộ xử lý cho SIGINT, SIGQUIT và SIGTERM, và bộ xử lý gọi IHostApplicationLifetime.StopApplication(). Từ đó host dừng theo thứ tự:

  1. ApplicationStopping chạy, rồi host gọi IServer.StopAsync của Kestrel.
  2. Kestrel đóng socket nghe: kết nối mới bị từ chối từ lúc này. Kết nối đang mở được báo ngừng nhận request mới: HTTP/1.1 tắt keep-alive sau response hiện tại, HTTP/2 và HTTP/3 gửi GOAWAY.
  3. Request đang chạy được chạy tiếp tới hết HostOptions.ShutdownTimeout. Quá hạn, Kestrel abort các kết nối còn lại và chờ chúng thêm tối đa 1 giây (TransportConnectionManager.AbortAllConnectionsAsync).
  4. ApplicationStopped chạy, Main trả về, tiến trình thoát.

ShutdownTimeout mặc định 30 giây từ .NET 6, 5 giây ở .NET 5 trở về trước. API ở mục 3 in giá trị này lúc khởi động: 00:00:30 trên .NET 10.0.12. Đổi bằng khóa cấu hình shutdownTimeoutSeconds (số nguyên giây, ví dụ tham số --shutdownTimeoutSeconds 15) hoặc builder.Services.Configure<HostOptions>(...). Máy thử chạy Windows, nên bộ triển khai không gửi tín hiệu mà gửi chữ term qua stdin, và app gọi StopApplication(), đúng lời gọi mà SIGTERM dẫn tới trên Linux.

3. Mô hình: hai instance, một load balancer, 20 lệnh mỗi giây

Api.cs là API thanh toán giả. Endpoint /api/thanh-toan/{maLenh} chờ choMs mili giây thay cho lời gọi cổng. App ghi nhan khi handler bắt đầu và xong khi handler kết thúc. Sau lệnh rut, /healthz/ready trả 503 còn /healthz/live vẫn trả 200.

#:sdk Microsoft.NET.Sdk.Web
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
var app = builder.Build();
string ten = app.Configuration["ten"] ?? "api";
int dangChay = 0;        // lệnh thanh toán đang xử lý
bool sanSang = true;     // trả lời readiness
void Ghi(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {ten} {s}");

app.MapPost("/api/thanh-toan/{maLenh:int}", async (int maLenh, int choMs) =>
{
    Interlocked.Increment(ref dangChay);
    Ghi($"nhan {maLenh}");
    try
    {
        await Task.Delay(choMs);       // mô phỏng chờ cổng thanh toán, bộ tạo tải chọn 1–3 s
        return Results.Text($"da-tru {maLenh} tai {ten}");
    }
    finally { Interlocked.Decrement(ref dangChay); Ghi($"xong {maLenh}"); }
});
app.MapGet("/healthz/live", () => Results.Ok());
app.MapGet("/healthz/ready", () => sanSang ? Results.Ok() : Results.StatusCode(503));
app.Lifetime.ApplicationStarted.Register(() => Ghi(
    $"bat-dau .NET {Environment.Version}, ShutdownTimeout = {app.Services.GetRequiredService<IOptions<HostOptions>>().Value.ShutdownTimeout}"));
app.Lifetime.ApplicationStopping.Register(() => Ghi($"dang-dung dangChay={dangChay}"));
app.Lifetime.ApplicationStopped.Register(() => Ghi($"da-dung dangChay={dangChay}"));
_ = Task.Run(() =>       // lệnh của bộ triển khai qua stdin, thay cho tín hiệu
{
    for (string? lenh; (lenh = Console.ReadLine()) != null;)
    {
        if (lenh == "rut") { sanSang = false; Ghi($"rut dangChay={dangChay}"); }
        if (lenh == "term") app.Lifetime.StopApplication();     // SIGTERM trên Linux đi vào đúng lời gọi này
    }
});
app.Run();

TrienKhai.cs đóng ba vai, và là mô hình hóa vai trò của load balancer hay Kubernetes, không phải bản thật. Vai load balancer: mỗi giây hỏi /healthz/ready của mọi instance (timeout 500 ms) và chia lệnh vòng tròn cho các instance trả 200. Vai bộ tạo tải: 480 lệnh trong 24 giây, đều 20 lệnh mỗi giây, thời gian chờ cổng rút đều từ 1.000 đến 3.000 ms với hạt giống 42, nên lần chạy nào cũng gửi cùng một dãy lệnh. Mức này cao hơn nhiều so với cao điểm thật của BHPay (khoảng 40.000 giao dịch mỗi ngày), để mỗi lần đo có đủ lệnh mà đếm. Vai bộ triển khai: từ giây thứ 4, thay A1 bằng A2 rồi B1 bằng B2, mỗi bước chờ load balancer thấy instance mới sẵn sàng rồi mới dừng instance cũ. Đó là rolling update mặc định của Kubernetes với 2 replica: maxSurge 25% làm tròn lên thành 1, maxUnavailable 25% làm tròn xuống thành 0.

using System.Collections.Concurrent;
using System.Diagnostics;

int soLan = args.Length > 0 ? int.Parse(args[0]) : 5, cong = 5110, vong = 0;
string[] cachDung = ["kill", "term", "rut-roi-term", "rut-roi-term-1s"];
Process.Start("dotnet", "build Api.cs -o out-api -v q").WaitForExit();
string exe = Path.GetFullPath(Path.Combine("out-api", OperatingSystem.IsWindows() ? "Api.exe" : "Api"));
var rng = new Random(42);
int[] choMs = Enumerable.Range(0, 480).Select(_ => rng.Next(1000, 3001)).ToArray();   // 24 s x 20 lệnh
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
May[] cum = [];
string Gio() => DateTime.UtcNow.ToString("HH:mm:ss.fff");

Console.WriteLine($"{"cach",-16}{"lan",4}{"gui",5}{"ok",5}{"ket_noi",8}{"cat",5}{"cat_xong",9}{"p50_ms",8}{"p99_ms",8}{"dung_ms",12}");
for (int lan = 1; lan <= soLan; lan++)
    foreach (var cach in cachDung) { await MotLan(cach, lan, cong); cong += 4; }

async Task MotLan(string cach, int lan, int c)
{
    string[] them = cach.EndsWith("1s") ? ["--shutdownTimeoutSeconds", "1"] : [];
    May a1 = Khoi("A1", c, them), b1 = Khoi("B1", c + 1, them);
    cum = [a1, b1];
    using var het = new CancellationTokenSource();
    var kiem = KiemReadiness(het.Token);
    await ChoSanSang(a1); await ChoSanSang(b1);
    var viec = new List<Task<(int ma, May? may, string kq, double ms)>>();
    var tai = Task.Run(async () =>                // 20 lệnh mỗi giây trong 24 s
    {
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < choMs.Length; await Task.Delay(5))
            while (i < choMs.Length && i * 50 <= sw.ElapsedMilliseconds) viec.Add(Gui(i++));
    });
    await Task.Delay(4000);
    var dung = new List<long>();
    foreach (var (cu, ten, cm) in new[] { (a1, "A2", c + 2), (b1, "B2", c + 3) })
    {
        var moi = Khoi(ten, cm, them);            // rolling update: maxSurge 1, maxUnavailable 0
        cum = [.. cum, moi];
        await ChoSanSang(moi);
        var sw = Stopwatch.StartNew();
        if (cach == "kill") cu.P.Kill();
        else if (cach.StartsWith("rut")) { cu.P.StandardInput.WriteLine("rut"); await Task.Delay(2000); }
        if (cach != "kill") cu.P.StandardInput.WriteLine("term");
        await cu.P.WaitForExitAsync();
        dung.Add(sw.ElapsedMilliseconds);
        cu.Log.Enqueue($"{Gio()} {cu.Ten} thoat, nhan cuoi {cu.NhanCuoi}");
    }
    await tai;
    var kq = await Task.WhenAll(viec);
    het.Cancel(); await kiem;
    foreach (var m in cum.Where(m => !m.P.HasExited)) { m.P.StandardInput.WriteLine("term"); await m.P.WaitForExitAsync(); }
    // Lỗi mà instance đã ghi "nhan" là bị cắt giữa chừng; chưa ghi là lỗi kết nối.
    int ok = kq.Count(k => k.kq == "ok");
    int cat = kq.Count(k => k.kq != "ok" && k.may != null && k.may.DaNhan.ContainsKey(k.ma));
    int catXong = kq.Count(k => k.kq != "ok" && k.may != null && k.may.DaNhan.GetValueOrDefault(k.ma));
    var ms = kq.Where(k => k.kq == "ok").Select(k => k.ms).Order().ToArray();
    Console.WriteLine($"{cach,-16}{lan,4}{kq.Length,5}{ok,5}{kq.Length - ok - cat,8}{cat,5}{catXong,9}" +
        $"{ms[ms.Length / 2],8:F0}{ms[(int)(ms.Length * 0.99)],8:F0}{string.Join("+", dung),12}");
    foreach (var loi in kq.Where(k => k.kq != "ok").GroupBy(k => k.kq))
        Console.WriteLine($"    {loi.Key}: {loi.Count()}, trung binh {loi.Average(k => k.ms):F0} ms");
    if (lan == 1) foreach (var dong in a1.Log.Concat(b1.Log)) Console.WriteLine("    " + dong);
}

May Khoi(string ten, int c, string[] them)
{
    var psi = new ProcessStartInfo(exe, ["--urls", $"http://127.0.0.1:{c}", "--ten", ten, .. them])
        { RedirectStandardInput = true, RedirectStandardOutput = true };
    var m = new May(ten, c, Process.Start(psi)!);
    m.P.OutputDataReceived += (_, e) =>
    {
        if (e.Data is not { } d) return;
        if (d.Contains(" nhan ")) { m.DaNhan[int.Parse(d[(d.LastIndexOf(' ') + 1)..])] = false; m.NhanCuoi = d[..12]; }
        else if (d.Contains(" xong ")) m.DaNhan[int.Parse(d[(d.LastIndexOf(' ') + 1)..])] = true;
        else m.Log.Enqueue(d);
    };
    m.P.BeginOutputReadLine();
    return m;
}

async Task KiemReadiness(CancellationToken ct)    // vai trò load balancer: kiểm readiness mỗi 1 s
{
    using var kiem = new HttpClient { Timeout = TimeSpan.FromMilliseconds(500) };
    while (!ct.IsCancellationRequested)
        await Task.WhenAll([Task.Delay(1000), .. cum.Select(async m =>
        {
            bool san;
            try { using var r = await kiem.GetAsync($"http://127.0.0.1:{m.Cong}/healthz/ready"); san = r.IsSuccessStatusCode; }
            catch { san = false; }
            if (m.SanSang && !san) m.Log.Enqueue($"{Gio()} {m.Ten} LB bo ra");
            m.SanSang = san;
        })]);
}
async Task ChoSanSang(May m) { while (!m.SanSang) await Task.Delay(20); }

async Task<(int, May?, string, double)> Gui(int ma)
{
    var san = cum.Where(m => m.SanSang).ToArray();
    if (san.Length == 0) return (ma, null, "khong_co_may", 0);
    var m = san[Interlocked.Increment(ref vong) % san.Length];
    var sw = Stopwatch.StartNew();
    try
    {
        using var r = await http.PostAsync($"http://127.0.0.1:{m.Cong}/api/thanh-toan/{ma}?choMs={choMs[ma]}", null);
        return (ma, m, r.IsSuccessStatusCode ? "ok" : $"HTTP {(int)r.StatusCode}", sw.Elapsed.TotalMilliseconds);
    }
    catch (Exception e) { return (ma, m, (e as HttpRequestException)?.HttpRequestError.ToString() ?? e.GetType().Name, sw.Elapsed.TotalMilliseconds); }
}

sealed class May(string ten, int cong, Process p)
{
    public readonly string Ten = ten; public readonly int Cong = cong; public readonly Process P = p;
    public volatile bool SanSang; public string NhanCuoi = "";
    public readonly ConcurrentDictionary<int, bool> DaNhan = new();   // mã lệnh -> handler đã chạy xong chưa
    public readonly ConcurrentQueue<string> Log = new();
}

Đặt hai file cùng thư mục và chạy dotnet run TrienKhai.cs (file-based app, cần .NET SDK 10). Harness tự build Api.cs vào out-api và dùng các cổng 5110 đến 5189 trên 127.0.0.1. Bốn cách dừng instance cũ: kill gọi Process.Kill(), tương đương kill -9. term gửi term ngay, tức StopApplication() với ShutdownTimeout mặc định 30 s. rut-roi-term gửi rut, chờ 2 s, rồi gửi term. rut-roi-term-1s làm như vậy với instance chạy ShutdownTimeout 1 s. Lệnh lỗi được phân loại theo nhật ký của instance, không theo thông báo lỗi: instance đã ghi nhan là bị cắt, chưa ghi là lỗi kết nối (handler chưa chạy). cat_xong đếm lệnh bị cắt mà handler vẫn kịp ghi xong. Lệnh không tìm được instance nào sẵn sàng hiện ở dòng khong_co_may.

4. Kết quả: năm lần mỗi cách

Chạy trên laptop Intel Core Ultra 5 125U, Windows 11, .NET SDK 10.0.401 với runtime 10.0.12. Cả 20 lần chạy hết khoảng 10 phút, trong lúc máy chạy song song việc khác, nên thời gian dao động và số đếm là kết luận chính. Bảng gộp 5 lần: trung vị, trong ngoặc là thấp nhất và cao nhất. Mỗi lần có 480 lệnh và hai instance cũ được thay. Thời gian dừng tính từ lệnh đầu tiên gửi cho instance cũ đến lúc tiến trình thoát, trên 10 instance của mỗi cách.

Cách dừng Thành công Lỗi kết nối Bị cắt Dừng một instance
kill 430 (429–432) 18 (16–18) 33 (30–35) 10 ms (6–15)
term 460 (441–466) 18 (14–20) 0 2,6 s (2,2–3,2)
rut-roi-term 480 0 0 3,5 s (2,6–3,9)
rut-roi-term-1s 477 (476–479) 0 3 (1–4) 3,3 s (2,5–3,8)

Chỉ rút khỏi load balancer trước, với thời gian chờ đủ dài, mới cho 0 lệnh hỏng

Lỗi kết nốiBị cắt giữa chừng
kill51 lệnhterm ngay18 lệnhRút rồi term0 lệnhRút rồi term, timeout 1 s3 lệnh
Trung vị của 5 lần chạy, mỗi lần 480 lệnh, 20 lệnh mỗi giây, thay 2 instance. .NET 10.0.12, Windows 11, Intel Core Ultra 5 125U.
Bảng số liệu
Lỗi kết nốiBị cắt giữa chừngTổng
kill18 lệnh33 lệnh51 lệnh
term ngay18 lệnh0 lệnh18 lệnh
Rút rồi term0 lệnh0 lệnh0 lệnh
Rút rồi term, timeout 1 s0 lệnh3 lệnh3 lệnh

kill cắt gần đúng tổng số lệnh đang chạy trên hai instance cũ: ở lần chạy 1, nhật ký của ba cách còn lại ghi 15 đến 21 lệnh đang chạy trên mỗi instance lúc bắt đầu dừng (các dòng rut dangChay= và dang-dung dangChay=). Lỗi kết nối là lệnh load balancer còn gửi tới instance đã chết trong khoảng 1,4 giây, trước khi lần kiểm readiness kế tiếp bỏ nó ra.

term không cắt lệnh nào: 16 đến 19 lệnh đang chạy đều xong trong 2,2 đến 3,2 giây, xa dưới 30 giây. Nhưng số lỗi kết nối ngang kill, vì Kestrel đóng cổng nghe ngay khi StopApplication() chạy, còn load balancer chỉ biết sau 1,4 đến 1,5 giây. Trên máy thử, kết nối tới cổng đã đóng mất khoảng 2 giây mới báo lỗi (trung bình 2.034 đến 2.053 ms), nên lần kiểm readiness tới instance đó phải chờ hết timeout 500 ms.

Lần 2 và lần 4 của term còn có 7 và 21 lệnh bị load balancer từ chối vì không còn instance nào sẵn sàng (khong_co_may), không tính vào bảng. Hai lần đó có p99 cao bất thường, 3.197 và 5.053 ms, nên nhiều khả năng máy bị nghẽn làm lần kiểm của cả A2 và B2 quá 500 ms. Sáu lần chạy riêng cách term sau đó không gặp lại. Readiness probe của Kubernetes tránh kiểu bỏ nhầm này bằng mặc định 3 lần thất bại liên tiếp, mỗi lần timeout 1 giây.

rut-roi-term cho 0 lỗi ở cả 5 lần. Sau rut, load balancer bỏ instance ra sau 0,79 đến 1,00 giây (lần chạy 1), và lệnh đến trong khoảng đó vẫn được phục vụ vì cổng còn mở. Tới lúc term, mỗi instance chỉ còn 6 đến 8 lệnh, xả xong trong 1,0 đến 1,8 giây. Cái giá là thời gian: mỗi instance cũ mất 2,6 đến 3,9 giây để dừng thay vì khoảng 10 ms. Lệnh thành công không chậm đi: trừ hai lần máy nghẽn ở trên, p50 của 18 lần chạy còn lại từ 2.027 đến 2.047 ms và p99 từ 2.978 đến 3.006 ms, đúng bằng thời gian chờ cổng.

rut-roi-term-1s cắt 1 đến 4 lệnh mỗi lần, tùy số lệnh còn chạy quá 1 giây sau term. Ở mọi lần, cat_xong bằng cat: Kestrel đã reset kết nối, nhưng handler vẫn chờ cổng tiếp và ghi xong trước khi tiến trình thoát. Ở hệ thống thật, đó là lệnh ngân hàng đã trừ tiền, API đã có kết quả, còn app nhận lỗi. Handler dài hơn khoảng 1 giây Kestrel chờ thêm thì bị bỏ dở khi tiến trình thoát.

5. Thứ tự đúng: rút, xả, rồi mới dừng

Cách duy nhất cho 0 lỗi tách việc dừng thành ba bước, mỗi bước chờ bước trước có hiệu lực.

sequenceDiagram
  participant D as Bộ triển khai
  participant LB as Load balancer
  participant A1 as A1 (bản cũ)
  Note over D,A1: A2 (bản mới) đã sẵn sàng và có trong danh sách của LB
  D->>A1: rut
  LB->>A1: GET /healthz/ready
  A1-->>LB: 503, bỏ khỏi danh sách
  Note over D: Chờ 2 s, dài hơn một chu kỳ kiểm
  D->>A1: term, tức StopApplication()
  Note over A1: Đóng cổng, chạy nốt 6–8 lệnh
  A1-->>D: Thoát
Gửi term ngay LB gửi tới A1 bị từ chối kết nối LB bỏ ra 1,41 s A1 cổng đóng, xả 16 request đang chạy thoát 2,65 s Rút trước rồi term LB gửi tới A1 vẫn phục vụ LB bỏ ra 0,79 s A1 readiness 503, cổng mở xả 6 request term 2,01 s thoát 3,03 s 0 1 s 2 s 3 s 0 là lúc A1 nhận lệnh đầu tiên từ bộ triển khai (term hoặc rut)
Hai lần dừng instance A1 ở lần chạy 1, với 16 và 15 lệnh đang chạy lúc bắt đầu. Rút trước làm dừng chậm hơn 0,4 giây nhưng không request nào gặp cổng đã đóng.

Ba khoảng thời gian quyết định kết quả:

  1. Thời gian chờ sau khi rút phải dài hơn thời gian load balancer cần để ngừng gửi. Mô hình kiểm mỗi 1 giây, đo được 0,79–1,00 giây, chờ 2 giây.
  2. ShutdownTimeout phải dài hơn request dài nhất. Lệnh của mô hình dài tối đa 3 giây: 30 giây dư, 1 giây thì cắt. API thật gọi cổng với HttpClient.Timeout 10 giây như ở Một request HTTPS mất bao lâu, nên lệnh dài nhất khoảng 10 giây cộng thời gian database.
  3. Thời gian bộ điều phối chờ trước khi kill phải dài hơn tổng hai khoảng trên, nếu không là quay về cách kill.

Readiness và liveness phải là hai endpoint riêng. Lúc rút, /healthz/ready trả 503 để load balancer ngừng gửi, còn /healthz/live vẫn trả 200. Trên Kubernetes, container thất bại liveness quá ngưỡng thì bị kubelet khởi động lại, tức bị dừng giữa lúc đang xả.

6. Trên Kubernetes: preStop, SIGTERM và grace period

Theo tài liệu Pod Lifecycle, khi một pod bị xóa, pod chuyển sang Terminating và đồng hồ grace period bắt đầu chạy, mặc định terminationGracePeriodSeconds: 30. Sau đó:

  1. Kubelet chạy hook preStop nếu có, chờ hook xong, rồi mới gửi TERM tới process 1 của container. Grace period tính từ trước khi preStop chạy.
  2. Cùng lúc kubelet bắt đầu dừng pod, control plane đánh dấu endpoint của pod trong EndpointSlice là terminating với ready: false, để load balancer không dùng nó cho traffic thường.
  3. Hết grace period mà container chưa thoát thì container runtime gửi SIGKILL.

Bước 1 và bước 2 diễn ra cùng lúc, tài liệu không đặt thứ tự giữa chúng. Không có preStop, SIGTERM có thể tới khi kube-proxy, ingress controller hay load balancer ngoài cụm chưa nhận thay đổi của EndpointSlice. Kestrel đóng cổng ngay, và request còn được định tuyến tới pod gặp đúng kết quả của cách term: 14–20 lỗi kết nối mỗi lần triển khai trong mô hình. Mẫu graceful shutdown cho Kubernetes trong repo dotnet/dotnet-docker mô tả cùng hiện tượng với ingress: pod còn nhận request mới vài giây sau SIGTERM, vì ingress và control plane hoạt động độc lập.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bhpay-api
spec:
  replicas: 2
  strategy:
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  selector:
    matchLabels: { app: bhpay-api }
  template:
    metadata:
      labels: { app: bhpay-api }
    spec:
      terminationGracePeriodSeconds: 30         # phải lớn hơn 10 + 15
      containers:
        - name: api
          image: registry.example/bhpay-api:2.15
          args: ["--shutdownTimeoutSeconds", "15"]   # dài hơn request dài nhất (cổng timeout 10 s)
          readinessProbe: { httpGet: { path: /healthz/ready, port: 8080 }, periodSeconds: 2 }
          lifecycle:
            preStop: { sleep: { seconds: 10 } }   # chờ endpoint được gỡ khắp nơi rồi mới nhận SIGTERM

preStop sleep đóng vai bước chờ 2 giây của mô hình. Con số 10 giây là ví dụ: chọn theo thời gian pod còn nhận request sau khi bị xóa, đo trên chính cụm của bạn. Action sleep bật mặc định từ Kubernetes 1.30 (beta) và GA từ 1.34. Cụm cũ hơn dùng exec chạy lệnh sleep, cần image có lệnh đó. Không dùng được preStop, mẫu của dotnet/dotnet-docker thay IHostLifetime để gọi StopApplication() muộn vài giây sau SIGTERM.

Grace period 30 giây chứa 10 giây preStop, 15 giây ShutdownTimeout, tối đa 1 giây Kestrel chờ sau khi abort, và phần còn lại làm biên. ShutdownTimeout dài hơn phần còn lại sau preStop thì một lệnh chạy lâu sẽ gặp SIGKILL trước khi .NET kịp abort: quay về cách kill. Trong một lần triển khai, readiness probe lo phía khởi động: với maxUnavailable: 0, Deployment chỉ dừng pod cũ khi pod mới đã ready, như bước ChoSanSang của mô hình. Manifest đã kiểm cú pháp bằng kubernetes-validate 1.36.0 với schema Kubernetes 1.30 và 1.36. Bài không chạy nó trên cụm thật.

7. Rolling, blue-green và canary

Chiến lược triển khai quyết định bao nhiêu instance đổi bản cùng lúc. Nó không thay được thứ tự rút, xả, dừng: chiến lược nào cũng có lúc dừng instance cũ.

Chiến lược Instance cũ dừng khi nào Gắn với số đo
Rolling Từng cái, sau khi instance mới ready Như mô hình: cách dừng quyết định 0 hay 15–21 lệnh bị cắt ở mỗi instance cũ
Blue-green Cả môi trường cũ, sau khi router chuyển hết sang môi trường mới Chuyển router là bước rút cho mọi instance cùng lúc. Kill môi trường cũ ngay sau đó cắt mọi lệnh đang chạy, khoảng 40 ở tải mô hình (20 lệnh/s × 2 s, ước lượng). Quay lại bản cũ chỉ là chuyển router lần nữa, nếu môi trường cũ còn chạy
Canary Sau khi một phần traffic chạy bản mới đủ lâu Giới hạn số khách gặp lỗi của bản mới theo tỉ lệ replica, ví dụ 3:1 trong tài liệu Kubernetes. Không giảm lỗi do cách dừng

Những chỗ hay hiểu sai

  • "Graceful shutdown của .NET là đủ." Kestrel đóng cổng ngay khi StopApplication() chạy. Load balancer chưa biết thì lệnh mới bị từ chối: 14–20 lệnh mỗi lần triển khai trong mô hình.
  • "Pod được gỡ khỏi Service rồi mới nhận SIGTERM." Hai việc diễn ra cùng lúc. Muốn có thứ tự thì thêm preStop.
  • "terminationGracePeriodSeconds tính từ lúc SIGTERM." Nó tính từ trước khi preStop chạy.
  • "ShutdownTimeout ngắn cho triển khai nhanh." Ngắn hơn lệnh dài nhất là cắt lệnh, và handler có thể vẫn trừ tiền xong sau khi app đã nhận lỗi.

Đọc tiếp

Nguồn

Đọc tiếp