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

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.

Mục lục
  1. 1. Vấn đề: một lần dán inspect vào ticket làm lộ mật khẩu SQL
  2. 2. Mục đích: không bí mật nào trong stack hay image, xoay khóa không build lại
  3. 3. Cơ sở lý thuyết: biến môi trường đi theo spec và image, secret đi theo task
  4. 4. Cách giải quyết: secret có phiên bản, target là tên khóa cấu hình
  5. 5. Cách cài đặt: tạo secret, gắn vào stack, đọc bằng KeyPerFile
  6. 6. Chứng minh: cấu hình đã kiểm, xoay khóa trên .NET thật
  7. 7. Kết luận
  8. Đọc tiếp
  9. Nguồn

Đọc nhanh

  • Vấn đề: Chuỗi kết nối SQL nằm trong biến môi trường của stack, khóa BHPay nằm trong ENV của Dockerfile, nên docker service inspect, docker image inspect và file stack trong git đều lộ chúng.
  • Cách giải: Docker secret có số phiên bản, gắn vào /run/secrets với target là tên khóa cấu hình, đọc bằng KeyPerFile; xoay khóa bằng secret _v2 và hai login SQL luân phiên.
  • Chứng minh: KiemStack.cs đếm 4 vi phạm ở stack cũ, 0 ở stack mới; .NET chạy thật xoay v1 sang v2 với cả hai task cùng sẵn sàng; chưa dựng cụm thật.
  • Trong .NET: AddKeyPerFile("/run/secrets") thêm sau biến môi trường, đọc bằng GetConnectionString("BanHang") như cũ, và kiểm ký tự trắng trước khi dùng khóa.

1. Vấn đề: một lần dán inspect vào ticket làm lộ mật khẩu SQL

Tuần đầu chạy Swarm, một kỹ sư dán output của docker service inspect banhang_api vào ticket nhờ đội hạ tầng xem cấu hình mạng. Trong đó có dòng ConnectionStrings__BanHang=Server=sql01;Database=BanHang;User Id=banhang_api;Password=GiaMatKhau#2025;.... Hệ thống ticket có khoảng 40 người đọc được (minh họa). Mọi mật khẩu và khóa trong bài là giả.

Chuỗi đó cũng nằm trong file stack trên git. Khóa API của BHPay nằm trong dòng ENV BHPay__ApiKey=... của Dockerfile, nên mọi bản image từ 1.4.0 đều mang nó, và ai pull được image từ registry.banhang.local đều đọc được bằng docker image inspect.

Đổi mật khẩu đòi sửa file stack, build lại image và triển khai lại, trong lúc các task cũ vẫn dùng mật khẩu cũ. Đội vận hành ngại làm, nên mật khẩu SQL không đổi từ 2025.

2. Mục đích: không bí mật nào trong stack hay image, xoay khóa không build lại

  • 0 giá trị bí mật trong file stack, kết quả render của stack và Dockerfile, đếm bằng KiemStack.cs của bài 2.
  • App đọc hai bí mật từ /run/secrets mà dòng đọc cấu hình giữ nguyên: GetConnectionString("BanHang") và ["BHPay:ApiKey"].
  • Xoay sang _v2 không build lại image, không sửa code, task cũ và mới cùng sẵn sàng trong lúc chuyển; task còn trỏ _v1 sau khi thu hồi thì không khởi động được.
  • Ngoài phạm vi: kho bí mật ngoài như Vault hay Azure Key Vault, bí mật trên máy khách (bài dịch ngược), mã hóa bản backup của Raft.

3. Cơ sở lý thuyết: biến môi trường đi theo spec và image, secret đi theo task

Biến môi trường đi tới những đâu

Đặt ở Nằm trong Ai đọc được
ENV trong Dockerfile Config và lịch sử build của image Ai pull được image: docker image inspect, docker history
environment trong stack File stack và service spec Ai đọc repo, ai gọi được Docker API của manager: docker service inspect
Trong container Môi trường của tiến trình docker inspect trên node, /proc/<pid>/environ, tiến trình con, bản dump bộ nhớ

