Docker Image .NET 10 — Từ 800MB xuống dưới 50MB với Chiseled Containers

Posted on: 4/20/2026 6:11:32 PM

Bạn đã bao giờ kiểm tra kích thước Docker image của ứng dụng .NET và giật mình vì con số 800MB - 1.2GB chưa? Đó là thực tế khi dùng SDK image mặc định để deploy. Với .NET 10, Microsoft mang đến những cải tiến đáng kể cho container story — từ Ubuntu Chiseled images cho đến Native AOT compilation, giúp giảm image xuống dưới 50MB mà vẫn đảm bảo hiệu năng và bảo mật.

Bài viết này sẽ đi sâu vào từng kỹ thuật tối ưu, kèm Dockerfile production-ready và benchmark thực tế để bạn áp dụng ngay vào dự án.

~900MB SDK Image mặc định
~210MB ASP.NET Runtime Image
~105MB Chiseled Image
<15MB Native AOT + Chiseled

Tại sao kích thước Image quan trọng?

Kích thước Docker image không chỉ là con số trên disk. Nó ảnh hưởng trực tiếp đến toàn bộ lifecycle của ứng dụng containerized:

  • Tốc độ deploy: Image 800MB mất 45-60 giây pull từ registry, trong khi image 50MB chỉ mất 3-5 giây. Khi auto-scaling cần spin up 10 instance mới, sự khác biệt là 1 phút vs 5 giây.
  • Chi phí storage: Container registry tính tiền theo GB. Với CI/CD chạy nhiều lần/ngày, mỗi build tạo 1 image → chi phí tích lũy đáng kể.
  • Attack surface: Image càng lớn = càng nhiều package = càng nhiều CVE tiềm ẩn. Image chứa bash, apt, curl là lời mời cho attacker sau khi có RCE.
  • Cold start: Trên Kubernetes, pod scheduling phụ thuộc image pull time. Image nhỏ giúp horizontal scaling phản hồi nhanh hơn khi traffic spike.

Phân loại Docker Image trong .NET 10

.NET 10 cung cấp nhiều loại base image khác nhau, mỗi loại phục vụ một mục đích riêng. Điểm thay đổi lớn nhất: Debian image không còn được ship cho .NET 10 — Ubuntu trở thành base OS mặc định.

