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

15.12 — Mở rộng và đào sâu

Tóm tắt

Những chủ đề triển khai nối tiếp Module 15, kèm mức độ ưu tiên. Hai thứ đáng làm ngay và rẻ tới mức không có lý do gì để bỏ qua: multi-stage build (image từ 1,2 GB xuống 220 MB chỉ bằng cách tách giai đoạn build khỏi giai đoạn chạy) và chạy bằng user không phải root (một dòng USER, và nó thu hẹp đáng kể thiệt hại nếu container bị xâm nhập). Về chiến lược triển khai, có một điểm hay bị bỏ qua: rolling update — mặc định của Kubernetes — nghĩa là phiên bản cũ và mới chạy song song vài phút, nên mọi thay đổi schema database phải tương thích ngược, nếu không bạn có lỗi trong chính khoảng đó.

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

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

  • Viết Dockerfile multi-stage tối ưu.
  • Chạy container an toàn với user không phải root.
  • Chọn chiến lược triển khai phù hợp.
  • Biết GitOps và SBOM giải quyết gì.

Nội dung bài học​

15.12.1 — Multi-stage build: làm ngay​

# Giai doan BUILD — co SDK, cong cu, ma nguon
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Copy csproj TRƯỚC để tận dụng cache layer
COPY ["src/Crm.Api/Crm.Api.csproj", "src/Crm.Api/"]
COPY ["src/Crm.Application/Crm.Application.csproj", "src/Crm.Application/"]
RUN dotnet restore "src/Crm.Api/Crm.Api.csproj"

COPY . .
RUN dotnet publish "src/Crm.Api/Crm.Api.csproj" -c Release -o /app/publish

# Giai doan CHAY — chi co runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app

# Chạy bằng user KHÔNG phải root
RUN adduser --disabled-password --gecos "" --uid 1001 appuser
USER appuser

COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
Image mot giai doan (co SDK):  1,2 GB
Image multi-stage: 220 MB

Giai đoạn cuối không chứa SDK, mã nguồn, hay công cụ build — chỉ có runtime và file đã publish. Nhỏ hơn nghĩa là kéo về nhanh hơn, khởi động nhanh hơn, và bề mặt tấn công nhỏ hơn.

Chi tiết quan trọng — copy csproj trước: Docker cache từng layer. Nếu bạn copy toàn bộ mã nguồn rồi mới restore, mọi thay đổi code đều làm mất cache và phải tải lại toàn bộ NuGet package. Copy csproj trước nghĩa là restore chỉ chạy lại khi danh sách package thay đổi.

Khác biệt thực tế: build tăng dần từ 3 phút xuống khoảng 30 giây (bài 15.3).

USER appuser là một dòng và nó ngăn được cả một lớp tấn công: nếu container bị xâm nhập, kẻ tấn công không có quyền root trong container, nên không cài được gói, không sửa được file hệ thống, và khó thoát ra host hơn nhiều.

15.12.2 — Distroless và chainguard​

Điều kiện kích hoạt: yêu cầu bảo mật cao, hoặc muốn giảm số lỗ hổng phải vá.

FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final

Image "chiseled" chỉ chứa những gì .NET cần chạy — không có shell, package manager, hay tiện ích hệ thống.

Image thườngChiseled
Kích thước~220 MB~110 MB
Số lỗ hổng đã biếtVài chụcGần như không
docker exec vào shellĐượcKhông được
Debug bên trongDễKhó

Hàng thứ hai là lợi ích chính: ít gói hệ thống nghĩa là ít CVE phải theo dõi và vá.

Hàng cuối là cái giá thật: không có shell nghĩa là không docker exec vào xem được. Bạn phải dựa hoàn toàn vào log và chỉ số — điều này hợp lý với môi trường production được giám sát tốt, nhưng gây khó khi cần chẩn đoán khẩn cấp.

Cách cân bằng: image thường cho môi trường phát triển và staging, chiseled cho production.

15.12.3 — Chiến lược triển khai​

