DevOpsDocker Swarm và k3s, phần 2/8

Rolling update trên Swarm: lên 1.5.0 không cắt lệnh, bản lỗi tự rollback

Cập nhật BanHang.Api từ 1.4.0 lên 1.5.0 với update_config mặc định của Swarm cắt lệnh dài sau 10 giây và đẩy một bản treo lên cả 4 replica. start-first, stop_grace_period lớn hơn ShutdownTimeout, monitor và delay dài hơn thời gian phát hiện task treo cùng failure_action rollback giữ ít nhất 3/4 replica và tự rollback.

Mục lục
  1. 1. Vấn đề: lên 1.5.0 cắt lệnh đang chạy và đẩy bản treo lên cả cụm
  2. 2. Mục đích: không cắt lệnh nào, bản lỗi chỉ chạm một slot
  3. 3. Cơ sở lý thuyết: updater của Swarm chỉ thấy task thoát, không thấy request lỗi
  4. 4. Cách giải quyết: start-first, grace dài hơn .NET, delay và monitor dài hơn thời gian phát hiện
  5. 5. Cách cài đặt: update_config, rollback_config, ShutdownTimeout
  6. 6. Chứng minh: mô hình cập nhật, cấu hình đã kiểm, dừng .NET thật
  7. 7. Kết luận
  8. Đọc tiếp
  9. Nguồn

Đọc nhanh

  • Vấn đề: Lên 1.5.0 với update_config mặc định, Swarm dừng task cũ trước, giết lệnh dài sau 10 giây, và đẩy một bản treo lên cả 4 replica trước khi phát hiện.
  • Cách giải: order: start-first, stop_grace_period 20 giây lớn hơn ShutdownTimeout 15 giây, monitor và delay 90 giây dài hơn thời gian phát hiện task treo, failure_action: rollback.
  • Chứng minh: Mô hình theo mã nguồn swarmkit: bản treo chỉ chạm 1 slot và còn ít nhất 3/4 replica; .NET chạy thật: lệnh 14 giây bị cắt 5/5 lần ở grace 10 giây, 0/5 ở 20 giây; chưa dựng cụm thật.
  • Trong .NET: HostOptions.ShutdownTimeout nhỏ hơn stop_grace_period, healthcheck trỏ /healthz/live để Swarm thấy tiến trình treo, và một KiemStack.cs chặn cấu hình sai trong CI.

1. Vấn đề: lên 1.5.0 cắt lệnh đang chạy và đẩy bản treo lên cả cụm

14:05 thứ Năm 01/10, đội vận hành chạy docker stack deploy để đưa BanHang.Api từ 1.4.0 lên 1.5.0. Stack có healthcheck từ bài 1, còn mọi thiết lập cập nhật để mặc định. Hai chuyện xảy ra.

Thứ nhất, mỗi lần dừng một task cũ, Swarm chỉ chờ tối đa 10 giây sau SIGTERM rồi giết tiến trình. Lệnh ký hóa đơn điện tử đang chờ đối tác quá 10 giây bị cắt giữa chừng, dù BanHang.Api đặt ShutdownTimeout 15 giây.

Thứ hai, 1.5.0 có lỗi sync-over-async trong cache bảng giá: dưới tải thật, khoảng 20 giây sau khi mở cổng, thread pool cạn và mọi request treo. Swarm cập nhật xong cả 4 replica trong chưa đầy một phút, rồi healthcheck giết và khởi động lại chúng liên tục. Đến khi có người chạy docker service rollback, API chỉ còn 1–2 replica trả lời. Diễn biến này là minh họa; mô hình ở mục 6 với cùng cấu hình đếm 115.018 request hỏng trong 15 phút đầu.

2. Mục đích: không cắt lệnh nào, bản lỗi chỉ chạm một slot

  • Bản tốt: 0 request bị từ chối, 0 lệnh bị cắt, luôn đủ 4/4 replica trong lúc cập nhật, theo mô hình ở mục 6.
  • Bản treo sau 20 giây: chỉ 1 slot chạy bản lỗi, luôn còn ít nhất 3/4 replica tốt, và Swarm tự rollback.
  • Lệnh dài 14 giây đang chạy lúc task bị gỡ chạy xong, đo trên tiến trình .NET thật.
  • Ngoài phạm vi: lỗi chỉ lộ ở nghiệp vụ, như trả sai giá mà vẫn 200, vì Swarm không thấy; migration database đi cùng bản mới.

3. Cơ sở lý thuyết: updater của Swarm chỉ thấy task thoát, không thấy request lỗi

Updater cập nhật từng slot

Mỗi replica là một slot. Updater trong updater.go của swarmkit thay slot theo update_config:

Thiết lập Mặc định Ý nghĩa
parallelism 1 Số slot thay cùng lúc
order stop-first Dừng task cũ rồi mới chạy task mới, hoặc ngược lại với start-first
delay 0 s Chờ sau mỗi slot
monitor 5 s Cửa sổ canh lỗi, tính từ lúc task mới RUNNING
failure_action pause Khi có lỗi: pause, continue hoặc rollback
max_failure_ratio 0 Tỉ lệ slot lỗi được bỏ qua

Task mới RUNNING khi container chạy nếu không có healthcheck, hoặc khi lần kiểm đầu tiên thành công nếu có. Updater sang slot kế tiếp ngay khi task mới RUNNING và hết delay; nó không chờ hết monitor. Một lỗi là một task do lần cập nhật tạo ra kết thúc trước khi RUNNING, hoặc trong monitor kể từ lúc RUNNING. Với max_failure_ratio 0, một lỗi là đủ để pause hay rollback. Rollback chép spec cũ đè lên spec mới rồi chạy lại updater với rollback_config.

Updater không đếm request lỗi. Nó chỉ thấy task thoát. Một task treo chỉ thành lỗi khi healthcheck đánh dấu unhealthy và tiến trình thật sự thoát.

Swarm dừng một task thế nào

controller.go của moby dừng task theo bốn bước: gỡ task khỏi routing mesh, chờ 2 giây cho các node cập nhật bảng định tuyến, gửi SIGTERM, rồi gửi SIGKILL nếu tiến trình chưa thoát sau stop_grace_period, mặc định 10 giây.

