Quinto post da série. No anterior, mergulhamos dentro da GPU. Agora vamos automatizar tudo ao redor dela. Porque entender GPUs é metade da batalha; provisionar elas de forma consistente e em escala é onde engenharia de infraestrutura realmente encontra AI.

O typo de $4.000

Comecei a semana com uma vitória. Provisionei um cluster GPU manualmente em East US 2 pra um experimento de ML: AKS com node pool Standard_NC6s_v3, accelerated networking, drivers NVIDIA, taints corretos. Levou quase um dia, mas funcionou.

Três semanas depois, o mesmo time precisa do setup idêntico em West US 3. Sem problema, pensei. Abri o portal, referenciando um Slack thread pro SKU, uma wiki pra config de rede, e minha memória pro resto.

Alguém digitou errado o SKU. Ao invés de Standard_NC6s_v3 (VM GPU a ~$3.80/hr), o node pool ficou rodando Standard_D16s_v5, uma VM CPU sem GPU nenhuma. O training job lançou, não achou CUDA device, fez fallback pra CPU. Ninguém percebeu por três dias porque o job não falhou, só rodou devagar. Quando alguém checou, o cluster tinha queimado $4.000 em compute que nem conseguia fazer o que precisava.

Foi a última vez que provisionei infra de AI manualmente.

Por que IaC é não-negociável pra AI

Infra de aplicações web tradicionais é tolerante. Um App Service mal configurado custa uns $50/mês extra. Um cluster GPU mal configurado custa milhares por dia.

RazãoPor que importa pra AI
ComplexidadeQuotas GPU por região, driver versions, taints, InfiniBand, NVMe ephemeral, private endpoints. Nenhum humano segura tudo na cabeça
CustoND A100 4-nodes = ~$350/dia. Cada minuto de misconfiguration é dinheiro queimando
ReprodutibilidadeML experiments precisam ser repetíveis. Mesmo SKU, driver, topologia de rede
ComplianceQuem mudou o que, quando, por quê. Git dá audit trail de graça

Tradução infra ↔ AI: Quando o ML engineer diz “preciso do mesmo ambiente da semana passada”, ele quer reprodutibilidade de infra. Quando compliance pergunta “o que mudou”, quer audit trail. IaC responde ambos com o mesmo artefato: um arquivo de configuração versionado.

Landscape de IaC pra AI

CritérioTerraformBicepAzure CLIPulumi
ParadigmaDeclarativoDeclarativoImperativoDeclarativo (code)
Multi-cloud❌ Azure only❌ Azure only
State managementRemote state fileNenhum (ARM gerencia)NenhumRemote state file
LinguagemHCLBicep DSLBash/PowerShellPython, TS, Go, C#
Learning curveModeradaBaixa (Azure users)BaixaModerada-Alta
Melhor praPlataformas multi-cloudTimes Azure-nativeAutomação rápida, glueTimes developer-first

Quando usar cada: Terraform quando precisa multi-cloud ou platform engineering em escala. Bicep quando é 100% Azure e quer o caminho mais simples. Azure CLI pra glue, prototyping e operações ad-hoc. Muitos times usam mais de um: Terraform/Bicep pra provisioning, Azure CLI pra operações, GitHub Actions pra orquestrar tudo.

Terraform pra infra de AI

Variables com validação (previne typos)

variable "gpu_vm_size" {
  description = "VM SKU for GPU node pool"
  type        = string
  default     = "Standard_NC6s_v3"

  validation {
    condition     = can(regex("^Standard_N", var.gpu_vm_size))
    error_message = "GPU VM size must be an N-series SKU (e.g., Standard_NC6s_v3, Standard_NC24ads_A100_v4)."
  }
}

variable "gpu_max_nodes" {
  description = "Maximum number of GPU nodes for autoscaling"
  type        = number
  default     = 5
}

Aquela validation não é decorativa. Ela previne exatamente o erro da história de abertura. Pego no terraform plan, não na fatura.

AKS com GPU node pool

resource "azurerm_kubernetes_cluster" "ai" {
  name                = "aks-ai-${var.environment}"
  location            = azurerm_resource_group.ai.location
  resource_group_name = azurerm_resource_group.ai.name
  dns_prefix          = "aks-ai-${var.environment}"
  kubernetes_version  = "1.30"

  default_node_pool {
    name            = "system"
    vm_size         = "Standard_D4s_v5"
    node_count      = 2
    os_disk_size_gb = 128

    upgrade_settings {
      max_surge = "33%"
    }
  }

  identity {
    type = "SystemAssigned"
  }

  network_profile {
    network_plugin = "azure"
    network_policy = "calico"
  }
}

resource "azurerm_kubernetes_cluster_node_pool" "gpu" {
  name                  = "gpu"
  kubernetes_cluster_id = azurerm_kubernetes_cluster.ai.id
  vm_size               = var.gpu_vm_size
  mode                  = "User"
  os_disk_size_gb       = 256
  auto_scaling_enabled  = true
  min_count             = 0
  max_count             = var.gpu_max_nodes

  node_taints = [
    "sku=gpu:NoSchedule"
  ]

  node_labels = {
    "hardware" = "gpu"
    "gpu-type" = "nvidia"
    "workload" = "ai"
  }
}

O taint sku=gpu:NoSchedule é essencial. Sem ele, Kubernetes coloca monitoring DaemonSets e log collectors nos seus nós GPU de $3.80/hr.

Remote state (obrigatório)

Nunca guarde state de Terraform localmente pra infra GPU. State corrompido ou perdido = Terraform não consegue rastrear ou destruir recursos que custam dinheiro real a cada hora.

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "stterraformstate"
    container_name       = "tfstate"
    key                  = "ai-platform.terraform.tfstate"
  }
}