Chiến lượcCách hoạt độngDowntimeTài nguyênQuay lại
RecreateDừng hết, chạy mớiCóThấpChậm
RollingThay dần từng podKhôngThấpTrung bình
Blue-greenHai môi trường, đổi định tuyếnKhôngGấp đôiTức thì
CanaryChuyển dần % lưu lượngKhôngHơi cao hơnNhanh
# Rolling — mặc định, phù hợp phần lớn trường hợp
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # tao them toi da 1 pod
maxUnavailable: 0 # KHÔNG giảm số pod đang phục vụ

maxUnavailable: 0 quan trọng: nó đảm bảo số pod phục vụ không bao giờ giảm trong lúc triển khai. Đánh đổi là cần thêm tài nguyên cho 1 pod và triển khai chậm hơn một chút.

Điều quan trọng nhất về rolling update: phiên bản cũ và mới chạy song song trong vài phút. Hệ quả:

Phiên bản cũ VÀ mới cùng đọc/ghi CÙNG database
-> Mọi thay đổi schema phải TƯƠNG THÍCH NGƯỢC
-> Không thể vừa đổi tên cột vừa triển khai code mới

Đây chính là lý do cần expand-contract cho thay đổi schema (bài 13.13).

Canary là chiến lược an toàn nhất cho thay đổi rủi ro:

# Voi Argo Rollouts
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 10m } # theo doi chi so
- setWeight: 25
- pause: { duration: 10m }
- setWeight: 50
- pause: { duration: 10m }

Nếu tỷ lệ lỗi tăng ở mức 5%, bạn dừng lại và chỉ 5% người dùng bị ảnh hưởng — thay vì 100%.

15.12.4 — GitOps​

Điều kiện kích hoạt: nhiều môi trường, hoặc cần kiểm toán mọi thay đổi hạ tầng.

Truyền thống (push):
CI build image -> CI chay kubectl apply -> cluster doi

GitOps (pull):
CI build image -> CI cập nhật tag trong Git repo cấu hình
-> Argo CD hoặc Flux TRONG cluster phát hiện và đồng bộ

Ba lợi ích cụ thể:

  • Git là nguồn sự thật. Trạng thái mong muốn của cluster nằm trong Git, có lịch sử đầy đủ và review qua PR.
  • Tự sửa lệch. Nếu ai đó kubectl edit trực tiếp, công cụ GitOps phát hiện và đưa về trạng thái trong Git.
  • CI không cần quyền vào cluster. Đây là lợi ích bảo mật lớn: thay vì lưu credential cluster trong CI, cluster tự kéo về.

Điểm cuối đáng nhấn mạnh — credential cluster trong CI là mục tiêu tấn công có giá trị cao, và GitOps loại bỏ hoàn toàn nhu cầu đó.

Cái giá: thêm một công cụ phải vận hành, và luồng triển khai gián tiếp hơn nên chẩn đoán khó hơn khi có sự cố.

15.12.5 — SBOM và quét lỗ hổng​

Điều kiện kích hoạt: ngay khi có image chạy production.

# Sinh danh sach thanh phan
syft crm-api:1.2.3 -o spdx-json > sbom.json

# Quet lo hong
grype crm-api:1.2.3 --fail-on high

SBOM (Software Bill of Materials) liệt kê mọi thành phần trong image — thư viện NuGet, gói hệ thống, phiên bản runtime.

Giá trị thật của nó lộ ra khi có lỗ hổng nghiêm trọng được công bố: câu hỏi "chúng ta có dùng thư viện đó không, ở phiên bản nào, trong những image nào" trả lời được trong vài giây thay vì vài giờ rà soát thủ công.

# Trong CI
- name: Quet lo hong
run: |
grype ${{ env.IMAGE }} --fail-on critical

Đặt ngưỡng ở critical để bắt đầu — đặt high ngay sẽ làm CI đỏ liên tục vì hầu như image nào cũng có vài lỗ hổng mức trung bình chưa có bản vá.

15.12.6 — Những chủ đề khác​