Phía .NET, SIGTERM đi vào StopApplication(): Kestrel ngừng nhận kết nối mới và chạy tiếp request đang dở tới hết HostOptions.ShutdownTimeout, mặc định 30 giây từ .NET 6. Chi tiết nằm ở Triển khai không gián đoạn. Hai hệ quả:

  • Bước rút khỏi load balancer Swarm đã làm, nên BanHang.Api đặt RutChoGiay 0 trên Swarm.
  • stop_grace_period nhỏ hơn ShutdownTimeout thì Docker giết trước khi .NET xả xong. Mặc định 10 giây của Docker nhỏ hơn cả 30 giây mặc định của .NET.
sequenceDiagram
  participant S as Swarm
  participant N as Task mới 1.5.0
  participant C as Task cũ 1.4.0
  S->>N: tạo task, chạy container
  N-->>S: lần kiểm đầu tiên thành công: RUNNING, vào mesh
  S->>C: gỡ khỏi mesh
  Note over C: chờ 2 s
  S->>C: SIGTERM
  Note over C: .NET xả lệnh đang chạy, tối đa ShutdownTimeout 15 s
  C-->>S: thoát mã 0, hoặc SIGKILL khi hết stop_grace_period
  Note over S: chờ delay rồi sang slot sau, task mới thoát trong monitor là lỗi

Bao lâu thì Swarm biết một bản treo

Bản treo sau T giây chỉ thành lỗi khi tiến trình thoát. Thời gian đó, tính từ lúc task RUNNING, tối đa là:

T_phát_hiện = T + retries × (interval + 2) + 2 + stop_grace_period
            = 20 + 3 × (5 + 2) + 2 + 20 = 63 s

Số 2 trong ngoặc là thời gian KiemTra chờ một endpoint đã treo, số 2 sau đó là lúc gỡ task khỏi mesh trước SIGTERM.

Tiến trình treo không xử lý SIGTERM nên luôn chờ hết stop_grace_period. Với monitor 5 giây mặc định, task treo sau 20 giây thoát ngoài cửa sổ canh lỗi: updater coi cập nhật thành công, và restart policy khởi động lại bản lỗi mãi.

4. Cách giải quyết: start-first, grace dài hơn .NET, delay và monitor dài hơn thời gian phát hiện

Cách Ưu Nhược Khi nào dùng
Mặc định Swarm Cập nhật nhanh stop-first thiếu 1 replica mỗi slot, monitor 5 s bỏ sót bản treo Không dùng cho API
start-first, rollback, delay 0 Nhanh, tự rollback Bản lỗi chạm cả 4 slot trước khi bị phát hiện Lỗi lộ ngay lúc khởi động
start-first, rollback, delay và monitor 90 s Bản lỗi chạm 1 slot 4 replica mất gần 8 phút API trụ sở
Hai service xanh và lam, đổi ở load balancer Quay lại tức thì Gấp đôi tài nguyên, đổi tay Bản thay đổi lớn

Bài chọn dòng thứ ba:

  1. order: start-first cho cả cập nhật lẫn rollback, để luôn có 4 replica phục vụ. Cụm cần chỗ cho task thứ năm: reservations 0,5 CPU và 512 MB.
  2. stop_grace_period 20 giây, lớn hơn ShutdownTimeout 15 giây cộng 1 giây Kestrel chờ thêm sau khi abort kết nối.
  3. monitor và delay 90 giây, lớn hơn 63 giây phát hiện. Slot sau chỉ bắt đầu khi slot trước đã qua cửa sổ đó, nên một bản treo trong 47 giây đầu chỉ chạm một slot.
  4. failure_action: rollback, max_failure_ratio: 0.
  5. Thêm KiemStack.cs vào CI để chặn các luật trên, vì docker stack config không kiểm chúng.

5. Cách cài đặt: update_config, rollback_config, ShutdownTimeout

Phiên bản như bài 1: Docker Engine 29.8.2, .NET 10.0.12. Phần stack của bài này:

services:
  api:
    stop_grace_period: 20s          # > ShutdownTimeout 15 s + 1 s
    environment:
      RutChoGiay: "0"               # Swarm đã gỡ task khỏi mesh 2 s trước SIGTERM
    deploy:
      update_config:
        parallelism: 1
        delay: 90s                  # >= 63 s phát hiện task treo
        order: start-first
        failure_action: rollback
        monitor: 90s
        max_failure_ratio: 0
      rollback_config:
        parallelism: 1
        order: start-first
        failure_action: pause
        monitor: 30s

Trong BanHang.Api.cs ở bài 1, hai dòng quyết định thời gian dừng:

builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(15));
var rutCho = TimeSpan.FromSeconds(builder.Configuration.GetValue("RutChoGiay", 0));

Cập nhật bằng chính file stack, để lần docker stack deploy sau không kéo service về tag cũ, rồi theo dõi:

# image: registry.banhang.local/banhang/api:1.5.0 trong banhang-api.stack.yml
docker stack deploy -c banhang-api.stack.yml banhang
docker service inspect banhang_api --format '{{json .UpdateStatus}}'
docker service ps banhang_api --format '{{.Name}} {{.Image}} {{.CurrentState}} {{.Error}}'

KiemStack.cs đọc file stack đã render và kiểm năm nhóm luật: giá trị liệt kê, grace so với ShutdownTimeout, healthcheck, thời gian phát hiện, và bí mật cho bài 3. Mã thoát bằng số vi phạm.

KiemStack.cs: luật cho stack mà docker stack config không kiểmC# · 71 dòng
#:package YamlDotNet@18.1.0
// KiemStack.cs: luật mà docker stack config không kiểm. Mã thoát = số vi phạm, dùng được trong CI.
// docker stack config -c banhang-api.stack.yml > render.yml
// dotnet run KiemStack.cs -- render.yml [Dockerfile]
using System.Text.RegularExpressions;
using YamlDotNet.RepresentationModel;

