Django on a Budget: Como Escalar para 100k Requests por Minuto com Infraestrutura Cloud de Baixo Custo (Stack Otimizada por Apenas $100/mês)

Published on: 2026-01-25
Post image
pt django-on-a-budget django-low-cost-infrastructure django-cloud-optimization django-performance-optimization django-scaling-on-a-budget django-100k-rpm django-high-performance django-cloud-architecture django-production-stack django-cost-optimized

Construir uma aplicação em Django capaz de suportar alta carga com baixo custo exige uma combinação de arquitetura eficiente, escolhas realistas de infraestrutura e disciplina de otimização. Um alvo como 100.000 requisições por minuto (RPM) parece extremo, mas costuma ser viável quando a maior parte do tráfego é atendida por cache, quando o banco é usado com inteligência e quando o servidor web é configurado para lidar bem com concorrência.

Um “stack” econômico não significa improviso, e sim foco em eficiência antes de escalar. A ideia central é simples: reduzir trabalho repetido, minimizar chamadas ao banco, evitar bloqueios desnecessários e entregar conteúdo estático fora da aplicação. Com isso, uma infraestrutura em torno de US$ 100/mês pode sustentar picos altos com estabilidade, desde que haja monitoramento e limites bem definidos.

Objetivo de desempenho e o que significa 100k RPM

RPM (requisições por minuto) é uma medida de volume, e 100.000 RPM equivale a aproximadamente 1.666 requisições por segundo em média. Na prática, tráfego real vem em ondas, e o desenho do sistema precisa aguentar picos, não apenas médias. Outra distinção essencial está entre requisições “baratas” (respondidas por cache ou páginas simples) e requisições “caras” (consultas complexas, escrita no banco, geração pesada).

Em aplicações web, a meta costuma ser manter latências baixas em percentis: P50 (mediana), P95 e P99. Isso evita que poucos casos lentos estraguem a experiência geral e também impede que filas se formem no servidor. Um stack econômico busca previsibilidade: tempos de resposta estáveis, uso de memória controlado e conexões de banco sem explosões.

Princípio central: eficiência antes de escalar

Escalar “para fora” (adicionar servidores) é útil, mas caro e frequentemente mascara problemas de eficiência. A maior parte dos gargalos em Django vem de consultas excessivas, ausência de índices, serialização lenta e falta de cache em rotas quentes. Ao otimizar primeiro, a infraestrutura necessária diminui, e a aplicação fica mais simples de operar.

A eficiência aparece em decisões pequenas e repetidas: reduzir N+1 queries, evitar carregar colunas desnecessárias, paginar listas, limitar payloads, comprimir respostas e cachear o que não precisa ser real-time. Quando isso é aplicado de forma consistente, o ganho composto é grande. A partir desse ponto, escalar passa a ser uma escolha planejada, não uma emergência.

Servidor de aplicação: Gunicorn com workers assíncronos

O servidor de aplicação é onde o Django executa. Um padrão comum é usar Gunicorn, que cria processos de trabalho (workers) para atender requisições. Em cargas com muita concorrência e I/O (espera de rede, cache, chamadas externas), workers assíncronos costumam aumentar o throughput, pois evitam que um processo fique parado aguardando respostas.

Uma configuração típica usa número de workers em torno de 2x o número de vCPUs, ajustando depois conforme CPU e latência. Também entram limites para reciclagem de workers, evitando vazamentos de memória e degradação ao longo do tempo. O exemplo abaixo ilustra uma configuração com gevent (modelo cooperativo assíncrono) e um limite de conexões concorrentes por worker.

# gunicorn.conf.py
# Configuração do Gunicorn para um app Django com alta concorrência

bind = "127.0.0.1:8000"

# Em geral, 2x núcleos é um bom ponto de partida
workers = 8

# Worker assíncrono baseado em gevent (boa opção para I/O e muitas conexões)
worker_class = "gevent"
worker_connections = 1000

# Reciclagem de workers para reduzir impacto de vazamentos e fragmentação
max_requests = 10000
max_requests_jitter = 1000

# Timeouts e keep-alive para reduzir overhead de conexões
timeout = 30
keepalive = 5

Proxy e SSL: Nginx como frente da aplicação

