Lần đầu mình dùng Docker cho dự án .NET, mình tạo một Dockerfile copy toàn bộ source vào container, chạy dotnet run, rồi deploy. Image nặng 2GB, build mất 8 phút, và mỗi lần sửa một dòng code thì phải build lại từ đầu. Chạy được, nhưng sai gần hết.
Docker không khó. Nhưng Docker đúng cách cho .NET thì có nhiều thứ cần biết: multi-stage build để giảm image size, layer caching để build nhanh, docker compose cho local development, health check, security hardening, và cách xử lý những vấn đề thực tế mà docs không nói.
Bài viết này đi từ Dockerfile cơ bản đến production-ready setup, dùng stack ASP.NET Core + PostgreSQL — stack mình dùng hàng ngày.
Dockerfile cơ bản — Và tại sao nó chưa đủ
Bắt đầu bằng Dockerfile đơn giản nhất:
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /out
ENTRYPOINT ["dotnet", "/out/MyApp.dll"]
Chạy được. Nhưng có ba vấn đề lớn.
Image nặng vì dùng SDK image (~900MB) — trong khi production chỉ cần runtime (~220MB). Không cần compiler, NuGet tools, hay MSBuild trên production server.
Build chậm vì mỗi lần sửa code, Docker phải COPY lại toàn bộ source và restore lại tất cả NuGet packages — kể cả khi packages không đổi.
Không an toàn vì chạy với user root và chứa SDK tools mà attacker có thể lợi dụng.
Multi-stage Build — Giảm image từ 900MB xuống 220MB
Multi-stage build dùng nhiều FROM trong một Dockerfile: stage đầu build code, stage sau chỉ copy kết quả build.
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy csproj trước để tận dụng layer cache
COPY ["src/MyApp.Api/MyApp.Api.csproj", "src/MyApp.Api/"]
COPY ["src/MyApp.Domain/MyApp.Domain.csproj", "src/MyApp.Domain/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
RUN dotnet restore "src/MyApp.Api/MyApp.Api.csproj"
# Copy toàn bộ source và build
COPY . .
RUN dotnet publish "src/MyApp.Api/MyApp.Api.csproj" \
-c Release \
-o /app/publish \
--no-restore
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Kết quả: image chỉ chứa runtime + published DLLs — khoảng 220MB thay vì 900MB. SDK, source code, NuGet cache — tất cả bỏ lại ở build stage.
Tại sao copy csproj trước?
Đây là trick quan trọng nhất cho build speed. Docker cache mỗi layer — nếu layer không đổi, Docker dùng cache thay vì chạy lại.
# Layer 1: Copy csproj (ít thay đổi)
COPY ["src/MyApp.Api/MyApp.Api.csproj", "src/MyApp.Api/"]
# Layer 2: Restore packages (chỉ chạy lại khi csproj đổi)
RUN dotnet restore
# Layer 3: Copy source (thay đổi thường xuyên)
COPY . .
# Layer 4: Build (chạy lại khi source đổi)
RUN dotnet publish
Khi bạn sửa code mà không thêm/xóa NuGet package, Docker skip layer 1+2 (dùng cache) và chỉ chạy lại layer 3+4. Restore packages thường mất 30-60 giây — skip được bước này mỗi lần build tiết kiệm rất nhiều thời gian.
Nếu COPY tất cả source trước restore (như Dockerfile đầu tiên), thì bất kỳ thay đổi nào cũng invalidate cache từ đó trở đi — kể cả khi packages không đổi.
.dockerignore — Đừng copy rác vào image
Tương tự .gitignore, file .dockerignore ngăn Docker copy những thứ không cần thiết:
**/.git
**/.vs
**/.vscode
**/bin
**/obj
**/node_modules
**/.idea
**/Thumbs.db
**/*.user
**/*.suo
docker-compose*.yml
.env
README.md
.dockerignore
Dockerfile
Thiếu .dockerignore, Docker copy cả thư mục bin/, obj/, .git/ vào build context — vừa chậm vừa nặng, và bin/obj từ máy dev có thể conflict với build trong container. Mình đã từng mất cả buổi debug lỗi build trong Docker mà nguyên nhân là thư mục obj từ máy local bị copy vào.
Docker Compose — Local development stack
Development cần nhiều hơn chỉ app: database, cache, mail server, monitoring. Docker Compose quản lý tất cả trong một file:
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
target: build # Dùng build stage, có SDK để hot reload
ports:
- "5000:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Host=db;Database=myapp;Username=postgres;Password=postgres
volumes:
- ./src:/src # Mount source code để hot reload
depends_on:
db:
condition: service_healthy
networks:
- myapp-network
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres-data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
networks:
- myapp-network
volumes:
postgres-data:
networks:
myapp-network:
driver: bridge
Giải thích những phần quan trọng
depends_on với condition: service_healthy — API chỉ start sau khi PostgreSQL thực sự sẵn sàng nhận connection. Không có health check, depends_on chỉ đợi container start — nhưng PostgreSQL cần thêm vài giây sau khi container start mới accept connection. Thiếu điều này, app khởi động và crash ngay vì không connect được database.
volumes: postgres-data — Lưu data PostgreSQL ra named volume. Không có volume, mỗi lần docker compose down sẽ mất hết data.
./scripts/init.sql — PostgreSQL tự động chạy SQL files trong /docker-entrypoint-initdb.d/ khi khởi tạo database lần đầu. Mình dùng để tạo schema, seed data, hoặc tạo extension (CREATE EXTENSION IF NOT EXISTS "uuid-ossp").
Network — Tất cả services trong cùng network tự thấy nhau qua service name. App connect database bằng hostname db thay vì localhost — đây là chi tiết mà nhiều người miss khi chuyển từ develop trên máy local sang Docker.
Chạy
# Start tất cả services
docker compose up -d
# Xem logs
docker compose logs -f api
# Stop và xóa containers (giữ data)
docker compose down
# Stop và xóa cả data
docker compose down -v
Dockerfile cho Development vs Production
Development cần hot reload, debugging. Production cần nhẹ, nhanh, an toàn. Mình dùng hai target trong cùng một Dockerfile:
# Stage 1: Base
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/MyApp.Api/MyApp.Api.csproj", "src/MyApp.Api/"]
COPY ["src/MyApp.Domain/MyApp.Domain.csproj", "src/MyApp.Domain/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
RUN dotnet restore "src/MyApp.Api/MyApp.Api.csproj"
COPY . .
# Stage 2: Development — có SDK, có hot reload
FROM build AS development
WORKDIR /src/src/MyApp.Api
ENTRYPOINT ["dotnet", "watch", "run", "--urls", "http://0.0.0.0:8080"]
# Stage 3: Publish
FROM build AS publish
RUN dotnet publish "src/MyApp.Api/MyApp.Api.csproj" \
-c Release \
-o /app/publish \
--no-restore
# Stage 4: Production — chỉ runtime, non-root user
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS production
WORKDIR /app
# Tạo non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=publish /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Dùng target khác nhau cho từng môi trường:
# docker-compose.yml (dev)
services:
api:
build:
target: development
# docker-compose.prod.yml (production)
services:
api:
build:
target: production
Alpine image cho Production
aspnet:8.0-alpine nhỏ hơn aspnet:8.0 đáng kể: khoảng 110MB vs 220MB. Alpine dùng musl libc thay vì glibc — nhẹ hơn, ít attack surface hơn.
Nhưng có gotcha: một số NuGet packages dùng native libraries có thể không tương thích musl. Mình từng gặp lỗi với một thư viện xử lý ảnh trên Alpine. Nếu gặp lỗi runtime lạ trên Alpine mà không gặp trên Debian-based image, đây có thể là nguyên nhân.
Quy tắc mình dùng: bắt đầu với Alpine, nếu có lỗi thì chuyển về Debian. Phần lớn ứng dụng ASP.NET Core chạy trên Alpine không vấn đề gì.
Health Check — Kubernetes và load balancer cần biết app có khỏe không
Health check trong ASP.NET Core
// Program.cs
builder.Services.AddHealthChecks()
.AddNpgSql(
builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
tags: ["db", "ready"])
.AddCheck("self", () => HealthCheckResult.Healthy(),
tags: ["live"]);
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
Hai endpoint khác nhau: /health/live kiểm tra app process còn sống không (liveness), /health/ready kiểm tra app có thể xử lý request không — bao gồm database connection (readiness). Kubernetes dùng liveness probe để restart container bị stuck, readiness probe để ngừng gửi traffic đến container chưa sẵn sàng.
Health check trong Dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health/live || exit 1
Health check trong Docker Compose
services:
api:
# ...
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health/live"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
start_period quan trọng — cho app thời gian khởi động trước khi bắt đầu check. Không có start_period, container có thể bị đánh unhealthy ngay khi start vì app chưa kịp boot.
Environment Variables và Secrets
Configuration trong Docker
ASP.NET Core đọc config từ environment variables tự động. Trong Docker Compose:
services:
api:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Host=db;Database=myapp;...
- Anthropic__ApiKey=${ANTHROPIC_API_KEY}
- Logging__LogLevel__Default=Warning
Dấu __ (double underscore) thay thế : trong JSON path. ConnectionStrings__DefaultConnection tương ứng ConnectionStrings:DefaultConnection trong appsettings.json.
Secrets — Không commit vào git
Cách 1: dùng file .env:
# .env (KHÔNG commit file này)
ANTHROPIC_API_KEY=sk-ant-...
DB_PASSWORD=super-secret-password
# docker-compose.yml
services:
api:
env_file:
- .env
Cách 2: Docker secrets (cho Swarm/production):
services:
api:
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
Đọc secret trong code:
// Docker secrets mount vào /run/secrets/
var dbPassword = File.ReadAllText("/run/secrets/db_password").Trim();
EF Core Migrations trong Docker
Đây là câu hỏi mình hay gặp: chạy migration ở đâu — trong container hay ngoài?
Cách 1: Migration lúc app khởi động
// Program.cs
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
Đơn giản nhưng nguy hiểm cho production: nếu có 3 instance chạy đồng thời, cả 3 đều cố migrate cùng lúc. PostgreSQL advisory lock giải quyết phần nào, nhưng vẫn rủi ro. Mình chỉ dùng cách này cho development.
Cách 2: Migration riêng trong container init
services:
migrate:
build:
context: .
target: build
command: >
dotnet ef database update
--project src/MyApp.Infrastructure
--startup-project src/MyApp.Api
environment:
- ConnectionStrings__DefaultConnection=Host=db;...
depends_on:
db:
condition: service_healthy
api:
# ...
depends_on:
migrate:
condition: service_completed_successfully
Container migrate chạy migration, xong rồi exit. API chỉ start sau khi migration thành công. Đây là cách mình dùng — clean và an toàn.
Security Hardening
Chạy non-root user
Mặc định, container chạy với root — nếu có lỗ hổng trong app, attacker có full quyền trong container. Luôn dùng non-root user:
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS production
# aspnet:8.0 image đã có user 'app' sẵn (UID 1654)
# Nhưng Alpine image cần tạo thủ công
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=publish /app/publish .
# Chuyển sang non-root user
USER appuser
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Read-only filesystem
services:
api:
read_only: true
tmpfs:
- /tmp
Container chỉ có thể viết vào /tmp. Nếu attacker vào được container, họ không thể sửa đổi binary hay thêm file.
Giới hạn resources
services:
api:
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
reservations:
memory: 256M
cpus: '0.5'
Ngăn một container chiếm hết resources của host — quan trọng khi chạy nhiều services trên cùng server.
Logging — Xem log đúng cách trong Docker
ASP.NET Core mặc định log ra console — Docker capture console output tự động. Chỉ cần đảm bảo log format phù hợp:
// Program.cs
builder.Logging.AddJsonConsole(options =>
{
options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
});
JSON log dễ parse bằng log aggregator (ELK, Loki, Datadog). Plain text log khó filter khi hàng ngàn dòng log trộn lẫn từ nhiều container.
# Xem log realtime
docker compose logs -f api
# Xem 100 dòng cuối
docker compose logs --tail 100 api
# Filter bằng grep (tại sao cần JSON log)
docker compose logs api | grep "error"
Dockerfile production-ready hoàn chỉnh
Tổng hợp tất cả best practices:
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
# Copy csproj và restore (layer cache)
COPY ["src/MyApp.Api/MyApp.Api.csproj", "src/MyApp.Api/"]
COPY ["src/MyApp.Domain/MyApp.Domain.csproj", "src/MyApp.Domain/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
RUN dotnet restore "src/MyApp.Api/MyApp.Api.csproj"
# Copy source và publish
COPY . .
RUN dotnet publish "src/MyApp.Api/MyApp.Api.csproj" \
-c Release \
-o /app/publish \
--no-restore \
/p:UseAppHost=false
# Stage 2: Production
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS production
# Security: non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Timezone (nếu cần)
RUN apk add --no-cache tzdata
ENV TZ=Asia/Ho_Chi_Minh
WORKDIR /app
COPY --from=build --chown=appuser:appgroup /app/publish .
USER appuser
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD wget -qO- http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Những chi tiết đáng chú ý:
/p:UseAppHost=false bỏ qua việc tạo native executable — trong container chỉ cần dotnet MyApp.dll, không cần self-contained binary.
DOTNET_EnableDiagnostics=0 tắt diagnostic port — giảm attack surface trên production.
--chown=appuser:appgroup đảm bảo published files thuộc sở hữu non-root user.
Timezone set Asia/Ho_Chi_Minh vì nhiều dự án cần timestamp đúng múi giờ — Alpine mặc định không có timezone data.
Docker Compose production-ready
# docker-compose.prod.yml
services:
api:
build:
context: .
target: production
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Host=db;Database=myapp_prod;Username=${DB_USER};Password=${DB_PASSWORD}
env_file:
- .env.production
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
depends_on:
db:
condition: service_healthy
read_only: true
tmpfs:
- /tmp
networks:
- internal
db:
image: postgres:16-alpine
volumes:
- postgres-prod-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp_prod
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1G
restart: unless-stopped
networks:
- internal
volumes:
postgres-prod-data:
networks:
internal:
driver: bridge
Không expose port PostgreSQL ra ngoài (không có ports). Database chỉ accessible trong internal network — API connect qua hostname db, bên ngoài không ai connect trực tiếp được.
restart: unless-stopped tự restart container nếu crash, nhưng không restart nếu bạn chủ động stop.
Những sai lầm phổ biến
Quên .dockerignore
Mỗi lần build, Docker gửi toàn bộ build context lên Docker daemon. Không có .dockerignore, thư mục node_modules, .git, bin/obj đều bị gửi — chậm build đáng kể.
Dùng latest tag
# ĐỪNG
FROM mcr.microsoft.com/dotnet/aspnet:latest
# NÊN
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
latest thay đổi không báo trước — build hôm nay dùng .NET 8, tháng sau có thể .NET 9. Pin version cụ thể để build reproducible.
Connection string dùng localhost
# SAI — localhost trong container là chính container đó
ConnectionStrings__DefaultConnection=Host=localhost;...
# ĐÚNG — dùng service name
ConnectionStrings__DefaultConnection=Host=db;...
Trong Docker network, mỗi container có localhost riêng. Muốn connect đến container khác, dùng service name được Docker DNS resolve.
Không có health check cho depends_on
# SAI — chỉ đợi container start, không đợi service ready
depends_on:
- db
# ĐÚNG — đợi PostgreSQL thực sự accept connections
depends_on:
db:
condition: service_healthy
Kết luận
Docker hóa ứng dụng .NET không chỉ là viết Dockerfile. Đó là multi-stage build để giảm image size, layer caching để build nhanh, Docker Compose để quản lý cả stack, health check để orchestrator biết app status, security hardening để production an toàn, và logging setup để debug được khi có vấn đề.
Checklist trước khi deploy production: image dùng Alpine + non-root user, multi-stage build với dotnet publish, health check endpoint hoạt động, secrets không nằm trong image hay git, database connection string dùng service name, resource limits được set, restart policy được config.
Một khi đã quen với Docker workflow, bạn sẽ không muốn quay lại "works on my machine" nữa. Mọi developer trong team chạy cùng môi trường, deploy lên staging/production giống hệt local — và đó mới là giá trị thực sự của Docker.
Leave a comment
Your email address will not be published. Required fields are marked *