Chủ đềNội dungƯu tiên
Multi-stage buildImage nhỏ, build nhanhCao
User không phải rootMột dòng, giảm rủi ro nhiềuCao
Quét lỗ hổng trong CIBắt CVE trước khi lên productionCao
.dockerignoreKhông copy bin, obj, .git vào buildCao
Distroless / chiseledÍt lỗ hổng, image nhỏ hơnTrung bình
Canary deploymentGiảm rủi ro thay đổi lớnTrung bình
GitOpsGit là nguồn sự thậtTrung bình
Native AOTCold start nhanh, image rất nhỏThấp

.dockerignore bị bỏ qua nhiều nhất trong nhóm ưu tiên cao:

bin/
obj/
.git/
.vs/
**/node_modules/
*.user

Không có nó, Docker copy cả bin, obj và .git vào build context — làm build chậm hơn đáng kể, và tệ hơn là .git có thể chứa lịch sử với bí mật đã bị xoá (bài 3.13).

15.12.7 — Thứ tự nên làm​

Bốn việc đáng làm ngay hôm nay, mỗi việc mất vài phút:

  1. Multi-stage build — image nhỏ hơn 5 lần.
  2. USER không phải root — một dòng.
  3. .dockerignore — build nhanh hơn, không lộ .git.
  4. Quét lỗ hổng trong CI — một bước pipeline.

Sau đó mới cân nhắc chiseled image, canary, và GitOps — những thứ cần thay đổi quy trình.

Sau Module 15, bước tiếp theo là Stage 5 — Senior Engineering.

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

Danh sách rà soát triển khai

  • •Dockerfile dùng multi-stage build.
  • •Copy csproj trước khi restore để tận dụng cache layer.
  • •Container chạy bằng user không phải root.
  • •Có .dockerignore loại trừ bin, obj và .git.
  • •CI có bước quét lỗ hổng image.
  • •Rolling update đặt maxUnavailable bằng 0.
  • •Thay đổi schema tương thích ngược vì hai phiên bản chạy song song.
  • •Đã cân nhắc canary cho thay đổi rủi ro cao.

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

Bài 1 — Đo kích thước image​

Build một image một giai đoạn và một multi-stage, so sánh kích thước và thời gian build tăng dần.

Tiêu chí hoàn thành: bạn nêu được vì sao kích thước image quan trọng ngoài chuyện dung lượng đĩa, và biết những gì multi-stage không giải quyết.

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

Gợi ý. Image được tải về bao nhiêu lần trong vòng đời của nó?

Lời giải — một giai đoạn:

FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app
WORKDIR /app
ENTRYPOINT ["dotnet", "Crm.Api.dll"]

Multi-stage:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["src/Crm.Api/Crm.Api.csproj", "src/Crm.Api/"]
RUN dotnet restore "src/Crm.Api/Crm.Api.csproj"
COPY . .
RUN dotnet publish "src/Crm.Api/Crm.Api.csproj" -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}'
crm-api:mot-giai-doan     1.24GB
crm-api:multi-stage 112MB
crm-api:chiseled 68MB

Kích thước theo image gốc:

Image gốcKích thước cuốiGhi chú
sdk:9.0 (một giai đoạn)1,24 GBChứa cả trình biên dịch, mã nguồn, gói NuGet
aspnet:9.0220 MBDebian đầy đủ
aspnet:9.0-alpine112 MBAlpine, musl libc
aspnet:9.0-noble-chiseled68 MBKhông shell, không package manager
Native AOT + chiseled~22 MBKhông cần runtime

Vì sao kích thước quan trọng ngoài dung lượng đĩa — năm lý do:

1. Thời gian kéo image, nhân với số lần kéo. Đây là lý do lớn nhất, và nó bị nhân lên theo quy mô:

Một image được kéo bao nhiêu lần?
mỗi node trong cụm × mỗi lần deploy × mỗi lần scale × mỗi lần node được thay

Cụm 20 node, deploy 3 lần một ngày:
1,24 GB × 20 × 3 = 74 GB băng thông mỗi ngày
68 MB × 20 × 3 = 4 GB mỗi ngày

