Search

Docker Hóa Ứng Dụng .NET Core: Từ Development Đến Production

Docker Hóa Ứng Dụng .NET Core: Từ Development Đến Production

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.

Tags:
Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy