.NET Docker DevOps Containers CI/CD Segurança

Docker multi-stage build no .NET: imagens mínimas, seguras e otimizadas para produção

Guia avançado de Dockerfile multi-stage para .NET: cache de camadas, imagens non-root, distroless, análise de vulnerabilidades com Trivy e integração com.

N
Neryx Digital Architects
24 de outubro de 2025
12 min de leitura
210 profissionais leram
Categoria: .NET Público: Times de plataforma e operação Etapa: Decisão

Um Dockerfile ingênuo para .NET produz imagens de 500MB+ com o SDK completo, ferramentas de build, arquivos temporários e vulnerabilidades desnecessárias. Com multi-stage build e algumas técnicas de otimização, você chega a imagens abaixo de 80MB, sem SDK em produção e rodando como non-root. Este artigo mostra como.

O Dockerfile básico — e por que não usar em produção

# ❌ NÃO faça isso em produção
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /publish
EXPOSE 8080
ENTRYPOINT ["dotnet", "/publish/MyApp.dll"]

Problemas desta abordagem: imagem de ~900MB com todo o SDK, ferramentas de build, código-fonte, arquivos de projeto e todos os pacotes NuGet em cache. Todo COPY . . invalida o cache mesmo que apenas um arquivo de código tenha mudado.

Multi-stage build completo e otimizado

# ============================================================
# Estágio 1: Restore das dependências (camada de cache)
# ============================================================
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS restore

WORKDIR /src

# Copia APENAS os arquivos de projeto e solution primeiro
# Isso faz o restore ser cacheado enquanto o código não muda os .csproj
COPY ["src/Api/Api.csproj", "src/Api/"]
COPY ["src/Application/Application.csproj", "src/Application/"]
COPY ["src/Domain/Domain.csproj", "src/Domain/"]
COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"]
COPY ["MyApp.sln", "./"]

# Restore é cacheado enquanto os .csproj não mudam
RUN dotnet restore --runtime linux-musl-x64

# ============================================================
# Estágio 2: Build e publish
# ============================================================
FROM restore AS build

# Agora copia o código-fonte (invalida cache apenas quando código muda)
COPY src/ src/

RUN dotnet publish src/Api/Api.csproj \
    --configuration Release \
    --runtime linux-musl-x64 \
    --self-contained false \
    --no-restore \
    --output /publish \
    /p:PublishSingleFile=false \
    /p:DebugType=None \
    /p:DebugSymbols=false \
    /p:UseAppHost=false

# ============================================================
# Estágio 3: Imagem de produção (sem SDK, sem código-fonte)
# ============================================================
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS production

# Instalar dependências de ICU para globalização (necessário para Alpine)
RUN apk add --no-cache icu-libs

ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
    ASPNETCORE_URLS=http://+:8080 \
    DOTNET_RUNNING_IN_CONTAINER=true \
    # Desabilita telemetria (LGPD / privacidade)
    DOTNET_CLI_TELEMETRY_OPTOUT=1 \
    ASPNETCORE_ENVIRONMENT=Production

# ============================================================
# Segurança: Non-root user
# ============================================================
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

WORKDIR /app

# Copia apenas o output compilado do estágio de build
COPY --from=build --chown=appuser:appgroup /publish .

# Cria diretório para arquivos temporários da aplicação
RUN mkdir -p /app/tmp && chown appuser:appgroup /app/tmp

# Troca para non-root ANTES do EXPOSE/ENTRYPOINT
USER appuser

EXPOSE 8080

# Health check nativo do Docker
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

ENTRYPOINT ["dotnet", "Api.dll"]

Variante com imagem Distroless (ainda menor e mais segura)

# Distroless: sem shell, sem package manager, sem utilitários Linux
# Superfície de ataque mínima
FROM gcr.io/distroless/dotnet8-aspnet:nonroot AS production-distroless

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

ENV ASPNETCORE_URLS=http://+:8080

# :nonroot tag já usa user 65532
USER 65532:65532

EXPOSE 8080

ENTRYPOINT ["/app/Api"]  # Requer PublishSingleFile=true ou runtime já embutido

Distroless não tem shell — isso significa que docker exec -it container bash não funciona. Para debug emergencial, use imagens :debug que incluem BusyBox.

Cache de camadas: a técnica que mais acelera o build

O Docker reusa camadas cacheadas da última build. A ordem das instruções no Dockerfile determina o aproveitamento do cache. A regra é: mude o que muda menos primeiro, o que muda mais por último.

# ✅ Cache otimizado — na prática, 80% das builds aproveitam cache de restore
COPY ["src/Api/Api.csproj", "src/Api/"]          # Muda raramente
COPY ["src/Application/Application.csproj", ...]  # Muda raramente
RUN dotnet restore                                  # Cacheado até mudar um .csproj