Theo man page proc_pid_environ(5), /proc/<pid>/environ giữ môi trường lúc execve, và đọc được khi qua kiểm tra ptrace PTRACE_MODE_READ_FSCREDS, thường là tiến trình cùng user hoặc root. Mỗi chỗ trên là một bản sao có vòng đời riêng: xóa khỏi stack không xóa được khỏi image đã đẩy lên registry.

Docker secret đi đường khác

Theo tài liệu Docker về secret:

  • docker secret create gửi giá trị tới manager qua TLS hai chiều; giá trị nằm trong Raft log đã mã hóa và được sao lên các manager.
  • Node chỉ nhận secret khi là manager hoặc đang chạy task được cấp secret đó.
  • Trong container, secret đã giải mã được gắn vào filesystem trong RAM, mặc định ở /run/secrets/<tên>, hoặc ở target tự đặt. Task dừng thì secret bị gỡ và xóa khỏi bộ nhớ của node.
  • docker secret inspect chỉ trả metadata; service spec chỉ giữ tên và id của secret.
  • Secret tối đa 500 KB, không sửa hay đổi tên được: muốn đổi giá trị thì tạo secret mới, tài liệu khuyên thêm số phiên bản vào tên.
flowchart LR
  subgraph cu["Trước: biến môi trường"]
    direction TB
    E1["file stack trong git"] --> E2["service spec"] --> E3["docker service inspect"]
    D1["ENV trong Dockerfile"] --> D2["config của image"] --> D3["docker image inspect"]
  end
  subgraph moi["Sau: docker secret"]
    direction TB
    S1["docker secret create"] --> S2["Raft log mã hóa"] --> S3["node có task cần"] --> S4["/run/secrets trong RAM"] --> S5["KeyPerFile"]
  end
  cu ~~~ moi

KeyPerFile đọc mỗi file thành một khóa cấu hình

Microsoft.Extensions.Configuration.KeyPerFile có sẵn trong shared framework của ASP.NET Core. AddKeyPerFile(thưMục, optional) đọc mỗi file trong thư mục thành một khóa: tên file là khóa, __ đổi thành :, nội dung là giá trị. Theo mã nguồn KeyPerFileConfigurationProvider.cs, provider bỏ đúng một Environment.NewLine ở cuối giá trị và bỏ qua file bắt đầu bằng ignore.; thư mục phải là đường dẫn tuyệt đối.

Vì vậy đặt target: ConnectionStrings__BanHang thì file /run/secrets/ConnectionStrings__BanHang thành khóa ConnectionStrings:BanHang, đúng khóa mà biến môi trường cùng tên tạo ra trước đây. Code GetConnectionString("BanHang") không đổi.

Provider thêm sau thắng provider thêm trước. WebApplication.CreateBuilder đã thêm biến môi trường và tham số dòng lệnh, nên AddKeyPerFile gọi sau đó làm secret thắng một biến môi trường cũ còn sót lại.

Environment.NewLine là của máy chạy app. Trong container Linux, provider bỏ \n; một file secret soạn trên Windows kết thúc bằng \r\n sẽ để lại \r ở cuối khóa. DefaultRequestHeaders.Add từ chối giá trị đó bằng FormatException (đã thử trên .NET 10.0.12), nên mọi lời gọi BHPay hỏng trong khi health check vẫn trả 200.

4. Cách giải quyết: secret có phiên bản, target là tên khóa cấu hình