2. Cold start. Với scale-to-zero hay autoscaling, thời gian kéo image nằm thẳng trên đường phản hồi của request đầu tiên:

1,24 GB -> 45–90 s kéo image
68 MB -> 2–4 s

Chi tiết ở bài 15.7.

3. Bề mặt tấn công. Đây là lý do quan trọng nhất về mặt bảo mật:

grype crm-api:mot-giai-doan
grype crm-api:chiseled
mot-giai-doan:  Critical 2, High 18, Medium 94, Low 210
chiseled: Critical 0, High 0, Medium 3, Low 8

Ít gói hơn nghĩa là ít CVE hơn, và ít việc vá hơn. Image chiseled bỏ hẳn shell, apt, curl, wget — những công cụ mà kẻ tấn công cần sau khi vào được container:

docker run --rm -it crm-api:chiseled /bin/sh
exec /bin/sh: no such file or directory

Không có shell thì phần lớn payload khai thác không chạy được.

4. Chi phí lưu trữ registry. Mỗi lần build là một image mới; giữ 100 phiên bản gần nhất:

1,24 GB × 100 = 124 GB
68 MB × 100 = 6,8 GB

Layer được chia sẻ nên con số thật thấp hơn, nhưng tỷ lệ vẫn giữ nguyên.

5. Mã nguồn không bị đóng gói cùng. Image một giai đoạn chứa toàn bộ mã nguồn của bạn:

docker run --rm crm-api:mot-giai-doan ls /src/src/Crm.Api
Program.cs  Controllers  appsettings.json  appsettings.Development.json ...

Với một image nội bộ thì đây chỉ là lãng phí. Với image đẩy lên registry công khai, hoặc gửi cho khách hàng, đó là rò rỉ.

Thời gian build:

                          Lần đầu   Sau khi sửa một dòng code
Một giai đoạn 124 s 118 s
Multi-stage (thứ tự đúng) 131 s 28 s

Multi-stage chậm hơn ở lần build đầu (thêm bước copy giữa các stage), nhưng nhanh hơn nhiều ở các lần sau nhờ layer cache — và các lần sau mới là thứ bạn trải qua hằng ngày.

Những gì multi-stage KHÔNG giải quyết:

1. Secret trong giai đoạn build. Nếu bạn truyền ARG SECRET vào stage build, nó nằm lại trong layer của stage đó. Image cuối sạch, nhưng nếu ai đó build với --target build hoặc bạn đẩy stage đó lên registry, secret vẫn lộ — chi tiết ở bài 15.4.

2. Lỗ hổng trong chính image runtime. aspnet:9.0 vẫn có hàng chục CVE ở mức Low và Medium. Multi-stage loại bỏ SDK, không loại bỏ những gì còn lại. Phải quét và cập nhật định kỳ.

3. Kích thước phụ thuộc của ứng dụng. Nếu bạn phụ thuộc vào một thư viện native 200 MB, nó vẫn phải có mặt trong image cuối.

4. File thừa mà bạn tự copy vào:

COPY --from=build /app .        # copy TẤT CẢ, kể cả file .pdb và appsettings.Development.json
# Tốt hơn — không sinh file thừa ngay từ đầu
RUN dotnet publish -c Release -o /app --no-restore \
/p:DebugType=none /p:GenerateDocumentationFile=false

5. Chạy bằng root. Multi-stage không tự động thêm USER. Phải khai tường minh:

USER $APP_UID

Khi nào KHÔNG dùng chiseled:

- Cần chẩn đoán bằng docker exec (không có shell)
- Ứng dụng gọi công cụ hệ thống (ffmpeg, imagemagick, wkhtmltopdf)
- Cần thư viện native không có sẵn
- Dùng System.Drawing.Common hoặc thứ phụ thuộc libgdiplus

Với những trường hợp đó, aspnet:9.0-alpine là điểm cân bằng tốt: nhỏ hơn Debian đáng kể, vẫn có shell và apk.