const double ShutdownTimeout = 15, KiemTraTimeout = 2, Gossip = 2, TreoToiDa = 20;   // theo BanHang.Api.cs và bài 2
string[] khoaCanCo = ["ConnectionStrings__BanHang", "BHPay__ApiKey"];                // khóa app đọc qua KeyPerFile
var biMat = new Regex(@"(?i)(password|pwd)\s*=|bhpay_\w+_sk_|api_?key|secret");
var loi = new List<string>();
var yaml = new YamlStream(); yaml.Load(new StreamReader(args[0]));
var goc = (YamlMappingNode)yaml.Documents[0].RootNode;

foreach (var (tenNode, svNode) in (YamlMappingNode)goc["services"])
{
    var sv = (YamlMappingNode)svNode; string ten = tenNode.ToString();
    string? Lay(params string[] duong)
    {
        YamlNode n = sv;
        foreach (var d in duong) { if (n is not YamlMappingNode m || !m.Children.TryGetValue(d, out var con)) return null; n = con; }
        return n.ToString();
    }
    void Phai(bool dung, string thongBao) { if (!dung) loi.Add($"{ten}: {thongBao}"); }

    // 1. Giá trị liệt kê: docker stack config để lọt "rollbak"
    Phai(Lay("deploy", "update_config", "order") is null or "start-first" or "stop-first", "update_config.order sai");
    Phai(Lay("deploy", "update_config", "failure_action") is null or "pause" or "continue" or "rollback", "update_config.failure_action sai");
    Phai(Lay("deploy", "rollback_config", "failure_action") is null or "pause" or "continue", "rollback_config.failure_action sai");
    Phai(Lay("deploy", "restart_policy", "condition") is null or "none" or "on-failure" or "any", "restart_policy.condition sai");

    // 2. Docker phải chờ lâu hơn .NET: grace > ShutdownTimeout + RutChoGiay + 1 s
    double grace = Giay(Lay("stop_grace_period") ?? "10s");
    double rut = double.Parse(Lay("environment", "RutChoGiay") ?? "0");
    Phai(grace > ShutdownTimeout + rut + 1, $"stop_grace_period {grace} s phải lớn hơn {ShutdownTimeout + rut + 1} s");

    // 3. Healthcheck: có, không dựa vào curl/wget (image ASP.NET Core không có), timeout dài hơn KiemTra
    var test = (sv.Children.TryGetValue("healthcheck", out var hc) && ((YamlMappingNode)hc).Children.TryGetValue("test", out var tt))
        ? ((YamlSequenceNode)tt).Select(x => x.ToString()).ToArray() : [];
    Phai(test.Length > 1 && test[0] != "NONE", "thiếu healthcheck: task vào mesh trước khi Kestrel mở cổng");
    Phai(!test.Any(x => x.Contains("curl") || x.Contains("wget")), "healthcheck dùng curl/wget");
    double interval = Giay(Lay("healthcheck", "interval") ?? "30s"), timeout = Giay(Lay("healthcheck", "timeout") ?? "30s");
    int retries = int.Parse(Lay("healthcheck", "retries") ?? "3");
    Phai(timeout > KiemTraTimeout, $"healthcheck.timeout {timeout} s phải lớn hơn {KiemTraTimeout} s của KiemTra");

    // 4. Task treo sau TreoToiDa giây phải bị phát hiện trong monitor, trước khi slot sau được cập nhật
    double phatHien = TreoToiDa + retries * (interval + KiemTraTimeout) + Gossip + grace;
    Phai(Lay("deploy", "update_config", "failure_action") == "rollback", "update_config.failure_action phải là rollback");
    Phai(Giay(Lay("deploy", "update_config", "monitor") ?? "5s") >= phatHien, $"update_config.monitor phải từ {phatHien} s");
    Phai(Giay(Lay("deploy", "update_config", "delay") ?? "0s") >= phatHien, $"update_config.delay phải từ {phatHien} s");

    // 5. Bí mật không nằm trong biến môi trường; secret có đủ khóa app cần
    if (sv.Children.TryGetValue("environment", out var env))
        foreach (var (k, v) in (YamlMappingNode)env)
            Phai(!biMat.IsMatch(k.ToString()) && !biMat.IsMatch(v.ToString()), $"biến môi trường {k} chứa bí mật");
    var dich = sv.Children.TryGetValue("secrets", out var sc)
        ? ((YamlSequenceNode)sc).Select(x => x is YamlMappingNode m && m.Children.TryGetValue("target", out var tg) ? tg.ToString() : x.ToString()).ToHashSet() : [];
    foreach (var k in khoaCanCo) Phai(dich.Contains(k), $"thiếu secret có target {k}");
}
if (args.Length > 1)   // Dockerfile: ENV/ARG mang bí mật thì nằm trong mọi bản image
    foreach (var (dong, so) in File.ReadLines(args[1]).Select((d, i) => (d.Trim(), i + 1)))
        if (Regex.IsMatch(dong, @"^(ENV|ARG)\s", RegexOptions.IgnoreCase) && biMat.IsMatch(dong))
            loi.Add($"Dockerfile dòng {so}: {dong.Split(' ', '=')[0]} {dong.Split(' ', 3)[1].Split('=')[0]} mang bí mật vào image");

foreach (var l in loi) Console.WriteLine("VI PHẠM " + l);
Console.WriteLine($"{loi.Count} vi phạm");
return loi.Count;

static double Giay(string s) => Regex.Matches(s, @"([\d.]+)(h|ms|m|s)").Sum(m =>
    double.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture) *
    m.Groups[2].Value switch { "h" => 3600, "m" => 60, "ms" => 0.001, _ => 1 });

6. Chứng minh: mô hình cập nhật, cấu hình đã kiểm, dừng .NET thật

Bằng chứng có ba loại, và bài chưa dựng cụm thật. Số replica và số request dưới đây là của mô hình, không phải số đo trên cụm.

Cấu hình đã qua công cụ kiểm

docker stack config của Docker CLI 28.0.4 và docker compose config của Compose v2.34.0 đọc cả stack trước và sau không lỗi. KiemStack.cs, YamlDotNet 18.1.0, .NET 10.0.12:

> dotnet run KiemStack.cs -- bai2-truoc.render.yml
VI PHẠM api: stop_grace_period 10 s phải lớn hơn 16 s
VI PHẠM api: update_config.failure_action phải là rollback
VI PHẠM api: update_config.monitor phải từ 53 s
VI PHẠM api: update_config.delay phải từ 53 s
4 vi phạm
> dotnet run KiemStack.cs -- banhang-api.render.yml Dockerfile
0 vi phạm

Ở stack trước, grace 10 giây nên thời gian phát hiện là 53 giây, không phải 63.

Mô hình cập nhật 4 replica

MoHinhCapNhat.cs cài lại luật của updater.go, restart.go, controller.go và health.go ở bước 10 ms. Giả định: container chạy 1 giây sau khi task được tạo, Kestrel mở cổng sau 1,82 giây như đo ở bài 1, 300 request mỗi giây chia vòng tròn cho task trong mesh, 1 trên 50 request là lệnh gọi đối tác dài 1–14 giây, bản lỗi treo 20 giây sau khi mở cổng. Kết quả 15 phút đầu:

Bản tốt, đếm trong 15 phút: bị từ chối là request vào task chưa mở cổng, bị cắt là lệnh đang chạy khi tiến trình bị giết.

Cấu hình Bị từ chối Bị cắt Replica tốt ít nhất Cập nhật xong
Mặc định, không healthcheck 682 1 2/4 46 s
Stack bài 1, có healthcheck 0 2 3/4 55 s
+ start-first, grace 20 s 0 0 4/4 17 s
+ rollback, monitor 90 s 0 0 4/4 102 s
+ delay 90 s, cấu hình chọn 0 0 4/4 463 s

Bản treo: request hỏng gồm bị từ chối, bị cắt, vào task đã treo, và lúc mesh không còn task nào.

Cấu hình Request hỏng Replica tốt ít nhất Slot chạy bản lỗi Tự rollback
Mặc định, không healthcheck 258.017 0/4 4 không
Stack bài 1, có healthcheck 115.018 1/4 4 không
+ start-first, grace 20 s 167.540 0/4 4 không
+ rollback, monitor 90 s 12.105 0/4 4 giây 60
+ delay 90 s, cấu hình chọn 1.107 3/4 1 giây 60

Bản 1.5.0 treo: chỉ cấu hình có delay 90 s giữ ít nhất 3 replica tốt

Stack bài 1, cập nhật mặc địnhstart-first, rollback, delay 0Cấu hình chọn, delay 90 s

Replica tốt trong mesh

012340102030405060708090100110120

Giây

Mô hình MoHinhCapNhat.cs, mẫu mỗi 10 giây trong 120 giây đầu. Replica tốt là task trong mesh, đã mở cổng và chưa treo. Chưa dựng cụm thật.
Bảng số liệu
GiâyStack bài 1, cập nhật mặc địnhstart-first, rollback, delay 0Cấu hình chọn, delay 90 s
0344
10344
20344
30213
40203
50203
60203
70114
80144
90244
100144
110144
120244
  • Không healthcheck, 682 request rơi vào task đã vào mesh mà Kestrel chưa mở cổng, và updater có lúc chỉ còn 2/4 vì không chờ app sẵn sàng.
  • Stack bài 1 với cập nhật mặc định: task treo chỉ thoát khoảng 40–53 giây sau khi RUNNING, ngoài monitor 5 giây, nên bản lỗi chạy mãi và cụm dao động 1–2 replica.
  • start-first không có delay còn tệ hơn với bản lỗi: 4 slot cùng treo gần như cùng lúc, có lúc không còn task nào trong mesh.
  • Cấu hình chọn: rollback bắt đầu ở giây 60, đúng thời gian phát hiện của slot đầu, và 3 slot còn lại chưa từng chạy bản lỗi. Giá phải trả: bản tốt mất 463 giây cho 4 replica.
MoHinhCapNhat.cs: luật cập nhật của swarmkit và moby, 5 cấu hình, bản tốt và bản treoC# · 238 dòng
// MoHinhCapNhat.cs: mô hình cập nhật 4 replica 1.4.0 lên 1.5.0 trên Swarm. Không phải cụm thật.
// dotnet run MoHinhCapNhat.cs -- [giây khởi động tới mở cổng] [giây dừng .NET khi rảnh]
// Luật lấy từ mã nguồn, nhánh master ngày 2026-10-04:
//  - swarmkit update/updater.go: từng slot theo parallelism; start-first gỡ task cũ khi task mới RUNNING;
//    stop-first chờ task cũ dừng mới chạy task mới; task của lần cập nhật kết thúc khi chưa RUNNING hoặc
//    trong monitor kể từ lúc RUNNING là một lỗi; lỗi/số slot > max_failure_ratio thì pause hoặc rollback;
//    hết slot thì canh thêm monitor, monitor = delay + 1 s nếu delay >= monitor.
//  - moby executor/container/controller.go: có healthcheck thì task RUNNING và vào mesh khi healthy, không có
//    thì ngay khi container chạy; dừng task: gỡ khỏi mesh, chờ 2 s, SIGTERM, quá stop_grace_period thì SIGKILL;
//    unhealthy thì dừng task như trên, task kết thúc lúc tiến trình thoát.
//  - moby daemon/health.go: lần kiểm đầu sau start_interval, trong start_period chỉ dùng start_interval khi còn
//    "starting"; lỗi trong start_period khi chưa từng khỏe không tính; retries lỗi liên tiếp là unhealthy.
//  - swarmkit restart/restart.go: task hỏng được thay sau restart delay 5 s, theo spec hiện tại của service.
// Giả định của mô hình: container chạy 1 s sau khi task được tạo (image có sẵn), 300 request/s chia vòng tròn cho
// task trong mesh, 1 trên 50 request là lệnh gọi đối tác (BHPay, ký hóa đơn) dài 1–14 s, còn lại 50 ms; bản lỗi treo 20 s sau khi mở cổng.
double khoiDong = args.Length > 0 ? double.Parse(args[0]) : 1.817;   // trung vị tới live 200, DoKhoiDong.cs ở bài 1
double dungNet = args.Length > 1 ? double.Parse(args[1]) : 0.06;   // dừng lúc rảnh, DungMem.cs
var cauHinh = new CauHinh[]
{
    new("Mặc định Swarm",           Hc: false, StartFirst: false, Delay: 0,  Monitor: 5,  Rollback: false, Grace: 10),
    new("+ healthcheck",            Hc: true,  StartFirst: false, Delay: 0,  Monitor: 5,  Rollback: false, Grace: 10),
    new("+ start-first, grace 20 s",Hc: true,  StartFirst: true,  Delay: 0,  Monitor: 5,  Rollback: false, Grace: 20),
    new("+ rollback, monitor 90 s", Hc: true,  StartFirst: true,  Delay: 0,  Monitor: 90, Rollback: true,  Grace: 20),
    new("+ delay 90 s (chọn)",      Hc: true,  StartFirst: true,  Delay: 90, Monitor: 90, Rollback: true,  Grace: 20),
};
Console.WriteLine($"Khởi động tới mở cổng {khoiDong:F2} s, .NET dừng khi rảnh {dungNet:F2} s, 15 phút đầu");
Console.WriteLine($"{"cấu hình",-27}{"bản",-5}{"từ chối",8}{"bị cắt",7}{"treo",7}{"không task",11}{"ít nhất",8}{"0 task tốt",11}{"slot lỗi",9}{"kết thúc",20}");
var chuoi = new Dictionary<string, List<(double t, int tot)>>();
foreach (var ch in cauHinh)
    foreach (bool loi in new[] { false, true })
    {
        var mp = new MoPhong(ch, loi, khoiDong, dungNet); var kq = mp.Chay();
        if (loi) chuoi[ch.Ten] = mp.ChuoiTot;
        Console.WriteLine($"{ch.Ten,-27}{(loi ? "lỗi" : "tốt"),-5}{kq.TuChoi,8}{kq.BiCat,7}{kq.Treo,7}{kq.KhongTask,11}{kq.ItNhat + "/4",8}{kq.GiayKhongTot,10:F0}s{kq.SlotLoi,9}{kq.KetThuc,20}");
    }

Console.WriteLine();
Console.WriteLine("Số task tốt trong mesh, bản lỗi, mỗi 10 s từ 0 đến 120 s:");
foreach (var (ten, c) in chuoi) Console.WriteLine($"{ten,-27}{string.Join(",", c.Select(x => x.tot))}");

record CauHinh(string Ten, bool Hc, bool StartFirst, double Delay, double Monitor, bool Rollback, double Grace);
record KetQua(int TuChoi, int BiCat, int Treo, int KhongTask, int ItNhat, double GiayKhongTot, int SlotLoi, string KetThuc);

sealed class Task1(int slot, bool banMoi, bool loi, double tao, double chay)
{
    public int Slot = slot; public bool BanMoi = banMoi, Loi = loi;
    public double Tao = tao, Chay = chay;                 // Chay: lúc container chạy (NaN: đang chờ task cũ dừng)
    public double MoCong = double.NaN, Treo = double.PositiveInfinity, Running = double.NaN;
    public bool TrongMesh, MuonDung, KetThuc;
    public double GoMesh = double.NaN, Sigterm = double.NaN, Kill = double.NaN, Thoat = double.NaN;
    // healthcheck
    public double KiemTiep = double.NaN; public int ChuoiLoi; public bool TungKhoe, KhongKhoe;
    public List<(double ketThuc, int id)> DangXuLy = [];
}

sealed class MoPhong(CauHinh ch, bool banLoi, double khoiDong, double dungNet)
{
    const double Dt = 0.01, HetGio = 900, TaiReq = 300, ChayContainer = 1, TreoSau = 20, ShutdownTimeout = 15;
    const double Interval = 5, Timeout = 2, StartPeriod = 60, StartInterval = 1, ChayKiem = 0.1, RestartDelay = 5, Gossip = 2;
    const int Retries = 3;
    readonly List<Task1> tasks = [];
    double t;
    int tuChoi, biCat, treo, khongTask, vong, reqId;
    int itNhat = 4; double giayKhongTot; int slotLoi;
    // updater: danh sách slot cần cập nhật, cấu hình đang dùng, spec hiện tại của service
    bool specMoi = true, dangRollback, dungCapNhat; string ketThuc = ""; double phatHien = double.NaN;
    Queue<int> slotCho = new(); Task1? moi, cu; double choDelayToi = double.NaN, monitorHet = double.NaN;
    readonly Dictionary<Task1, double> taskCapNhat = [];   // task do updater tạo -> lúc RUNNING (NaN nếu chưa)
    readonly List<(double luc, int slot)> restartCho = [];
    public readonly List<(double t, int tot)> ChuoiTot = [];   // số task tốt trong mesh, mỗi 10 s

    public KetQua Chay()
    {
        for (int s = 0; s < 4; s++) { var k = new Task1(s, false, false, -30, -30); BatDau(k); k.Chay = -29; k.MoCong = -28; k.Running = -27; k.TrongMesh = true; k.TungKhoe = true; k.KiemTiep = 0; tasks.Add(k); }
        for (int s = 0; s < 4; s++) slotCho.Enqueue(s);
        double denReq = 0;
        for (int buoc = 0; buoc * Dt < HetGio; buoc++)
        {
            t = buoc * Dt;
            BuocUpdater();
            ChayKhiCuDung();
            foreach (var k in tasks.ToArray()) BuocTask(k);
            BuocRestart();
            for (; denReq <= t; denReq += 1 / TaiReq) GuiRequest();
            int tot = tasks.Count(k => k.TrongMesh && !k.KetThuc && t >= k.MoCong && t < k.Treo);
            itNhat = Math.Min(itNhat, tot);
            if (tot == 0) giayKhongTot += Dt;
            if (buoc % 1000 == 0 && t <= 120) ChuoiTot.Add((t, tot));
        }
        slotLoi = tasks.Where(k => k.Loi && !double.IsNaN(k.Running)).Select(k => k.Slot).Distinct().Count();
        if (ketThuc == "") ketThuc = dungCapNhat ? "dừng giữa chừng" : "chưa xong";
        return new(tuChoi, biCat, treo, khongTask, itNhat, giayKhongTot, slotLoi, ketThuc);
    }

    void BatDau(Task1 k) { k.Chay = t + ChayContainer; k.MoCong = k.Chay + khoiDong; if (k.Loi) k.Treo = k.MoCong + TreoSau; k.KiemTiep = k.Chay + StartInterval; }

    Task1 TaoTask(int slot, bool choCuDung)
    {
        bool moiSpec = specMoi;
        var k = new Task1(slot, moiSpec, moiSpec && banLoi, t, double.NaN);
        if (!choCuDung) BatDau(k);
        tasks.Add(k);
        return k;
    }

    void YeuCauDung(Task1 k)
    {
        if (k.MuonDung || k.KetThuc) return;
        k.MuonDung = true;
        if (double.IsNaN(k.Chay)) { k.KetThuc = true; k.Thoat = t; return; }   // chưa chạy thì bỏ luôn
        k.TrongMesh = false; k.GoMesh = t; k.Sigterm = t + Gossip; k.Kill = k.Sigterm + ch.Grace;
    }

    void BuocTask(Task1 k)
    {
        if (k.KetThuc) return;
        if (double.IsNaN(k.Chay)) return;   // stop-first: chờ task cũ dừng
        if (t < k.Chay) return;
        // healthcheck theo daemon/health.go
        if (ch.Hc && !k.MuonDung && t >= k.KiemTiep)
        {
            bool ok = t >= k.MoCong && t < k.Treo;
            double tg = t >= k.Treo ? Timeout : ChayKiem;     // treo: KiemTra chờ hết 2 s
            if (ok) { k.ChuoiLoi = 0; k.TungKhoe = true; }
            else if (k.TungKhoe || t - k.Chay >= StartPeriod) { if (++k.ChuoiLoi >= Retries) k.KhongKhoe = true; }
            double ke = (t - k.Chay < StartPeriod && !k.TungKhoe) ? StartInterval : Interval;
            k.KiemTiep = t + tg + ke;
            if (ok && double.IsNaN(k.Running)) { k.Running = t + tg; k.TrongMesh = true; DanhDauRunning(k); }
            if (k.KhongKhoe) YeuCauDung(k);    // controller.Wait: unhealthy thì Shutdown
        }
        if (!ch.Hc && double.IsNaN(k.Running)) { k.Running = k.Chay; k.TrongMesh = true; DanhDauRunning(k); }
        // dừng: SIGTERM rồi .NET xả request; quá ShutdownTimeout thì cắt; quá grace thì SIGKILL
        if (!double.IsNaN(k.Sigterm) && t >= k.Sigterm)
        {
            bool treo = t >= k.Treo;
            double xong = k.DangXuLy.Count == 0 ? k.Sigterm + dungNet : Math.Max(k.DangXuLy.Max(r => r.ketThuc), k.Sigterm + dungNet);
            double hanNet = k.Sigterm + ShutdownTimeout + dungNet;
            double thoat = treo ? k.Kill : Math.Min(Math.Min(xong, hanNet), k.Kill);
            if (t >= thoat)
            {
                foreach (var r in k.DangXuLy) if (r.ketThuc > t) biCat++;
                k.DangXuLy.Clear();
                k.KetThuc = true; k.Thoat = t;
                TaskKetThuc(k);
                return;
            }
        }
        // request xong hoặc kẹt khi tiến trình treo
        if (t >= k.Treo) { treo += k.DangXuLy.Count; k.DangXuLy.Clear(); }
        else k.DangXuLy.RemoveAll(r => r.ketThuc <= t);
    }

    void GuiRequest()
    {
        int id = reqId++;
        double dai = id % 50 == 0 ? 1 + (id / 50) % 14 : 0.05;   // lệnh gọi đối tác 1–14 s
        var mesh = tasks.Where(k => k.TrongMesh && !k.KetThuc).ToArray();
        if (mesh.Length == 0) { khongTask++; return; }
        var k = mesh[vong++ % mesh.Length];
        if (t < k.MoCong) { tuChoi++; return; }         // trong mesh nhưng Kestrel chưa nghe: kết nối bị từ chối
        if (t >= k.Treo) { treo++; return; }
        k.DangXuLy.Add((t + dai, id));
    }

    void DanhDauRunning(Task1 k) { if (taskCapNhat.ContainsKey(k) && double.IsNaN(taskCapNhat[k])) taskCapNhat[k] = t; }

    void TaskKetThuc(Task1 k)
    {
        if (k.MuonDung && !k.KhongKhoe) return;    // task bị gỡ có chủ đích
        // task hỏng: updater xét lỗi, restart policy tạo task thay thế theo spec hiện tại
        if (taskCapNhat.TryGetValue(k, out var running) && !dungCapNhat &&
            (double.IsNaN(running) || t - running <= MonitorHieuLuc()))
        {
            if (ch.Rollback && !dangRollback) { phatHien = t; BatDauRollback(); }
            else if (!ch.Rollback) { dungCapNhat = true; ketThuc = $"pause lúc {t:F0} s"; }
        }
        restartCho.Add((t + RestartDelay, k.Slot));
    }

    double MonitorHieuLuc() => slotCho.Count == 0 && moi == null && ch.Delay >= ch.Monitor ? ch.Delay + 1 : ch.Monitor;

    void BuocRestart()
    {
        foreach (var r in restartCho.Where(r => t >= r.luc).ToArray())
        {
            restartCho.Remove(r);
            if (tasks.Any(k => k.Slot == r.slot && !k.KetThuc && !k.MuonDung)) continue;
            var k = TaoTask(r.slot, false);
            if (dangRollback) taskCapNhat[k] = double.NaN;
        }
    }

    void BatDauRollback()
    {
        dangRollback = true; specMoi = false;
        slotCho = new(tasks.Where(k => !k.KetThuc && !k.MuonDung && k.BanMoi).Select(k => k.Slot).Distinct()
            .Concat(restartCho.Select(r => r.slot)).Distinct().Order());
        restartCho.Clear(); moi = null; cu = null; choDelayToi = double.NaN; monitorHet = double.NaN;
        taskCapNhat.Clear();
    }