graph TD
    A["dotnet/sdk:10.0
~900MB"] --> B["dotnet/aspnet:10.0
~210MB"] A --> C["dotnet/runtime:10.0
~190MB"] B --> D["dotnet/aspnet:10.0-noble-chiseled
~105MB"] C --> E["dotnet/runtime:10.0-noble-chiseled
~95MB"] D --> F["dotnet/aspnet:10.0-noble-chiseled-aot
~12MB"] E --> G["dotnet/runtime-deps:10.0-noble-chiseled
~13MB"] style A fill:#e94560,stroke:#fff,color:#fff style B fill:#2c3e50,stroke:#fff,color:#fff style C fill:#2c3e50,stroke:#fff,color:#fff style D fill:#4CAF50,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff style F fill:#16213e,stroke:#e94560,color:#fff style G fill:#16213e,stroke:#e94560,color:#fff

Hệ thống phân cấp Docker Image trong .NET 10 — từ SDK đến Chiseled AOT

Image Kích thước Bao gồm Use case
dotnet/sdk:10.0 ~900MB SDK, compiler, build tools, runtime Build stage, CI pipeline
dotnet/aspnet:10.0 ~210MB ASP.NET Core runtime, Ubuntu Noble Production web apps
dotnet/runtime:10.0 ~190MB .NET runtime, Ubuntu Noble Console apps, workers
dotnet/aspnet:10.0-noble-chiseled ~105MB ASP.NET runtime, no shell, non-root Production web (khuyến nghị)
dotnet/runtime-deps:10.0-noble-chiseled ~13MB Chỉ native dependencies Self-contained / Native AOT

Multi-Stage Build — Nền tảng tối ưu

Multi-stage build là kỹ thuật đầu tiên và quan trọng nhất. Ý tưởng đơn giản: dùng SDK image để build, nhưng chỉ copy artifact sang runtime image cho production. Source code, NuGet cache, build tools — tất cả bị loại bỏ khỏi image cuối cùng.

Nguyên tắc vàng

Không bao giờ ship SDK image ra production. dotnet/sdk chứa compiler, MSBuild, NuGet — những thứ hoàn toàn không cần thiết khi ứng dụng đã được compile.

Dockerfile Multi-Stage cơ bản

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Copy csproj trước để tận dụng Docker layer cache
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"

# Copy toàn bộ source và publish
COPY . .
RUN dotnet publish "MyApi/MyApi.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

USER $APP_UID
ENTRYPOINT ["dotnet", "MyApi.dll"]

Chỉ với multi-stage build đơn giản này, image giảm từ 900MB xuống ~220MB — giảm 75%.

Tối ưu Docker Layer Cache

Thứ tự COPY trong Dockerfile ảnh hưởng trực tiếp đến tốc độ build. Docker cache hoạt động theo nguyên tắc: nếu một layer thay đổi, tất cả layer phía sau bị invalidate.

graph LR
    A["COPY *.csproj"] --> B["dotnet restore"]
    B --> C["COPY source code"]
    C --> D["dotnet publish"]

    style A fill:#4CAF50,stroke:#fff,color:#fff
    style B fill:#4CAF50,stroke:#fff,color:#fff
    style C fill:#e94560,stroke:#fff,color:#fff
    style D fill:#e94560,stroke:#fff,color:#fff
    

Layer caching: restore (xanh) được cache khi chỉ source code thay đổi (đỏ)

# Tối ưu: Copy solution và tất cả csproj trước
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
COPY ["MyApi.sln", "./"]
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyApi.Core/MyApi.Core.csproj", "src/MyApi.Core/"]
COPY ["src/MyApi.Infrastructure/MyApi.Infrastructure.csproj", "src/MyApi.Infrastructure/"]

RUN dotnet restore "MyApi.sln"

# Source code thay đổi thường xuyên → layer riêng
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore

Mẹo thực tế

Với solution có nhiều project, copy từng file .csproj giữ đúng cấu trúc thư mục. Khi thêm NuGet package mới, chỉ layer restore bị rebuild — tiết kiệm 30-60 giây mỗi lần build.

Ubuntu Chiseled — Distroless cho .NET

Ubuntu Chiseled là câu trả lời của Canonical và Microsoft cho Google Distroless. Đây là image được "đẽo gọt" (chiseled) từ Ubuntu, chỉ giữ lại đúng những package mà .NET runtime cần.

Chiseled loại bỏ những gì?

Thành phần Ubuntu Full Chiseled
Shell (bash, sh) Không
Package manager (apt) Không
Networking tools (curl, wget) Không
User management tools Không
Non-root by default Không Có (UID 1654)
Timezone data Có (từ .NET 10)
Globalization (ICU) Có (extra variant)

Dockerfile với Chiseled Image

# Build stage không thay đổi
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
RUN dotnet publish "MyApi/MyApi.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore

# Thay đổi DUY NHẤT: chuyển sang chiseled image
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .

# Không cần USER — Chiseled đã mặc định non-root
ENTRYPOINT ["dotnet", "MyApi.dll"]

Chỉ thay đổi 1 dòng FROM, image giảm từ ~220MB xuống ~115MB. Và quan trọng hơn: container chạy mặc định ở non-root user, không có shell để attacker exploit.

Lưu ý khi dùng Chiseled

Vì không có shell, bạn không thể docker exec -it container bash để debug. Thay vào đó, sử dụng dotnet-monitor, sidecar container, hoặc kubectl debug trên Kubernetes. Đây là trade-off có chủ đích: bảo mật hơn nhưng debug phức tạp hơn.

Chiseled Extra và Globalization

Nếu ứng dụng cần xử lý timezone, globalization (ICU), hoặc các thư viện native bổ sung, .NET 10 cung cấp variant -extra:

# Cho ứng dụng cần full ICU + timezone
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra AS final

# Cho ứng dụng chỉ cần globalization invariant (API đơn giản)
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final

Từ .NET 10, Chiseled image cũng đi kèm Chisel manifest — cho phép bạn cài thêm package cụ thể mà không cần chuyển về full Ubuntu image. Ví dụ cần libgdiplus cho image processing:

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

# Cài thêm package qua chisel slice
RUN --mount=type=cache,target=/var/cache/chisel \
    chisel cut --root / libgdiplus_lib

Native AOT — Vũ khí tối thượng

Native AOT (Ahead-of-Time compilation) biên dịch ứng dụng .NET thành mã máy gốc, loại bỏ hoàn toàn .NET runtime và JIT compiler khỏi image. Kết quả: ứng dụng chạy như binary C/C++, khởi động trong milliseconds.

graph LR
    subgraph "Truyền thống (JIT)"
    A1["Source Code"] --> B1["IL Code (.dll)"]
    B1 --> C1[".NET Runtime
JIT Compile"] C1 --> D1["Machine Code"] end subgraph "Native AOT" A2["Source Code"] --> B2["IL Code"] B2 --> C2["AOT Compiler
(build time)"] C2 --> D2["Native Binary"] end style C1 fill:#e94560,stroke:#fff,color:#fff style D1 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style C2 fill:#4CAF50,stroke:#fff,color:#fff style D2 fill:#4CAF50,stroke:#fff,color:#fff style A1 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style B1 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style A2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style B2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

JIT compile tại runtime vs AOT compile tại build time

Cấu hình Native AOT trong .csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <StripSymbols>true</StripSymbols>
    <InvariantGlobalization>true</InvariantGlobalization>
    <IlcOptimizationPreference>Size</IlcOptimizationPreference>
  </PropertyGroup>
</Project>

Dockerfile Native AOT + Chiseled

# Build với AOT — cần thêm native build tools
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
RUN apt-get update && apt-get install -y clang zlib1g-dev
WORKDIR /src

COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
RUN dotnet publish "MyApi/MyApi.csproj" \
    -c Release \
    -o /app/publish

# Runtime: chỉ cần runtime-deps, không cần .NET runtime
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .

ENTRYPOINT ["./MyApi"]
~12MB Final image size
<10ms Startup time
~30MB Memory footprint
98% Giảm so với SDK image

Khi nào dùng Native AOT?

Native AOT phù hợp nhất cho: API microservices, serverless functions, CLI tools, sidecar containers. Không phù hợp cho ứng dụng dùng nhiều reflection (Entity Framework Core full, dynamic assembly loading) — mặc dù .NET 10 đã cải thiện đáng kể khả năng AOT compatibility của EF Core.

dotnet publish container — Không cần Dockerfile

.NET 10 hỗ trợ build container image trực tiếp từ dotnet publish, không cần viết Dockerfile:

# Publish trực tiếp thành container image
dotnet publish /t:PublishContainer \
    -c Release \
    --os linux \
    --arch x64 \
    -p:ContainerImageName=myapi \
    -p:ContainerImageTag=1.0.0

# Với chiseled base image
dotnet publish /t:PublishContainer \
    -c Release \
    --os linux \
    -p:ContainerBaseImage=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled \
    -p:ContainerImageName=myapi

# Push trực tiếp lên registry
dotnet publish /t:PublishContainer \
    -c Release \
    -p:ContainerRegistry=myregistry.azurecr.io \
    -p:ContainerImageName=myapi \
    -p:ContainerImageTag=latest

Cấu hình container trong .csproj:

<PropertyGroup>
  <ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled</ContainerBaseImage>
  <ContainerImageName>myapi</ContainerImageName>
  <ContainerPort Include="8080" Type="tcp" />
  <ContainerUser>1654</ContainerUser>
</PropertyGroup>

Khi nào dùng dotnet publish container?

Phù hợp cho dự án đơn giản, CI/CD pipeline muốn tối giản, hoặc khi team không muốn maintain Dockerfile. Tuy nhiên, với ứng dụng phức tạp cần multi-stage build tùy chỉnh, Dockerfile vẫn linh hoạt hơn.

Container Security Hardening

Giảm kích thước image và tăng cường bảo mật là hai mặt của cùng một đồng xu. Dưới đây là các practice quan trọng khi deploy .NET container vào production:

1. Chạy Non-Root User

# Với full Ubuntu image — cần set USER manually
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

# Tạo user riêng (UID 1654 là convention của .NET images)
USER 1654
ENTRYPOINT ["dotnet", "MyApi.dll"]

# Với Chiseled — đã mặc định non-root, không cần thêm gì

2. Read-Only Filesystem

# docker-compose.yml
services:
  myapi:
    image: myapi:latest
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
# Kubernetes pod spec
spec:
  containers:
    - name: myapi
      image: myapi:latest
      securityContext:
        readOnlyRootFilesystem: true
        runAsNonRoot: true
        runAsUser: 1654
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

3. Scan Vulnerability

# Scan image với Trivy
trivy image myapi:latest

# Scan trong CI pipeline (fail nếu có HIGH/CRITICAL)
trivy image --exit-code 1 --severity HIGH,CRITICAL myapi:latest

# Docker Scout (tích hợp sẵn Docker Desktop)
docker scout cves myapi:latest
docker scout recommendations myapi:latest

Đừng dùng tag :latest trong production

Luôn pin version cụ thể: aspnet:10.0.1-noble-chiseled thay vì aspnet:10.0-noble-chiseled. Tag :latest hoặc minor version có thể thay đổi bất cứ lúc nào, gây ra deploy không reproducible và tiềm ẩn breaking changes.

Production Dockerfile toàn diện

Dưới đây là Dockerfile production-grade kết hợp tất cả kỹ thuật đã nói:

# ============================================
# Stage 1: Restore dependencies (cached layer)
# ============================================
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore
WORKDIR /src

COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
COPY ["MyApi.sln", "./"]
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyApi.Core/MyApi.Core.csproj", "src/MyApi.Core/"]
COPY ["src/MyApi.Infrastructure/MyApi.Infrastructure.csproj", "src/MyApi.Infrastructure/"]

RUN dotnet restore "MyApi.sln"

# ============================================
# Stage 2: Build and publish
# ============================================
FROM restore AS publish
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore \
    /p:DebugType=None \
    /p:DebugSymbols=false

# ============================================
# Stage 3: Production runtime
# ============================================
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final

LABEL maintainer="team@example.com"
LABEL org.opencontainers.image.source="https://github.com/org/myapi"

WORKDIR /app
COPY --from=publish /app/publish .

ENV DOTNET_EnableDiagnostics=0
ENV ASPNETCORE_URLS=http://+:8080

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD ["dotnet", "MyApi.dll", "--urls", "http://localhost:8080/health"]

ENTRYPOINT ["dotnet", "MyApi.dll"]
Giải thích các tối ưu
  • /p:DebugType=None — loại bỏ PDB files, giảm 10-30MB tùy project
  • DOTNET_EnableDiagnostics=0 — tắt diagnostic port, giảm attack surface
  • ASPNETCORE_URLS=http://+:8080 — bind port non-privileged (không cần root cho port < 1024)
  • 3 stage riêng biệt: restore → publish → runtime, tối ưu cache cho CI/CD

Benchmark kích thước Image thực tế

Dưới đây là kết quả đo thực tế với một ASP.NET Core Web API tiêu biểu (3 controllers, Entity Framework Core, Serilog, Swagger):

Cấu hình Image Size Startup RAM idle Giảm %
SDK image (naive) 912MB ~2.5s ~120MB
Multi-stage + aspnet runtime 225MB ~1.8s ~95MB 75%
Multi-stage + Chiseled 118MB ~1.5s ~90MB 87%
Chiseled + trimming 82MB ~1.2s ~75MB 91%
Native AOT + Chiseled 42MB ~28ms ~32MB 95%
Native AOT + Chiseled + Size opt 14MB ~15ms ~28MB 98%
xychart-beta
    title "Kích thước Docker Image theo cấu hình (MB)"
    x-axis ["SDK", "Runtime", "Chiseled", "Chiseled+Trim", "AOT+Chiseled", "AOT+Size"]
    y-axis "MB" 0 --> 950
    bar [912, 225, 118, 82, 42, 14]
    

So sánh kích thước image qua từng cấp tối ưu

CI/CD Pipeline tích hợp

Dockerfile tối ưu chỉ phát huy hết tác dụng khi đi kèm CI/CD pipeline phù hợp. Dưới đây là ví dụ với GitHub Actions:

name: Build and Push
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to ACR
        uses: docker/login-action@v3
        with:
          registry: myregistry.azurecr.io
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: myregistry.azurecr.io/myapi:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myregistry.azurecr.io/myapi:${{ github.sha }}
          severity: HIGH,CRITICAL
          exit-code: 1

BuildKit cache với GitHub Actions

cache-from: type=gha tận dụng GitHub Actions cache để lưu Docker layer cache giữa các lần build. Lần build đầu mất ~5 phút, các lần sau chỉ 30-60 giây nếu chỉ source code thay đổi.

Các lỗi thường gặp

Lỗi 1: COPY toàn bộ trước restore

# SAI — mỗi thay đổi source invalidate restore cache
COPY . .
RUN dotnet restore
RUN dotnet publish

# ĐÚNG — tách csproj và source
COPY ["*.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish

Lỗi 2: Để PDB files trong image

# Thêm vào publish command
RUN dotnet publish -c Release -o /app \
    /p:DebugType=None \
    /p:DebugSymbols=false

Lỗi 3: Chạy container bằng root

# Luôn set USER ở cuối Dockerfile (nếu không dùng Chiseled)
RUN adduser --disabled-password --gecos "" appuser
USER appuser

Lỗi 4: Build image không đúng platform

# Build multi-platform image
docker buildx build \
    --platform linux/amd64,linux/arm64 \
    -t myregistry.azurecr.io/myapi:latest \
    --push .

Checklist tối ưu trước khi deploy

Trước khi push image lên production registry, chạy qua checklist sau:

# Hạng mục Kiểm tra
1 Multi-stage build SDK không nằm trong final image
2 Base image Dùng Chiseled hoặc runtime-deps (AOT)
3 Non-root Container chạy USER non-root
4 No PDB DebugType=None, không có *.pdb trong /app
5 Pinned version Base image dùng tag cụ thể, không :latest
6 HEALTHCHECK Có health endpoint, container tự báo trạng thái
7 CVE scan Trivy/Scout không phát hiện HIGH/CRITICAL
8 Layer cache Restore layer tách biệt, tận dụng cache CI/CD

Kết luận

Tối ưu Docker image cho .NET 10 không phải là kỹ thuật phức tạp, nhưng ít team thực sự áp dụng đầy đủ. Chỉ với 3 bước đơn giản — multi-stage build, chuyển sang Chiseled image, và bật trimming — bạn có thể giảm 90% kích thước image mà không thay đổi một dòng code ứng dụng nào.

Với Native AOT, con số đó có thể lên đến 98%. Image 14MB khởi động trong 15ms — lý tưởng cho serverless, edge computing, và auto-scaling workloads.

Hãy bắt đầu từ bước đơn giản nhất: thay aspnet:10.0 bằng aspnet:10.0-noble-chiseled trong Dockerfile hiện tại. Bạn sẽ thấy kết quả ngay lập tức.

Tham khảo