EngineeringSenior DeveloperTechnical Sessions

Session 23: Azure DevOps, CI/CD & .NET Aspire

Mục tiêu: Master CI/CD pipeline cho .NET, Docker workflows, và .NET Aspire cho cloud-native development.


1. CI/CD Pipeline Patterns

GitHub Actions cho .NET

yaml
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  DOTNET_VERSION: '9.0.x'
  REGISTRY: myregistry.azurecr.io
  IMAGE_NAME: order-service

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

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release
          --logger "trx;LogFileName=test-results.trx"
          --collect:"XPlat Code Coverage"
          -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: '**/*.trx'

      - name: Code Coverage Report
        uses: codecov/codecov-action@v3
        with:
          files: '**/coverage.opencover.xml'

  docker-build:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to ACR
        uses: azure/docker-login@v1
        with:
          login-server: ${{ env.REGISTRY }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push
        run: |
          docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
          docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

  deploy-staging:
    needs: docker-build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          containerAppName: order-service
          resourceGroup: rg-staging
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval
    steps:
      - uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to Production
        uses: azure/container-apps-deploy-action@v1
        with:
          containerAppName: order-service
          resourceGroup: rg-production
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

Azure DevOps Pipeline

yaml
# azure-pipelines.yml
trigger:
  branches:
    include: [main]

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  dotnetVersion: '9.0.x'

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        steps:
          - task: UseDotNet@2
            inputs:
              version: $(dotnetVersion)

          - script: dotnet build --configuration $(buildConfiguration)
          - script: dotnet test --configuration $(buildConfiguration) --logger trx

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: 'VSTest'
              testResultsFiles: '**/*.trx'

  - stage: DeployStaging
    dependsOn: Build
    jobs:
      - deployment: DeployToStaging
        environment: staging
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'Azure-Connection'
                    appName: 'myapp-staging'
                    package: '$(Pipeline.Workspace)/**/*.zip'

2. Docker Best Practices cho .NET

dockerfile
# Production Dockerfile - multi-stage, optimized
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Copy csproj files first → layer caching
COPY src/Api/Api.csproj src/Api/
COPY src/Domain/Domain.csproj src/Domain/
COPY src/Application/Application.csproj src/Application/
COPY src/Infrastructure/Infrastructure.csproj src/Infrastructure/
RUN dotnet restore src/Api/Api.csproj

# Copy source and build
COPY src/ src/
RUN dotnet publish src/Api/Api.csproj \
    -c Release \
    -o /app/publish \
    --no-restore \
    /p:UseAppHost=false

# Runtime image - minimal
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS runtime
WORKDIR /app

# Security: non-root user
RUN adduser --disabled-password --no-create-home appuser
USER appuser

COPY --from=build /app/publish .

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

ENTRYPOINT ["dotnet", "Api.dll"]
yaml
# docker-compose.yml cho local development
services:
  api:
    build: .
    ports: ["5001:8080"]
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__Default=Server=db;Database=MyApp;User=sa;Password=P@ss1234;TrustServerCertificate=true
    depends_on:
      db: { condition: service_healthy }
      rabbitmq: { condition: service_healthy }

  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=P@ss1234
    ports: ["1433:1433"]
    healthcheck:
      test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P P@ss1234 -Q "SELECT 1"
      interval: 10s
      timeout: 3s

  rabbitmq:
    image: rabbitmq:3-management
    ports: ["5672:5672", "15672:15672"]
    healthcheck:
      test: rabbitmq-diagnostics -q ping
      interval: 10s

3. .NET Aspire - Cloud-Native Development

.NET Aspire = development orchestrator + cloud-native building blocks

┌──────────────────────────────────────────────────┐
│                 Aspire Dashboard                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│  │ Traces   │ │  Logs    │ │    Metrics       │ │
│  │ (OTel)   │ │ (Live)   │ │    (Prometheus)  │ │
│  └──────────┘ └──────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────┘

AppHost (orchestrator):
  → Start all services
  → Wire up service discovery
  → Configure resources (DB, Redis, RabbitMQ)
  → Inject connection strings automatically
csharp
// MyApp.AppHost/Program.cs - orchestrator
var builder = DistributedApplication.CreateBuilder(args);

// Infrastructure resources
var postgres = builder.AddPostgres("postgres")
    .WithPgAdmin()
    .AddDatabase("orderdb");

var redis = builder.AddRedis("redis")
    .WithRedisInsight();

var rabbitmq = builder.AddRabbitMQ("messaging")
    .WithManagementPlugin();

// Application services
var orderApi = builder.AddProject<Projects.OrderService>("order-service")
    .WithReference(postgres)
    .WithReference(redis)
    .WithReference(rabbitmq)
    .WithExternalHttpEndpoints();

var catalogApi = builder.AddProject<Projects.CatalogService>("catalog-service")
    .WithReference(postgres)
    .WithReference(redis);

// Frontend
builder.AddNpmApp("web", "../Web")
    .WithReference(orderApi)
    .WithReference(catalogApi)
    .WithExternalHttpEndpoints()
    .WithHttpEndpoint(port: 3000, env: "PORT");

builder.Build().Run();
csharp
// Service project (e.g., OrderService/Program.cs)
var builder = WebApplication.CreateBuilder(args);

// Aspire auto-injects connection strings!
builder.AddServiceDefaults();      // OpenTelemetry, health checks, service discovery
builder.AddNpgsqlDbContext<OrderDbContext>("orderdb");  // Auto connection string
builder.AddRedisDistributedCache("redis");
builder.AddRabbitMQClient("messaging");

var app = builder.Build();
app.MapDefaultEndpoints(); // /health, /alive
app.Run();

Aspire Benefits

✅ 1-click start: tất cả services + infrastructure
✅ Auto service discovery (no manual URLs)
✅ Auto connection strings (no appsettings.json cho dev)
✅ Built-in dashboard: traces, logs, metrics
✅ Container management: auto-pull, auto-start
✅ Deployment manifest generation (Azure, AWS, Kubernetes)

4. Infrastructure as Code (Bicep)

bicep
// main.bicep
param location string = resourceGroup().location
param appName string

// App Service Plan
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: '${appName}-plan'
  location: location
  sku: { name: 'P1v3', tier: 'PremiumV3' }
  properties: { reserved: true } // Linux
}

// App Service
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
  name: '${appName}-api'
  location: location
  properties: {
    serverFarmId: appServicePlan.id
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|9.0'
      alwaysOn: true
      healthCheckPath: '/health'
    }
  }
  identity: { type: 'SystemAssigned' }
}

// SQL Server
resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = {
  name: '${appName}-sql'
  location: location
  properties: {
    administratorLogin: 'sqladmin'
    administratorLoginPassword: sqlPassword
  }
}

// Key Vault access
resource keyVaultAccess 'Microsoft.KeyVault/vaults/accessPolicies@2023-07-01' = {
  parent: keyVault
  name: 'add'
  properties: {
    accessPolicies: [{
      tenantId: webApp.identity.tenantId
      objectId: webApp.identity.principalId
      permissions: { secrets: ['get', 'list'] }
    }]
  }
}

Tổng Kết Session 23

ConceptKey Takeaway
CI/CDGitHub Actions hoặc Azure DevOps, multi-stage
DockerMulti-stage build, Alpine image, non-root user
.NET AspireOrchestrator for dev, auto-service discovery
BicepIaC cho Azure, type-safe, modular

Bài tập: Setup .NET Aspire project với 2 services + PostgreSQL + Redis. Create GitHub Actions CI/CD pipeline. Deploy to Azure Container Apps.