    void BuocUpdater()
    {
        if (dungCapNhat || ketThuc != "") return;
        bool startFirst = dangRollback || ch.StartFirst;     // rollback_config: start-first, delay 0, monitor 30 s
        double delay = dangRollback ? 0 : ch.Delay;
        if (!double.IsNaN(choDelayToi)) { if (t < choDelayToi) return; choDelayToi = double.NaN; }
        if (moi != null)
        {
            if (double.IsNaN(moi.Running) && !moi.KetThuc) return;     // chờ task mới RUNNING
            if (startFirst && cu != null) YeuCauDung(cu);
            moi = null; cu = null;
            if (delay > 0) { choDelayToi = t + delay; return; }
        }
        if (moi == null && slotCho.Count > 0)
        {
            int s = slotCho.Dequeue();
            cu = tasks.FirstOrDefault(k => k.Slot == s && !k.KetThuc && !k.MuonDung);
            if (startFirst) moi = TaoTask(s, false);
            else
            {
                moi = TaoTask(s, cu != null);
                if (cu != null) { YeuCauDung(cu); var c = cu; var m = moi; choCuDung.Add((c, m)); }
                cu = null;
            }
            taskCapNhat[moi] = double.NaN;
            return;
        }
        if (moi == null && slotCho.Count == 0)
        {
            if (double.IsNaN(monitorHet)) monitorHet = t + (dangRollback ? 30 : MonitorHieuLuc());
            if (t >= monitorHet) ketThuc = dangRollback ? $"rollback {phatHien:F0} s, xong {t:F0} s" : $"cập nhật xong {t:F0} s";
        }
    }
    readonly List<(Task1 cu, Task1 moi)> choCuDung = [];
    // stop-first: task mới chỉ chạy khi task cũ đã thoát (DelayStart với waitStop)
    void ChayKhiCuDung() { foreach (var p in choCuDung.ToArray()) if (p.cu.KetThuc) { BatDau(p.moi); choCuDung.Remove(p); } }
}

Dừng .NET thật theo thứ tự của Swarm

DungMem.cs chạy bản build của BanHang.Api.cs với RutChoGiay 0, gửi bốn lệnh dài 4, 8, 12 và 14 giây, rồi làm đúng thứ tự của controller.go: ngừng gửi request mới, chờ 2 giây, gửi SIGTERM, và kill nếu quá grace. Máy thử chạy Windows nên SIGTERM là dòng term qua stdin, đi vào cùng lời gọi StopApplication(). Laptop Intel Core Ultra 5 125U, .NET 10.0.12, năm lần mỗi grace:

.NET 10.0.12, ShutdownTimeout 15 s, 5 lần mỗi grace
stop_grace_period 10 s: thoát sau SIGTERM 10.01–10.04 s, bị kill
  lệnh  4 s (còn 1.7 s lúc SIGTERM): bị cắt 0/5
  lệnh  8 s (còn 5.7 s lúc SIGTERM): bị cắt 0/5
  lệnh 12 s (còn 9.7 s lúc SIGTERM): bị cắt 0/5
  lệnh 14 s (còn 11.7 s lúc SIGTERM): bị cắt 5/5
stop_grace_period 20 s: thoát sau SIGTERM 11.76–11.82 s, tự thoát mã 0
  lệnh  4 s (còn 1.7 s lúc SIGTERM): bị cắt 0/5
  lệnh  8 s (còn 5.7 s lúc SIGTERM): bị cắt 0/5
  lệnh 12 s (còn 9.7 s lúc SIGTERM): bị cắt 0/5
  lệnh 14 s (còn 11.7 s lúc SIGTERM): bị cắt 0/5
dừng lúc rảnh: thoát sau 0.06 s (trung vị, 0.05–0.11)

Với grace 20 giây, tiến trình tự thoát ngay khi lệnh 14 giây xong, không chờ hết 20 giây. Grace dài chỉ tốn thời gian khi thật sự có lệnh dài.

DungMem.cs: gỡ mesh, chờ 2 s, SIGTERM, kill khi hết grace, trên tiến trình BanHang.Api thậtC# · 69 dòng
// DungMem.cs: chạy thứ tự dừng task của Swarm trên tiến trình BanHang.Api thật.
// dotnet run DungMem.cs -- [số lần]   (cần ../out/api từ DoKhoiDong.cs của bài 1)
// Thứ tự theo moby controller.go: gỡ task khỏi mesh (ở đây: ngừng gửi request mới), chờ 2 s, SIGTERM
// (máy thử Windows: dòng "term" qua stdin, cùng lời gọi StopApplication), quá stop_grace_period thì SIGKILL.
using System.Diagnostics;

int soLan = args.Length > 0 ? int.Parse(args[0]) : 5;
string exe = Path.GetFullPath(Path.Combine("../out/api", OperatingSystem.IsWindows() ? "BanHang.Api.exe" : "BanHang.Api"));
string biMat = Path.GetFullPath("../bai1/bimat");
const string Url = "http://127.0.0.1:5182";
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
int[] lenhDaiGiay = [4, 8, 12, 14];   // lệnh gọi đối tác đang chạy lúc task bị gỡ