Cách Ưu Nhược Khi nào dùng
Biến môi trường trong stack Đơn giản, .NET đọc sẵn Lộ qua inspect và git; đổi là sửa stack Giá trị không bí mật
ENV hoặc appsettings trong image Không cần cấu hình lúc chạy Ai pull image cũng đọc được; đổi là build lại Không dùng cho bí mật
Docker config Gắn file vào container Không gắn qua RAM disk, dành cho dữ liệu không bí mật appsettings.Production.json
Docker secret và KeyPerFile Mã hóa trong Raft, chỉ tới node cần, không đổi code đọc cấu hình Không sửa được, chỉ dùng cho service Swarm Bí mật của service trên Swarm
Kho bí mật ngoài Tự xoay, có nhật ký truy cập Thêm một hệ thống phải luôn sẵn sàng Nhiều cụm, yêu cầu audit

Bài chọn Docker secret và KeyPerFile. Các bước:

  1. Tạo secret bằng printf, không có xuống dòng cuối, tên có hậu tố _v1.
  2. Stack gắn secret với target là tên khóa cấu hình; xóa hai biến khỏi environment và dòng ENV khỏi Dockerfile.
  3. BanHang.Api gọi AddKeyPerFile("/run/secrets"), dừng ngay khi thiếu khóa hoặc khóa có ký tự trắng.
  4. Xoay: tạo login banhang_api_b và khóa BHPay thứ hai, cả hai còn hiệu lực song song với cái cũ; tạo secret _v2; sửa source trong file stack và docker stack deploy, rolling update ở bài 2 thay từng task; khi task cuối dùng _v2, khóa login A, thu hồi khóa v1 và docker secret rm các secret _v1.

Mật khẩu SQL Server gắn với một login, đổi là login cũ hỏng ngay cho mọi task chưa kịp đổi. Hai login luân phiên A và B cho phép task cũ và task mới cùng chạy trong suốt lần rolling update.

5. Cách cài đặt: tạo secret, gắn vào stack, đọc bằng KeyPerFile

Phiên bản như bài 1: Docker Engine 29.8.2, .NET 10.0.12, Microsoft.Data.SqlClient 7.1.1. Lệnh trên một manager, giá trị giả:

printf '%s' 'Server=sql01;Database=BanHang;User Id=banhang_api_a;Password=GiaMatKhau#A-2026;Encrypt=True' \
  | docker secret create banhang_sql_v1 -
printf '%s' 'bhpay_test_sk_GIA_v1_7Qm2Xc9Lp4' | docker secret create bhpay_api_key_v1 -
docker stack deploy -c banhang-api.stack.yml banhang
# Xoay: tạo _v2, đổi _v1 thành _v2 trong file stack (cả mục secrets cuối file), deploy lại, rồi xóa _v1
printf '%s' 'Server=sql01;Database=BanHang;User Id=banhang_api_b;Password=GiaMatKhau#B-2026;Encrypt=True' \
  | docker secret create banhang_sql_v2 -
printf '%s' 'bhpay_test_sk_GIA_v2_Rb8Tn3Vw6Y' | docker secret create bhpay_api_key_v2 -
docker stack deploy -c banhang-api.stack.yml banhang
docker secret rm banhang_sql_v1 bhpay_api_key_v1

Phần stack của bài này:

services:
  api:
    secrets:
      - source: banhang_sql_v1
        target: ConnectionStrings__BanHang  # KeyPerFile đọc thành ConnectionStrings:BanHang
      - source: bhpay_api_key_v1
        target: BHPay__ApiKey
secrets:
  banhang_sql_v1:
    external: true                          # tạo trước bằng docker secret create
  bhpay_api_key_v1:
    external: true

Trên sql01, quyền nằm ở một role chung để hai login như nhau:

USE master;
CREATE LOGIN banhang_api_b WITH PASSWORD = N'GiaMatKhau#B-2026';
USE BanHang;
CREATE USER banhang_api_b FOR LOGIN banhang_api_b;
ALTER ROLE vai_tro_banhang_api ADD MEMBER banhang_api_b;   -- role đã cấp quyền cho banhang_api_a
-- Sau khi mọi task đã chạy với secret _v2:
ALTER LOGIN banhang_api_a DISABLE;

Trong BanHang.Api.cs ở bài 1:

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");