COPY src/ src/                                     # Muda a cada commit
RUN dotnet publish ...                              # Invalidado só quando código muda

# ❌ Cache quebrado — qualquer mudança de código refaz o restore inteiro
COPY . .
RUN dotnet restore
RUN dotnet publish

BuildKit e cache de NuGet entre builds

# syntax=docker/dockerfile:1
# Usar BuildKit com mount de cache (evita re-baixar pacotes NuGet)

FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS restore

WORKDIR /src
COPY ["src/Api/Api.csproj", "src/Api/"]
# ... outros .csproj

# Cache do NuGet montado como volume — persiste entre builds na mesma máquina
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore --runtime linux-musl-x64

COPY src/ src/

RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet publish src/Api/Api.csproj \
        --configuration Release \
        --no-restore \
        --output /publish
# Habilitar BuildKit (já padrão no Docker 23+)
export DOCKER_BUILDKIT=1

# Build com cache ativo
docker build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-from myapp:latest \
  -t myapp:$(git rev-parse --short HEAD) \
  -t myapp:latest \
  .

Secrets no build: nunca em variáveis de ambiente

# ❌ NUNCA faça isso — a chave fica visível em `docker history`
ARG NUGET_AUTH_TOKEN
ENV NUGET_AUTH_TOKEN=$NUGET_AUTH_TOKEN
RUN dotnet restore

# ✅ Use BuildKit secrets — não ficam na imagem nem no histórico
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=nuget_token \
    NUGET_AUTH_TOKEN=$(cat /run/secrets/nuget_token) \
    dotnet restore

# Passar o secret no build (nunca aparece na imagem final)
docker build \
  --secret id=nuget_token,src=~/.config/nuget/token \
  .

Análise de vulnerabilidades com Trivy

# Instalar Trivy (scanner de CVE open-source da Aqua Security)
brew install aquasecurity/trivy/trivy  # macOS
# ou
apt-get install trivy  # Debian/Ubuntu

# Escanear imagem antes de fazer push
trivy image myapp:latest

# Modo strict — falha o pipeline se houver CRITICAL ou HIGH
trivy image \
  --exit-code 1 \
  --severity CRITICAL,HIGH \
  --ignore-unfixed \
  myapp:latest

# Gerar relatório SARIF para GitHub Security
trivy image \
  --format sarif \
  --output trivy-results.sarif \
  myapp:latest

docker-compose para desenvolvimento local

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: build  # Para dev, use o estágio de build (tem hot-reload)
    ports:
      - "8080:8080"
    volumes:
      # Hot-reload em desenvolvimento
      - ./src:/src:ro
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_USE_POLLING_FILE_WATCHER=1
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  postgres_data:

GitHub Actions completo com cache e scan

# .github/workflows/docker.yml
name: Build and Push Docker Image

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write  # Para upload do SARIF

    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Login to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern={{version}}

      - name: Build image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # Exportar localmente para scan (não faz push ainda)
          load: ${{ github.event_name == 'pull_request' }}

      - name: Scan com Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1
          ignore-unfixed: true

      - name: Upload resultado do Trivy para GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()  # Upload mesmo se o scan falhar
        with:
          sarif_file: trivy-results.sarif

Comparação de tamanhos de imagem

Abordagem Tamanho CVEs típicas Non-root
SDK completo (ubuntu) ~900MB Muitas Não
aspnet:8.0 (ubuntu) ~215MB Moderadas Não
aspnet:8.0-alpine ~105MB Poucas ✅ (configurado)
distroless/dotnet8-aspnet ~80MB Mínimas ✅ (built-in)

Checklist de produção

  • Multi-stage obrigatório: SDK nunca vai para produção
  • Non-root user: UID/GID > 1000, nunca root
  • Cache de camadas: .csproj antes do código-fonte
  • Alpine ou Distroless: superfície de ataque mínima
  • Trivy no pipeline: bloqueia push se CRITICAL/HIGH não corrigido
  • BuildKit secrets: nenhum token/senha em ARG ou ENV
  • HEALTHCHECK: Docker e Kubernetes detectam containers travados
  • Imagem imutável: nunca instale pacotes no container em runtime

Quer um review do seu Dockerfile e pipeline de CI/CD? Na Neryx fazemos auditoria de segurança e performance de containers como parte da nossa consultoria DevOps. Entre em contato.

Quer sair do modo reativo e priorizar o que mais importa?

O diagnóstico de maturidade ajuda a transformar sintomas operacionais em um plano mais claro de evolução.

Avaliar maturidade

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.