O Nginx costuma ficar na frente do Gunicorn como reverse proxy, encerrando SSL/TLS, reaproveitando conexões e aplicando limites básicos. Isso melhora desempenho porque o Nginx lida de forma mais eficiente com conexões de rede e pode manter conexões persistentes com clientes. Além disso, ajuda a proteger a aplicação de tráfego abusivo com regras simples de rate limiting e buffers.

Uma boa configuração define um upstream com keepalive para o Gunicorn, ajusta timeouts e encaminha cabeçalhos corretamente. Também é comum ativar HTTP/2 no TLS para melhorar multiplexação de conexões. O bloco a seguir mostra um esqueleto funcional, mantendo a ideia de “terminar SSL no Nginx e proxy para o Django”.

# /etc/nginx/sites-enabled/app.conf
upstream django {
    server 127.0.0.1:8000;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name exemplo.com;

    # Certificados (caminhos variam conforme o provisionamento)
    ssl_certificate     /etc/ssl/certs/exemplo.pem;
    ssl_certificate_key /etc/ssl/private/exemplo.key;

    # Otimizações de conexão
    keepalive_timeout 65;
    keepalive_requests 1000;

    location / {
        proxy_pass http://django;
        proxy_http_version 1.1;

        # Mantém conexão reutilizável no upstream
        proxy_set_header Connection "";

        # Cabeçalhos comuns
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeouts sensatos
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;
    }
}

Compressão e redução de banda no Nginx

Compressão reduz tráfego e acelera respostas, especialmente em JSON, HTML, CSS e JavaScript. Em cenários com custo limitado, a economia de banda e a redução de tempo de transferência ajudam a manter latência estável. O trade-off é CPU, mas, em geral, a compressão bem configurada traz ganhos líquidos.

Além do gzip, ambientes modernos também usam Brotli, mas o gzip já resolve grande parte. O foco é comprimir tipos corretos e evitar comprimir arquivos muito pequenos. A configuração abaixo exemplifica gzip com tipos comuns e um tamanho mínimo.

# Trecho para incluir no http {} do nginx.conf ou no server {}
gzip on;
gzip_min_length 1000;
gzip_comp_level 5;
gzip_types
    text/plain
    text/css
    application/json
    application/javascript
    application/xml
    image/svg+xml;

Banco de dados: PostgreSQL gerenciado e disciplina de consultas

O PostgreSQL é frequentemente o componente mais caro em termos de desempenho, porque consultas ruins escalam de forma destrutiva. Em Django, muitos problemas aparecem como excesso de queries, joins desnecessários ou falta de índices. Um banco gerenciado reduz carga operacional e facilita crescer verticalmente quando necessário, mas não substitui otimização.

Do ponto de vista do Django, uma configuração importante é CONN_MAX_AGE, que mantém conexões reutilizáveis (persistentes) e reduz overhead de abrir/fechar conexões. Em alguns ambientes gerenciados, existe pooling (agrupamento de conexões) via PgBouncer, que estabiliza o número de conexões reais no banco. O trecho abaixo mostra uma configuração básica com timeout e conexões persistentes.

# settings.py (trecho)
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "app",
        "USER": "app",
        "PASSWORD": "senha-forte",
        "HOST": "db-host",
        "PORT": "5432",
        # Mantém conexão viva para reduzir overhead; útil com pooling
        "CONN_MAX_AGE": 600,
        "OPTIONS": {
            "connect_timeout": 10,
        },
    }
}

Índices: o fator que mais derruba latência no banco

Índice é uma estrutura que acelera buscas e ordenações, evitando varreduras completas de tabela. Em sistemas com alto RPM, falta de índices causa picos, pois cada consulta “simples” vira um trabalho pesado repetido milhares de vezes. Em Django, chaves estrangeiras geralmente já criam índice, mas campos usados para filtro, ordenação e busca também precisam ser avaliados.

Índices devem refletir padrões reais: filtrar por status, ordenar por data, buscar por usuário, etc. Também existem índices compostos (mais de uma coluna) úteis para filtros combinados frequentes. O exemplo a seguir mostra como declarar índices no model, mantendo o foco em campos consultados com frequência.

# models.py (exemplo)
from django.db import models

class Artigo(models.Model):
    autor_id = models.BigIntegerField(db_index=True)
    status = models.CharField(max_length=20, db_index=True)
    publicado_em = models.DateTimeField(db_index=True)
    titulo = models.CharField(max_length=200)

    class Meta:
        indexes = [
            models.Index(fields=["status", "-publicado_em"], name="idx_status_pub"),
        ]

O problema clássico N+1 e como evitar com select_related e prefetch_related

N+1 queries acontece quando uma lista de objetos dispara uma consulta extra para cada item relacionado. Em Django, isso aparece ao acessar relações sem carregar os dados juntos. O resultado é um aumento linear de queries com o tamanho da lista, o que destrói o banco sob carga. A solução depende do tipo de relação: select_related para relações “um-para-um” e “muitos-para-um”, e prefetch_related para relações “muitos-para-muitos” e “um-para-muitos”.

O ganho costuma ser enorme porque reduz centenas de queries para duas ou três. Também melhora a previsibilidade do tempo de resposta e reduz lock contention no banco. O exemplo abaixo mostra a diferença de forma objetiva, mantendo a mesma lógica de acesso.

# Exemplo de N+1 e correção

# Ruim: pode gerar 1 consulta para artigos + 1 por autor
for artigo in Artigo.objects.all():
    nome = artigo.autor.nome  # dispara consulta adicional se autor não estiver carregado
    print(nome)

# Bom: traz autor junto em uma única consulta com JOIN
for artigo in Artigo.objects.select_related("autor"):
    nome = artigo.autor.nome
    print(nome)

Cache: Redis local para reduzir carga do banco

Cache é a técnica de guardar resultados prontos para reutilizar, evitando trabalho repetido. Em alto RPM, cache é o divisor de águas: respostas e consultas quentes deixam de ir ao banco em grande parte do tempo. O Redis é um armazenamento em memória rápido e muito usado para cache e filas. Em um orçamento apertado, rodar Redis no mesmo servidor de aplicação pode ser suficiente, desde que haja controle de memória e observação constante.

Uma estratégia saudável combina cache de página, cache de fragmentos, cache de resultados de query e cache de valores computados. O ponto crítico é escolher um TTL (tempo de expiração) coerente com a frequência de atualização. O trecho abaixo configura Redis como backend de cache no Django e limita o pool de conexões para manter estabilidade.

# settings.py (trecho)
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 50},
        },
        "TIMEOUT": 300,  # padrão de 5 minutos, ajustado por caso
    }
}

Cache de página e controle de consistência

Cache de página guarda a resposta inteira de uma rota por um período. Isso funciona muito bem para listas públicas, páginas que mudam pouco e endpoints de leitura com alta repetição. Em Django, é comum usar o decorador cache_page para aplicar cache por tempo fixo. O principal cuidado é não cachear conteúdo personalizado por usuário sem variar por cabeçalhos e chaves corretas.

Uma boa prática é cachear o que é “bom o suficiente” por alguns minutos, reduzindo a necessidade de consistência em tempo real. Isso derruba dramaticamente leituras no banco e aumenta a capacidade do servidor de aplicação. O exemplo abaixo cacheia uma rota por 15 minutos.

# views.py (exemplo)
from django.shortcuts import render
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)  # 15 minutos
def lista_artigos(request):
    # Consultas e renderização ficam "congeladas" durante o TTL
    return render(request, "artigos.html", {"itens": []})

Cache de fragmentos e cache de consultas

Quando apenas partes da página são caras, cache de fragmentos permite salvar blocos prontos e recompor o restante dinamicamente. Outra abordagem frequente é cachear resultados de consultas, especialmente quando a mesma consulta aparece em várias rotas. Em Django, a camada de cache pode ser usada diretamente via API de cache, criando chaves com nomes previsíveis.

Uma regra simples é: chaves devem incluir versão e parâmetros importantes, e o TTL deve acompanhar a taxa de mudança. Também é relevante invalidar cache em eventos críticos, como publicação de conteúdo ou alteração de preço. O exemplo abaixo mostra cache manual de uma lista calculada, com chave estável e expiração curta.

# services.py (exemplo)
from django.core.cache import cache

def obter_top_artigos():
    chave = "v1:top_artigos"
    dados = cache.get(chave)
    if dados is not None:
        return dados

    # Simulação de operação cara (consulta, agregação, etc.)
    dados = [{"id": 1, "titulo": "Exemplo"}]

    cache.set(chave, dados, timeout=60)  # 1 minuto
    return dados

CDN e entrega de estáticos: separar o que é dinâmico do que é estático

