← todos os artigos

Azure

Como eu containerizei minha API e fiz deploy no Azure

Um Dockerfile multi-stage, Docker Compose com um SQL Server com health check, e a Sports Betting API rodando no Azure Container Apps com Azure SQL por trás.

Como eu containerizei minha API e fiz deploy no Azure

A Sports Betting API é o primeiro projeto que levei do zero, rodando na minha máquina, até estar de fato acessível na internet. Docker para o empacotamento, Azure Container Apps para o hosting, Azure SQL Database por trás.

O Dockerfile tem dois stages, não um

A versão ingênua de um Dockerfile .NET usa a imagem do SDK e para por aí. Isso funciona, e gera uma imagem com o SDK do .NET inteiro dentro, o que é bem maior do que o necessário só para rodar a coisa.

Então o build acontece num stage e o resultado é copiado para um menor:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# ... restore e publish ...
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "SportsBetting.API.dll"]

A imagem final é baseada no runtime do ASP.NET e contém só o output publicado. O stage do SDK faz seu trabalho e é descartado.

Copiando os arquivos do projeto antes do source

Esse é o detalhe que não é óbvio e que eu errei da primeira vez. Todo .csproj é copiado primeiro, depois dotnet restore roda, e só então o resto do source é copiado:

COPY SportsBetting.sln ./
COPY src/Backend/SportsBetting.API/SportsBetting.API.csproj src/Backend/SportsBetting.API/
COPY src/Backend/SportsBetting.Application/SportsBetting.Application.csproj src/Backend/SportsBetting.Application/
# ... o resto dos projetos ...

RUN dotnet restore
COPY . .

O Docker faz cache de cada layer e reaproveita se nada do que aquele layer depende mudou. Como o restore só depende dos arquivos de projeto, editar um arquivo C# não invalida ele, e os pacotes não são baixados de novo. Copiar tudo antes e só depois rodar o restore faz com que toda mudança no source baixe todos os pacotes de novo.

É um pequeno reordenamento que transforma a maioria dos rebuilds de lentos em quase instantâneos.

Compose, para o database subir junto com a aplicação

Localmente a API precisa do SQL Server, e iniciar os dois separadamente na ordem certa cansa rápido. O Compose cuida dos dois, e a parte útil é fazer a API esperar até o database estar genuinamente pronto em vez de só iniciado:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    healthcheck:
      test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "..." -Q "SELECT 1" -C
      interval: 10s
      retries: 5
      start_period: 30s

  api:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      sqlserver:
        condition: service_healthy

O depends_on sozinho só espera o container iniciar, e um container de SQL Server aceita conexões bem depois disso. Combinar isso com um health check que de fato roda uma query é o que faz a diferença entre a API iniciar direitinho e ela travar numa conexão feita cedo demais.

Azure Container Apps

Para o deploy eu fui de Azure Container Apps em vez de uma VM ou App Service. Ele roda uma imagem de container diretamente, que é exatamente o que eu já tinha, e escala para zero quando nada está batendo nele.

Escalar para zero tem uma consequência visível: o primeiro request depois de um período ocioso demora alguns segundos enquanto um container sobe. Para um projeto de estudo essa é uma troca boa, já que não custa nada enquanto fica ocioso. Para algo com usuários reais eu manteria pelo menos uma réplica quente em vez disso.

O database é Azure SQL Database, a opção managed, então não estou mantendo uma instância de SQL Server eu mesmo. Configuração e a connection string vêm de environment variables no Container App, o mesmo mecanismo que o Compose usa localmente, o que significa que a imagem em si é idêntica nos dois lugares.

O que realmente fez isso fazer sentido

A coisa que só entendi depois de fazer: o container é a unidade deployável, e o ambiente é só configuração em volta dele. A mesma imagem que eu construo e rodo localmente é a que está rodando no Azure, com environment variables diferentes apontando para infraestrutura diferente. Nenhum build separado para produção, nenhum código que se comporta diferente dependendo de onde está rodando.

Essa é a parte que fez a ideia inteira do Docker fazer sentido para mim. Não "empacota a aplicação", mas que a coisa que eu testei localmente é literalmente a coisa rodando no cloud.

O que eu faria a seguir

Um GitHub Actions workflow que builda a imagem e faz o deploy dela num push para main, para que fazer deploy não seja algo que eu faço na mão. Essa é a lacuna óbvia entre isso e como funcionaria num time de verdade.