optional: true để chạy được trên máy dev không có /run/secrets; hai dòng throw bảo đảm production không chạy thiếu bí mật. Thiếu secret thì tiến trình thoát mã khác 0, Swarm thấy task hỏng và failure_action: rollback của bài 2 đưa service về bản cũ.

6. Chứng minh: cấu hình đã kiểm, xoay khóa trên .NET thật

Bằng chứng có hai loại, và bài chưa dựng cụm thật: việc mã hóa Raft log, TLS hai chiều và filesystem trong RAM lấy từ tài liệu Docker, chưa kiểm 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 cũ và mới không lỗi. KiemStack.cs của bài 2 trên stack cũ và Dockerfile cũ:

> dotnet run KiemStack.cs -- bai3-truoc.render.yml Dockerfile.truoc
VI PHẠM api: biến môi trường ConnectionStrings__BanHang chứa bí mật
VI PHẠM api: thiếu secret có target ConnectionStrings__BanHang
VI PHẠM api: thiếu secret có target BHPay__ApiKey
VI PHẠM Dockerfile dòng 12: ENV BHPay__ApiKey mang bí mật vào image
4 vi phạm

Stack mới và Dockerfile mới: 0 vi phạm. Luật bí mật trong KiemStack.cs là một biểu thức chính quy trên tên và giá trị biến môi trường, cộng danh sách khóa app cần:

var biMat = new Regex(@"(?i)(password|pwd)\s*=|bhpay_\w+_sk_|api_?key|secret");
string[] khoaCanCo = ["ConnectionStrings__BanHang", "BHPay__ApiKey"];

Stack và Dockerfile mới không còn vi phạm nào về bí mật

TrướcSau
Bí mật trong biến môi trường1 vi phạm0 vi phạmBí mật trong ENV của image1 vi phạm0 vi phạmKhóa app cần mà thiếu secret2 vi phạm0 vi phạm
KiemStack.cs (bài 2), YamlDotNet 18.1.0, chạy trên kết quả docker stack config của Docker CLI 28.0.4.
Bảng số liệu
TrướcSau
Bí mật trong biến môi trường1 vi phạm0 vi phạm
Bí mật trong ENV của image1 vi phạm0 vi phạm
Khóa app cần mà thiếu secret2 vi phạm0 vi phạm

Xoay khóa trên tiến trình .NET thật

XoayBiMat.cs tạo hai login SQL trên LocalDB, hai thư mục giả lập /run/secrets cho v1 và v2, và một BHPay giả chỉ nhận khóa trong danh sách hợp lệ. Mọi task chạy với biến môi trường BHPay__ApiKey mang giá trị cũ, để thấy secret thắng. Laptop Intel Core Ultra 5 125U, Windows 11, .NET 10.0.12, SQL Server 2019 LocalDB 15.0.4382.1:

B1. Task đọc secret v1; biến môi trường BHPay__ApiKey có giá trị khác
    task v1: ready 200, BHPay 200; login SQL đang mở: kumeo_s_api_a
B2. BHPay nhận cả khóa v2; task mới đọc secret v2 chạy song song (start-first)
    task v1: ready 200, BHPay 200; task v2: ready 200, BHPay 200; login: kumeo_s_api_a, kumeo_s_api_b
B3. Gỡ task v1, khóa login A, BHPay bỏ khóa v1
    task v2: ready 200, BHPay 200; login: kumeo_s_api_b
    task mới còn trỏ secret v1: không khởi động được, thoát mã 1: SQL không trả lời, dừng khởi động
