DevOpsDocker Swarm và k3s, phần 1/8
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.
Mục lục
- 1. Vấn đề: máy chạy API khởi động lại là bán hàng online dừng
- 2. Mục đích: mất bất kỳ một node nào, API vẫn bán và cụm vẫn điều khiển được
- 3. Cơ sở lý thuyết: Raft quyết định ai được điều khiển, healthcheck quyết định ai được nhận request
- 4. Cách giải quyết: 3 manager, 2 worker, healthcheck vào live, drain trước khi vá
- 5. Cách cài đặt: khởi tạo cụm, stack và healthcheck
- 6. Chứng minh: mô hình quorum, cấu hình đã kiểm, health check chạy thật
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề:
BanHang.Apichạy Docker Compose trên một máy, nên mỗi lần máy tự khởi động lại sau bản vá là web và app bán hàng dừng vài phút. - Cách giải: Cụm Swarm 3 manager và 2 worker, 4 replica trải trên 5 node, routing mesh ở cổng 8080, drain từng node trước khi vá, healthcheck trỏ
/healthz/live. - Chứng minh: Mô hình theo tài liệu và mã nguồn Swarm: 3 manager còn quorum ở 5/5 tình huống mất một node, 2 manager chỉ 3/5; chưa dựng cụm thật.
- Trong .NET:
MapHealthCheckstách live và ready, kiểm SQL một lần trướcRunAsync, và mộtKiemTraNativeAOT làm lệnh HEALTHCHECK vì image không có curl.
1. Vấn đề: máy chạy API khởi động lại là bán hàng online dừng
10:41 thứ Ba 29/09, máy chủ ở trụ sở chạy BanHang.Api tự cài bản vá kernel rồi khởi động lại. API chạy bằng Docker Compose trên đúng máy đó, nên từ 10:41 đến 10:45 web và app bán hàng nhận lỗi 502 từ load balancer. Đó là giờ cao điểm 10:00–11:30, khoảng 300 request mỗi giây: 4 phút là khoảng 72.000 request lỗi, kèm các giỏ hàng đang thanh toán bị bỏ dở (số minh họa).
Tắt tự vá thì máy thiếu bản vá bảo mật. Dời lịch vá sang 02:00 vẫn dừng bán hàng online, và một lần hỏng nguồn bất ngờ vẫn dừng tất cả. Đội vận hành có năm máy cùng cấu hình, đặt tên sw-mgr-1, sw-mgr-2, sw-mgr-3, sw-wkr-1, sw-wkr-2. SQL Server 2022 BanHang nằm ngoài, trên máy sql01.
Câu hỏi của bài: dựng năm máy thành cụm thế nào để vá, khởi động lại, hoặc mất hẳn một máy bất kỳ mà API vẫn bán, và cụm vẫn nhận lệnh triển khai.
2. Mục đích: mất bất kỳ một node nào, API vẫn bán và cụm vẫn điều khiển được
- Drain rồi khởi động lại bất kỳ 1 trong 5 node: API giữ ít nhất 3 trong 4 replica, 0 request đi vào task chưa mở cổng.
- Mất hẳn 1 node không báo trước: cụm còn quorum để dời task và nhận lệnh triển khai, replica về lại 4/4 trong khoảng nửa phút.
- Chứng minh bằng mô hình có nguồn rằng 3 manager chịu được 1 node hỏng còn 2 manager thì không.
- Ngoài phạm vi: cập nhật phiên bản (bài 2), bí mật (bài 3), độ sẵn sàng của
sql01, và dựng cụm thật.
3. Cơ sở lý thuyết: Raft quyết định ai được điều khiển, healthcheck quyết định ai được nhận request
Manager giữ trạng thái cụm bằng Raft
Swarm có hai vai. Manager giữ trạng thái cụm, gồm service, task, secret, và quyết định task chạy ở đâu; worker chỉ chạy task. Mặc định manager cũng chạy task như worker.
Các manager đồng thuận bằng Raft: mỗi thay đổi, từ tạo task tới dời task, chỉ có hiệu lực khi đa số manager ghi nhận. Đa số đó gọi là quorum. Với N manager, quorum là ⌊N/2⌋ + 1 và cụm chịu được ⌊(N − 1)/2⌋ manager hỏng:
| Số manager | Quorum | Chịu được bao nhiêu manager hỏng |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 5 | 3 | 2 |
Hai manager tệ hơn một: quorum vẫn là 2, nên hỏng manager nào cũng mất quorum, và số máy có thể làm mất quorum tăng gấp đôi. Ba manager hỏng một vẫn còn 2, đủ quorum.
Mất quorum không làm API dừng ngay. Theo tài liệu quản trị Swarm, task trên các node còn sống vẫn chạy, nhưng cụm không thêm, sửa, xóa node, và không chạy, dừng, dời hay cập nhật task nào. Node hỏng tiếp theo mang theo replica của nó, và không ai tạo replica thay thế.
Scheduler trải task và dời task khi node rời cụm
Scheduler của swarmkit chọn node có ít task của cùng service nhất, hòa thì chọn node có ít task tổng nhất. Với 4 replica trên 5 node, mỗi node nhận nhiều nhất một replica mà không cần cấu hình gì thêm.
Node rời cụm theo hai cách:
- Mất node: manager chờ heartbeat. Chu kỳ là 5 giây ± 0,5 giây, quá 3 chu kỳ không có heartbeat thì node bị đánh dấu
Down, tức tối đa khoảng 16,5 giây. Task thay thế chạy saurestart_policy.delay5 giây. - Drain:
docker node update --availability draindừng task trên node và chạy task thay thế ở node khác. Task thay thế chỉ chạy khi task cũ đã dừng, và không chịu restart delay.
Khi node trở lại Active, Swarm không tự chia lại task cho node đó. Theo tài liệu, đây là thiết kế có chủ đích để không làm gián đoạn task đang chạy.
Routing mesh và healthcheck quyết định khi nào task nhận request
Cổng publish ở chế độ ingress đi qua routing mesh: node nào cũng nhận cổng 8080, kể cả node không có replica, và chuyển request tới một task đang hoạt động. Load balancer phía trước chỉ cần trỏ vào cả năm node.
Thời điểm task vào mesh nằm trong controller.go của moby. Container không có healthcheck vào mesh ngay khi tiến trình chạy, lúc Kestrel chưa mở cổng, nên request tới đó bị từ chối kết nối. Container có healthcheck chỉ vào mesh khi lần kiểm đầu tiên thành công.
Swarm chỉ có một healthcheck, và nó làm hai việc: cổng vào mesh lúc khởi động, và liveness lúc đang chạy. Task unhealthy bị dừng và thay bằng task mới; không có readiness kéo task ra khỏi mesh rồi cho vào lại. Trỏ healthcheck vào /healthz/ready, nơi kiểm SQL, thì sql01 mất kết nối khoảng 20 giây, đủ cho 3 lần kiểm lỗi liên tiếp cách nhau 5 giây, là cả 4 replica unhealthy và bị thay cùng lúc.
4. Cách giải quyết: 3 manager, 2 worker, healthcheck vào live, drain trước khi vá
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| Compose trên một máy | Đơn giản | Máy khởi động lại là API dừng | Môi trường thử |
| Swarm 1 manager, 4 worker | Dời được task khi worker hỏng | Manager hỏng là không dời, không triển khai được | Không dùng cho production |
| Swarm 2 manager, 3 worker | Không có | Hỏng một trong hai manager là mất quorum | Không dùng |
| Swarm 3 manager, 2 worker | Chịu được 1 node hỏng bất kỳ | Mất 2 manager cùng lúc là mất quorum | 5 máy, vá từng máy |
| Swarm 5 manager | Chịu được 2 node hỏng | Mỗi lần ghi Raft cần 3 máy xác nhận, cả 5 máy giữ khóa của cụm | Nhiều phòng máy |
| k3s nhiều server | Hệ sinh thái Kubernetes | Nhiều thành phần hơn cho đội nhỏ | Xem bài 6 |
Bài chọn 3 manager và 2 worker, vì yêu cầu là vá từng máy một và chịu được một máy hỏng bất ngờ. Các bước:
- Khởi tạo cụm trên
sw-mgr-1, chosw-mgr-2,sw-mgr-3vào làm manager, hai máy còn lại làm worker. Mở cổng 2377/TCP giữa các manager, 7946/TCP+UDP và 4789/UDP giữa mọi node. - Stack chạy 4 replica, publish 8080 qua routing mesh, giới hạn CPU và RAM để API không tranh tài nguyên với Raft trên manager, như tài liệu khuyên khi manager cũng chạy task.
- Healthcheck trỏ
/healthz/live, không kiểm SQL. API kiểm SQL một lần trước khi mở cổng: chuỗi kết nối sai thì tiến trình thoát mã 1 và Swarm thấy task hỏng, cònsql01chập chờn lúc đang chạy thì không replica nào bị giết. - Image không có curl nên lệnh healthcheck là
KiemTra, một chương trình .NET nhỏ biên dịch NativeAOT. - Vá theo vòng: drain một node, chờ task dời xong, vá và khởi động lại, đặt lại
Active, rồi mới sang node kế tiếp.
5. Cách cài đặt: khởi tạo cụm, stack và healthcheck
Phiên bản: Docker Engine 29.8.2, bản ổn định mới nhất trên docs.docker.com ngày 2026-10-04; .NET SDK 10.0.401, runtime 10.0.12; image mcr.microsoft.com/dotnet/aspnet:10.0, từ .NET 10 dựa trên Ubuntu 24.04; Microsoft.Data.SqlClient 7.1.1. Lệnh dựng cụm và vá một node:
# sw-mgr-1
docker swarm init --advertise-addr 10.10.0.11
docker swarm join-token manager # chạy lệnh in ra trên sw-mgr-2, sw-mgr-3
docker swarm join-token worker # chạy lệnh in ra trên sw-wkr-1, sw-wkr-2
docker stack deploy -c banhang-api.stack.yml banhang
# Vá một node: drain, chờ task dời, vá, khởi động lại, đặt lại Active
docker node update --availability drain sw-wkr-1
docker service ps banhang_api --filter desired-state=running
docker node update --availability active sw-wkr-1
Phần stack của bài này, trích từ file chung của series:
services:
api:
image: registry.banhang.local/banhang/api:1.5.0
ports:
- { target: 8080, published: 8080, protocol: tcp, mode: ingress }
healthcheck:
test: ["CMD", "/kiemtra/KiemTra", "http://127.0.0.1:8080/healthz/live"]
interval: 5s
timeout: 3s
retries: 3
start_period: 60s
start_interval: 1s # cần Docker Engine 25.0 trở lên
deploy:
replicas: 4
resources:
limits: { cpus: "2", memory: 1024M }
reservations: { cpus: "0.5", memory: 512M }
restart_policy: { condition: any, delay: 5s }
Trong BanHang.Api, live không chạy check nào, ready chạy check có tag ready, và SQL được kiểm một lần trước khi Kestrel mở cổng:
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
var han = DateTime.UtcNow.AddSeconds(builder.Configuration.GetValue("SqlChoKhoiDongGiay", 30));
while (!await SqlTraLoi(chuoiKetNoi, CancellationToken.None))
{
if (DateTime.UtcNow > han) { Console.Error.WriteLine("SQL không trả lời, thoát mã 1"); return 1; }
await Task.Delay(1000);
}
await app.RunAsync();
KiemTra.cs trả mã 0 khi endpoint trả 2xx, mã 1 trong mọi trường hợp khác, đúng quy ước của HEALTHCHECK. dotnet publish KiemTra.cs ra bản NativeAOT, vì file-based app bật PublishAot mặc định:
// dotnet run KiemTra.cs -- http://127.0.0.1:8080/healthz/live → mã 0 là khỏe, 1 là không
var url = args.Length > 0 ? args[0] : "http://127.0.0.1:8080/healthz/live";
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; // nhỏ hơn timeout 3 s của healthcheck
try
{
using var r = await http.GetAsync(url);
Console.WriteLine($"{(int)r.StatusCode} {url}"); // Docker lưu 4.096 byte đầu, xem bằng docker inspect
return r.IsSuccessStatusCode ? 0 : 1;
}
catch (Exception e) { Console.WriteLine($"{e.GetType().Name} {url}"); return 1; }
File API đầy đủ dùng chung cho ba bài Swarm. PublishAot=false vì Microsoft.Data.SqlClient và JSON cho kiểu ẩn danh cần reflection; image chạy bản framework-dependent. AddKeyPerFile cần đường dẫn tuyệt đối.
BanHang.Api.cs: health check, kiểm SQL lúc khởi động, dừng mềm (bài 2), secret (bài 3)
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Data.SqlClient@7.1.1
#:property PublishAot=false
// dotnet run BanHang.Api.cs -- --urls http://127.0.0.1:8080 --ThuMucBiMat /duong/dan/tuyet/doi
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Diagnostics.HealthChecks;
var builder = WebApplication.CreateBuilder(args);
// Bài 3: mỗi file trong /run/secrets là một khóa cấu hình, "__" đổi thành ":".
// Thêm sau biến môi trường nên secret thắng nếu trùng khóa.
builder.Configuration.AddKeyPerFile(builder.Configuration["ThuMucBiMat"] ?? "/run/secrets", optional: true);
string chuoiKetNoi = builder.Configuration.GetConnectionString("BanHang")
?? throw new InvalidOperationException("Thiếu ConnectionStrings:BanHang (secret ConnectionStrings__BanHang)");
string khoaBhPay = builder.Configuration["BHPay:ApiKey"]
?? throw new InvalidOperationException("Thiếu BHPay:ApiKey (secret BHPay__ApiKey)");
if (khoaBhPay.Any(char.IsWhiteSpace)) // không in giá trị, chỉ báo lỗi
throw new InvalidOperationException($"BHPay:ApiKey có ký tự trắng (dài {khoaBhPay.Length}); tạo secret bằng printf, không để xuống dòng cuối");
builder.Services.AddHttpClient("BHPay", c =>
{
c.BaseAddress = new Uri(builder.Configuration["BHPay:BaseUrl"] ?? "https://bhpay.banhang.local/");
c.DefaultRequestHeaders.Add("X-Api-Key", khoaBhPay);
c.Timeout = TimeSpan.FromSeconds(10);
});
// Bài 2: Swarm gửi SIGTERM rồi chờ stop_grace_period; giữ ShutdownTimeout nhỏ hơn.
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(15));
var rutCho = TimeSpan.FromSeconds(builder.Configuration.GetValue("RutChoGiay", 0));
var dangDung = false;
builder.Services.AddHealthChecks()
.AddCheck("dang-dung", () => dangDung ? HealthCheckResult.Unhealthy("đang dừng") : HealthCheckResult.Healthy(), tags: ["ready"])
.AddAsyncCheck("sql", async ct => await SqlTraLoi(chuoiKetNoi, ct)
? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy("không mở được SQL"), tags: ["ready"]);
var app = builder.Build();
// Bài 1: live không kiểm phụ thuộc nào; ready kiểm SQL và trạng thái dừng.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
app.Lifetime.ApplicationStopping.Register(() => { dangDung = true; Thread.Sleep(rutCho); });
app.MapGet("/api/may", (IConfiguration c) => new
{
node = c["BanHang:Node"] ?? Environment.MachineName, // Swarm điền {{.Node.Hostname}}
task = c["BanHang:Task"],
netVersion = Environment.Version.ToString()
});
app.MapPost("/api/thanh-toan/{maLenh}", async (string maLenh, int choMs) =>
{
await Task.Delay(choMs); // thay cho lời gọi BHPay, tối đa 10 s + SQL
return Results.Text($"da-xu-ly {maLenh}");
});
app.MapGet("/api/bhpay/ping", async (IHttpClientFactory f) =>
(int)(await f.CreateClient("BHPay").GetAsync("v1/ping")).StatusCode);
// Bài 1: kiểm SQL một lần trước khi mở cổng. Chuỗi kết nối sai thì tiến trình thoát mã 1,
// Swarm thấy task hỏng; còn SQL chập chờn lúc đang chạy thì live vẫn 200, không task nào bị giết.
var han = DateTime.UtcNow.AddSeconds(builder.Configuration.GetValue("SqlChoKhoiDongGiay", 30));
while (!await SqlTraLoi(chuoiKetNoi, CancellationToken.None))
{
if (DateTime.UtcNow > han) { Console.Error.WriteLine("SQL không trả lời, dừng khởi động"); return 1; }
await Task.Delay(1000);
}
_ = Task.Run(() => // máy thử chạy Windows: "term" qua stdin thay cho SIGTERM; trong container stdin đóng ngay
{
while (Console.ReadLine() is { } lenh)
if (lenh == "term") app.Lifetime.StopApplication(); // SIGTERM trên Linux đi vào đúng lời gọi này
});
await app.RunAsync();
return 0;
static async Task<bool> SqlTraLoi(string chuoi, CancellationToken ct)
{
try
{
using var han = CancellationTokenSource.CreateLinkedTokenSource(ct);
han.CancelAfter(TimeSpan.FromSeconds(2));
// kiểm nhanh: không tự thử lại khi mở kết nối, quá 2 s là hỏng
var b = new SqlConnectionStringBuilder(chuoi) { ConnectTimeout = 2, ConnectRetryCount = 0 };
await using var cn = new SqlConnection(b.ConnectionString);
await cn.OpenAsync(han.Token);
await using var cmd = new SqlCommand("SELECT 1", cn);
return (int)(await cmd.ExecuteScalarAsync(han.Token) ?? 0) == 1;
}
catch (Exception e) when (e is SqlException or OperationCanceledException or InvalidOperationException) { return false; }
}
Dockerfile build cả hai chương trình. Ảnh SDK 10.0-noble-aot có sẵn trình liên kết cho NativeAOT; image này chưa được build trong bài vì không chạy Docker.
Dockerfile: API framework-dependent, KiemTra NativeAOT
# Image registry.banhang.local/banhang/api. Chưa build trong bài vì không chạy Docker.
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src
COPY BanHang.Api.cs KiemTra.cs ./
RUN dotnet publish BanHang.Api.cs -o /out/api \
&& dotnet publish KiemTra.cs -o /out/kiemtra
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /out/api ./
COPY --from=build /out/kiemtra/KiemTra /kiemtra/KiemTra
USER $APP_UID
ENTRYPOINT ["dotnet", "BanHang.Api.dll"]
banhang-api.stack.yml: file stack chung của ba bài Swarm
# Stack BanHang.Api trên cụm Swarm trụ sở: sw-mgr-1..3 (manager), sw-wkr-1..2 (worker).
# Triển khai từ một manager: docker stack deploy -c banhang-api.stack.yml banhang
services:
api:
image: registry.banhang.local/banhang/api:1.5.0
environment:
ASPNETCORE_ENVIRONMENT: Production
BanHang__Node: "{{.Node.Hostname}}" # Swarm thay bằng tên node lúc tạo task
BanHang__Task: "{{.Task.Name}}"
RutChoGiay: "0" # Swarm đã rút task khỏi mesh 2 s trước SIGTERM
ports:
- target: 8080 # cổng mặc định của image ASP.NET Core từ .NET 8
published: 8080
protocol: tcp
mode: ingress # routing mesh: node nào cũng nhận cổng 8080
healthcheck:
test: ["CMD", "/kiemtra/KiemTra", "http://127.0.0.1:8080/healthz/live"]
interval: 5s
timeout: 3s
retries: 3
start_period: 60s
start_interval: 1s
stop_grace_period: 20s # > ShutdownTimeout 15 s + 1 s của Kestrel
secrets:
- source: banhang_sql_v1
target: ConnectionStrings__BanHang # KeyPerFile đọc thành ConnectionStrings:BanHang
- source: bhpay_api_key_v1
target: BHPay__ApiKey
deploy:
mode: replicated
replicas: 4
resources:
limits: { cpus: "2", memory: 1024M }
reservations: { cpus: "0.5", memory: 512M }
restart_policy:
condition: any
delay: 5s
update_config:
parallelism: 1
delay: 90s # >= thời gian phát hiện task treo, bài 2
order: start-first
failure_action: rollback
monitor: 90s
max_failure_ratio: 0
rollback_config:
parallelism: 1
delay: 0s
order: start-first
failure_action: pause
monitor: 30s
secrets:
banhang_sql_v1:
external: true # tạo trước bằng docker secret create
bhpay_api_key_v1:
external: true
6. Chứng minh: mô hình quorum, cấu hình đã kiểm, health check chạy thật
Bằng chứng có ba loại, và bài chưa dựng cụm thật: không chạy Docker, không có swarm nào được tạo. Không con số nào dưới đây là số đo trên cụm.
Mô hình: quorum và replica khi mất node
MoHinhCum.cs duyệt mọi tập node hỏng có 1 hoặc 2 phần tử trên 5 node, với quorum ⌊N/2⌋ + 1 và quy tắc chọn node của scheduler ở mục 3. Node không có replica lúc đầu được thử cả 5 khả năng, bảng lấy trường hợp xấu nhất. Compose tính là còn điều khiển khi máy chạy API còn sống. Mỗi ô ghi kết quả khi mất 1 node, rồi khi mất 2 node.
| Thiết kế | Còn điều khiển | Replica sau khi dời |
|---|---|---|
Compose trên sw-mgr-1 |
4/5, 6/10 | 0/1, 0/1 |
| Swarm 1 manager | 4/5, 6/10 | 3/4, 2/4 |
| Swarm 2 manager | 3/5, 3/10 | 3/4, 2/4 |
| Swarm 3 manager | 5/5, 7/10 | 4/4, 2/4 |
| Swarm 5 manager | 5/5, 10/10 | 4/4, 4/4 |
Ngay lúc mất một node, mọi thiết kế Swarm còn 3/4 replica; khác nhau ở chỗ có ai tạo replica thay thế hay không.
3 manager còn quorum ở mọi tình huống mất một node; 2 manager kém cả 1 manager
Bảng số liệu
| Mất 1 node (trên 5) | Mất 2 node (trên 10) | |
|---|---|---|
| 1 manager | 4 tình huống | 6 tình huống |
| 2 manager | 3 tình huống | 3 tình huống |
| 3 manager | 5 tình huống | 7 tình huống |
| 5 manager | 5 tình huống | 10 tình huống |
Phần thứ hai của mô hình tính một slot rời node, với hằng số lấy từ mã nguồn swarmkit và moby (mục Nguồn) và thời gian khởi động đo ở dưới:
| Sự kiện | Thời gian còn 3/4 replica | Request vào task chưa mở cổng |
|---|---|---|
| Drain, có healthcheck | 5,1 s | 0 |
| Drain, không healthcheck | 3,1 s | 136 mỗi lần dời, 545–681 cho một vòng vá 5 node |
| Mất node, không drain | tối đa 24,5 s | không mô hình |
Không có healthcheck, task mới vào mesh sớm hơn 2 giây nhưng nhận 1/4 tải trong 1,82 giây khi Kestrel chưa mở cổng, ở 300 request mỗi giây.
MoHinhCum.cs: quorum Raft, replica khi mất node, dòng thời gian drain
// MoHinhCum.cs: mô hình, không phải cụm thật. dotnet run MoHinhCum.cs -- [ms tới live]
// Phần 1: quorum Raft, majority = M/2 + 1 (docs.docker.com, Raft consensus in swarm mode).
// Phần 2: replica còn chạy và còn dời được khi node hỏng; scheduler chọn node ít task của service nhất,
// hòa thì node ít task tổng nhất (swarmkit manager/scheduler/scheduler.go, nodeLess).
// Phần 3: dòng thời gian của một slot khi drain và khi mất node, theo hằng số trong mã nguồn swarmkit/moby.
double khoiDong = args.Length > 0 ? double.Parse(args[0]) / 1000 : 1.8; // giây tới live 200, đo ở DoKhoiDong.cs
string[] node = ["sw-mgr-1", "sw-mgr-2", "sw-mgr-3", "sw-wkr-1", "sw-wkr-2"];
const int Replica = 4;
var thietKe = new (string ten, int manager)[] { ("Compose 1 máy", 0), ("Swarm 1 manager", 1), ("Swarm 2 manager", 2), ("Swarm 3 manager", 3), ("Swarm 5 manager", 5) };
// Mọi tập node hỏng có k phần tử
IEnumerable<int[]> ToHop(int n, int k, int tu = 0)
{
if (k == 0) { yield return []; yield break; }
for (int i = tu; i <= n - k; i++)
foreach (var r in ToHop(n, k - 1, i + 1)) yield return [i, .. r];
}
Console.WriteLine("Phần 1-2: 5 node, 4 replica; node trống ban đầu lấy cả 5 khả năng, báo trường hợp xấu nhất");
Console.WriteLine($"{"thiết kế",-17}{"k",3}{"tình huống",11}{"còn quorum",11}{"API còn chạy",13}{"replica ngay",13}{"replica sau dời",16}");
foreach (var (ten, m) in thietKe)
for (int k = 1; k <= 2; k++)
{
int tong = 0, quorum = 0, conChay = 0, xauNgay = int.MaxValue, xauSau = int.MaxValue;
foreach (var hong in ToHop(node.Length, k))
{
tong++;
bool coQuorum = m == 0 ? !hong.Contains(0) : (m - hong.Count(i => i < m)) >= m / 2 + 1;
if (coQuorum) quorum++;
int ngayMin = int.MaxValue, sauMin = int.MaxValue;
if (m == 0) // Compose: một container trên sw-mgr-1, không có gì để dời
ngayMin = sauMin = hong.Contains(0) ? 0 : 1;
else
for (int trong = 0; trong < node.Length; trong++) // node không có replica lúc đầu
{
var soTask = Enumerable.Range(0, node.Length).Select(i => i == trong ? 0 : 1).ToArray();
int ngay = Enumerable.Range(0, node.Length).Where(i => !hong.Contains(i)).Sum(i => soTask[i]);
// Còn quorum thì manager tạo task thay thế trên node còn sống; mất quorum thì không ai dời.
int sau = coQuorum && hong.Length < node.Length ? Replica : ngay;
ngayMin = Math.Min(ngayMin, ngay); sauMin = Math.Min(sauMin, sau);
}
if (ngayMin > 0) conChay++;
xauNgay = Math.Min(xauNgay, ngayMin); xauSau = Math.Min(xauSau, sauMin);
}
int toiDa = m == 0 ? 1 : Replica;
Console.WriteLine($"{ten,-17}{k,3}{tong,11}{quorum,11}{conChay,13}{$"{xauNgay}/{toiDa}",13}{$"{xauSau}/{toiDa}",16}");
}
// Phần 3: một node rời cụm. Hằng số: heartbeat 5 s ± 0,5 s, node DOWN sau 3 chu kỳ (dispatcher.go, period.go);
// restart delay 5 s, không áp cho node drain (restart.go); drain: task cũ dừng xong mới chạy task mới (waitStop);
// task cũ: gỡ khỏi mesh, chờ 2 s rồi SIGTERM (moby controller.go, defaultGossipConvergeDelay);
// có healthcheck: vào mesh khi lần kiểm đầu tiên thành công, start_interval 1 s; không có: vào mesh ngay khi container chạy.
const double Gossip = 2, DungNet = 0.06, ChayContainer = 1, StartInterval = 1, TaiReqGiay = 300; // DungNet đo ở bài 2
double ChoKiem(double t) => Math.Ceiling(t / StartInterval) * StartInterval; // lần kiểm thành công đầu tiên sau khi mở cổng
double drainHc = Gossip + DungNet + ChayContainer + ChoKiem(khoiDong);
double drainKhong = Gossip + DungNet + ChayContainer; // vào mesh ngay khi container chạy, cổng chưa mở
double matNode = 5.5 * 3 + 5 + ChayContainer + ChoKiem(khoiDong);
Console.WriteLine();
Console.WriteLine($"Phần 3: khởi động tới live 200 = {khoiDong:F2} s, tải {TaiReqGiay} request/s chia đều cho task trong mesh");
Console.WriteLine($" drain, có healthcheck: 3/4 replica trong {drainHc:F1} s, request vào task chưa mở cổng: 0");
Console.WriteLine($" drain, không healthcheck: 3/4 replica trong {drainKhong:F1} s, rồi task mới nhận 1/4 tải {khoiDong:F2} s khi chưa mở cổng: " +
$"{TaiReqGiay / Replica * khoiDong:F0} request bị từ chối");
Console.WriteLine($" mất node (không drain): 3/4 replica tối đa {matNode:F1} s (16,5 s phát hiện + 5 s restart delay + khởi động)");
Console.WriteLine($" vá cả 5 node lần lượt bằng drain (4–5 lần dời task): có healthcheck 0, không healthcheck " +
$"{4 * TaiReqGiay / Replica * khoiDong:F0}–{5 * TaiReqGiay / Replica * khoiDong:F0} request bị từ chối");
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 file stack chung không lỗi, cả hai chạy offline, không cần daemon. Hai công cụ bắt được khóa sai tên như oder và thời lượng sai như 1 giay, nhưng không bắt giá trị liệt kê sai: failure_action: rollbak vẫn qua. Bài 2 thêm một công cụ C# cho các luật đó.
Health check .NET chạy thật
DoKhoiDong.cs chạy bản build của BanHang.Api.cs với SQL Server 2019 LocalDB, database Kumeo_S, rồi đưa database offline trong lúc API chạy. Laptop Intel Core Ultra 5 125U, Windows 11, .NET 10.0.12, bỏ một dòng nhật ký của API:
.NET 10.0.12, 20 lần khởi động
tới live 200: trung vị 1817 ms (1246–8602)
tới ready 200: trung vị 1843 ms (1266–8701)
dotnet KiemTra.dll: mã 0, thời gian trung vị 290 ms (180–480), CPU trung vị 250 ms (156–406)
KiemTra NativeAOT: mã 0, thời gian trung vị 112 ms (79–150), CPU trung vị 62 ms (31–94)
database offline: live 200 (10 ms), ready 503 (67 ms), KiemTra(live) mã 0, KiemTra(ready) mã 1
database online lại: ready 200 sau 4994 ms; live suốt thời gian đó: 200
khởi động khi offline: thoát mã 1 sau 12.9 s, live từng 200: không
Khi database offline, healthcheck trỏ live vẫn trả mã 0, nên Swarm giữ nguyên replica; trỏ ready thì trả mã 1, và sau 3 lần liên tiếp Swarm sẽ thay mọi replica cùng lúc. Khởi động khi database offline thì tiến trình thoát mã 1 mà cổng chưa từng mở, nên task không bao giờ vào mesh. Ở chu kỳ 5 giây, bản NativeAOT tốn khoảng 62 ms CPU mỗi lần kiểm, bản chạy qua dotnet khoảng 250 ms.
DoKhoiDong.cs: thời gian khởi động, giá của KiemTra, live và ready khi database offline
#:package Microsoft.Data.SqlClient@7.1.1
#:property PublishAot=false
// dotnet run DoKhoiDong.cs -- [số lần] (BanHang.Api.cs và KiemTra.cs ở ../app, bản build ra ../out)
// Bản NativeAOT của KiemTra: dotnet publish ../app/KiemTra.cs -o ../out/kiemtra-aot
using System.Diagnostics;
using Microsoft.Data.SqlClient;
int soLan = args.Length > 0 ? int.Parse(args[0]) : 10;
string app = Path.GetFullPath("../app"), outDir = Path.GetFullPath("../out");
string exe = OperatingSystem.IsWindows() ? ".exe" : "";
Process.Start("dotnet", $"build \"{app}/BanHang.Api.cs\" -o \"{outDir}/api\" -v q").WaitForExit();
Process.Start("dotnet", $"build \"{app}/KiemTra.cs\" -o \"{outDir}/kiemtra\" -v q").WaitForExit();
string apiExe = Path.Combine(outDir, "api", "BanHang.Api" + exe);
// Hai cách chạy KiemTra: qua dotnet (framework-dependent) và bản NativeAOT (dotnet publish KiemTra.cs)
var cachKiem = new List<(string ten, string file, string[] dau)> { ("dotnet KiemTra.dll", "dotnet", [Path.Combine(outDir, "kiemtra", "KiemTra.dll")]) };
string aot = Path.Combine(outDir, "kiemtra-aot", "KiemTra" + exe);
if (File.Exists(aot)) cachKiem.Add(("KiemTra NativeAOT", aot, []));
const string Master = @"Server=(localdb)\MSSQLLocalDB;Database=master;Integrated Security=true;Encrypt=False";
const string Url = "http://127.0.0.1:5181";
// Thư mục giả lập /run/secrets: tên file là khóa cấu hình, ghi không có xuống dòng cuối như printf.
string biMat = Path.GetFullPath("bimat"); // AddKeyPerFile cần đường dẫn tuyệt đối
Directory.CreateDirectory(biMat);
File.WriteAllText(Path.Combine(biMat, "ConnectionStrings__BanHang"),
@"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_S;Integrated Security=true;Encrypt=False;Application Name=BanHang.Api");
File.WriteAllText(Path.Combine(biMat, "BHPay__ApiKey"), "bhpay_test_sk_GIA_v1_7Qm2Xc9Lp4");
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
async Task<(int ma, double ms)> Hoi(string duong)
{
var sw = Stopwatch.StartNew();
try { using var r = await http.GetAsync(Url + duong); return ((int)r.StatusCode, sw.Elapsed.TotalMilliseconds); }
catch { return (0, sw.Elapsed.TotalMilliseconds); } // 0: chưa mở cổng hoặc quá 3 s
}
Process ChayApi(params string[] them)
{
var psi = new ProcessStartInfo(apiExe, ["--urls", Url, "--ThuMucBiMat", biMat, .. them])
{ RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true };
var p = Process.Start(psi)!;
p.OutputDataReceived += (_, _) => { };
p.ErrorDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine(" api: " + e.Data); };
p.BeginOutputReadLine(); p.BeginErrorReadLine();
return p;
}
async Task ChoReady(Process p) { while ((await Hoi("/healthz/ready")).ma != 200) { if (p.HasExited) throw new Exception($"API thoát mã {p.ExitCode}"); await Task.Delay(20); } }
async Task Dung(Process p) { p.StandardInput.WriteLine("term"); await p.WaitForExitAsync(); }
async Task SqlMaster(string lenh) { await using var cn = new SqlConnection(Master); await cn.OpenAsync(); await new SqlCommand(lenh, cn).ExecuteNonQueryAsync(); }
async Task<(int ma, double ms, double cpu)> KiemTra((string ten, string file, string[] dau) c, string duong)
{
var sw = Stopwatch.StartNew();
var p = Process.Start(new ProcessStartInfo(c.file, [.. c.dau, Url + duong]) { RedirectStandardOutput = true })!;
await p.WaitForExitAsync();
return (p.ExitCode, sw.Elapsed.TotalMilliseconds, p.TotalProcessorTime.TotalMilliseconds);
}
string Tk(IEnumerable<double> x) { var s = x.Order().ToArray(); return $"trung vị {s[s.Length / 2]:F0} ms ({s[0]:F0}–{s[^1]:F0})"; }
// 1. Thời gian khởi động tới live 200 và ready 200
await SqlMaster("ALTER DATABASE Kumeo_S SET ONLINE");
var live = new List<double>(); var ready = new List<double>();
for (int lan = 1; lan <= soLan; lan++)
{
var sw = Stopwatch.StartNew();
var p = ChayApi();
double tLive = -1;
while (true)
{
if (p.HasExited) throw new Exception($"API thoát mã {p.ExitCode}");
if (tLive < 0 && (await Hoi("/healthz/live")).ma == 200) tLive = sw.Elapsed.TotalMilliseconds;
if (tLive >= 0 && (await Hoi("/healthz/ready")).ma == 200) break;
await Task.Delay(20);
}
live.Add(tLive); ready.Add(sw.Elapsed.TotalMilliseconds);
await Dung(p);
}
Console.WriteLine($".NET {Environment.Version}, {soLan} lần khởi động");
Console.WriteLine($" tới live 200: {Tk(live)}");
Console.WriteLine($" tới ready 200: {Tk(ready)}");
// 2. Giá một lần chạy KiemTra khi API đang khỏe: thời gian và CPU của tiến trình kiểm
var api = ChayApi();
await ChoReady(api);
foreach (var c in cachKiem)
{
var kq = new List<(int ma, double ms, double cpu)>();
for (int i = 0; i < 20; i++) kq.Add(await KiemTra(c, "/healthz/live"));
Console.WriteLine($" {c.ten}: mã {string.Join("", kq.Select(k => k.ma).Distinct())}, thời gian {Tk(kq.Select(k => k.ms))}, CPU {Tk(kq.Select(k => k.cpu))}");
}
// 3. Database offline trong lúc chạy: live và ready trả gì, KiemTra trả mã nào
await SqlMaster("ALTER DATABASE Kumeo_S SET OFFLINE WITH ROLLBACK IMMEDIATE");
var l = await Hoi("/healthz/live"); var r = await Hoi("/healthz/ready");
Console.WriteLine($"database offline: live {l.ma} ({l.ms:F0} ms), ready {r.ma} ({r.ms:F0} ms), " +
$"KiemTra(live) mã {(await KiemTra(cachKiem[^1], "/healthz/live")).ma}, KiemTra(ready) mã {(await KiemTra(cachKiem[^1], "/healthz/ready")).ma}");
await SqlMaster("ALTER DATABASE Kumeo_S SET ONLINE");
var t0 = Stopwatch.StartNew();
await ChoReady(api);
Console.WriteLine($"database online lại: ready 200 sau {t0.Elapsed.TotalMilliseconds:F0} ms; live suốt thời gian đó: {(await Hoi("/healthz/live")).ma}");
await Dung(api);
// 4. Khởi động khi database offline: tiến trình phải thoát mã khác 0, live không bao giờ 200
await SqlMaster("ALTER DATABASE Kumeo_S SET OFFLINE WITH ROLLBACK IMMEDIATE");
t0.Restart();
var hong = ChayApi("--SqlChoKhoiDongGiay", "10");
bool tungLive = false;
while (!hong.HasExited) { tungLive |= (await Hoi("/healthz/live")).ma == 200; await Task.Delay(100); }
Console.WriteLine($"khởi động khi offline: thoát mã {hong.ExitCode} sau {t0.Elapsed.TotalSeconds:F1} s, live từng 200: {(tungLive ? "có" : "không")}");
await SqlMaster("ALTER DATABASE Kumeo_S SET ONLINE");
Điều chưa chứng minh: chưa dựng cụm thật nên thời gian phát hiện node chết, thời gian dời task và hành vi của routing mesh khi mất node mới là mô hình theo mã nguồn, chưa đo. Thời gian khởi động đo trên laptop Windows, chưa đo trong container Linux.
7. Kết luận
Ba manager là số nhỏ nhất chịu được một node hỏng bất kỳ; hai manager kém cả một. Để vá từng máy mà không rơi request, cần thêm healthcheck để task mới chỉ vào routing mesh khi đã mở cổng, và drain trước khi khởi động lại.
Trong dự án .NET của bạn:
- Tách
/healthz/livevà/healthz/readybằngMapHealthChecks; trỏ healthcheck của Swarm vào live, không vào endpoint kiểm database. - Kiểm database một lần trước
app.RunAsync()và trả mã thoát khác 0 khi hết hạn, để cấu hình sai lộ ra ngay ở task đầu tiên. - Viết lệnh healthcheck bằng một file-based app .NET, publish NativeAOT, vì image
aspnetkhông có curl. - Đặt
resources.limitscho service khi manager cũng chạy task, và vá theo vòng drain, vá,active.
Những chỗ hay hiểu sai
- "Hai manager an toàn hơn một." Quorum của 2 là 2: hỏng manager nào cũng mất quyền điều khiển.
- "Mất quorum là API dừng." Task đang chạy vẫn phục vụ; chỉ là không ai dời hay tạo task mới.
- "Node quay lại thì Swarm tự chia lại task." Không; dùng
docker service update --forcenếu cần. - "Healthcheck nên kiểm cả database." Trên Swarm, healthcheck hỏng là task bị thay; SQL chập chờn sẽ thay mọi replica cùng lúc.
Đọc tiếp
- Rolling update trên Swarm: bài sau, cập nhật 1.4.0 lên 1.5.0 mà không rơi request và tự rollback bản lỗi.
- Secret và cấu hình trên Swarm: chuỗi kết nối và khóa BHPay trong
/run/secrets. - Swarm hay k3s: khi nào trụ sở nên chuyển sang k3s nhiều server.
- Triển khai không gián đoạn: readiness,
ApplicationStoppingvà thứ tự dừng của Kestrel.
Nguồn
- Docker Docs, đọc ngày 2026-10-04: Raft consensus in swarm mode, Administer and maintain a swarm (bảng quorum, mất quorum, manager chạy task, rebalance), How nodes work, Drain a node, Routing mesh, Swarm tutorial: open ports, Dockerfile HEALTHCHECK, Engine release notes (29.8.2, 2026-09-30).
- swarmkit, nhánh master: scheduler.go (
nodeLess), dispatcher.go và period.go (heartbeat), restart.go, defaults/service.go. - moby, nhánh master: daemon/cluster/executor/container/controller.go (vào mesh khi healthy, chờ 2 s khi dừng), daemon/health.go.
- Microsoft Learn: Health checks in ASP.NET Core (curl không có trong image .NET), Default ASP.NET Core port changed to 8080, Default .NET container tags now use Ubuntu.