Chuyển tới nội dung chính

15.5 — 4. Environment và Secrets

Tóm tắt

Biến môi trường là hợp đồng cấu hình giữa ứng dụng và hệ thống triển khai — nhưng chúng không phải nơi an toàn cho secret. Biến môi trường lộ qua ít nhất bốn đường: docker inspect, /proc/<pid>/environ, log crash dump, và bất kỳ thư viện nào in cấu hình khi khởi động. Rủi ro lớn hơn nữa ở tầng build: ARG và ENV trong Dockerfile nằm lại trong layer image vĩnh viễn — xoá chúng ở lệnh RUN sau không loại bỏ chúng, vì layer trước vẫn còn và ai cũng docker history ra được. Và nguyên tắc bao trùm: secret đã lộ là secret phải xoay, không phải secret cần xoá khỏi file.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Ánh xạ khoá cấu hình phân cấp sang biến môi trường.
  • Chọn nơi lưu secret cho từng môi trường.
  • Tránh secret lọt vào image lúc build.
  • Xoay khoá mà không downtime.
  • Xác thực cấu hình lúc khởi động.

Nội dung bài học​

15.5.1 — Biến môi trường là hợp đồng​

ConnectionStrings__Default="Server=db;Database=Crm;..."
Jwt__SecretKey="..."
Serilog__MinimumLevel__Default="Information"
ASPNETCORE_ENVIRONMENT="Production"

Hai dấu gạch dưới thay dấu hai chấm (bài 8.7). Một dấu gạch dưới không có tác dụng và bị bỏ qua im lặng.

Ba biến của .NET đáng biết:

ASPNETCORE_ENVIRONMENT=Production      # chon appsettings.{Env}.json
ASPNETCORE_HTTP_PORTS=8080 # cong (thay ASPNETCORE_URLS)
DOTNET_ENVIRONMENT=Production # cho ứng dụng không phải web

Ưu tiên: biến môi trường đè file cấu hình, và tham số dòng lệnh đè biến môi trường.

Vì vậy cùng một image chạy được ở mọi môi trường — chỉ đổi biến. Đó là nguyên tắc build một lần, chạy mọi nơi, và nó là lý do không được có appsettings.Production.json chứa giá trị thật trong image.

15.5.2 — Vì sao biến môi trường không an toàn cho secret​

# 1. Bat ky ai co quyen Docker
docker inspect crm-api | grep -i password

# 2. Tu trong container
cat /proc/1/environ | tr '\0' '\n'

# 3. Tiến trình con KẾ THỪA mọi biến môi trường
# 4. Crash dump chứa toàn bộ bộ nhớ, gồm cả biến môi trường

Thêm nữa, nhiều thư viện và framework in cấu hình khi khởi động ở mức Debug — và nếu ai đó bật log Debug trên production, secret vào log.

Nơi lưuAn toànDùng khi
Hardcode trong codeKhông bao giờ—
appsettings.json đã commitKhông bao giờ—
Biến môi trườngThấpDev, staging nội bộ
File .envThấpDev local
Docker/K8s secret (file)Trung bìnhProduction đơn giản
Key Vault / Secrets ManagerCaoProduction thật

Hai hàng cuối khác biệt ở chỗ: secret dạng file không lộ qua docker inspect hay environ, và Key Vault còn cho xoay khoá tự động, audit ai đọc secret, và thu hồi quyền truy cập.

// Doc secret tu file — mau chuan cho Docker/K8s secret
public static class SecretFileConfiguration
{
public static IConfigurationBuilder AddSecretFiles(
this IConfigurationBuilder builder, string directory = "/run/secrets")
{
if (!Directory.Exists(directory)) return builder;

var secrets = Directory.GetFiles(directory)
.ToDictionary(
f => Path.GetFileName(f).Replace("__", ":"),
f => File.ReadAllText(f).Trim());

return builder.AddInMemoryCollection(secrets!);
}
}

builder.Configuration.AddSecretFiles();

Đặt file tên ConnectionStrings__Default trong /run/secrets/ và nó trở thành khoá cấu hình ConnectionStrings:Default — không qua biến môi trường.

15.5.3 — Secret nằm lại trong image​

Đây là lỗi nghiêm trọng nhất và ít người biết:

# RAT NGUY HIEM — token nam VINH VIEN trong layer image
ARG NUGET_TOKEN
RUN dotnet nuget add source https://nuget.company.com \
--username ci --password $NUGET_TOKEN

Mọi ARG và ENV đều nằm trong metadata của image, và ai cũng đọc được:

docker history --no-trunc myimage | grep -i token

Xoá ở lệnh sau không giúp gì:

RUN echo $SECRET > /tmp/key && use-it && rm /tmp/key
# => layer TRƯỚC vẫn chứa /tmp/key

Union filesystem chỉ đánh dấu xoá ở layer mới; nội dung vẫn nằm trong layer cũ và ai cũng trích ra được.

# ĐÚNG — BuildKit secret mount, KHÔNG vào layer
# syntax=docker/dockerfile:1

RUN --mount=type=secret,id=nuget_token \
dotnet nuget add source https://nuget.company.com \
--username ci --password "$(cat /run/secrets/nuget_token)"
docker build --secret id=nuget_token,env=NUGET_TOKEN -t myimage .

Secret được mount chỉ trong lúc lệnh đó chạy và không nằm trong layer nào.

Kiểm tra image đã build:

docker history --no-trunc myimage
docker inspect myimage | jq '.[0].Config.Env'

Thêm bước quét secret vào CI:

- uses: gitleaks/gitleaks-action@v2
- name: Scan image
run: trivy image --scanners secret myrepo/crm-api:${{ github.sha }}

15.5.4 — Xoay khoá​

Secret đã lộ là secret phải xoay. Xoá khỏi Git, khỏi image, khỏi log đều không khôi phục được — bản sao có thể đã tồn tại ở nơi khác (bài 10.11).

Xoay khoá không downtime cần chấp nhận hai giá trị cùng lúc:

// JWT — chấp nhận cả khoá cũ và khoá mới trong giai đoạn chuyển tiếp
options.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKeys = new[] { currentKey, previousKey }, // CA HAI
...
};

Ba bước:

  1. Thêm khoá mới vào danh sách chấp nhận; vẫn ký bằng khoá cũ.
  2. Chuyển sang ký bằng khoá mới; vẫn chấp nhận cả hai.
  3. Gỡ khoá cũ sau khi mọi token cũ đã hết hạn.

Với connection string database, tạo user mới trước rồi mới xoá user cũ — cùng nguyên tắc expand-contract (bài 12.9).

Azure Key Vault và AWS Secrets Manager hỗ trợ xoay tự động, và .NET đọc được với cache có thời hạn:

builder.Configuration.AddAzureKeyVault(
new Uri($"https://{vaultName}.vault.azure.net/"),
new DefaultAzureCredential(),
new AzureKeyVaultConfigurationOptions { ReloadInterval = TimeSpan.FromMinutes(30) });

ReloadInterval là cách secret mới có hiệu lực mà không cần restart — nhưng chỉ với IOptionsMonitor, không với IOptions (bài 7.8).

15.5.5 — Xác thực cấu hình lúc khởi động​

builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.Validate(o => !string.IsNullOrWhiteSpace(o.SecretKey), "Jwt:SecretKey bắt buộc")
.Validate(o => Encoding.UTF8.GetByteCount(o.SecretKey) >= 32, "Jwt:SecretKey phải ≥ 32 byte")
.Validate(o => !o.SecretKey.Contains("change-me"), "Jwt:SecretKey vẫn là giá trị mẫu")
.ValidateOnStart();

ValidateOnStart() khiến ứng dụng không khởi động được với cấu hình sai — tốt hơn nhiều so với chạy bình thường bằng khoá mẫu rồi phát hiện sau vài tuần.

Với container, nó còn có lợi ích phụ: orchestrator thấy container chết ngay và không chuyển traffic sang nó.

Đừng in giá trị cấu hình lúc khởi động để "kiểm tra":

// SAI — secret vao log
logger.LogInformation("Config: {@Config}", builder.Configuration.AsEnumerable());

// ĐÚNG — chỉ báo có hay không
logger.LogInformation("Jwt:SecretKey configured: {HasKey}",
!string.IsNullOrEmpty(jwt.SecretKey));

GetDebugView() cũng in cả secret (bài 8.7) — chỉ dùng ở Development.