CDN (rede de distribuição de conteúdo) mantém arquivos estáticos em bordas próximas de onde o tráfego está, reduzindo latência e tirando carga do servidor. Em um stack econômico, a CDN é valiosa porque o servidor Django passa a atender quase só conteúdo dinâmico. Arquivos como CSS, JavaScript e imagens podem ser cacheados por longos períodos quando possuem versionamento no nome.

Além de performance, CDN costuma oferecer proteção contra ataques volumétricos e regras básicas de rate limiting. Isso reduz o risco de o servidor de aplicação ser o primeiro a sofrer com tráfego indevido. O desenho ideal é: estáticos e media com cache agressivo na borda, e rotas dinâmicas com cache seletivo na aplicação.

Armazenamento de arquivos: object storage e separação de “media”

Object storage é um armazenamento para arquivos (uploads, imagens, documentos) com custo baixo por GB e alta durabilidade. Em vez de guardar mídia no disco do servidor, o que dificulta backups e escalabilidade, usa-se um bucket externo. Isso também ajuda quando existe mais de um servidor de aplicação, pois todos acessam a mesma origem de arquivos.

No Django, essa integração é feita via backend de storage, permitindo que DEFAULT_FILE_STORAGE aponte para um provedor compatível. É comum também configurar cache headers para que a CDN armazene esses arquivos com eficiência. O trecho abaixo ilustra a ideia de configurar um backend de storage, mantendo o conceito de bucket e credenciais.

# settings.py (exemplo conceitual)
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"

AWS_STORAGE_BUCKET_NAME = "bucket-app"
AWS_S3_REGION_NAME = "regiao"
AWS_ACCESS_KEY_ID = "chave"
AWS_SECRET_ACCESS_KEY = "segredo"

Tarefas em segundo plano: Celery com Redis no mesmo host

Fila de tarefas evita que operações demoradas travem requisições web. Envio de e-mails, geração de relatórios, processamento de imagens e integrações podem ser executados por workers fora do caminho crítico. O Celery é uma ferramenta popular para isso no ecossistema Python. Em orçamento limitado, Redis pode servir como broker (mensageria) e backend de resultados no mesmo servidor, com limites claros para não disputar memória com o cache.

Alguns ajustes melhoram confiabilidade: task_acks_late confirma a tarefa apenas após terminar (reduz perdas em falhas), e worker_prefetch_multiplier controla pré-busca para distribuir carga de forma mais justa. Também é importante separar filas quando há tarefas pesadas e leves, evitando que tarefas longas atrasem as curtas. O exemplo abaixo configura Celery com Redis e parâmetros comuns de robustez.

# celery.py (exemplo)
import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "projeto.settings")

app = Celery("projeto")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

app.conf.broker_url = "redis://127.0.0.1:6379/0"
app.conf.result_backend = "redis://127.0.0.1:6379/0"

# Confiabilidade e controle de carga
app.conf.task_acks_late = True
app.conf.worker_prefetch_multiplier = 1

Views assíncronas: ganho em operações de I/O

View assíncrona é uma função que pode aguardar operações de I/O sem bloquear a execução do worker, desde que o servidor e a pilha suportem o modelo. Isso faz diferença quando há chamadas externas, como APIs de terceiros ou serviços internos. O ganho aparece principalmente em concorrência, pois o tempo de espera vira oportunidade de atender outras requisições.

Nem tudo deve virar assíncrono, e operações de banco via ORM tradicional ainda podem ser gargalo se usadas de forma incorreta. O uso mais seguro é em endpoints que fazem chamadas HTTP externas, usam bibliotecas assíncronas e retornam respostas rápidas. O exemplo abaixo mostra uma view assíncrona consumindo uma API com um cliente HTTP assíncrono.

# views.py (exemplo)
import httpx
from django.http import JsonResponse

async def buscar_dados_externos(request):
    async with httpx.AsyncClient(timeout=5) as client:
        resposta = await client.get("https://api.exemplo.com/dados")
    return JsonResponse(resposta.json())

Paginação e lazy loading: limitar o que entra em memória e no banco

Paginação divide resultados em páginas menores, reduzindo tempo de consulta, custo de serialização e tamanho de resposta. Em listas, carregar “tudo” é um erro comum que funciona em desenvolvimento, mas falha sob tráfego real. Além da paginação, é importante limitar colunas carregadas com métodos como only/defer e evitar joins desnecessários.

