Docker Image .NET 10 — Từ 800MB xuống dưới 50MB với Chiseled Containers
Posted on: 4/20/2026 6:11:32 PM
Table of contents
- Tại sao kích thước Image quan trọng?
- Phân loại Docker Image trong .NET 10
- Multi-Stage Build — Nền tảng tối ưu
- Ubuntu Chiseled — Distroless cho .NET
- Native AOT — Vũ khí tối thượng
- dotnet publish container — Không cần Dockerfile
- Container Security Hardening
- Production Dockerfile toàn diện
- Benchmark kích thước Image thực tế
- CI/CD Pipeline tích hợp
- Các lỗi thường gặp
- Checklist tối ưu trước khi deploy
- Kết luận
- Tham khảo
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.
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,curllà 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) | Có | Không |
| Package manager (apt) | Có | Không |
| Networking tools (curl, wget) | Có | Không |
| User management tools | Có | Không |
| Non-root by default | Không | Có (UID 1654) |
| Timezone data | Có | Có (từ .NET 10) |
| Globalization (ICU) | Có | 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"]
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"]
/p:DebugType=None— loại bỏ PDB files, giảm 10-30MB tùy projectDOTNET_EnableDiagnostics=0— tắt diagnostic port, giảm attack surfaceASPNETCORE_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
Bun Runtime 2026: Tại Sao JavaScript Runtime Này Đang Thay Đổi Cuộc Chơi?
Concurrency Patterns trong .NET 10 — Xử lý Song song Hiệu quả
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.