15.5.6 — Theo môi trường​

Môi trườngCách lưu secret
Dev localdotnet user-secrets — ngoài cây mã nguồn
CI/CDSecret của pipeline, và đánh dấu là masked
Docker ComposeDocker secret dạng file; .env chỉ cho giá trị không nhạy cảm
KubernetesSecret mount thành file, cộng External Secrets Operator
CloudKey Vault, Secrets Manager, với managed identity

Dòng cuối là mẫu tốt nhất: managed identity loại bỏ secret cuối cùng — ứng dụng xác thực với Key Vault bằng danh tính của chính nó, không cần lưu bất kỳ khoá nào ở đâu.

// Không có secret nào trong cấu hình
builder.Configuration.AddAzureKeyVault(
new Uri(vaultUri), new DefaultAzureCredential());

Với CI, nhớ rằng secret bị che trong log nhưng vẫn lộ nếu bạn vô tình biến đổi nó:

# SAI — base64 của secret KHÔNG bị che
run: echo "${{ secrets.API_KEY }}" | base64

15.5.7 — Rà lại code của bạn​

Danh sách rà soát cấu hình và secret

  • •Không có secret nào trong appsettings.json đã commit.
  • •Cùng một image chạy được ở mọi môi trường, chỉ đổi biến.
  • •Không có ARG hay ENV nào chứa secret trong Dockerfile.
  • •Secret lúc build dùng BuildKit secret mount.
  • •Đã kiểm tra docker history và image metadata không chứa secret.
  • •CI có bước quét secret trong mã nguồn và trong image.
  • •Production dùng secret dạng file hoặc Key Vault, không dùng biến môi trường.
  • •Có kế hoạch xoay khoá chấp nhận hai giá trị cùng lúc.
  • •Cấu hình được validate với ValidateOnStart.
  • •Không in giá trị cấu hình ra log, kể cả để kiểm tra.
  • •Đã cân nhắc managed identity để loại bỏ secret cuối cùng.

Bài tập áp dụng​

Bài 1 — Secret nằm lại trong layer​

Build image với ARG SECRET và dùng nó trong một lệnh RUN, rồi RUN rm ở lệnh sau. Chạy docker history --no-trunc và tìm lại giá trị.

Tiêu chí hoàn thành: bạn giải thích được vì sao xoá ở lệnh sau không xoá được gì, và biết cách truyền secret vào build một cách an toàn.

Gợi ý và lời giải — Bài 1

Gợi ý. Image là gì, về mặt cấu trúc? Xoá một file có làm layer trước đó biến mất không?

Lời giải — bản có lỗi:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG NUGET_TOKEN
RUN dotnet nuget add source https://nuget.pkg.github.com/cty/index.json \
--username cty --password $NUGET_TOKEN --store-password-in-clear-text
RUN dotnet restore
RUN rm -f /root/.nuget/NuGet/NuGet.Config # "xoá" secret
docker build --build-arg NUGET_TOKEN=ghp_abc123xyz -t crm-api .
docker history --no-trunc crm-api | grep -i nuget
sha256:...  RUN /bin/sh -c dotnet nuget add source https://nuget.pkg.github.com/cty/index.json
--username cty --password ghp_abc123xyz --store-password-in-clear-text

Token hiện nguyên trong lịch sử image. Và nó còn ở một chỗ nữa:

docker save crm-api | tar -xO --wildcards '*/layer.tar' | tar -tv 2>/dev/null | grep NuGet.Config
docker inspect crm-api --format '{{json .Config}}' | jq '.Env, .Labels'

Vì sao xoá ở lệnh sau không xoá được gì. Vì image Docker là một chồng layer chỉ-đọc, và mỗi layer ghi lại thay đổi so với layer trước:

Layer 1 (FROM)      base image
Layer 2 (RUN nuget) + NuGet.Config chứa token <- token NẰM VĨNH VIỄN Ở ĐÂY
Layer 3 (RUN restore) + các file gói
Layer 4 (RUN rm) - đánh dấu NuGet.Config là "đã xoá"

Layer 4 chỉ thêm một whiteout file — một dấu hiệu nói với filesystem hợp nhất rằng "đừng hiển thị file này". Nội dung ở layer 2 không bị chạm tới:

Cái bạn thấy khi chạy container:   file không tồn tại
Cái thật sự có trong image: file vẫn nằm nguyên ở layer 2

Bất kỳ ai có image đều lấy lại được, bằng docker save rồi giải nén, hoặc đơn giản là docker history.

Và điều này áp dụng cho mọi thứ, không chỉ secret: xoá một file 500 MB ở lệnh RUN sau cũng không làm image nhỏ đi.

Hai chỗ khác cũng lưu lại ARG:

ENV API_KEY=abc123          # nằm trong metadata, docker inspect đọc được
LABEL token=$NUGET_TOKEN # tương tự

Cách đúng — BuildKit secret mount:

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build

RUN --mount=type=secret,id=nuget_token \
dotnet nuget add source https://nuget.pkg.github.com/cty/index.json \
--username cty --password "$(cat /run/secrets/nuget_token)" \
--store-password-in-clear-text \
&& dotnet restore \
&& rm -f /root/.nuget/NuGet/NuGet.Config
docker build --secret id=nuget_token,env=NUGET_TOKEN -t crm-api .
# hoặc từ file
docker build --secret id=nuget_token,src=./token.txt -t crm-api .
docker history --no-trunc crm-api | grep -i ghp_
# (không có kết quả)

Secret được mount vào /run/secrets/ chỉ trong thời gian lệnh RUN đó chạy, và không bao giờ được ghi vào layer. Lịch sử image chỉ thấy RUN --mount=type=secret,id=nuget_token ... mà không có giá trị.

Chú ý cả cách ghép bốn thao tác vào một RUN bằng &&: nó đảm bảo NuGet.Config được tạo và xoá trong cùng một layer, nên ngay cả nếu secret mount không có sẵn, file vẫn không tồn tại ở bất kỳ layer nào.

Trong GitHub Actions:

- uses: docker/build-push-action@v6
with:
secrets: |
nuget_token=${{ secrets.NUGET_TOKEN }}

Ba cách khác, theo thứ tự nên cân nhắc:

1. Multi-stage — secret chỉ tồn tại ở giai đoạn build, không copy sang runtime:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG NUGET_TOKEN
RUN dotnet nuget add source ... --password $NUGET_TOKEN
RUN dotnet publish -o /app

FROM mcr.microsoft.com/dotnet/aspnet:9.0
COPY --from=build /app . # chỉ copy kết quả

Image cuối cùng sạch. Nhưng nếu bạn docker build --target build hoặc đẩy giai đoạn build lên registry, secret vẫn lộ. Đây là biện pháp giảm thiểu, không phải giải pháp.

2. SSH agent forwarding — cho git private repo:

RUN --mount=type=ssh git clone git@github.com:cty/private-lib.git
docker build --ssh default -t crm-api .

3. Tốt nhất: đừng cần secret lúc build. Nếu bạn đang truyền secret vào build, hãy hỏi có cách nào tránh được không:

NuGet private feed  -> restore ở CI (có secret sẵn), rồi COPY thư mục packages vào
Khoá API để tải tài nguyên -> tải ở CI, COPY vào
Connection string -> KHÔNG BAO GIỜ cần lúc build; nó là cấu hình lúc chạy

Dòng cuối là lỗi phổ biến nhất: nhiều người nhúng connection string vào image "cho tiện", khiến image không dùng lại được giữa các môi trường và chứa secret.

Quét image để kiểm chứng:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
trufflesecurity/trufflehog:latest docker --image crm-api
# Hoặc bằng tay, nhanh và đủ cho phần lớn trường hợp
docker history --no-trunc crm-api | grep -iE "password|token|secret|key|ghp_|sk-"
docker inspect crm-api --format '{{json .Config.Env}}' | tr ',' '\n'

Đưa lệnh trên vào CI như một bước chặn:

- name: Quét secret trong image
run: |
if docker history --no-trunc ${{ env.IMAGE }} \
| grep -iE "password=|token=|secret=|ghp_[A-Za-z0-9]{36}"; then
echo "Phát hiện secret trong layer của image"
exit 1
fi

Và nếu đã lỡ đẩy image chứa secret lên registry: xoá image không đủ. Phải coi secret đó là đã lộ và xoay khoá ngay. Registry có bản sao, CI có cache, và có thể ai đó đã pull về.