B4. File khóa có xuống dòng cuối; máy này Environment.NewLine = CRLF
    LF: không khởi động được, thoát mã -532462766: BHPay:ApiKey có ký tự trắng (dài 32); tạo secret bằng printf, không để xuống dòng cuối
    CRLF: chạy, BHPay 200
  • B1: BHPay trả 200 nên header mang khóa v1 từ file, không phải giá trị trong biến môi trường.
  • B2: hai đời bí mật cùng phục vụ, đúng khoảng chồng nhau của start-first; code đọc cấu hình không đổi giữa hai task.
  • B3: sau khi thu hồi, một task còn trỏ _v1 thoát mã 1 ở bước kiểm SQL lúc khởi động, nên Swarm thấy task hỏng thay vì nhận request.
  • B4: trên Windows, provider chỉ bỏ \r\n, nên file kết thúc bằng \n để lại một ký tự trắng, khóa dài 32 thay vì 31, và tiến trình dừng vì ngoại lệ chưa bắt. Trong container Linux thì ngược lại, theo mã nguồn ở mục 3. Dòng kiểm ký tự trắng chặn lỗi đó lúc khởi động thay vì ở mỗi lời gọi BHPay.
XoayBiMat.cs: hai login SQL, secret v1 và v2, BHPay giả, xoay trên tiến trình BanHang.Api thậtC# · 116 dòng
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Data.SqlClient@7.1.1
#:property PublishAot=false
// XoayBiMat.cs: xoay secret v1 sang v2 trên tiến trình BanHang.Api thật, SQL LocalDB và một BHPay giả.
// dotnet run XoayBiMat.cs   (cần ../out/api từ bài 1). Mọi mật khẩu và khóa ở đây là giả.
using System.Diagnostics;
using Microsoft.Data.SqlClient;

string exe = Path.GetFullPath(Path.Combine("../out/api", OperatingSystem.IsWindows() ? "BanHang.Api.exe" : "BanHang.Api"));
const string Master = @"Server=(localdb)\MSSQLLocalDB;Database=master;Integrated Security=true;Encrypt=False";
const string KumeoS = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_S;Integrated Security=true;Encrypt=False";
const string KhoaV1 = "bhpay_test_sk_GIA_v1_7Qm2Xc9Lp4", KhoaV2 = "bhpay_test_sk_GIA_v2_Rb8Tn3Vw6Y";
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };

// Hai login cho hai đời secret: đổi mật khẩu một login thì task cũ hỏng ngay, nên luân phiên A và B.
foreach (var l in new[] { "kumeo_s_api_a", "kumeo_s_api_b" })
{
    await Sql(Master, $"IF SUSER_ID('{l}') IS NULL CREATE LOGIN {l} WITH PASSWORD = 'GiaMatKhau#{l[^1]}-2026'; ALTER LOGIN {l} ENABLE;");
    await Sql(KumeoS, $"IF USER_ID('{l}') IS NULL CREATE USER {l} FOR LOGIN {l};");
}
string ThuMuc(string ten, string login, string khoa, string cuoi = "")
{
    var d = Path.GetFullPath(ten); Directory.CreateDirectory(d);
    File.WriteAllText(Path.Combine(d, "ConnectionStrings__BanHang"),
        $@"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_S;User Id={login};Password=GiaMatKhau#{login[^1]}-2026;Encrypt=False");
    File.WriteAllText(Path.Combine(d, "BHPay__ApiKey"), khoa + cuoi);   // như printf: không xuống dòng cuối
    return d;
}
string v1 = ThuMuc("run-secrets-v1", "kumeo_s_api_a", KhoaV1), v2 = ThuMuc("run-secrets-v2", "kumeo_s_api_b", KhoaV2);

// BHPay giả: chỉ nhận khóa trong danh sách hợp lệ, như hệ thống cho phép hai khóa cùng lúc khi xoay.
var hopLe = new HashSet<string> { KhoaV1 };
var bhpayBuilder = WebApplication.CreateBuilder();
bhpayBuilder.Logging.ClearProviders();
var bhpay = bhpayBuilder.Build();
bhpay.Urls.Add("http://127.0.0.1:5199");
bhpay.MapGet("/v1/ping", (HttpRequest r) => Results.StatusCode(hopLe.Contains(r.Headers["X-Api-Key"].ToString()) ? 200 : 401));
await bhpay.StartAsync();