Và nếu cần chẩn đoán một container chiseled đang chạy, dùng ephemeral debug container thay vì bỏ chiseled:

kubectl debug -it crm-api-7d4f --image=busybox:1.36 --target=crm-api

Nó gắn một container có shell vào cùng namespace của pod — bạn chẩn đoán được mà image production vẫn giữ nguyên bề mặt tấn công nhỏ.


Bài 2 — Chạy bằng non-root​

Thêm USER vào Dockerfile và xác nhận ứng dụng vẫn chạy, kiểm tra bằng whoami trong container.

Tiêu chí hoàn thành: bạn nêu được kẻ tấn công làm thêm được gì khi container chạy bằng root, và biết những gì hỏng khi chuyển sang non-root.

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

Gợi ý. User id trong container và trên máy chủ có phải cùng một không gian không?

Lời giải:

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
docker exec crm-api whoami
docker exec crm-api id
root
uid=0(root) gid=0(root) groups=0(root)
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID # biến có sẵn trong image .NET 8+, = 1654
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
docker exec crm-api id
uid=1654(app) gid=1654(app) groups=1654(app)

Kẻ tấn công làm thêm được gì khi container chạy bằng root:

1. Cài công cụ:          apt-get install nmap netcat tcpdump
2. Sửa mọi file: kể cả nhị phân ứng dụng, để cài backdoor bền vững
3. Đọc mọi file: /run/secrets/, chứng chỉ, khoá riêng
4. Bind cổng đặc quyền: chạy dịch vụ giả ở cổng 80, 443
5. Sửa bảng định tuyến: nếu container có NET_ADMIN
6. Đọc /proc của mọi tiến trình trong container
7. Nếu có lỗ hổng thoát container -> ROOT TRÊN MÁY CHỦ

Điểm 7 là điểm quyết định, và nó dựa trên một thực tế mà nhiều người không biết: user id trong container và trên máy chủ là CÙNG một không gian (trừ khi bật user namespace remapping, thứ ít cụm nào bật).

uid 0 trong container == uid 0 trên máy chủ

-> Một lỗ hổng thoát container (CVE runc, CVE containerd, volume mount sai cấu hình)
biến root-trong-container thành root-trên-máy-chủ
-> Mọi container khác trên cùng node bị chiếm

Với uid 1654, cùng lỗ hổng đó chỉ cho kẻ tấn công quyền của một user thường trên máy chủ — vẫn xấu, nhưng khác hẳn về mức độ.

Và điểm 2 đáng nói thêm: với root, kẻ tấn công sửa được chính nhị phân ứng dụng. Container restart vẫn chạy mã của họ, và không có gì trong git hay registry cho thấy điều đó.

Cấu hình đầy đủ:

FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled
WORKDIR /app
COPY --from=build --chown=$APP_UID:$APP_UID /app .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Crm.Api.dll"]

--chown trong COPY là chi tiết dễ quên: không có nó, file thuộc về root và user app chỉ đọc được, không ghi được. Với ứng dụng chỉ đọc thì không sao — và đó thực ra là điều bạn muốn.

# Kubernetes — làm cứng thêm
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault

Ý nghĩa từng dòng:

Cấu hìnhTác dụng
runAsNonRoot: truePod bị từ chối nếu image chạy bằng root — chặn ở tầng cụm
allowPrivilegeEscalation: falseChặn setuid, nên không leo thang quyền được
readOnlyRootFilesystem: trueKhông ghi được vào filesystem — chặn cài backdoor
capabilities: drop ALLBỏ mọi đặc quyền Linux
seccompProfile: RuntimeDefaultGiới hạn syscall được phép gọi

Những gì hỏng khi chuyển sang non-root — bốn thứ, và cách xử lý:

1. Bind cổng dưới 1024:

System.Net.Sockets.SocketException (13): Permission denied

Đã được giải quyết từ .NET 8 bằng cổng mặc định 8080 (bài 15.2). Nếu còn dùng EXPOSE 80, đây là lúc đổi.

2. Ghi vào filesystem:

System.UnauthorizedAccessException: Access to the path '/app/logs' is denied.

Với readOnlyRootFilesystem: true, mọi lần ghi đều thất bại. Cấp chỗ ghi tường minh qua emptyDir:

volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: logs, mountPath: /app/logs }
volumes:
- { name: tmp, emptyDir: {} }
- { name: logs, emptyDir: {} }

/tmp gần như luôn cần, vì .NET dùng nó cho file tạm và cho một số thao tác của runtime.

Nhưng câu hỏi tốt hơn là: tại sao ứng dụng ghi file? Log nên ra stdout (bài 15.3), file upload nên vào object storage, dữ liệu nên vào database. Một container thật sự stateless không cần ghi gì cả.

3. File được mount không đúng quyền:

volumes:
- name: config
configMap:
name: crm-config
defaultMode: 0444 # đọc được cho mọi user

Với secret mount, cần fsGroup để user trong container đọc được:

securityContext:
fsGroup: 1654

4. Chứng chỉ và data protection key:

builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/app/keys")); // cần quyền ghi

Tốt hơn: lưu vào Redis hoặc blob storage, vừa giải quyết quyền vừa giải quyết vấn đề đa instance ở bài 15.10:

builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
.SetApplicationName("crm");

Kiểm chứng:

docker exec crm-api id
docker exec crm-api touch /test 2>&1 || echo "Không ghi được — ĐÚNG"
kubectl exec crm-api-7d4f -- id
kubectl get pod crm-api-7d4f -o jsonpath='{.spec.securityContext}' | jq

Và chặn ở tầng cụm, để không ai deploy image chạy bằng root:

apiVersion: v1
kind: Namespace
metadata:
name: crm
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest

Pod Security Standard restricted từ chối mọi pod không thoả các yêu cầu ở trên. Đây là cách duy nhất khiến quy tắc được thực thi thay vì chỉ được khuyến nghị — và nó bắt được cả những image bên thứ ba mà bạn không kiểm soát Dockerfile.


Bài 3 — Quét lỗ hổng​

Chạy grype trên image hiện tại và đếm số lỗ hổng theo mức độ.

Tiêu chí hoàn thành: bạn phân loại được lỗ hổng theo mức độ có khai thác được trong bối cảnh của bạn hay không, và biết đưa quét vào CI mà không làm CI đỏ liên tục.

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

Gợi ý. Một CVE trong curl có nguy hiểm không, nếu ứng dụng của bạn không bao giờ gọi curl?

Lời giải:

