DevOpsDocker Swarm và k3s, phần 5/8
Probe, giới hạn bộ nhớ và HPA trên k3s: pod cửa hàng trụ được flash sale
Flash sale 9/9 ở ch01, pod BanHang.CuaHang restart liên tục vì liveness gọi endpoint có kiểm trụ sở, heap limit ghi bằng số thập phân thành 16 GiB, và một pod không gánh nổi tải. Tách ba probe, để GC tự lấy 75% memory limit, và chạy HPA 2–3 pod; kiểm bằng app .NET chạy thật, Job Object 512 MiB và mô hình kubelet, HPA.
Mục lục
- 1. Vấn đề: flash sale 9/9, pod của ch01 restart, hết bộ nhớ và quá tải
- 2. Mục đích: pod ở lại khi trụ sở treo, bộ nhớ dưới limit, đủ pod khi tải tăng
- 3. Cơ sở lý thuyết: kubelet, cgroup và HPA đọc cấu hình đúng theo chữ
- 4. Cách giải quyết: tách probe, để GC đọc limit, thêm pod theo CPU
- 5. Cách cài đặt: health check trong app, probe và HPA trong manifest
- 6. Chứng minh: app đo thật, Job Object 512 MiB, mô hình kubelet và HPA
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Flash sale 9/9 ở
ch01: liveness gọi endpoint có kiểm trụ sở nên pod restart liên tục khi đường truyền nghẽn, heap limit ghi sai cơ số, và một pod không gánh nổi tải. - Cách giải: Startup probe cho khởi động, liveness không phụ thuộc gì, readiness chỉ kiểm SQLite cục bộ; bỏ heap limit tự đặt để GC lấy 75% memory limit; HPA giữ 2–3 pod theo CPU.
- Chứng minh: Chưa dựng cụm thật; mô hình kubelet cho 10 restart xuống 0 trong 30 phút trụ sở treo, và trong giới hạn 512 MiB, bộ nhớ đỉnh từ 431–511 MiB xuống 380–382 MiB.
- Trong .NET: Check trụ sở trả
Degradedthay vìUnhealthy, đồng bộ chạy trongBackgroundService, vàGC.GetGCMemoryInfo()cho biết GC đang thấy giới hạn nào.
1. Vấn đề: flash sale 9/9, pod của ch01 restart, hết bộ nhớ và quá tải
10:00 ngày 9/9/2026, cửa hàng ch01 Cầu Giấy mở flash sale 30 phút. Máy k3s-ch01 chạy BanHang.CuaHang 2.0.3 với manifest viết lúc chuyển sang k3s (bài 4): một pod, CPU limit 1, memory limit 512Mi, liveness và readiness cùng gọi /healthz/ready. Manifest còn biến DOTNET_GCHeapHardLimit=402653184, chép từ cấu hình service cũ với ý định 384 MiB.
10:04, đường truyền về trụ sở nghẽn vì cả chuỗi cùng chạy flash sale. Lần đồng bộ của app hết thời gian, và 90 giây sau /healthz/ready trả 503 vì bản 2.0.3 coi trụ sở là bắt buộc. Liveness gọi đúng endpoint đó, nên kubelet giết container. Bản 2.0.3 đồng bộ với trụ sở trước khi mở cổng, nên lần khởi động sau bị chặn khoảng 35 giây và lại bị giết. Pod vào CrashLoopBackOff; quầy chuyển sang ghi đơn giấy.
Trong những lúc chạy được, pod dùng trọn 1 nhân của limit trong khi máy còn 3 nhân rảnh, và một lần bị OOMKilled với exit code 137. Diễn biến trên là minh họa; mục 6 đo lại từng cơ chế trên app thật và mô hình.
2. Mục đích: pod ở lại khi trụ sở treo, bộ nhớ dưới limit, đủ pod khi tải tăng
- Trụ sở treo 30 phút: 0 lần restart và pod Ready suốt thời gian đó, theo mô hình kubelet dựng từ tài liệu Kubernetes 1.36 với thời gian mở cổng đo trên app.
- App khởi động chậm tới 120 giây, ví dụ lúc chạy migration, không bị giết.
- GC thấy đúng 75% memory limit, tức 384 MiB với 512Mi. Trong giới hạn 512 MiB kiểu container, bộ nhớ đỉnh dưới 400 MiB ở 5/5 lần chạy và 0 lần chết.
- Tải tăng từ 15 lên 100 request/s trong 20 giây: dưới 10 giây quá tải theo mô hình HPA.
- Ngoài phạm vi: tối ưu code của app; dữ liệu trên volume (bài 7).
3. Cơ sở lý thuyết: kubelet, cgroup và HPA đọc cấu hình đúng theo chữ
Ba probe, ba câu hỏi
| Probe | Hỏi | Thất bại thì |
|---|---|---|
startupProbe |
Đã khởi động xong chưa | Quá failureThreshold × periodSeconds thì giết. Chưa qua thì liveness và readiness chưa chạy |
livenessProbe |
Tiến trình còn trả lời không | failureThreshold lần liên tiếp thì giết, rồi chạy lại |
readinessProbe |
Nhận request được không | Rời endpoint của Service, không bị giết |
Giết rồi chạy lại theo backoff: lần đầu ngay, sau đó 10, 20, 40 giây, gấp đôi tới 300 giây; container chạy ổn 10 phút thì bộ đếm về đầu. Feature gate ReduceDefaultCrashLoopBackOffDecay, rút xuống 1 và 60 giây, vẫn là alpha và tắt mặc định ở Kubernetes 1.36.
Bản 2.0.3 sai ở hai chỗ. Thứ nhất, liveness gọi endpoint có kiểm phụ thuộc ngoài: trụ sở chậm thì kubelet giết pod, mà giết pod không làm đường truyền nhanh lên. Thứ hai, khởi động chặn 35 giây vừa vượt ngân sách của liveness: initialDelaySeconds 15 cộng 3 lần thử cách nhau 10 giây là bị giết ở giây 35, trước khi app kịp mở cổng.
Memory limit là cgroup, và heap .NET đọc nó
limits.memory thành giới hạn cgroup của container. Tài liệu Kubernetes ghi giới hạn này do kernel thực thi bằng OOM kill, và container bị giết hiện Reason: OOMKilled, Exit Code: 137, tức 128 cộng tín hiệu 9 (SIGKILL). App không có cơ hội ghi log hay xả request.
GC của .NET đọc giới hạn đó. Theo tài liệu cấu hình GC, trong môi trường có memory limit, limit được coi là toàn bộ bộ nhớ vật lý, và heap hard limit mặc định là 75% limit, tối thiểu 20 MB. Với 512Mi, GC giữ heap dưới 384 MiB; chạm mức đó thì nó dọn mạnh hơn, rồi mới ném OutOfMemoryException mà app bắt được. 25% còn lại dành cho bộ nhớ ngoài heap: runtime, JIT, stack của thread, bộ đệm của SQLite.
Hai biến môi trường đè mặc định này, và cả hai đọc giá trị là số hệ 16:
| Viết | Runtime đọc | Ý định |
|---|---|---|
DOTNET_GCHeapHardLimit=402653184 |
0x402653184 = 17.220.055.428 byte, khoảng 16 GiB |
384 MiB |
DOTNET_GCHeapHardLimit=0x18000000 |
402.653.184 byte = 384 MiB | 384 MiB |
DOTNET_GCHeapHardLimitPercent=60 |
0x60 = 96% |
60% |
Đây là chỗ hỏng của 2.0.3: GC tin rằng nó có 16 GiB trong khi kernel chỉ cho 512 MiB, nên GC không còn lý do dọn sớm. ASP.NET Core chạy Server GC; từ .NET 9, Server GC bật DATAS mặc định để co heap theo dữ liệu sống.
HPA đếm theo request, không theo limit
HPA chạy mỗi 15 giây, lấy CPU trung bình của các pod từ metrics-server, mà k3s chạy với --metric-resolution=15s, rồi tính:
số pod mong muốn = ceil(số pod hiện tại × giá trị đo / giá trị đích)
Mức sử dụng tính theo requests, không theo limits. Tỉ số nằm trong dung sai 0,1 thì không đổi gì. Ví dụ ở ch01: 2 pod, mỗi pod dùng 600m trên request 500m, tức 120%; đích 70% cho ceil(2 × 120 / 70) = ceil(3,43) = 4, rồi bị kẹp về maxReplicas 3. Khi tăng pod, pod chưa ready được tính là dùng 0%; khi giảm, HPA lấy đề xuất cao nhất trong 300 giây gần nhất.
Máy cửa hàng có 4 nhân và chỉ một node. maxReplicas: 3 với CPU limit 1 mỗi pod chừa một nhân cho k3s, Traefik và hệ điều hành.
4. Cách giải quyết: tách probe, để GC đọc limit, thêm pod theo CPU
| Chỗ sửa | 2.0.3 | 2.1.0 | Vì sao |
|---|---|---|---|
| Liveness | /healthz/ready, trễ 15 s |
/healthz/live, không check nào |
Phụ thuộc hỏng không phải lý do giết tiến trình |
| Khởi động | Đồng bộ trụ sở trước khi mở cổng | Đồng bộ trong BackgroundService; startupProbe 24 × 5 s |
Khởi động không phụ thuộc mạng; migration chậm có 120 s |
| Readiness | Trụ sở bắt buộc | SQLite cục bộ; trụ sở chỉ Degraded |
Cửa hàng bán được khi mất mạng |
| Heap | DOTNET_GCHeapHardLimit=402653184 |
Không đặt, GC lấy 75% limit | Đổi limit thì heap đổi theo |
| Số pod | 1 cố định | HPA 2–3, đích 70% CPU request | Dùng nhân rảnh lúc cao điểm, 2 pod để dự phòng |
Với số pod, có ba cách:
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| 1 pod, CPU limit 3 | Đơn giản | Pod treo là dừng bán; giữ chỗ 3 nhân cả ngày | App không chạy được nhiều bản |
| 3 pod cố định | Không phải chờ HPA | Giữ 1,5 GiB và 1,5 nhân request cả ngày | Tải cao suốt ngày |
| HPA 2–3 pod | 2 pod dự phòng, thêm pod khi cần | Trễ 15–30 s; cần metrics-server | Tải có đỉnh như flash sale |
Bài chọn HPA 2–3 pod. Ba pod cùng ghi một file SQLite trên cùng node được vì SQLite ở chế độ WAL hỗ trợ nhiều tiến trình trên một máy; bài 7 nói về volume đó. Deployment bỏ trường spec.replicas để HPA giữ số pod.
5. Cách cài đặt: health check trong app, probe và HPA trong manifest
Phiên bản: .NET SDK 10.0.401, runtime 10.0.12, gói Microsoft.Data.Sqlite 10.0.12; manifest cho k3s v1.36.5+k3s1 (Kubernetes 1.36.5). Trong app, check trụ sở chỉ đọc kết quả của vòng đồng bộ nền, nên probe không bao giờ chờ mạng:
builder.Services.AddHealthChecks()
.AddCheck("dang-dung", () => dangDung ? HealthCheckResult.Unhealthy() : HealthCheckResult.Healthy(), tags: ["ready"])
.AddCheck("sqlite", () => Csdl.KiemTra(csdl), tags: ["ready"])
// Mất trụ sở: Degraded, vẫn HTTP 200. Bản 2.0.3 trả Unhealthy, tức 503.
.AddCheck("tru-so", () => DongBo.VuaThanhCong()
? HealthCheckResult.Healthy()
: new HealthCheckResult(HealthStatus.Degraded, "bán offline"),
tags: ["ready"]);
builder.Services.AddHostedService(_ => new DongBo(csdl, maCuaHang)); // đồng bộ chạy nền, không chặn Kestrel
MapHealthChecks mặc định trả 200 cho Healthy và Degraded, 503 cho Unhealthy. Trong manifest, liveness và startup gọi /healthz/live, readiness gọi /healthz/ready, và không có biến DOTNET_GCHeapHardLimit:
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: "1", memory: 512Mi } # GC tự đặt heap hard limit 384 MiB
startupProbe: # tối đa 24 x 5 s = 120 s để khởi động
httpGet: { path: /healthz/live, port: http }
periodSeconds: 5
failureThreshold: 24
livenessProbe: # không kiểm phụ thuộc nào
httpGet: { path: /healthz/live, port: http }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe: # SQLite cục bộ; trụ sở chỉ là Degraded
httpGet: { path: /healthz/ready, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
Memory request bằng limit để scheduler đặt chỗ đúng mức pod có thể dùng. HPA nhắm averageUtilization: 70 trên CPU, minReplicas: 2, maxReplicas: 3. App chạy bằng dotnet run CuaHang.cs -- --urls http://127.0.0.1:8080; thêm --CuaHang:CauHinhCu true để có hành vi của 2.0.3.
CuaHang.cs: backend rút gọn, health check, đồng bộ nền, cờ CauHinhCu cho hành vi 2.0.3
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Data.Sqlite@10.0.12
// BanHang.CuaHang 2.1.0, bản rút gọn: dotnet run CuaHang.cs -- --urls http://127.0.0.1:8080
// Cùng một file (một image) chạy ba việc: web (mặc định), "sao-luu" (CronJob), "khoi-phuc" (initContainer).
using System.Diagnostics;
using System.Runtime;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Diagnostics.HealthChecks;
if (args is ["sao-luu", ..]) return await SaoLuu.ChayAsync(args);
if (args is ["khoi-phuc", ..]) return KhoiPhuc.Chay(args);
var batDau = Stopwatch.StartNew();
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Configuration.AddKeyPerFile("/run/secrets", optional: true); // khóa BHPay từ Secret
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(15));
var cfg = builder.Configuration;
string csdl = cfg.GetConnectionString("CuaHang") ?? "Data Source=cuahang.db";
string maCuaHang = cfg["CuaHang:Ma"] ?? "CHUA-DAT";
DongBo.TruSo = new Uri(cfg["TruSo:Url"] ?? "https://api.banhang.local");
DongBo.ChuKy = TimeSpan.FromSeconds(cfg.GetValue("DongBo:ChuKyGiay", 30));
bool cauHinhCu = cfg.GetValue("CuaHang:CauHinhCu", false); // bản 2.0.3: đồng bộ chặn lúc khởi động, trụ sở là bắt buộc
Csdl.TaoBang(csdl);
var dangDung = false;
builder.Services.AddHealthChecks()
.AddCheck("dang-dung", () => dangDung ? HealthCheckResult.Unhealthy() : HealthCheckResult.Healthy(), tags: ["ready"])
.AddCheck("sqlite", () => Csdl.KiemTra(csdl), tags: ["ready"])
.AddCheck("tru-so", () => DongBo.VuaThanhCong()
? HealthCheckResult.Healthy()
: new HealthCheckResult(cauHinhCu ? HealthStatus.Unhealthy : HealthStatus.Degraded, "bán offline"),
tags: ["ready"]);
builder.Services.AddHostedService(_ => new DongBo(csdl, maCuaHang));
var app = builder.Build();
// live: không chạy check nào, chỉ cần tiến trình còn trả lời. ready: SQLite cục bộ, trạng thái trụ sở, đang dừng.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
ResponseWriter = (ctx, r) =>
{
ctx.Response.ContentType = "text/plain; charset=utf-8";
return ctx.Response.WriteAsync($"{r.Status} " + string.Join(" ", r.Entries.Select(e => $"{e.Key}={e.Value.Status}")));
}
});
app.MapPost("/api/don-hang/{maDon}", (string maDon, long tongTien) =>
{
using var cn = Csdl.Mo(csdl);
using var cmd = cn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO DonHangOffline (MaDon, NgayTao, TongTien) VALUES ($ma, $ngay, $tien)";
cmd.Parameters.AddWithValue("$ma", maDon);
cmd.Parameters.AddWithValue("$ngay", DateTime.UtcNow.ToString("O"));
cmd.Parameters.AddWithValue("$tien", tongTien);
return cmd.ExecuteNonQuery() == 1 ? Results.Text($"da-ghi {maDon}", statusCode: 201) : Results.Text($"da-co {maDon}");
});
app.MapGet("/api/thong-tin", () =>
{
var gc = GC.GetGCMemoryInfo();
var p = Process.GetCurrentProcess();
return Results.Text($"""
BanHang.CuaHang 2.1.0 cua hang {maCuaHang}, .NET {Environment.Version}, ServerGC={GCSettings.IsServerGC}
TotalAvailableMemoryBytes={gc.TotalAvailableMemoryBytes / 1048576.0:F0} MiB, heap={GC.GetTotalMemory(false) / 1048576.0:F1} MiB
private={p.PrivateMemorySize64 / 1048576.0:F0} MiB, working set={p.WorkingSet64 / 1048576.0:F0} MiB, don cho dong bo={Csdl.ChoDongBo(csdl)}
""");
});
app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine($"nghe cong sau {batDau.ElapsedMilliseconds} ms"));
app.Lifetime.ApplicationStopping.Register(() => dangDung = true); // readiness 503; preStop đã chờ trước đó
if (cauHinhCu) await DongBo.MotLanAsync(csdl, maCuaHang, soLan: 3); // bản 2.0.3: chặn ở đây, chưa mở cổng
app.Run();
return 0;
static class Csdl
{
public static SqliteConnection Mo(string cs)
{
var cn = new SqliteConnection(cs);
cn.Open();
return cn;
}
public static void TaoBang(string cs)
{
using var cn = Mo(cs);
using var cmd = cn.CreateCommand();
cmd.CommandText = """
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS DonHangOffline (
MaDon text PRIMARY KEY,
NgayTao text NOT NULL,
TongTien integer NOT NULL, -- đồng
DaDongBo integer NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS IX_DonHangOffline_ChoDongBo ON DonHangOffline (MaDon) WHERE DaDongBo = 0;
""";
cmd.ExecuteNonQuery();
}
public static long ChoDongBo(string cs)
{
using var cn = Mo(cs);
using var cmd = cn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM DonHangOffline WHERE DaDongBo = 0";
return (long)cmd.ExecuteScalar()!;
}
public static HealthCheckResult KiemTra(string cs)
{
try { return HealthCheckResult.Healthy($"{ChoDongBo(cs)} đơn chờ đồng bộ"); }
catch (Exception e) { return HealthCheckResult.Unhealthy(e.Message); }
}
// Mở file chỉ đọc, kiểm integrity_check và đếm đơn. Dùng cho bản sao lưu.
public static (bool Tot, long SoDon) KiemFile(string file)
{
try
{
using var cn = new SqliteConnection($"Data Source={file};Mode=ReadOnly;Pooling=False");
cn.Open();
using var cmd = cn.CreateCommand();
cmd.CommandText = "PRAGMA integrity_check";
if ((string)cmd.ExecuteScalar()! != "ok") return (false, 0);
cmd.CommandText = "SELECT COUNT(*) FROM DonHangOffline";
return (true, (long)cmd.ExecuteScalar()!);
}
catch (SqliteException) { return (false, 0); }
}
}
// Đẩy đơn chưa đồng bộ về BanHang.Api mỗi DongBo:ChuKyGiay giây. Lỗi mạng không làm app dừng.
sealed class DongBo(string csdl, string maCuaHang) : BackgroundService
{
public static Uri TruSo = new("https://api.banhang.local");
public static TimeSpan ChuKy = TimeSpan.FromSeconds(30);
static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };
static long _lanCuoi; // ticks UTC của lần đồng bộ thành công gần nhất
// Trụ sở "tốt" nếu đồng bộ thành công trong ba chu kỳ gần nhất.
public static bool VuaThanhCong() => DateTime.UtcNow.Ticks - Interlocked.Read(ref _lanCuoi) < 3 * ChuKy.Ticks;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await MotLanAsync(csdl, maCuaHang, soLan: 1, ct); }
catch (Exception e) when (!ct.IsCancellationRequested) { Console.WriteLine($"dong-bo loi: {e.Message}"); }
await Task.Delay(ChuKy, ct);
}
}
public static async Task MotLanAsync(string csdl, string maCuaHang, int soLan, CancellationToken ct = default)
{
for (int lan = 1; lan <= soLan; lan++)
{
try
{
using var cn = Csdl.Mo(csdl);
using var cmd = cn.CreateCommand();
cmd.CommandText = "SELECT MaDon || ';' || NgayTao || ';' || TongTien FROM DonHangOffline WHERE DaDongBo = 0 LIMIT 100";
var dong = new List<string>();
using (var r = cmd.ExecuteReader()) while (r.Read()) dong.Add(r.GetString(0));
using var res = await Http.PostAsync(new Uri(TruSo, $"/api/cua-hang/{maCuaHang}/don-hang"),
new StringContent(string.Join('\n', dong)), ct);
res.EnsureSuccessStatusCode(); // trụ sở khử trùng theo MaDon
foreach (var d in dong)
{
cmd.CommandText = "UPDATE DonHangOffline SET DaDongBo = 1 WHERE MaDon = $ma";
cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("$ma", d.Split(';')[0]);
cmd.ExecuteNonQuery();
}
Interlocked.Exchange(ref _lanCuoi, DateTime.UtcNow.Ticks);
return;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
if (lan < soLan) await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
}
}
static class SaoLuu
{
// sao-luu --du-lieu /data --sao-luu /sao-luu --giu 96
public static async Task<int> ChayAsync(string[] args)
{
string duLieu = Tham(args, "--du-lieu", "."), noiLuu = Tham(args, "--sao-luu", "sao-luu");
int giu = int.Parse(Tham(args, "--giu", "96"));
if (!File.Exists(Path.Combine(noiLuu, ".o-sao-luu")))
{
Console.Error.WriteLine($"o sao luu chua gan: thieu {Path.Combine(noiLuu, ".o-sao-luu")}"); // không ghi nhầm vào ổ chính
return 2;
}
var sw = Stopwatch.StartNew();
string tam = Path.Combine(noiLuu, $".dang-ghi-{Guid.NewGuid():N}.db");
using (var nguon = new SqliteConnection($"Data Source={Path.Combine(duLieu, "cuahang.db")};Mode=ReadWrite;Pooling=False"))
using (var dich = new SqliteConnection($"Data Source={tam};Pooling=False"))
{
nguon.Open();
dich.Open();
nguon.BackupDatabase(dich); // sqlite3_backup: một ảnh nhất quán, app vẫn ghi được
using var cmd = dich.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode = DELETE"; // bản sao đọc được trên mount chỉ đọc
cmd.ExecuteNonQuery();
}
var (tot, soDon) = Csdl.KiemFile(tam);
if (!tot) { File.Delete(tam); Console.Error.WriteLine("ban sao loi integrity_check"); return 3; }
string ten = Path.Combine(noiLuu, $"cuahang-{DateTime.UtcNow:yyyyMMdd-HHmmss}.db");
File.Move(tam, ten);
foreach (var cu in Directory.GetFiles(noiLuu, "cuahang-*.db").Order().SkipLast(giu)) File.Delete(cu);
Console.WriteLine($"sao-luu {Path.GetFileName(ten)}: {soDon} don, {sw.ElapsedMilliseconds} ms");
try // có mạng thì đẩy thêm một bản về trụ sở; mất mạng thì bản trên ổ thứ hai là đủ cho lần này
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
string ma = Environment.GetEnvironmentVariable("CuaHang__Ma") ?? "CHUA-DAT";
var goc = new Uri(Environment.GetEnvironmentVariable("TruSo__Url") ?? "https://api.banhang.local");
await using var f = File.OpenRead(ten);
using var res = await http.PutAsync(new Uri(goc, $"/api/cua-hang/{ma}/sao-luu/{Path.GetFileName(ten)}"), new StreamContent(f));
Console.WriteLine($"day ve tru so: {(int)res.StatusCode}");
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
Console.WriteLine($"chua day ve tru so: {e.GetType().Name}");
}
return 0;
}
public static string Tham(string[] args, string ten, string macDinh)
{
int i = Array.IndexOf(args, ten);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : macDinh;
}
}
static class KhoiPhuc
{
// khoi-phuc --du-lieu /data --sao-luu /sao-luu: chỉ chạy khi volume còn trống
public static int Chay(string[] args)
{
string duLieu = SaoLuu.Tham(args, "--du-lieu", "."), noiLuu = SaoLuu.Tham(args, "--sao-luu", "sao-luu");
string dich = Path.Combine(duLieu, "cuahang.db");
if (File.Exists(dich)) { Console.WriteLine("da co du lieu, bo qua"); return 0; }
if (!File.Exists(Path.Combine(noiLuu, ".o-sao-luu")))
{
Console.WriteLine("CANH BAO: khong thay o sao luu, bat dau voi du lieu trong");
return 0;
}
foreach (var f in Directory.GetFiles(noiLuu, "cuahang-*.db").OrderDescending())
{
var (tot, soDon) = Csdl.KiemFile(f);
if (!tot) { Console.WriteLine($"bo qua {Path.GetFileName(f)}: hong"); continue; }
File.Copy(f, dich + ".tam", overwrite: true);
File.Move(dich + ".tam", dich);
Console.WriteLine($"khoi-phuc tu {Path.GetFileName(f)}: {soDon} don");
return 0;
}
Console.WriteLine("khong co ban sao luu, bat dau voi du lieu trong");
return 0;
}
}
deployment.yaml và hpa.yaml của bản 2.1.0, cùng deployment.yaml của bản 2.0.3
# ===== deployment.yaml (2.1.0)
# BanHang.CuaHang: backend bán tại quầy, bán được khi mất mạng, đồng bộ về trụ sở.
# Không ghi spec.replicas: HPA (hpa.yaml) giữ số pod trong khoảng 2-3.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: cuahang
template:
metadata:
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
terminationGracePeriodSeconds: 30 # > preStop 5 s + ShutdownTimeout 15 s
securityContext:
runAsNonRoot: true
runAsUser: 1654 # user "app" của image .NET từ bản 8
runAsGroup: 1654
fsGroup: 1654
seccompProfile:
type: RuntimeDefault
initContainers:
# Volume trống (máy mới, PVC mới) thì chép bản sao lưu mới nhất vào trước khi app chạy.
- name: khoi-phuc
image: registry.banhang.local/banhang/cuahang:2.1.0
args: ["khoi-phuc", "--du-lieu", "/data", "--sao-luu", "/sao-luu"]
envFrom:
- configMapRef:
name: cuahang-cauhinh
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: sao-luu
mountPath: /sao-luu
readOnly: true
- name: tmp
mountPath: /tmp
containers:
- name: cuahang
image: registry.banhang.local/banhang/cuahang:2.1.0
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: cuahang-cauhinh
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 512Mi # GC tự đặt heap hard limit 75% = 384 MiB
startupProbe: # tối đa 24 x 5 s = 120 s để khởi động
httpGet:
path: /healthz/live
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 24
livenessProbe: # không kiểm phụ thuộc nào
httpGet:
path: /healthz/live
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe: # SQLite cục bộ; mất kết nối trụ sở chỉ là Degraded
httpGet:
path: /healthz/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
lifecycle:
preStop:
sleep:
seconds: 5
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: bhpay-api-key
mountPath: /run/secrets
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: data
persistentVolumeClaim:
claimName: cuahang-data
- name: sao-luu # ổ thứ hai của máy, gắn sẵn tại /mnt/sao-luu
hostPath:
path: /mnt/sao-luu
type: Directory
- name: bhpay-api-key # Secret tạo ngoài Git, xem kustomization.yaml
secret:
secretName: bhpay-api-key-v1
items:
- key: api-key
path: BHPay__ApiKey
- name: tmp
emptyDir:
sizeLimit: 64Mi
# ===== hpa.yaml (2.1.0)
# HPA giữ 2 đến 3 pod, nhắm 70% CPU request (70% của 500m = 350m mỗi pod).
# 3 pod x limit 1 CPU chừa 1 nhân của máy mini PC 4 nhân cho k3s, Traefik và hệ điều hành.
# Deployment không ghi spec.replicas để HPA và GitOps không giành nhau trường này.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cuahang
minReplicas: 2
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300
# ===== deployment.yaml (2.0.3, bản gây sự cố)
# Bản 2.0 chạy ở ch01 ngày 09/09: một pod, liveness trỏ /healthz/ready, heap limit ghi bằng số thập phân.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: cuahang
template:
metadata:
labels:
app.kubernetes.io/name: cuahang
spec:
containers:
- name: cuahang
image: registry.banhang.local/banhang/cuahang:2.0.3
ports:
- name: http
containerPort: 8080
env:
- name: DOTNET_GCHeapHardLimit
value: "402653184" # ý định: 384 MiB
envFrom:
- configMapRef:
name: cuahang-cauhinh
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz/ready
port: http
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 10
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: cuahang-data
6. Chứng minh: app đo thật, Job Object 512 MiB, mô hình kubelet và HPA
Bài chưa dựng cụm thật: không chạy Docker hay k3s. Bằng chứng gồm ba loại: app .NET chạy trên máy, giới hạn bộ nhớ dựng bằng Job Object của Windows, và mô hình kubelet, HPA theo thuật toán trong tài liệu Kubernetes 1.36. Máy chạy: laptop Intel Core Ultra 5 125U, 14 luồng, 32 GB, Windows 11, .NET 10.0.12.
Probe: đo trên app rồi đưa vào mô hình kubelet
KiemProbe.cs dựng một "trụ sở treo": cổng TCP nhận kết nối nhưng không trả lời. Nó chạy CuaHang ở hai chế độ và hỏi hai endpoint mỗi 100 ms với timeout 1 giây như probe. Output, lược các dòng lặp:
2.0.3 t= 35.5 s live=het-gio | ready=503 Unhealthy dang-dung=Healthy sqlite=Healthy tru-so=Unhealthy
2.0.3 t= 35.6 s live=200 Healthy | ready=503 Unhealthy dang-dung=Healthy sqlite=Healthy tru-so=Unhealthy
2.1.0 t= 1.2 s live=het-gio | ready=200 Degraded dang-dung=Healthy sqlite=Healthy tru-so=Degraded
2.1.0 t= 1.3 s live=200 Healthy | ready=200 Degraded dang-dung=Healthy sqlite=Healthy tru-so=Degraded
Bản 2.0.3 mở cổng sau 35,5 giây: ba lần gọi trụ sở, mỗi lần hết 10 giây, cách nhau 2 giây. Bản 2.1.0 mở cổng ngay và báo Degraded. Lần hỏi đầu của cả hai hết giờ vì trên Windows, kết nối tới cổng chưa ai nghe mất khoảng 2 giây mới bị từ chối; trên Linux nó bị từ chối ngay.
MoHinh.cs đưa hai con số 35,5 và 0,7 giây vào mô hình kubelet: probe theo chu kỳ, đếm thất bại liên tiếp, giết, backoff 0, 10, 20, … 300 giây, đặt lại sau 10 phút chạy ổn. Pod đã chạy ổn từ trước; trụ sở treo từ phút 0 tới phút 30.
| Bản | Restart | Ready trong 30 phút | Ready lại sau khi trụ sở về |
|---|---|---|---|
| 2.0.3 | 10 | 1,8 phút | 160 s |
| 2.1.0 | 0 | 30 phút | 0 s |
Mười lần giết rơi vào giây 115, 151, 197, 253, 329, 445, 641, 977, 1.313 và 1.649. Sau khi trụ sở về, 2.0.3 còn mất 160 giây vì đang chờ backoff 300 giây. Mô hình bỏ qua độ lệch ngẫu nhiên của kubelet và thời gian tạo container.
KiemProbe.cs: trụ sở treo, hỏi /healthz/live và /healthz/ready mỗi 100 ms
// KiemProbe.cs: CuaHang mở cổng lúc nào và trả lời probe ra sao khi trụ sở treo.
// dotnet run KiemProbe.cs (cùng thư mục với CuaHang.cs)
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
Process.Start("dotnet", "build CuaHang.cs -o out-cuahang -v q").WaitForExit();
string exe = Path.GetFullPath(Path.Combine("out-cuahang", OperatingSystem.IsWindows() ? "CuaHang.exe" : "CuaHang"));
// Trụ sở "treo": nhận kết nối nhưng không trả lời, như đường truyền nghẽn giờ flash sale.
var hoDen = new TcpListener(IPAddress.Loopback, 5999);
hoDen.Start();
var dangGiu = new List<TcpClient>();
_ = Task.Run(async () => { while (true) dangGiu.Add(await hoDen.AcceptTcpClientAsync()); });
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(1) }; // timeoutSeconds của probe
foreach (var (ten, cauHinhCu) in new[] { ("2.0.3", true), ("2.1.0", false) })
{
string thuMuc = Directory.CreateTempSubdirectory("cuahang-probe-").FullName;
var psi = new ProcessStartInfo(exe, ["--urls", "http://127.0.0.1:8080", "--TruSo:Url", "http://127.0.0.1:5999",
"--CuaHang:CauHinhCu", cauHinhCu.ToString()]) { RedirectStandardOutput = true };
psi.Environment["ConnectionStrings__CuaHang"] = $"Data Source={Path.Combine(thuMuc, "cuahang.db")}";
var dongHo = Stopwatch.StartNew();
using var app = Process.Start(psi)!;
string truoc = "";
while (dongHo.Elapsed.TotalSeconds < 45)
{
string dong = $"live={await Hoi("/healthz/live")} | ready={await Hoi("/healthz/ready")}";
if (dong != truoc) Console.WriteLine($"{ten} t={dongHo.Elapsed.TotalSeconds,5:F1} s {dong}");
truoc = dong;
await Task.Delay(100);
}
app.Kill();
app.WaitForExit();
}
async Task<string> Hoi(string duong)
{
try
{
using var r = await http.GetAsync("http://127.0.0.1:8080" + duong);
return $"{(int)r.StatusCode} {await r.Content.ReadAsStringAsync()}";
}
catch (HttpRequestException) { return "tu-choi-ket-noi"; }
catch (TaskCanceledException) { return "het-gio"; }
}
Heap: GC thấy gì trong giới hạn 512 MiB
Windows giới hạn bộ nhớ container bằng Job Object; GC của .NET đọc giới hạn của Job Object như đọc cgroup trên Linux. GioiHanHeap.cs chạy mỗi cấu hình trong một tiến trình con với Server GC, đa số trong Job Object giới hạn 512 MiB. Tiến trình giữ 200 MiB đơn gần nhất và thêm 100 MiB mỗi giây trong 20 giây, mỗi cấu hình 5 lần:
| Cấu hình | GC thấy | Bộ nhớ đỉnh | Chết | OutOfMemoryException |
|---|---|---|---|---|
| Không giới hạn | 32.233 MiB | 893–894 MiB | 0/5 | 0/5 |
| 512 MiB, không đặt gì | 384 MiB | 380–382 MiB | 0/5 | 0/5 |
512 MiB, HeapHardLimit=402653184 |
16.422 MiB | 431–511 MiB | 1/5 | 0/5 |
512 MiB, HeapHardLimit=0x18000000 |
384 MiB | 379–384 MiB | 0/5 | 0/5 |
512 MiB, HeapHardLimitPercent=60 |
491 MiB | 420–434 MiB | 0/5 | 0/5 |
512 MiB, HeapHardLimitPercent=0x3C |
307 MiB | 318–320 MiB | 0/5 | 2/5 |
"Bộ nhớ đỉnh" là private bytes lớn nhất, gần với thứ cgroup tính. Giá trị thập phân làm GC thấy 16 GiB: bộ nhớ đỉnh lên sát 512 MiB, và một lần tiến trình chết hẳn với mã 0xC0000005 khi Windows từ chối cấp thêm, không qua OutOfMemoryException. Trên Linux, chỗ đó là OOMKilled. Không giới hạn gì, Server GC để bộ nhớ lên 893 MiB với cùng 200 MiB dữ liệu sống.
Hạ xuống 60% (0x3C) không an toàn hơn: heap 307 MiB không đủ cho 200 MiB dữ liệu sống cộng phần GC cần để dọn, và 2/5 lần ném OutOfMemoryException. Muốn heap nhỏ hơn thì phải đo dữ liệu sống trước.
GioiHanHeap.cs: chạy từng cấu hình GC trong Job Object 512 MiB, 5 lần
// GioiHanHeap.cs: GC .NET thấy giới hạn bộ nhớ nào, và giữ tiến trình dưới giới hạn tới đâu.
// dotnet run GioiHanHeap.cs -- [MiB dữ liệu sống] [giây] [số lần]
// Mỗi kịch bản là một tiến trình con. "job-512": chạy trong Job Object giới hạn 512 MiB mỗi tiến trình,
// cơ chế Windows dùng để giới hạn bộ nhớ container; trên Linux, k3s dùng cgroup cho cùng việc.
using System.Diagnostics;
using System.Runtime.InteropServices;
if (args is ["con", var a1, var a2]) return Con(int.Parse(a1), int.Parse(a2));
if (args is ["job", var mib, .. var conLai]) return TrongJob(long.Parse(mib), conLai);
int song = args.Length > 0 ? int.Parse(args[0]) : 200, giay = args.Length > 1 ? int.Parse(args[1]) : 20;
int soLan = args.Length > 2 ? int.Parse(args[2]) : 5;
(string Ten, long JobMiB, string[] Env)[] kichBan =
[
("khong gioi han", 0, []),
("job-512, khong dat gi", 512, []),
("job-512, HeapHardLimit=402653184", 512, ["DOTNET_GCHeapHardLimit=402653184"]),
("job-512, HeapHardLimit=0x18000000", 512, ["DOTNET_GCHeapHardLimit=0x18000000"]),
("job-512, HeapHardLimitPercent=60", 512, ["DOTNET_GCHeapHardLimitPercent=60"]),
("job-512, HeapHardLimitPercent=0x3C", 512, ["DOTNET_GCHeapHardLimitPercent=0x3C"]),
];
var ketQua = kichBan.ToDictionary(k => k.Ten, _ => new List<(int Exit, string GcThay, double Dinh, bool Oom)>());
for (int lan = 1; lan <= soLan; lan++)
foreach (var (ten, jobMiB, env) in kichBan)
{
var psi = new ProcessStartInfo(Environment.ProcessPath!) { RedirectStandardOutput = true, RedirectStandardError = true };
if (jobMiB > 0) { psi.ArgumentList.Add("job"); psi.ArgumentList.Add(jobMiB.ToString()); }
foreach (var a in new[] { "con", song.ToString(), giay.ToString() }) psi.ArgumentList.Add(a);
psi.Environment["DOTNET_gcServer"] = "1"; // như ASP.NET Core
foreach (var e in env) psi.Environment[e.Split('=')[0]] = e.Split('=')[1];
using var p = Process.Start(psi)!;
string outp = p.StandardOutput.ReadToEnd(); p.StandardError.ReadToEnd(); p.WaitForExit();
string Lay(string khoa) => outp.Split('\n').FirstOrDefault(d => d.StartsWith(khoa))?[(khoa.Length + 1)..].Trim() ?? "";
ketQua[ten].Add((p.ExitCode, Lay("gc-thay"), double.TryParse(Lay("dinh"), out var d) ? d : double.NaN, outp.Contains("oom")));
Console.WriteLine($"lan {lan} {ten}: exit {p.ExitCode}, GC thay {Lay("gc-thay")} MiB, private dinh {Lay("dinh")} MiB{(outp.Contains("oom") ? ", OutOfMemoryException" : "")}");
}
Console.WriteLine($"\n{"kich ban",-36}{"GC thay",10}{"private dinh (MiB)",22}{"chet",6}{"OOM",5}");
foreach (var (ten, _, _) in kichBan)
{
var l = ketQua[ten];
var dinh = l.Where(x => !double.IsNaN(x.Dinh)).Select(x => x.Dinh).ToList();
Console.WriteLine($"{ten,-36}{l[0].GcThay,10}{(dinh.Count > 0 ? $"{dinh.Min():F0}-{dinh.Max():F0}" : "-"),22}{l.Count(x => x.Exit != 0),6}{l.Count(x => x.Oom),5}");
}
return 0;
// Bộ đệm trượt: giữ songMiB MiB đơn gần nhất (mỗi đơn 16 KiB), thêm 100 MiB mỗi giây, bỏ đơn cũ nhất.
static int Con(int songMiB, int giay)
{
Console.WriteLine($"gc-thay {GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1048576}");
const int Kb = 16, MiBMoiGiay = 100;
var dem = new Queue<byte[]>(songMiB * 1024 / Kb);
var rng = new Random(42);
var p = Process.GetCurrentProcess();
var sw = Stopwatch.StartNew();
long them = 0, dinh = 0;
try
{
while (sw.Elapsed.TotalSeconds < giay)
{
var b = new byte[Kb * 1024];
for (int i = 0; i < b.Length; i += 4096) b[i] = (byte)rng.Next(); // chạm từng page, như dữ liệu thật
dem.Enqueue(b);
if (dem.Count > songMiB * 1024 / Kb) dem.Dequeue();
if ((++them & 255) == 0) { p.Refresh(); dinh = Math.Max(dinh, p.PrivateMemorySize64); }
while (them * Kb / 1024.0 > MiBMoiGiay * sw.Elapsed.TotalSeconds) Thread.Sleep(1);
}
}
catch (OutOfMemoryException)
{
dem.Clear();
Console.WriteLine("oom");
}
Console.WriteLine($"dinh {dinh / 1048576}");
return 0;
}
static int TrongJob(long mib, string[] conLai)
{
IntPtr job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
info.Basic.LimitFlags = 0x100; // JOB_OBJECT_LIMIT_PROCESS_MEMORY
info.ProcessMemoryLimit = (UIntPtr)(ulong)(mib * 1024 * 1024);
if (!SetInformationJobObject(job, 9, ref info, Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
|| !AssignProcessToJobObject(job, Process.GetCurrentProcess().Handle))
throw new InvalidOperationException($"Job Object loi {Marshal.GetLastWin32Error()}");
using var p = Process.Start(new ProcessStartInfo(Environment.ProcessPath!, conLai))!; // con thừa kế job
p.WaitForExit();
return p.ExitCode;
}
[DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr CreateJobObjectW(IntPtr attr, IntPtr name);
[DllImport("kernel32.dll", SetLastError = true)] static extern bool SetInformationJobObject(IntPtr job, int cls, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, int len);
[DllImport("kernel32.dll", SetLastError = true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit, PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass, SchedulingClass;
}
struct IO_COUNTERS { public ulong Doc, Ghi, Khac, BytesDoc, BytesGhi, BytesKhac; }
struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION Basic;
public IO_COUNTERS Io;
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
}
HPA: mô hình flash sale ở ch01
Phần hai của MoHinh.cs chạy thuật toán HPA ở mục 3 mỗi 15 giây trên tải minh họa: 15 request/s, lên 100 request/s trong 20 giây lúc 10:00, giữ 30 phút. Mỗi request tốn 25 ms CPU (giả định), nên một pod với limit 1 CPU phục vụ tối đa 40 request/s; pod mới ready sau 8 giây (giả định).
| Cấu hình | Request vượt sức | Số giây quá tải |
|---|---|---|
| 1 pod, không HPA | 108.767 | 1.846 |
| 2 pod, không HPA | 35.819 | 1.802 |
| HPA 2–3 pod | 98 | 7 |
10:00:15 tai 79 req/s, CPU TB 123% request, pod 2 -> 3
10:00:30 tai 100 req/s, CPU TB 182% request, pod 3 -> 3
HPA thấy 123% ở lần tính đầu sau khi tải tăng và thêm pod thứ ba; 7 giây quá tải là lúc pod đó chưa ready. Ba pod dùng 2,5 nhân, 167% request, dưới limit. Hệ số 25 ms và tải là số minh họa; mô hình chỉ cho thấy thời gian phản ứng của HPA.
MoHinh.cs: mô hình kubelet (probe, backoff) và HPA (công thức, dung sai, pod chưa ready)
// MoHinh.cs: mô hình kubelet (probe, restart) và HPA theo tài liệu Kubernetes 1.36. Không phải cụm thật.
// dotnet run MoHinh.cs -- [giây mở cổng của 2.0.3 khi trụ sở treo] [giây mở cổng khi trụ sở tốt]
using System.Globalization;
double ngheCu = args.Length > 0 ? double.Parse(args[0], CultureInfo.InvariantCulture) : 35.9;
double ngheTot = args.Length > 1 ? double.Parse(args[1], CultureInfo.InvariantCulture) : 0.8;
// ---- Phần 1: trụ sở treo 30 phút (t = 0 đến 1.800 s), pod đã chạy ổn từ trước ----
CauHinh[] cauHinh =
[
new("2.0.3, liveness trỏ /healthz/ready", Startup: null,
Liveness: new("/healthz/ready", Tre: 15, ChuKy: 10, NguongLoi: 3),
Readiness: new("/healthz/ready", Tre: 0, ChuKy: 10, NguongLoi: 3),
NgheKhiTruSoTreo: ngheCu, NgheKhiTruSoTot: ngheTot, ReadyCanTruSo: true),
new("2.1.0, startup + live + ready tách", Startup: new("/healthz/live", Tre: 0, ChuKy: 5, NguongLoi: 24),
Liveness: new("/healthz/live", Tre: 0, ChuKy: 10, NguongLoi: 3),
Readiness: new("/healthz/ready", Tre: 0, ChuKy: 5, NguongLoi: 2),
NgheKhiTruSoTreo: ngheTot, NgheKhiTruSoTot: ngheTot, ReadyCanTruSo: false),
];
Console.WriteLine("cau hinh restart ready trong su co ready lai sau khi tru so ve");
foreach (var c in cauHinh)
{
var k = Kubelet.Chay(c);
Console.WriteLine($"{c.Ten,-38}{k.Restart,8}{k.ReadyTrongSuCo / 60,14:F1} phut{k.ReadyLaiSau,20:F0} s");
Console.WriteLine($" restart luc (s): {string.Join(", ", k.LucRestart.Select(x => x.ToString("F0")))}");
}
// ---- Phần 2: flash sale 10:00-10:30, tải minh họa, 25 ms CPU mỗi request ----
Console.WriteLine();
foreach (var (ten, soPod, coHpa) in new[] { ("1 pod, khong HPA", 1, false), ("2 pod, khong HPA", 2, false), ("HPA 2-3 pod", 2, true) })
{
var h = Hpa.Chay(soPod, coHpa);
Console.WriteLine($"{ten,-18} request vuot suc: {h.Vuot,8:F0}, so giay qua tai: {h.GiayQuaTai,5}");
foreach (var d in h.Dong) Console.WriteLine(" " + d);
}
record Probe(string Duong, double Tre, double ChuKy, int NguongLoi);
record CauHinh(string Ten, Probe? Startup, Probe Liveness, Probe Readiness,
double NgheKhiTruSoTreo, double NgheKhiTruSoTot, bool ReadyCanTruSo);
record KetQuaKubelet(int Restart, double ReadyTrongSuCo, double ReadyLaiSau, List<double> LucRestart);
static class Kubelet
{
const double TreoTu = 0, TreoDen = 1800, Dt = 0.1;
// Readiness của app: 2.0 trả 503 khi lần đồng bộ thành công cuối đã quá 3 chu kỳ (90 s); 2.1 chỉ báo Degraded.
// Trụ sở trở lại thì lần đồng bộ kế tiếp thành công, trung bình sau nửa chu kỳ 30 s.
static bool TruSoTotGanDay(double t) => t < TreoTu + 90 || t >= TreoDen + 15;
static bool TruSoTreo(double t) => t >= TreoTu && t < TreoDen;
public static KetQuaKubelet Chay(CauHinh c, double podBatDau = -120, double ketThuc = 2700)
{
bool chay = false, daKhoiDong = false, ready = false;
double batDau = 0, nghe = 0, tiepStartup = 0, tiepLive = 0, tiepReady = 0, khoiDongLuc = podBatDau;
int loiStartup = 0, loiLive = 0, loiReady = 0, lienTiep = 0;
double readyTrongSuCo = 0, readyLaiSau = double.NaN;
var lucRestart = new List<double>();
void Giet(double t)
{
chay = false; ready = false;
lucRestart.Add(t);
if (t - batDau >= 600) lienTiep = 0; // chạy 10 phút không lỗi: đặt lại backoff
double cho = lienTiep == 0 ? 0 : Math.Min(10 * Math.Pow(2, lienTiep - 1), 300); // 0, 10, 20, 40, ... 300 s
lienTiep++;
khoiDongLuc = t + 1 + cho; // 1 s để container dừng
}
for (double t = podBatDau; t < ketThuc; t = Math.Round(t + Dt, 1))
{
if (!chay && t >= khoiDongLuc)
{
chay = true; batDau = t;
nghe = t + (TruSoTreo(t) ? c.NgheKhiTruSoTreo : c.NgheKhiTruSoTot);
daKhoiDong = c.Startup is null;
tiepStartup = t + (c.Startup?.Tre ?? 0);
tiepLive = daKhoiDong ? t + c.Liveness.Tre : double.MaxValue;
tiepReady = daKhoiDong ? t + c.Readiness.Tre : double.MaxValue;
loiStartup = loiLive = loiReady = 0;
}
if (chay)
{
bool live = t >= nghe;
bool Ok(Probe p) => p.Duong == "/healthz/live" ? live : live && (!c.ReadyCanTruSo || TruSoTotGanDay(t));
if (!daKhoiDong && t >= tiepStartup - 1e-9)
{
tiepStartup += c.Startup!.ChuKy;
if (Ok(c.Startup)) { daKhoiDong = true; tiepLive = t + c.Liveness.Tre; tiepReady = t + c.Readiness.Tre; }
else if (++loiStartup >= c.Startup.NguongLoi) Giet(t);
}
if (chay && daKhoiDong && t >= tiepLive - 1e-9)
{
tiepLive += c.Liveness.ChuKy;
if (Ok(c.Liveness)) loiLive = 0;
else if (++loiLive >= c.Liveness.NguongLoi) Giet(t);
}
if (chay && daKhoiDong && t >= tiepReady - 1e-9)
{
tiepReady += c.Readiness.ChuKy;
if (Ok(c.Readiness)) { loiReady = 0; ready = true; }
else if (++loiReady >= c.Readiness.NguongLoi) ready = false;
}
}
if (ready && TruSoTreo(t)) readyTrongSuCo += Dt;
if (ready && t >= TreoDen && double.IsNaN(readyLaiSau)) readyLaiSau = t - TreoDen;
}
return new(lucRestart.Count(x => x >= TreoTu), readyTrongSuCo, readyLaiSau, lucRestart);
}
}
record KetQuaHpa(double Vuot, int GiayQuaTai, List<string> Dong);
static class Hpa
{
const double ChiPhi = 0.025, Request = 0.5, Limit = 1.0, Dich = 0.70, DungSai = 0.1, ReadySau = 8;
const int Min = 2, Max = 3, ChuKy = 15, OnDinhGiam = 300;
// Request mỗi giây, t tính từ 09:55:00. Flash sale 10:00 đến 10:30.
static double Tai(int t) => t switch
{
< 300 => 15,
< 320 => 15 + (100 - 15) * (t - 300) / 20.0,
< 2100 => 100,
< 2160 => 100 - 70 * (t - 2100) / 60.0,
_ => 30,
};
public static KetQuaHpa Chay(int soPod, bool coHpa, int ketThuc = 3000)
{
var pods = Enumerable.Range(0, soPod).Select(_ => (BatDau: -600.0, ReadyLuc: -600.0)).ToList();
var dungTheoPod = new Dictionary<int, List<double>>(); // CPU từng giây, theo chỉ số pod
var deXuat = new List<(int t, int soPod)>();
double vuot = 0; int giayQuaTai = 0;
var dong = new List<string>();
for (int t = 0; t < ketThuc; t++)
{
var dangReady = pods.Select((p, i) => (p, i)).Where(x => x.p.ReadyLuc <= t).ToList();
double suc = dangReady.Count * Limit / ChiPhi, tai = Tai(t), phucVu = Math.Min(tai, suc);
if (tai > suc + 1e-9) { vuot += tai - suc; giayQuaTai++; }
for (int i = 0; i < pods.Count; i++)
{
if (!dungTheoPod.TryGetValue(i, out var l)) dungTheoPod[i] = l = [];
l.Add(pods[i].ReadyLuc <= t ? phucVu * ChiPhi / dangReady.Count : 0);
}
if (!coHpa || t % ChuKy != 0 || t == 0) continue;
// metrics-server: trung bình 15 s vừa qua; pod chưa Ready trọn cửa sổ thì để riêng.
var coSo = new List<double>(); int chuaReady = 0;
for (int i = 0; i < pods.Count; i++)
{
if (pods[i].ReadyLuc <= t - ChuKy) coSo.Add(dungTheoPod[i].Skip(dungTheoPod[i].Count - ChuKy).Average());
else chuaReady++;
}
int hienTai = pods.Count, muonCo = hienTai;
double tiLe = coSo.Average() / Request / Dich; // currentMetricValue / desiredMetricValue
if (tiLe > 1 && chuaReady > 0) tiLe = coSo.Sum() / Request / Dich / (coSo.Count + chuaReady); // pod chưa Ready tính 0%
if (Math.Abs(tiLe - 1) > DungSai) muonCo = (int)Math.Ceiling(tiLe * (coSo.Count + chuaReady));
muonCo = Math.Clamp(muonCo, Min, Max);
deXuat.Add((t, muonCo));
if (muonCo < hienTai) muonCo = deXuat.Where(d => d.t > t - OnDinhGiam).Max(d => d.soPod); // ổn định 300 s khi giảm
while (pods.Count < muonCo) pods.Add((t, t + ReadySau));
while (pods.Count > muonCo) pods.RemoveAt(pods.Count - 1);
if (t is >= 285 and <= 360 || t % 600 == 0)
dong.Add($"{TimeSpan.FromSeconds(t + 9 * 3600 + 55 * 60):hh\\:mm\\:ss} tai {tai,5:F0} req/s, CPU TB {coSo.Average() / Request * 100,4:F0}% request, pod {hienTai} -> {pods.Count}");
}
if (!coHpa) dong.Add($"{soPod} pod: suc toi da {soPod * Limit / ChiPhi:F0} req/s, flash sale can {100 * ChiPhi:F1} CPU");
return new(vuot, giayQuaTai, dong);
}
}
Cuối cùng, KiemManifest.cs của bài 4 chạy trên manifest 2.0.3 báo 1 lỗi và 1 cảnh báo: DOTNET_GCHeapHardLimit=402653184 được runtime đọc là hex, và liveness, readiness cùng gọi /healthz/ready. Manifest đó vẫn qua kubeconform.
Điều chưa chứng minh: chưa chạy trên cụm, nên thời gian thật của kubelet, metrics-server và Traefik, cũng như hành vi OOM của cgroup, chưa đo. Job Object không giống hệt cgroup: cgroup đếm cả page cache và không từ chối cấp phát mà giết tiến trình.
7. Kết luận
Probe, limit và HPA đều được kubelet, kernel và HPA đọc đúng theo chữ: liveness gọi endpoint có phụ thuộc là lệnh giết pod khi phụ thuộc chậm, và một con số thập phân trong biến hex là heap 16 GiB.
Trong dự án .NET của bạn:
- Liveness gọi endpoint không chạy check nào (
Predicate = _ => false); phụ thuộc ngoài trảDegradedtrong readiness, hoặc không có mặt trong probe nào. - Không đồng bộ hay gọi dịch vụ ngoài trước
app.Run(); đưa việc đó vàoBackgroundService, và dùngstartupProbecho migration chậm. - Để GC tự lấy 75%
limits.memory. Nếu phải đặtDOTNET_GCHeapHardLimithayDOTNET_GCHeapHardLimitPercent, ghi0xvà kiểm bằngGC.GetGCMemoryInfo().TotalAvailableMemoryBytes. - Đặt memory request bằng limit, CPU request theo mức dùng bình thường, và HPA theo phần trăm của request; trên một node,
maxReplicasnhân CPU limit phải chừa chỗ cho k3s.
Những chỗ hay hiểu sai
- "Liveness nên kiểm database cho chắc." Database chậm thì mọi pod cùng bị giết và khởi động lại cùng lúc.
- "Đặt
DOTNET_GCHeapHardLimitPercent=75là 75%." Runtime đọc0x75, tức 117%; trên máy thử, GC bỏ qua giá trị đó. - "HPA tính phần trăm theo limit." Nó tính theo request; 167% request vẫn có thể dưới limit.
- "Restart sẽ chữa được." Restart chỉ chữa tiến trình hỏng, không chữa mạng hay database.
Đọc tiếp
- Máy chủ cửa hàng chạy k3s: bài trước trong series, k3s một node và bộ manifest gốc.
- Swarm hay k3s: bài sau, trụ sở có nên chuyển từ Swarm sang k3s nhiều server.
- Dữ liệu cửa hàng khi pod dời node: volume SQLite mà ba pod cùng ghi.
- Rolling update trên Swarm: healthcheck và dừng mềm phía trụ sở.
- Triển khai không gián đoạn: readiness,
ApplicationStoppingvàpreStop.
Nguồn
- Kubernetes 1.36, Pod Lifecycle: probe, restart backoff, feature gate
ReduceDefaultCrashLoopBackOffDecay. Configure Liveness, Readiness and Startup Probes. Resource Management for Pods and Containers: OOM kill,OOMKilled, exit code 137. Horizontal Pod Autoscaling: công thức, dung sai, pod chưa ready, hành vi mặc định. Mã nguồn bản release-1.36 của trang tài liệu trên kubernetes/website. - k3s v1.36.5+k3s1, metrics-server-deployment.yaml:
--metric-resolution=15s. Đọc ngày 4/10/2026. - Microsoft Learn, Runtime configuration options for garbage collection: heap hard limit, 75% trong container, giá trị hex của biến môi trường, DATAS. Health checks in ASP.NET Core:
Degraded, mã trạng thái. - Microsoft Learn, Job Objects và JOBOBJECT_EXTENDED_LIMIT_INFORMATION.
- SQLite, Write-Ahead Logging: nhiều tiến trình trên cùng một máy.