Mihael Costa/índice
← índice
TESTE DE CARGA

Load Lab

Os dois serviços hospedados sob seis formas de carga, conduzido do jeito que um engajamento real é conduzido. Objetivos primeiro, o processo medido ao lado do cliente, um log de achados com re-testes, e os números versionados no repositório.

  • k6
  • .NET 10
  • ASP.NET Core
  • GitHub Actions

Objetivos antes dos números

O plano foi escrito antes da primeira rampa. Ele define objetivos de nível de serviço para cada serviço: um orçamento de erros, uma meta de latência para requisições aceitas, como o descarte de carga pode se parecer, o que precisa se recuperar e em quanto tempo, e o que uma rodada de vinte minutos deveria conseguir mostrar. Define também um modelo de carga, com cada número marcado como premissa para poder ser questionado.

Não existe tráfego de produção para derivar esse modelo, já que um portfólio recebe um visitante por vez. O modelo é uma meta de projeto: um post que chama atenção, cem visitantes em uma hora, cinco minutos cada. A lei de Little dá cerca de oito pessoas no site ao mesmo tempo. O perfil de carga esperada roda vinte, então os objetivos são atingidos com folga. Ao lado dos editores, uma taxa fixa de espectadores abre um link compartilhado e só lê, que é o tipo de tráfego que continua chegando não importa quão ocupado o serviço já esteja.

Seis formas (smoke, load, stress, spike, soak, breakpoint) estão definidas em um único arquivo, então “stress” significa a mesma escada para qualquer um dos serviços. Os scripts são jornadas, não endpoints isolados. Um editor obtém uma sessão, cria um fluxo, salva contra a versão dele, executa e acompanha a execução até o fim, pausando entre os passos como uma pessoa faz. Toda rodada que importava também teve o dotnet-counters no processo enquanto o k6 estava no socket. Um p95 plano pode significar que o serviço tem folga, ou que está perto de uma coleta de lixo que mudaria o quadro. Só o processo consegue distinguir os dois casos.

O que conta como falha

Os dois serviços recusam requisições de propósito quando estão ocupados. O Flow Studio tem seis vagas de execução; o SQL lab roda quatro consultas por vez e enfileira mais oito. Passados esses limites a resposta é um 429 imediato, em vez de uma requisição esperando com um spinner.

Então a primeira coisa que o lab precisa acertar é como contar. Um 429 é o serviço funcionando como projetado e é registrado como recusada. Um erro é um 5xx, um timeout, um corpo que não parseia ou uma execução que nunca termina, e é a essa taxa que os limiares se aplicam. Um teste de carga que tratasse descarte de carga como quebra reprovaria o serviço por fazer o que foi construído para fazer sob carga. Um teste que ignorasse os 429 perderia o ponto em que o serviço começou a recusar.

O bug encontrado antes de qualquer carga

O perfil smoke roda primeiro, com dois usuários. A primeira rodada contra o Flow Studio relatou toda execução como nunca concluída.

As execuções estavam concluindo. O endpoint de polling que relata o progresso respondia 500 no SDK do .NET 10: o System.Text.Json agora rejeita uma propriedade cujo nome coincide com o discriminador polimórfico, e o [JsonIgnore] na classe base não é lido nas sobrescritas. O endpoint de streaming tinha o mesmo defeito e, como já tinha enviado os cabeçalhos, só podia fechar a conexão. O teste existente teria quebrado num stream vazio em vez de relatar a causa. Agora ele relata.

A correção são cinco atributos e um comentário. A parte útil é que um teste de carga com uma etapa de smoke na frente também verifica a correção do caminho que vai receber carga, e este se pagou antes de a carga começar.

A escada

Vinte, quarenta, sessenta e oitenta editores, um minuto cada, com dez espectadores por segundo o tempo todo. A rodada é cortada nos limites dos degraus em vez de tirada a média, porque a pergunta de um teste de stress é o que mudou entre os degraus:

editores req/s p95 p99 execuções tentadas aceitas aceitas/s recusadas
20 50 1,5ms 2,2ms 240 240 4,0 0%
40 82 1,6ms 2,5ms 511 356 5,9 30%
60 108 1,8ms 2,9ms 808 370 6,2 54%
80 133 1,9ms 2,9ms 1.103 378 6,3 66%

A coluna de aceitas é o resultado. Seis vagas, cada uma ocupada por cerca do segundo que uma execução cadenciada leva, dão um teto de mais ou menos seis execuções por segundo. A escada chegou nele entre quarenta e sessenta editores e ficou lá. O p95 da linha em que dois terços das execuções foram recusadas é o mesmo da linha em que nenhuma foi. O processo, amostrado a cada cinco segundos, usou menos de um por cento da máquina no degrau mais alto. Sob sobrecarga um serviço pode enfileirar, ficar lento ou recusar, e este recusa.

O que só a carga mostrou

O catálogo do SQL lab, GET /api/scenarios, é uma leitura cacheada que leva uns dois milissegundos. Sob o spike de oitenta visitantes o p95 dele foi de 258ms, o que reprovou no único objetivo escrito para valer independente da carga.

O motivo era que o limitador global de concorrência media todos os caminhos menos o /health, então o catálogo esperava na mesma fila das consultas de 800ms que ele lista. Uma página que não consegue carregar a lista de cenários porque outras pessoas estão rodando cenários não está protegendo nada útil. O teto agora se aplica só a execuções, e a nova rodada do mesmo perfil lê 1,9ms.

O ambiente variou mais do que o código

Três rodadas daquele spike deram resultados diferentes. O p95 do lado lento foi 1,04s, 1,41s e depois 2,17s, e a segunda e a terceira rodadas usaram o mesmo código. O tempo de consulta que a API mede em volta da própria chamada cresceu junto com o tempo de ida e volta, o que aponta para baixo da API: o container do Postgres na VM do Docker Desktop, seus 128MB de shared buffers contra um dataset de 273MB, e o notebook que a pilha inteira divide com o gerador de carga.

Esse cronômetro inclui o driver e o salto até o container, então sozinho não conseguia inocentar o processo. O script agora registra o tempo de execução do próprio banco em toda rodada, e um quarto spike rodou com counters no processo e estatísticas do container. Os dois relógios concordaram até o décimo de milissegundo, o processo da API ficou em 1,2% de CPU, e o container do Postgres chegou a sete núcleos. A variância veio de quanto do notebook o banco conseguiu. A conclusão no relatório é que as latências absolutas do SQL lab nesta máquina não são números para citar; as formas e a ordem entre os lados são.

Vinte minutos

Do lado do cliente o soak não mostrou nada mudando: p95 de 1,8ms do primeiro minuto ao último, nada recusado, nada falhou. O processo mostrou duas mudanças. O working set cresceu de 39 para 96MB ao longo de todas as janelas de cinco minutos. Isso não é vazamento. O registro de execuções guarda cada execução concluída por quinze minutos e varre a cada dez, então 4.973 execuções ficaram retidas a cerca de 12KB cada, e um soak de vinte minutos sobre uma retenção que pode chegar a vinte e cinco só vê a subida. O que saiu disso foi o limite: taxa de execuções vezes retenção vezes 12KB, cerca de 115MB acima da base no teto de seis vagas.

A segunda mudança foi a contenção de locks, de 45 para 743 por janela sob carga constante. Cada início de execução contava as execuções em andamento enumerando os valores de um dicionário concorrente, o que pega todos os locks internos dele, e o dicionário tinha cinco mil entradas a mais no fim do que no começo. Agora é um contador, incrementado no início e decrementado na conclusão, e um segundo soak confirmou que o crescimento sumiu.

Contra produção

O bucket por visitante (doze execuções por minuto, quatro de volta a cada vinte segundos) é o único controle entre um endpoint público que roda SQL e qualquer pessoa com um for, então ele é conferido como aritmética e não como tempo: trinta e seis admitidas em dois minutos, Retry-After em toda recusa. Localmente, 35 de 235 requisições foram admitidas, com o cabeçalho nas 200 recusas. Contra o serviço publicado no Fly.io, 35 de 197, com o cabeçalho nas 162. A configuração que foi para produção é a que foi testada.

O relatório, o plano, o JSON por trás de cada tabela e as ferramentas que os produziram estão no repositório. Toda rodada teve k6 e serviço no mesmo notebook, então o gerador competiu com o que estava medindo. As latências absolutas pertencem àquela máquina; as formas pertencem ao serviço. O portão de CI roda smoke e load a cada mudança, confere o bucket exatamente, deixa os limiares de latência uma ordem de grandeza folgados e compara com a rodada anterior na main, reprovando só quando um p95 dobra, porque um portão que reprova por ruído acaba ignorado.

Uma suíte de testes e seus resultados versionados, não um serviço hospedado. Os dois serviços que ela mede reportam a própria saúde nas próprias páginas.