grype crm-api:latest
NAME          INSTALLED   FIXED-IN   TYPE  VULNERABILITY   SEVERITY
libssl3 3.0.13-0 3.0.14-0 deb CVE-2024-4741 High
zlib1g 1:1.3.dfsg (won't fix) deb CVE-2023-45853 Critical
Newtonsoft.Json 12.0.3 13.0.1 dotnet CVE-2024-21907 High
libc6 2.39-0 (none) deb CVE-2024-2961 Medium
grype crm-api:latest -o json | jq -r '.matches[].vulnerability.severity' \
| sort | uniq -c | sort -rn
    210 Low
94 Medium
18 High
2 Critical

324 lỗ hổng. Nếu bạn chặn CI trên bất kỳ CVE nào, CI sẽ đỏ mãi mãi — và đó là lý do nhiều nhóm tắt hẳn quét bảo mật sau vài tuần.

Phân loại theo khả năng khai thác trong bối cảnh của bạn — ba câu hỏi, theo thứ tự:

Câu hỏi 1: Gói đó có được ứng dụng dùng không?

CVE trong Newtonsoft.Json, ứng dụng dùng System.Text.Json
-> gói bị kéo vào qua một phụ thuộc gián tiếp
-> có thể không nằm trên đường code nào
-> ƯU TIÊN THẤP, nhưng vẫn nên gỡ nếu gỡ được

CVE trong System.Text.Json
-> deserialize mọi request body
-> ƯU TIÊN CAO NHẤT

Kiểm tra:

dotnet list package --include-transitive | grep -i newtonsoft
dotnet nuget why Crm.Api Newtonsoft.Json

Câu hỏi 2: Lỗ hổng có tiếp cận được từ bên ngoài không?

CVE cho phép RCE qua HTTP request
-> ứng dụng nhận HTTP từ internet
-> KHAI THÁC ĐƯỢC -> ưu tiên cao nhất

CVE trong bộ phân tích XML
-> ứng dụng chỉ nhận JSON, không bao giờ phân tích XML
-> không tiếp cận được -> ưu tiên thấp

CVE trong curl
-> image chiseled không có curl
-> không tồn tại trong image -> bỏ qua

Câu hỏi 3: Có bản vá không?

FIXED-IN có phiên bản  -> cập nhật, thường chỉ cần đổi tag image gốc
FIXED-IN "won't fix" -> đánh giá và bỏ qua, hoặc đổi image gốc
FIXED-IN "(none)" -> chưa có bản vá — theo dõi và giảm thiểu

Phần lớn CVE trong image .NET sửa được bằng một dòng:

FROM mcr.microsoft.com/dotnet/aspnet:9.0     # Microsoft vá hằng tháng

Build lại hằng tuần là biện pháp hiệu quả nhất, và rẻ nhất.

Ma trận ưu tiên:

MứcTiếp cận đượcCó bản váHành động
Critical/HighCóCóVá ngay
Critical/HighCóKhôngGiảm thiểu, theo dõi
Critical/HighKhôngCóVá ở lần cập nhật định kỳ
Critical/HighKhôngKhôngGhi nhận, theo dõi
Medium/Lowbất kỳCóVá theo lịch hằng tháng
Medium/LowKhôngKhôngBỏ qua, ghi lý do

Đưa vào CI mà không làm CI đỏ liên tục — bốn nguyên tắc:

1. Chặn theo mức độ, không chặn theo số lượng:

- name: Quét lỗ hổng
uses: anchore/scan-action@v6
with:
image: ${{ env.IMAGE }}
fail-build: true
severity-cutoff: high # chỉ High và Critical
only-fixed: true # chỉ CVE ĐÃ CÓ bản vá

only-fixed: true là tham số quan trọng nhất. Nó bỏ qua những CVE mà bạn không làm gì được — và đó chính là nguồn chính của sự bực bội khiến người ta tắt quét.

2. Cho phép bỏ qua có thời hạn, kèm lý do:

# .grype.yaml
ignore:
- vulnerability: CVE-2023-45853
reason: "zlib, won't fix upstream; không nằm trên đường code nào của ứng dụng"
expires: 2026-12-31 # BẮT BUỘC có hạn — buộc xem lại
- vulnerability: CVE-2024-21907
package: { name: Newtonsoft.Json }
reason: "phụ thuộc gián tiếp qua Swashbuckle, không deserialize input người dùng"
expires: 2026-06-30

Hai điều làm cách này hoạt động: lý do bắt buộc khiến quyết định được ghi lại và review được, và hạn hết hiệu lực ngăn danh sách bỏ qua trở thành nơi vứt bỏ vĩnh viễn.

3. Tách hai chế độ chạy:

# PR: cảnh báo, không chặn — để không ai bị kẹt vì một CVE mới công bố
- uses: anchore/scan-action@v6
if: github.event_name == 'pull_request'
with: { fail-build: false, severity-cutoff: critical }

# Nhánh main: chặn
- uses: anchore/scan-action@v6
if: github.ref == 'refs/heads/main'
with: { fail-build: true, severity-cutoff: high, only-fixed: true }

# Quét định kỳ trên image đang chạy ở production
on:
schedule:
- cron: '0 2 * * *'

Bước thứ ba là bước hay bị thiếu nhất: CVE mới được công bố sau khi image đã deploy. Một image sạch hôm build có thể có ba CVE High sau hai tuần, và không có gì báo cho bạn trừ khi bạn quét lại.

4. Đẩy kết quả vào GitHub Security để theo dõi, thay vì chỉ in ra log:

permissions:
security-events: write

steps:
- uses: anchore/scan-action@v6
id: scan
with: { image: ${{ env.IMAGE }}, output-format: sarif, fail-build: false }

- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: ${{ steps.scan.outputs.sarif }} }