Console.WriteLine($".NET {Environment.Version}, ShutdownTimeout 15 s, {soLan} lần mỗi grace");
foreach (int grace in new[] { 10, 20 })
{
    var catTheoLenh = lenhDaiGiay.ToDictionary(g => g, _ => 0);
    var thoat = new List<double>(); var maThoat = new List<string>();
    for (int lan = 0; lan < soLan; lan++)
    {
        var (p, _) = await ChayApi();
        var sw = Stopwatch.StartNew();
        var lenh = lenhDaiGiay.Select(async g =>
        {
            try { using var r = await http.PostAsync($"{Url}/api/thanh-toan/L{g}?choMs={g * 1000}", null); return (g, ok: r.IsSuccessStatusCode); }
            catch { return (g, ok: false); }
        }).ToArray();
        await Task.Delay(300);                       // lệnh đã vào handler
        await Task.Delay(2000);                      // Swarm: gỡ khỏi mesh rồi chờ defaultGossipConvergeDelay
        double tTerm = sw.Elapsed.TotalSeconds;
        p.StandardInput.WriteLine("term");           // SIGTERM
        bool tuThoat = p.WaitForExit(grace * 1000);
        if (!tuThoat) p.Kill();                      // SIGKILL khi hết stop_grace_period
        await p.WaitForExitAsync();
        thoat.Add(sw.Elapsed.TotalSeconds - tTerm);
        maThoat.Add(tuThoat ? $"tự thoát mã {p.ExitCode}" : "bị kill");
        foreach (var (g, ok) in await Task.WhenAll(lenh)) if (!ok) catTheoLenh[g]++;
    }
    Console.WriteLine($"stop_grace_period {grace} s: thoát sau SIGTERM {thoat.Min():F2}–{thoat.Max():F2} s, {string.Join(", ", maThoat.Distinct())}");
    foreach (var (g, cat) in catTheoLenh) Console.WriteLine($"  lệnh {g,2} s (còn {g - 2.3:F1} s lúc SIGTERM): bị cắt {cat}/{soLan}");
}

// Dừng lúc rảnh: thời gian từ SIGTERM tới khi tiến trình thoát
var rang = new List<double>();
for (int lan = 0; lan < soLan; lan++)
{
    var (p, _) = await ChayApi();
    var sw = Stopwatch.StartNew();
    p.StandardInput.WriteLine("term");
    await p.WaitForExitAsync();
    rang.Add(sw.Elapsed.TotalSeconds);
}
Console.WriteLine($"dừng lúc rảnh: thoát sau {rang.Order().ElementAt(rang.Count / 2):F2} s (trung vị, {rang.Min():F2}–{rang.Max():F2})");

async Task<(Process, double)> ChayApi()
{
    var p = Process.Start(new ProcessStartInfo(exe, ["--urls", Url, "--ThuMucBiMat", biMat, "--RutChoGiay", "0"])
        { RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true })!;
    p.OutputDataReceived += (_, _) => { }; p.ErrorDataReceived += (_, _) => { };
    p.BeginOutputReadLine(); p.BeginErrorReadLine();
    var sw = Stopwatch.StartNew();
    while (true)
    {
        if (p.HasExited) throw new Exception($"API thoát mã {p.ExitCode}");
        try { using var r = await http.GetAsync($"{Url}/healthz/ready"); if (r.IsSuccessStatusCode) break; } catch { }
        await Task.Delay(50);
    }
    return (p, sw.Elapsed.TotalSeconds);
}

Điều chưa chứng minh: chưa dựng cụm thật, nên thời gian tạo container, khởi động trong container Linux và hành vi routing mesh lúc gỡ task mới là giả định. Mô hình đơn giản hóa phần restart policy khi rollback bắt đầu đúng lúc một task đang được khởi động lại. Thời gian treo 20 giây của bản lỗi là giả định; bản treo muộn hơn 47 giây vẫn có thể chạm slot thứ hai.

7. Kết luận

Updater của Swarm chỉ thấy task thoát, nên an toàn của một lần cập nhật nằm ở ba con số: stop_grace_period lớn hơn thời gian .NET cần để xả, monitor và delay lớn hơn thời gian một bản lỗi cần để làm task thoát.

Trong dự án .NET của bạn:

  • Đặt HostOptions.ShutdownTimeout dài hơn lệnh dài nhất, rồi đặt stop_grace_period lớn hơn nó ít nhất 1 giây.
  • Dùng order: start-first và failure_action: rollback cho cả update_config, chừa tài nguyên cho thêm một task.
  • Tính thời gian phát hiện theo công thức ở mục 3 với interval, retries và grace của bạn, rồi đặt monitor và delay lớn hơn.
  • Chạy docker stack config rồi một công cụ như KiemStack.cs trong CI trước docker stack deploy.

Những chỗ hay hiểu sai

  • "Có healthcheck là bản lỗi tự rollback." Với monitor 5 giây, task treo thoát quá muộn để được tính là lỗi.
  • "start-first luôn an toàn hơn." Không có delay, bản lỗi lên cả 4 slot nhanh hơn.
  • "ShutdownTimeout 30 giây là đủ." Docker giết ở giây 10 nếu không đặt stop_grace_period.
  • "Grace dài làm mỗi lần cập nhật chậm hơn." Tiến trình thoát ngay khi xả xong, 0,06 giây lúc rảnh.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Secret trên Swarm: chuỗi kết nối và khóa BHPay ra khỏi biến môi trường

Output của docker service inspect dán vào một ticket làm lộ chuỗi kết nối SQL, còn khóa BHPay nằm trong ENV của image. Docker secret gắn vào /run/secrets với target là tên khóa cấu hình, KeyPerFile của .NET đọc mà không đổi code, và xoay khóa bằng secret _v2 cùng hai login SQL luân phiên.

11 phút đọc

Trong Docker Swarm và k3s

Docker Swarm ba manager: khởi động lại từng máy mà API trụ sở vẫn bán

API trụ sở của BanHang chạy Docker Compose trên một máy, nên mỗi lần máy khởi động lại sau bản vá là bán hàng online dừng. Cụm Swarm 3 manager và 2 worker giữ quorum khi mất bất kỳ node nào, 2 manager thì không; healthcheck viết bằng .NET giữ task chưa mở cổng ngoài routing mesh.

13 phút đọc

Trong Docker Swarm và k3s

Máy chủ cửa hàng chạy k3s: backend tự thay khi treo trên mini PC 8 GB

Backend ở cửa hàng ch04 treo lúc 19:40 và cửa hàng không bán được 52 phút, vì systemd chỉ chạy lại tiến trình đã thoát. k3s gói Kubernetes vào một binary dưới 100 MB với SQLite, containerd, Flannel, Traefik, ServiceLB và local-path; Deployment có liveness probe thay pod treo trong 32–67 giây theo mô hình.

14 phút đọc