Setup do storage (uma vez):

az group create --name rg-terraform-state --location eastus2

az storage account create \
  --name stterraformstate \
  --resource-group rg-terraform-state \
  --sku Standard_LRS \
  --encryption-services blob

az storage container create \
  --name tfstate \
  --account-name stterraformstate

Bicep pra infra de AI

Vantagem do Bicep: sem state file, sem backend, sem locking. ARM gerencia tudo. Pra times 100% Azure, remove uma categoria inteira de complexidade operacional.

GPU VM com NVIDIA Driver Extension

@allowed([
  'Standard_NC6s_v3'
  'Standard_NC12s_v3'
  'Standard_NC24ads_A100_v4'
  'Standard_NC48ads_A100_v4'
  'Standard_NC96ads_A100_v4'
])
@description('GPU VM size — must be an N-series SKU')
param vmSize string = 'Standard_NC6s_v3'

param vmName string = 'vm-gpu-ai'
param location string = resourceGroup().location
param adminUsername string = 'azureuser'

@secure()
param sshPublicKey string

resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = {
  name: vmName
  location: location
  properties: {
    hardwareProfile: { vmSize: vmSize }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      linuxConfiguration: {
        disablePasswordAuthentication: true
        ssh: {
          publicKeys: [{
            path: '/home/${adminUsername}/.ssh/authorized_keys'
            keyData: sshPublicKey
          }]
        }
      }
    }
    storageProfile: {
      imageReference: {
        publisher: 'Canonical'
        offer: '0001-com-ubuntu-server-jammy'
        sku: '22_04-lts-gen2'
        version: 'latest'
      }
      osDisk: {
        createOption: 'FromImage'
        managedDisk: { storageAccountType: 'Premium_LRS' }
        diskSizeGB: 256
      }
    }
    networkProfile: {
      networkInterfaces: [{ id: nic.id }]
    }
  }
}

resource nvidiaExtension 'Microsoft.Compute/virtualMachines/extensions@2024-07-01' = {
  parent: vm
  name: 'NvidiaGpuDriverLinux'
  location: location
  properties: {
    publisher: 'Microsoft.HpcCompute'
    type: 'NvidiaGpuDriverLinux'
    typeHandlerVersion: '1.9'
    autoUpgradeMinorVersion: true
  }
}

O decorator @allowed serve o mesmo propósito da validation do Terraform: previne SKUs não-GPU no deploy.

Estrutura modular pra produção

infra/
├── main.bicep              # Orquestrador
├── modules/
│   ├── network.bicep       # VNet, subnets, NSGs, private endpoints
│   ├── aks.bicep           # AKS cluster com GPU node pool
│   ├── storage.bicep       # Storage account pra modelos e dados
│   ├── monitoring.bicep    # Log Analytics, alerts, dashboards
│   └── keyvault.bicep      # Key Vault pra secrets
└── parameters/
    ├── dev.bicepparam
    ├── staging.bicepparam
    └── prod.bicepparam

Um time novo sobe um ambiente completo e compliant criando um único arquivo de parâmetros.

CI/CD: plan → approve → apply

Mudanças de infra de AI nunca devem ser aplicadas de um laptop. A pipeline dá gates de review, validação automatizada e audit trail.

GitHub Actions com OIDC

name: "AI Infrastructure — Plan & Apply"

on:
  push:
    branches: [main]
    paths: ["infra/**"]
  pull_request:
    branches: [main]
    paths: ["infra/**"]

permissions:
  id-token: write
  contents: read
  pull-requests: write

env:
  ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
  ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

jobs:
  plan:
    name: "Terraform Plan"
    runs-on: ubuntu-latest
    environment: ai-infrastructure
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.0"
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: terraform init
        working-directory: infra
      - run: terraform plan -out=tfplan -input=false
        working-directory: infra

  apply:
    name: "Terraform Apply"
    runs-on: ubuntu-latest
    needs: plan
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: ai-infrastructure-prod
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.0"
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: terraform init
        working-directory: infra
      - run: terraform apply -auto-approve tfplan
        working-directory: infra

O fluxo: PR = plan only (mostra o que vai mudar). Merge em main = apply com environment protection rule (reviewer precisa aprovar). Artefato do plan é o que executa, sem drift entre review e execução.

Sempre fixe versões de actions. @v4, @v3, @v2. Usar @latest em pipelines de produção significa que uma breaking change upstream pode derrubar seu deploy quando você mais precisa.

Governance: guardrails pra GPU

Azure Policy pode enforçar regras no nível de subscription. Pra infra de AI, a policy mais impactante: bloquear provisionamento de VMs GPU sem tag cost-center:

{
  "mode": "All",
  "policyRule": {
    "if": {
      "allOf": [
        {
          "field": "type",
          "equals": "Microsoft.Compute/virtualMachines"
        },
        {
          "field": "Microsoft.Compute/virtualMachines/sku.name",
          "in": [
            "Standard_NC24ads_A100_v4",
            "Standard_NC48ads_A100_v4",
            "Standard_ND96asr_v4"
          ]
        },
        {
          "field": "tags['cost-center']",
          "exists": "false"
        }
      ]
    },
    "then": {
      "effect": "deny"
    }
  }
}

Sem tag de cost center = sem GPU. Simples e efetivo.

No próximo post

Agora que a infra está automatizada e governada, vamos falar do ciclo de vida do modelo: MLOps. Como um modelo vai de “funciona no meu notebook” pra “roda em produção com SLA”. O que muda pra quem é de infra, e o que o time de ML espera de você nesse processo.