var cong = 5183;
async Task<(Process p, string url, string loi)> ChayTask(string thuMuc)
{
    string url = $"http://127.0.0.1:{cong++}";
    var psi = new ProcessStartInfo(exe, ["--urls", url, "--ThuMucBiMat", thuMuc, "--BHPay:BaseUrl", "http://127.0.0.1:5199/", "--SqlChoKhoiDongGiay", "5"])
        { RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true };
    psi.Environment["BHPay__ApiKey"] = "khoa-cu-trong-bien-moi-truong";   // secret phải thắng biến môi trường
    var p = Process.Start(psi)!;
    var loi = new List<string>();
    p.OutputDataReceived += (_, _) => { };
    p.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (loi) loi.Add(e.Data); };
    p.BeginOutputReadLine(); p.BeginErrorReadLine();
    while (await Ma(url + "/healthz/ready") != 200)
    {
        if (p.HasExited)
        {
            await Task.Delay(200);   // chờ đọc hết stderr
            string dong = loi.FirstOrDefault(d => d.Contains("BHPay") || d.Contains("SQL")) ?? "";
            return (p, url, $"không khởi động được, thoát mã {p.ExitCode}: {dong.Split("Exception: ")[^1].Trim()}");
        }
        await Task.Delay(100);
    }
    return (p, url, "");
}
async Task<int> Ma(string url) { try { using var r = await http.GetAsync(url); return (int)r.StatusCode; } catch { return 0; } }
async Task<string> BhPay(string url) { try { return (await http.GetStringAsync(url + "/api/bhpay/ping")); } catch { return "lỗi"; } }
async Task<string> Login() => string.Join(", ", await SqlDoc(Master,
    "SELECT DISTINCT login_name FROM sys.dm_exec_sessions WHERE login_name LIKE 'kumeo_s_api_%' ORDER BY 1"));
async Task Dung(Process p) { p.StandardInput.WriteLine("term"); await p.WaitForExitAsync(); }

Console.WriteLine($".NET {Environment.Version}, Microsoft.Data.SqlClient 7.1.1, SQL Server {string.Join("", await SqlDoc(Master, "SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(20))"))} LocalDB");
Console.WriteLine("B1. Task đọc secret v1; biến môi trường BHPay__ApiKey có giá trị khác");
var (t1, u1, _) = await ChayTask(v1);
Console.WriteLine($"    task v1: ready {await Ma(u1 + "/healthz/ready")}, BHPay {await BhPay(u1)}; login SQL đang mở: {await Login()}");

Console.WriteLine("B2. BHPay nhận cả khóa v2; task mới đọc secret v2 chạy song song (start-first)");
hopLe.Add(KhoaV2);
var (t2, u2, _) = await ChayTask(v2);
Console.WriteLine($"    task v1: ready {await Ma(u1 + "/healthz/ready")}, BHPay {await BhPay(u1)}; task v2: ready {await Ma(u2 + "/healthz/ready")}, BHPay {await BhPay(u2)}; login: {await Login()}");

Console.WriteLine("B3. Gỡ task v1, khóa login A, BHPay bỏ khóa v1");
await Dung(t1);
await Sql(Master, "ALTER LOGIN kumeo_s_api_a DISABLE;");
hopLe.Remove(KhoaV1);
SqlConnection.ClearAllPools();
Console.WriteLine($"    task v2: ready {await Ma(u2 + "/healthz/ready")}, BHPay {await BhPay(u2)}; login: {await Login()}");
var (t3, _, loi3) = await ChayTask(v1);
Console.WriteLine($"    task mới còn trỏ secret v1: {(t3.HasExited ? loi3 : "chạy")}");
if (!t3.HasExited) await Dung(t3);
await Dung(t2);

