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. Vấn đề: một lần dán inspect vào ticket làm lộ mật khẩu SQL
- 2. Mục đích: không bí mật nào trong stack hay image, xoay khóa không build lại
- 3. Cơ sở lý thuyết: biến môi trường đi theo spec và image, secret đi theo task
- 4. Cách giải quyết: secret có phiên bản, target là tên khóa cấu hình
- 5. Cách cài đặt: tạo secret, gắn vào stack, đọc bằng KeyPerFile
- 6. Chứng minh: cấu hình đã kiểm, xoay khóa trên .NET thật
- 7. Kết luận
- Đọc tiếp
- 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
ENVcủa Dockerfile, nêndocker service inspect,docker image inspectvà 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/secretsvớitargetlà tên khóa cấu hình, đọc bằng KeyPerFile; xoay khóa bằng secret_v2và 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ằngGetConnectionString("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.cscủa bài 2. - App đọc hai bí mật từ
/run/secretsmà dòng đọc cấu hình giữ nguyên:GetConnectionString("BanHang")và["BHPay:ApiKey"]. - Xoay sang
_v2khô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ỏ_v1sau 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 creategử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 ởtargettự đặt. Task dừng thì secret bị gỡ và xóa khỏi bộ nhớ của node. docker secret inspectchỉ 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:
- Tạo secret bằng
printf, không có xuống dòng cuối, tên có hậu tố_v1. - Stack gắn secret với
targetlà tên khóa cấu hình; xóa hai biến khỏienvironmentvà dòngENVkhỏi Dockerfile. BanHang.ApigọiAddKeyPerFile("/run/secrets"), dừng ngay khi thiếu khóa hoặc khóa có ký tự trắng.- Xoay: tạo login
banhang_api_bvà 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ửasourcetrong 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 rmcá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
Bảng số liệu
| Trước | Sau | |
|---|---|---|
| Bí mật trong biến môi trường | 1 vi phạm | 0 vi phạm |
| Bí mật trong ENV của image | 1 vi phạm | 0 vi phạm |
| Khóa app cần mà thiếu secret | 2 vi phạm | 0 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ỏ
_v1thoá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ật
#: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
targetcủa secret theo dạngSection__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-addlà đủ." Lầndocker stack deploysau 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.NewLinecủa máy chạy app; trong container Linux,\r\nđể lại\r. Tạo bằngprintf '%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
- Rolling update trên Swarm: bài trước, rolling update mà lần xoay khóa dựa vào.
- Máy chủ cửa hàng chạy k3s: bài sau,
BanHang.CuaHangtrên 12 máy mini PC. - Khóa API trong file DLL: vì sao app ở máy khách không được giữ khóa BHPay.
- Cụm Swarm ba manager: file stack chung và
BanHang.Api.csđầy đủ.
Nguồn
- Docker Docs, đọc ngày 2026-10-04: Manage sensitive data with Docker secrets (Raft mã hóa, TLS hai chiều, RAM disk, 500 KB, xoay secret), docker secret inspect, Store configuration data using Docker Configs, Compose secrets.
- dotnet/aspnetcore: KeyPerFileConfigurationProvider.cs, KeyPerFileConfigurationSource.cs.
- Microsoft Learn: Key-per-file configuration provider, CREATE LOGIN, ALTER LOGIN.
- Linux man-pages: proc_pid_environ(5).