Bài 2 — Secret trong biến môi trường lộ qua đâu​

Chạy container với secret trong biến môi trường, rồi lấy nó bằng docker inspect và bằng cat /proc/1/environ từ bên trong.

Tiêu chí hoàn thành: bạn liệt kê được năm chỗ biến môi trường bị lộ, và chọn được cơ chế lưu secret phù hợp cho từng môi trường.

Gợi ý và lời giải — Bài 2

Gợi ý. Biến môi trường được kế thừa xuống tiến trình con, và nằm trong /proc.

Lời giải:

docker run -d --name api -e ConnectionStrings__Default="Server=db;Password=SieuBiMat123!" crm-api

Năm chỗ lộ:

1. docker inspect — ai truy cập được Docker daemon đều đọc được:

docker inspect api --format '{{json .Config.Env}}' | tr ',' '\n'
"ConnectionStrings__Default=Server=db;Password=SieuBiMat123!"

2. /proc/<pid>/environ — từ bên trong container, hoặc từ máy chủ:

docker exec api cat /proc/1/environ | tr '\0' '\n'
# Từ máy chủ, không cần vào container
sudo cat /proc/$(docker inspect -f '{{.State.Pid}}' api)/environ | tr '\0' '\n'

Mọi tiến trình chạy bằng cùng user đều đọc được /proc/<pid>/environ. Nếu ứng dụng của bạn có lỗ hổng cho phép chạy lệnh, đây là thứ đầu tiên kẻ tấn công đọc.

3. Tiến trình con kế thừa toàn bộ môi trường:

Process.Start("ffmpeg", args);      // ffmpeg nhận TẤT CẢ biến môi trường

Một công cụ bên thứ ba ghi môi trường vào log chẩn đoán là đủ để secret rò ra.

4. Crash dump và exception:

foreach (DictionaryEntry e in Environment.GetEnvironmentVariables())
_logger.LogDebug("{Key}={Value}", e.Key, e.Value); // secret vào log tập trung

Nhiều thư viện chẩn đoán tự động đính kèm môi trường vào báo cáo lỗi. Và crash dump chứa toàn bộ bộ nhớ tiến trình, trong đó có môi trường.

5. Lịch sử shell, log CI, và file cấu hình orchestrator:

history | grep Password                    # ~/.bash_history
# docker-compose.yml commit vào git
environment:
ConnectionStrings__Default: "Server=db;Password=SieuBiMat123!"
kubectl get pod api -o yaml | grep -A2 env    # ai có quyền đọc pod đều thấy

Vậy có nên dùng biến môi trường không? Câu trả lời trung thực: có, nhưng biết rõ nó bảo vệ khỏi cái gì.

Biến môi trường BẢO VỆ khỏi:  secret bị commit vào git
Biến môi trường KHÔNG bảo vệ khỏi: ai đã vào được máy chủ hoặc container

Đó vẫn là một cải thiện lớn so với hardcode, và nó là mức tối thiểu chấp nhận được. Nhưng có những mức cao hơn.

Bốn tầng lưu secret, theo môi trường:

Tầng 1 — User Secrets (chỉ dành cho máy dev):

dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;..."

Lưu ngoài thư mục dự án (~/.microsoft/usersecrets/), nên không thể commit nhầm. Chỉ hoạt động ở Development và không mã hoá — nó chống commit nhầm, không chống truy cập máy.

Tầng 2 — Docker secrets hoặc file mount (VPS đơn giản):

services:
api:
environment:
ConnectionStrings__Default_FILE: /run/secrets/db_conn # đường dẫn, không phải giá trị
secrets:
- db_conn

secrets:
db_conn:
file: ./secrets/db_conn.txt # chmod 600, ngoài git
// Đọc từ file nếu có biến _FILE — quy ước phổ biến
var conn = Environment.GetEnvironmentVariable("ConnectionStrings__Default_FILE") is { } path
? File.ReadAllText(path).Trim()
: builder.Configuration.GetConnectionString("Default");

Secret nằm trong file tmpfs, không trong docker inspect, không trong /proc/1/environ.

Tầng 3 — Kubernetes Secret mount dạng file:

volumeMounts:
- name: secrets
mountPath: /run/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: crm-secrets