Kết quả hiện trong tab Security, có lịch sử, có phân công, và tự đóng khi CVE được vá. Log CI thì cuộn qua và không ai đọc.

Và tạo SBOM để trả lời nhanh khi có CVE mới:

syft crm-api:latest -o spdx-json > sbom.json

Khi một CVE lớn được công bố — như Log4Shell — câu hỏi đầu tiên là "chúng ta có dùng gói đó không, ở đâu, phiên bản nào?". Với SBOM, đó là một lệnh grep:

grype sbom:sbom.json         # quét lại SBOM cũ với dữ liệu CVE MỚI

Không có SBOM, câu trả lời là một cuộc điều tra qua nhiều repository và nhiều image — và trong một sự cố bảo mật, thời gian trả lời câu hỏi đó chính là thời gian bạn chưa bắt đầu khắc phục.

Tổng kết thứ tự ưu tiên thực tế:

1. Cập nhật image gốc hằng tuần          -> sửa phần lớn CVE, tốn gần như không công
2. Dùng image nhỏ nhất dùng được -> ít gói hơn, ít CVE hơn
3. Quét trên main với only-fixed -> chặn thứ sửa được
4. Quét định kỳ image đang production -> bắt CVE công bố sau khi deploy
5. SBOM -> trả lời nhanh khi có sự cố
6. Xử lý từng CVE còn lại theo ma trận -> phần tốn công nhất, làm sau cùng

Nhiều nhóm bắt đầu từ mục 6, bị ngợp, rồi bỏ cuộc. Mục 1 và 2 cho phần lớn lợi ích với phần nhỏ công sức.

Tự kiểm tra​

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

Vì sao copy csproj trước khi restore?

Vì Docker cache từng layer. Copy toàn bộ mã nguồn trước thì mọi thay đổi code đều làm mất cache và phải tải lại toàn bộ NuGet package. Copy csproj trước nghĩa là restore chỉ chạy lại khi danh sách package thay đổi.

Chạy bằng user không phải root giảm được rủi ro gì?

Nếu container bị xâm nhập, kẻ tấn công không có quyền root trong container nên không cài được gói, không sửa được file hệ thống, và khó thoát ra host hơn nhiều. Chỉ tốn một dòng USER trong Dockerfile.

Cái giá thật của image chiseled là gì?

Không có shell nên không docker exec vào xem được, phải dựa hoàn toàn vào log và chỉ số. Hợp lý với production được giám sát tốt nhưng gây khó khi cần chẩn đoán khẩn cấp.

Điều gì quan trọng nhất cần nhớ về rolling update?

Phiên bản cũ và mới chạy song song vài phút và cùng đọc ghi một database. Nên mọi thay đổi schema phải tương thích ngược, không thể vừa đổi tên cột vừa triển khai code mới.

Lợi ích bảo mật lớn nhất của GitOps là gì?

CI không cần quyền truy cập cluster, vì cluster tự kéo cấu hình về. Credential cluster lưu trong CI là mục tiêu tấn công có giá trị cao, và GitOps loại bỏ hoàn toàn nhu cầu đó.

SBOM có giá trị thật khi nào?

Khi có lỗ hổng nghiêm trọng được công bố. Câu hỏi chúng ta có dùng thư viện đó không, ở phiên bản nào, trong image nào trả lời được trong vài giây thay vì vài giờ rà soát thủ công.

Kết luận​

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

  1. Bốn việc làm ngay hôm nay: multi-stage, non-root user, .dockerignore, quét lỗ hổng.
  2. Rolling update cho hai phiên bản chạy song song — schema phải tương thích ngược.
  3. SBOM trả lời "chúng ta có bị ảnh hưởng không" trong vài giây thay vì vài giờ.

Tham khảo​

Điều hướng​