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.
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.