Mount dạng file, không phải envFrom. Nếu dùng envFrom, bạn quay lại đúng năm chỗ lộ ở trên.

Lưu ý: Kubernetes Secret mặc định chỉ được mã hoá base64, tức không mã hoá gì cả. Cần bật encryption at rest cho etcd, hoặc dùng tầng 4.

Tầng 4 — Secret manager, không có secret nào ở đâu cả:

builder.Configuration.AddAzureKeyVault(
new Uri($"https://{vaultName}.vault.azure.net/"),
new DefaultAzureCredential());
// Tốt nhất: managed identity — KHÔNG CÓ mật khẩu nào tồn tại
builder.Services.AddDbContext<CrmDbContext>(o =>
o.UseSqlServer("Server=tcp:crm.database.windows.net;Database=Crm;"
+ "Authentication=Active Directory Default;"));

Đây là mức cao nhất, và nó thay đổi bản chất bài toán: không có secret để quản lý, để xoay, hay để lộ. Danh tính được cấp bởi nền tảng và tự động hết hạn.

Bảng chọn:

Môi trườngCơ chếLý do
Máy devUser SecretsChống commit nhầm, đủ cho dev
CISecret của CI, mask trong logNgắn hạn, phạm vi hẹp
VPS đơn giảnDocker secrets hoặc file chmod 600Không cần hạ tầng thêm
KubernetesSecret mount dạng file, có encryption at restTích hợp sẵn
CloudManaged identityKhông có secret nào tồn tại

Và bất kể tầng nào, ba việc luôn phải làm:

1. Không bao giờ log secret:

// Che khi log connection string
var builder = new SqlConnectionStringBuilder(conn) { Password = "***" };
_logger.LogInformation("Kết nối tới {Conn}", builder.ConnectionString);

2. Xoay khoá định kỳ, và tự động hoá việc đó. Một secret không bao giờ đổi là một secret sẽ lộ — chỉ là chưa biết khi nào.

3. Giả định secret sẽ lộ, và chuẩn bị quy trình xoay khẩn cấp:

## Quy trình xoay khi nghi ngờ lộ

1. Tạo secret mới ở nguồn (database, API provider).
2. Cập nhật secret store; giữ secret cũ còn hiệu lực.
3. Deploy — ứng dụng dùng secret mới.
4. Xác minh mọi instance đã dùng secret mới.
5. Vô hiệu hoá secret cũ.
6. Kiểm tra log truy cập để xem secret cũ có bị dùng ở đâu không.

Bước 2 là chi tiết quyết định: nếu vô hiệu hoá secret cũ trước khi deploy xong, bạn gây ra gián đoạn dịch vụ ngay giữa lúc đang xử lý một sự cố bảo mật. Mọi hệ thống secret nghiêm túc đều hỗ trợ hai khoá cùng hiệu lực — hãy dùng tính năng đó.


Bài 3 — ValidateOnStart​

Xoá Jwt:SecretKey khỏi cấu hình và chạy ứng dụng có và không có ValidateOnStart. Ghi lại thời điểm và nội dung lỗi trong mỗi trường hợp.

Tiêu chí hoàn thành: bạn giải thích được vì sao thất bại sớm rẻ hơn thất bại muộn, và biết những gì khác nên được kiểm tra lúc khởi động.

Gợi ý và lời giải — Bài 3

Gợi ý. Nếu cấu hình sai chỉ lộ ra khi có người dùng gọi một endpoint cụ thể, bạn phát hiện nó khi nào?

Lời giải — không có ValidateOnStart:

builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
dotnet run       # khởi động BÌNH THƯỜNG
curl http://localhost:8080/health # OK
curl -X POST http://localhost:8080/auth/login -d '{...}'
System.ArgumentNullException: Value cannot be null. (Parameter 'key')
at System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.CreateToken(...)
at Crm.Api.AuthService.TaoTokenAsync(...)

HTTP 500
Thời điểm phát hiện:  khi người dùng ĐẦU TIÊN đăng nhập
Trong sản xuất: có thể vài phút, vài giờ, hoặc qua đêm sau khi deploy
Triệu chứng: HTTP 500 với thông điệp không liên quan tới nguyên nhân thật
Trạng thái pod: Running, health check XANH, load balancer vẫn gửi traffic

Có ValidateOnStart:

builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.ValidateDataAnnotations()
.Validate(o => o.SecretKey.Length >= 32, "Jwt:SecretKey phải có ít nhất 32 ký tự")
.Validate(o => !string.IsNullOrWhiteSpace(o.Issuer), "Jwt:Issuer là bắt buộc")
.ValidateOnStart();
public class JwtOptions
{
[Required(ErrorMessage = "Jwt:SecretKey là bắt buộc")]
public string SecretKey { get; set; } = null!;

[Required] public string Issuer { get; set; } = null!;
[Required] public string Audience { get; set; } = null!;

[Range(1, 1440, ErrorMessage = "Jwt:ExpiryMinutes phải từ 1 đến 1440")]
public int ExpiryMinutes { get; set; } = 60;
}
dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:
DataAnnotation validation failed for 'JwtOptions' members: 'SecretKey'
with the error: 'Jwt:SecretKey là bắt buộc'.

Exited with code 134
Thời điểm phát hiện:  ngay lúc khởi động, trước khi nhận request nào
Thông điệp: nói chính xác khoá cấu hình nào thiếu
Trạng thái pod: CrashLoopBackOff -> Kubernetes KHÔNG chuyển traffic sang

Vì sao thất bại sớm rẻ hơn thất bại muộn:

Thất bại lúc khởi độngThất bại lúc chạy
Ai phát hiệnPipeline triển khaiNgười dùng
Khi nàoTrong vài giâyCó thể nhiều giờ sau
Traffic bị ảnh hưởngKhông — rolling update dừng lạiMọi request tới đường code đó
Thông điệp lỗiNêu đúng khoá cấu hìnhLỗi phái sinh, khó truy nguyên
RollbackTự độngPhải có người quyết định
Chi phí điều traGần như bằng khôngHàng giờ

Điểm quan trọng nhất là dòng thứ ba. Với ValidateOnStart, pod mới không bao giờ đạt trạng thái Ready, nên Kubernetes dừng rolling update và giữ nguyên pod cũ đang chạy tốt:

Không ValidateOnStart:  pod mới lên, Ready, nhận traffic, trả 500 cho mọi request đăng nhập
-> rolling update HOÀN TẤT, pod cũ đã bị xoá
-> không còn gì để quay về

Có ValidateOnStart: pod mới crash, không bao giờ Ready
-> rolling update DỪNG
-> pod cũ vẫn chạy, người dùng không bị ảnh hưởng

Đây là khác biệt giữa "một lần deploy hỏng" và "một sự cố".

Những gì khác nên kiểm tra lúc khởi động:

1. Mọi lớp options — dùng một phương thức mở rộng để không ai quên:

public static IServiceCollection ThemOptionsCoKiemTra<T>(
this IServiceCollection services, IConfiguration config, string section)
where T : class
{
services.AddOptions<T>()
.Bind(config.GetSection(section))
.ValidateDataAnnotations()
.ValidateOnStart();
return services;
}
builder.Services
.ThemOptionsCoKiemTra<JwtOptions>(builder.Configuration, "Jwt")
.ThemOptionsCoKiemTra<EmailOptions>(builder.Configuration, "Email")
.ThemOptionsCoKiemTra<RedisOptions>(builder.Configuration, "Redis");

2. Chuỗi kết nối có đúng định dạng — kiểm tra được mà không cần kết nối:

.Validate(o =>
{
try { _ = new SqlConnectionStringBuilder(o.Default); return true; }
catch { return false; }
}, "ConnectionStrings:Default không đúng định dạng")

3. Mọi dịch vụ đăng ký đều dựng được — bắt lỗi DI trước khi có request:

builder.Host.UseDefaultServiceProvider((ctx, o) =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true; // bắt lỗi thiếu đăng ký NGAY lúc build
});

ValidateOnBuild bắt được lỗi "không đăng ký IEmailSender" lúc khởi động thay vì lúc một endpoint cụ thể được gọi. ValidateScopes bắt được lỗi inject dịch vụ Scoped vào Singleton — một lỗi có thể gây rò rỉ DbContext hoặc dùng sai tenant. Cả hai mặc định chỉ bật ở Development; hãy bật ở mọi môi trường.

4. Migration đã được áp dụng chưa — nếu bạn không chạy migration lúc khởi động:

var choDoi = await db.Database.GetPendingMigrationsAsync();
if (choDoi.Any())
throw new InvalidOperationException(
$"Còn {choDoi.Count()} migration chưa áp dụng: {string.Join(", ", choDoi)}");

5. Những thứ KHÔNG nên kiểm tra lúc khởi động — và đây là phần cân bằng:

KHÔNG: kết nối được tới database?
KHÔNG: Redis có sống không?
KHÔNG: API bên ngoài có phản hồi không?

Vì đó là trạng thái lúc chạy, không phải cấu hình. Database chậm khởi động không phải lý do để ứng dụng từ chối lên — và nếu bạn làm vậy, một sự cố database ngắn sẽ khiến mọi pod crash và không pod nào lên lại được (bài 15.3).

Ranh giới:

Lúc khởi động (fail fast):  thứ SAI VĨNH VIỄN nếu sai — thiếu cấu hình,
sai định dạng, thiếu đăng ký DI
Lúc chạy (health check): thứ CÓ THỂ TỰ KHỎI — kết nối, độ trễ, tài nguyên

Kiểm chứng trong CI trước khi deploy:

- name: Kiểm tra cấu hình của môi trường đích
env:
ASPNETCORE_ENVIRONMENT: Production
Jwt__SecretKey: ${{ secrets.JWT_SECRET_KEY }}
run: |
timeout 30 dotnet run --project src/Crm.Api --validate-config-only \
|| { echo "Cấu hình không hợp lệ"; exit 1; }
// Trong Program.cs
if (args.Contains("--validate-config-only"))
{
app.Services.GetRequiredService<IOptions<JwtOptions>>(); // kích hoạt validate
Console.WriteLine("Cấu hình hợp lệ");
return 0;
}

Bước này bắt được cấu hình sai trước khi một pod nào được tạo — rẻ hơn nữa so với crash lúc khởi động, vì không có gì phải rollback.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao biến môi trường không an toàn cho secret?

Chúng lộ qua docker inspect, qua proc environ từ bên trong container, được tiến trình con kế thừa, và nằm trong crash dump. Thêm nữa nhiều thư viện in cấu hình khi khởi động ở mức Debug, nên bật log Debug trên production là secret vào log.

Vì sao ARG và ENV trong Dockerfile nguy hiểm?

Chúng nằm trong metadata của image vĩnh viễn và ai cũng đọc được bằng docker history. Xoá ở lệnh RUN sau không giúp gì vì union filesystem chỉ đánh dấu xoá ở layer mới, nội dung vẫn nằm trong layer cũ và trích ra được.

BuildKit secret mount giải quyết gì?

Secret được mount chỉ trong lúc lệnh đó chạy và không nằm trong bất kỳ layer nào của image. Đó là cách duy nhất đúng để dùng secret lúc build, ví dụ token truy cập NuGet feed riêng.

Xoay khoá không downtime làm thế nào?

Chấp nhận hai giá trị cùng lúc trong giai đoạn chuyển tiếp. Với JWT thì thêm khoá mới vào danh sách chấp nhận nhưng vẫn ký bằng khoá cũ, rồi chuyển sang ký bằng khoá mới, rồi gỡ khoá cũ sau khi mọi token cũ đã hết hạn.

Secret dạng file hơn biến môi trường ở đâu?

Nó không lộ qua docker inspect hay proc environ, và không được tiến trình con kế thừa. Docker và Kubernetes đều mount secret thành file; .NET không tự đọc nên bạn cần một configuration provider nhỏ để nạp chúng.

Managed identity giải quyết vấn đề gì?

Nó loại bỏ secret cuối cùng: ứng dụng xác thực với Key Vault bằng danh tính của chính nó, nên không cần lưu bất kỳ khoá nào ở đâu. Đó là mẫu tốt nhất khi chạy trên cloud.

Kết luận​

Ba điều đáng nhớ nhất:

  1. ARG và ENV nằm lại trong image vĩnh viễn. Dùng BuildKit secret mount.
  2. Biến môi trường lộ qua bốn đường. Production dùng secret dạng file hoặc Key Vault.
  3. Secret đã lộ là secret phải xoay — xoá khỏi file không khôi phục được gì.

Tham khảo​

Điều hướng​