Console.WriteLine($"B4. File khóa có xuống dòng cuối; máy này Environment.NewLine = {(Environment.NewLine == "\r\n" ? "CRLF" : "LF")}");
await Sql(Master, "ALTER LOGIN kumeo_s_api_a ENABLE;");
foreach (var (ten, cuoi) in new[] { ("LF", "\n"), ("CRLF", "\r\n") })
{
    var d = ThuMuc($"run-secrets-{ten.ToLower()}", "kumeo_s_api_a", KhoaV1, cuoi);
    hopLe.Add(KhoaV1);
    var (t, u, loiT) = await ChayTask(d);
    if (!t.HasExited) { Console.WriteLine($"    {ten}: chạy, BHPay {await BhPay(u)}"); await Dung(t); }
    else Console.WriteLine($"    {ten}: {loiT}");
}

await bhpay.StopAsync();
SqlConnection.ClearAllPools();
foreach (var l in new[] { "kumeo_s_api_a", "kumeo_s_api_b" })
{
    await Sql(KumeoS, $"IF USER_ID('{l}') IS NOT NULL DROP USER {l};");
    await Sql(Master, $"IF SUSER_ID('{l}') IS NOT NULL BEGIN DECLARE @s int; DECLARE c CURSOR LOCAL FOR SELECT session_id FROM sys.dm_exec_sessions WHERE login_name = '{l}'; OPEN c; FETCH c INTO @s; WHILE @@FETCH_STATUS = 0 BEGIN EXEC('KILL ' + @s); FETCH c INTO @s; END; DROP LOGIN {l}; END");
}

static async Task Sql(string cs, string lenh) { await using var cn = new SqlConnection(cs); await cn.OpenAsync(); await new SqlCommand(lenh, cn).ExecuteNonQueryAsync(); }
static async Task<List<string>> SqlDoc(string cs, string lenh)
{
    await using var cn = new SqlConnection(cs); await cn.OpenAsync();
    await using var r = await new SqlCommand(lenh, cn).ExecuteReaderAsync();
    var ds = new List<string>(); while (await r.ReadAsync()) ds.Add(r.GetString(0)); return ds;
}

Điều chưa chứng minh: chưa dựng cụm thật, nên đường đi của secret trong cụm, việc gỡ secret khi task dừng và nội dung thật của docker service inspect chỉ dựa trên tài liệu. LocalDB đứng thay cho SQL Server 2022 trên sql01; quy trình hai login đã chạy thử với tên database Kumeo_S và login có tiền tố kumeo_s_.

7. Kết luận

Bí mật đặt trong biến môi trường đi theo service spec và image tới mọi nơi chúng được đọc; Docker secret chỉ tới node đang cần và nằm trong RAM của container. Đặt target bằng tên khóa cấu hình thì phía .NET chỉ thêm một dòng AddKeyPerFile.

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

  • Gọi AddKeyPerFile("/run/secrets", optional: true) sau các provider mặc định, đường dẫn tuyệt đối.
  • Đặt target của secret theo dạng Section__Key để code đọc cấu hình giữ nguyên.
  • Kiểm bí mật bắt buộc và ký tự trắng lúc khởi động, báo độ dài chứ không in giá trị.
  • Xoay bằng secret có hậu tố phiên bản và hai credential cùng hiệu lực, rồi thu hồi cái cũ sau khi rolling update xong.

Những chỗ hay hiểu sai

  • "Xóa biến khỏi stack là hết lộ." Image đã đẩy và lịch sử git vẫn giữ giá trị cũ; phải xoay khóa.
  • "docker service update --secret-add là đủ." Lần docker stack deploy sau với file stack cũ sẽ đưa service về _v1; sửa file stack.
  • "File secret soạn trên Windows dùng được ngay." KeyPerFile chỉ bỏ một Environment.NewLine của máy chạy app; trong container Linux, \r\n để lại \r. Tạo bằng printf '%s'.
  • "Secret được cập nhật tại chỗ." Secret không sửa được; đổi giá trị là tạo tên mới và rolling update.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

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

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

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.

12 phút đọc

Trong Docker Swarm và k3s

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.

13 phút đọc