No Django, Paginator é suficiente para muitos casos. O principal cuidado é paginar com ordenação determinística e evitar paginação por offset muito grande em tabelas gigantes, pois pode ficar caro. O exemplo abaixo mostra uma paginação simples e direta, com tamanho de página controlado.

# views.py (exemplo)
from django.core.paginator import Paginator
from django.shortcuts import render
from .models import Artigo

def lista_artigos(request):
    queryset = Artigo.objects.order_by("-publicado_em")
    paginator = Paginator(queryset, 25)

    numero_pagina = request.GET.get("page", "1")
    pagina = paginator.get_page(numero_pagina)

    return render(request, "artigos.html", {"pagina": pagina})

Monitoramento essencial: métricas que evitam surpresas

Sem monitoramento, um stack barato vira um risco, porque pequenos problemas se acumulam até virar queda. Monitoramento mínimo inclui saúde do serviço, taxas de erro, tempo de resposta por percentil e uso de recursos. Também é importante observar o banco: número de conexões, consultas lentas e uso de CPU/IO. Para filas, o principal é o tamanho da fila e o tempo médio de execução.

Um conjunto de métricas objetivas ajuda a decidir quando otimizar ou quando escalar. Uma linha de base comum é manter P50 baixo, P95 controlado e P99 sem explosões. Cache hit rate alto em rotas quentes também é indicador central, pois mostra que o banco está protegido por uma camada eficiente.

Os principais indicadores que costumam ser acompanhados em produção podem ser resumidos na lista a seguir.

  • Tempo de resposta: P50 < 50ms, P95 < 200ms, P99 < 500ms (valores típicos para rotas quentes).
  • Taxa de erro: idealmente abaixo de 0,1% em janelas curtas.
  • Cache hit rate: acima de 90% nos caminhos mais acessados.
  • Conexões do banco: uso abaixo de 50% do pool para manter folga.
  • Memória: abaixo de 80% para evitar swapping e instabilidade.
  • Fila (Celery): atraso e tamanho da fila sob controle, sem crescimento contínuo.

Distribuição de custos: onde o dinheiro realmente vai

Em um orçamento em torno de US$ 100/mês, os principais custos ficam no servidor de aplicação, no banco gerenciado e no armazenamento de arquivos. Cache no mesmo host reduz custo, e CDN gratuita ou barata reduz tráfego e carga. Um pequeno buffer financeiro ajuda a absorver picos de uso, crescimento de storage e variações de tráfego.

O detalhe que mais influencia custo é “quanto do tráfego chega no Django”. Quanto mais a borda e o cache absorvem, mais o servidor de aplicação fica livre para o que realmente precisa ser dinâmico. Em muitos sistemas, a maior economia vem de corrigir consultas e aumentar cache hit rate, evitando escalar banco cedo demais.

Quando escalar: sinais claros e caminhos previsíveis

Escalar vale a pena quando os gargalos já foram corrigidos e ainda assim há saturação real. Saturação aparece como CPU constantemente alta, aumento de latência em P95/P99, filas crescendo, pool de conexões do banco no limite ou Redis sem memória. Cada componente tem um “teto” diferente, então o melhor movimento depende de qual métrica está estourando.

Um padrão comum é adicionar um segundo servidor de aplicação e colocar ambos atrás do Nginx ou de um balanceador dedicado. Se o gargalo for o banco, costuma ser mais eficiente aumentar recursos do PostgreSQL ou revisar índices e queries antes de pensar em réplicas de leitura. Se o Redis estiver pressionado, separar cache e broker ou mover Redis para uma instância dedicada evita competição por RAM.

Encerramento: o que torna possível alta escala com baixo custo

Alcançar 100k RPM com orçamento limitado depende menos de “infra grande” e mais de escolhas consistentes: cache bem aplicado, consultas eficientes, índices corretos, compressão e entrega de estáticos via CDN. O servidor de aplicação precisa de concorrência bem configurada com Gunicorn e um Nginx robusto na frente. O banco deve ser tratado como recurso precioso, evitando N+1, reduzindo round-trips e mantendo conexões sob controle.

Quando esses elementos se alinham, o stack deixa de depender de força bruta e passa a depender de engenharia de eficiência. O resultado é previsível: menor custo mensal, maior capacidade por máquina e um caminho de escala mais tranquilo, baseado em sinais concretos de gargalo. Esse tipo de arquitetura fecha o ciclo com clareza, porque organiza desempenho, custo e operação em uma estrutura estável e sustentável.