// CASE DE ESTUDO · TESTES DE CARGA GITOPS

Testes de Carga GitOps com k6

Como sair de "alguém roda o k6 na própria máquina e o resultado evapora" para testes de carga versionados em Git, disparados por pipeline, rodando num cluster Kubernetes, com resultado visível num dashboard, do ambiente local até produção na AWS.

CASE COMPLETO K6KUBERNETESAZURE DEVOPSAWS EKSTERRAFORMGRAFANA

A motivação

Geralmente quando vou rodar testes de carga, utilizo K6 (localmente e com o Grafana Cloud também), já fiz utilização anteriormente de JMeter por exemplo, mas a duvida surgiu “Será que consigo utilizar o k6 de maneira distribuida e com uma pipeline?”.

Comecei a pesquisar, descobri o k6 Operator e tive algumas ideias, com a ideia pensei em: Repositório -> Azure Pipeline -> Azure Agent -> K6 Operator em um Cluster -> Testes sendo executados -> Dashboard no grafana.

Com essa ideia em mente, comecei a montar uma POC para isso acontecer.

Objetivo

Construir uma PoC completa de testes de carga GitOps. Basicamente:

  • Uma pipeline no Azure DevOps que aplica testes k6 num cluster Kubernetes via k6-operator
  • Testando uma API real, não um “hello world”
  • Com observabilidade de verdade (Prometheus + Grafana), porque rodar teste sem enxergar resultado não serve de muita coisa
  • E um segundo cenário orientado a eventos (fila RabbitMQ com producer/consumer), só pra provar que a abordagem funciona além de HTTP síncrono

Como funciona, de ponta a ponta

O fluxo começa sempre no Azure DevOps: alguém dispara a pipeline manualmente, escolhendo qual teste rodar. A pipeline não executa nada ela mesma, ela manda o agente (container Linux) aplicar um TestRun no cluster. Dali pra baixo é tudo Kubernetes nativo.

  1. Repositório Git: scripts k6 versionados + azure-pipelines.yml.
  2. Pipeline Azure DevOps: dispara o job no agente self-hosted, com parâmetros (qual teste, VUs, namespace).
  3. Agente self-hosted: container Linux com kubectl, aplica o TestRun no cluster.
  4. k6-operator: observa o TestRun e orquestra os pods que geram a carga.
  5. Alvo do teste: API síncrona (HTTP) ou cenário assíncrono (RabbitMQ + producer/worker).
  6. Observabilidade: o k6 escreve métricas direto no Prometheus via remote-write; o Grafana visualiza (3 dashboards: oficial + 2 customizados).

O que muda entre local e AWS é só onde o cluster roda, a lógica acima é idêntica nos dois ambientes. É por isso que o mesmo módulo Terraform (modules/k6-gitops-scenario) é reusado pelos dois roots. Os diagramas completos (macro, infra local e infra AWS) estão no repositório, em formato Mermaid.

Passo a passo no ambiente local

Pré-requisitos:

  • Docker Desktop com Kubernetes habilitado
  • kubectl
  • helm v3+
  • .NET 10 SDK
  • git

Duas rotas possíveis: manual (abaixo, pra entender cada peça) ou Terraform (atalho no fim desta seção).

1 · A aplicação alvo

Primeiro vamos só criar uma api (ou utilizar a api já criada, somente para teste que está disponivel no repositório git, só descer a página ai e pegar)

# API .NET 10 minimal, GET /api/produtos + POST /api/login
cd api-csharp
docker-compose up -d --build
curl http://localhost:8080/api/produtos   # deve retornar 200 com uma lista JSON

2 · Cluster Kubernetes + k6-operator

Vamos adicionar manualmente o k6-operator no nosso cluster. (No final eu prometo que temos o terraform)

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
helm install k6-operator grafana/k6-operator \
  --namespace k6-operator-system --create-namespace

kubectl get pods -n k6-operator-system   # deve mostrar 1/1 Running

Terminal mostrando o helm repo update, o helm install do k6-operator e o kubectl get pods confirmando 1/1 Running

Vamos rodar um mini teste, só para certificar que o k6-operator está up and running. (os arquivos de teste estão no repositório, só baixar zé)

Dois arquivos entram em cena aqui:

  • k6-gitops/scripts/02-load-test.js: o teste em si, escrito em JavaScript (é assim que se escreve um teste k6). Sobe gradualmente até 10 VUs (usuários virtuais) em 30s, segura essa carga por 2 minutos, e desce. Cada VU bate em GET /api/produtos e confere se a resposta foi 200. Os thresholds fazem o teste falhar sozinho se a taxa de erro passar de 1% ou o p95 de latência passar de 800ms, não precisa olhar gráfico pra saber se passou ou não.
  • k6-gitops/manifests/testrun-manual.yaml: o manifesto que vira um recurso TestRun do k6-operator. Ele não contém o teste, só aponta pro ConfigMap (k6-script-teste) onde o 02-load-test.js foi carregado (passo anterior, kubectl create configmap) e diz qual arquivo rodar (file: 02-load-test.js). É esse kind: TestRun que o operator observa e transforma em pods reais. É o “GitOps” do título: em vez de rodar k6 run script.js na mão, você aplica um YAML.
kubectl create namespace k6-tests
kubectl create configmap k6-script-teste --from-file=k6-gitops/scripts/02-load-test.js -n k6-tests
kubectl apply -f k6-gitops/manifests/testrun-manual.yaml
kubectl get pods -n k6-tests -w   # acompanhe os pods do teste subindo e terminando

Terminal mostrando o kubectl create configmap, o kubectl apply do testrun-manual.yaml e o kubectl get pods acompanhando o teste subir, rodar e terminar

3 · Observabilidade (Prometheus + Grafana)

Agora eu quero ver os resultados e não só olhar pra uma telinha preta, até porque como que eu vou mostrar um dashboard bonito pras outros pessoas? É isso que a gente quer afinal não?

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace \
  --set prometheus.prometheusSpec.enableRemoteWriteReceiver=true \
  --set grafana.adminPassword=SENHA-FORTE-AQUI \
  --set grafana.sidecar.dashboards.enabled=true

O enableRemoteWriteReceiver=true é o detalhe que faz tudo funcionar, porque sem ele o Prometheus rejeita as métricas que o k6 tenta enviar.

Dashboards não vêm prontos. O Helm chart só instala o Grafana em si, vazio. O grafana.sidecar.dashboards.enabled=true que passamos ali em cima liga um sidecar que fica de olho em ConfigMaps com o label grafana_dashboard: "1" no namespace monitoring e importa automaticamente qualquer dashboard que encontrar, sem reiniciar nada. O repositório já traz três prontos em monitoring/:

  • k6-dashboard-configmap.yaml: o dashboard oficial do k6 (ID 19665 no grafana.com), já embrulhado num ConfigMap com o label certo.
  • k6-execucoes-dashboard.json: customizado, tabela com nome, tipo, início, fim, duração e total de requisições de cada TestRun, com filtro por tipo de teste.
  • eventos-dashboard.json: customizado, métricas da fila RabbitMQ (profundidade, consumidores, taxa de publicação/consumo). Só faz sentido depois do passo 5, se você for rodar o cenário de eventos.
# dashboard oficial, já vem pronto como ConfigMap
kubectl apply -f monitoring/k6-dashboard-configmap.yaml

# dashboards customizados: viram ConfigMap na hora, já com o label que o sidecar procura
kubectl create configmap k6-execucoes-dashboard \
  --from-file=k6-execucoes-dashboard.json=monitoring/k6-execucoes-dashboard.json \
  -n monitoring --dry-run=client -o yaml | \
  kubectl label --local -f - grafana_dashboard=1 -o yaml --dry-run=client | \
  kubectl apply -f -

kubectl create configmap eventos-dashboard \
  --from-file=eventos-dashboard.json=monitoring/eventos-dashboard.json \
  -n monitoring --dry-run=client -o yaml | \
  kubectl label --local -f - grafana_dashboard=1 -o yaml --dry-run=client | \
  kubectl apply -f -

Dê uns 30 segundos pro sidecar notar os ConfigMaps novos. Reaplique o teste, agora exportando métricas, e os dashboards já aparecem com dado de verdade:

kubectl apply -f k6-gitops/manifests/testrun-com-metricas.yaml

kubectl port-forward -n monitoring svc/kube-prometheus-stack-grafana 33000:80
# abra http://localhost:33000 e confira o dashboard "k6 Prometheus"

Terminal mostrando o helm install do kube-prometheus-stack, o kubectl apply do testrun-com-metricas.yaml e o port-forward pro Grafana

Depois de rodar os testes e importar os ConfigMaps, os três dashboards aparecem certinho na lista do Grafana (o de eventos só vem com dado depois do passo 5):

Lista de dashboards no Grafana filtrada pela tag k6, mostrando os três: Eventos - RabbitMQ, K6 - Execuções de Teste e k6 Prometheus

4 · Conectando o Azure DevOps

Esse é o passo mais delicado. É aqui que entra um processo externo (o agente) que precisa de rede até o cluster. Antes do docker run lá embaixo, você precisa:

  • Criar um projeto e um repositório no Azure DevOps
  • Dar push no código
  • Criar um Agent Pool self-hosted
  • Apontar uma pipeline pro azure-pipelines.yml

O arquivo é esse aqui: k6-gitops/azure-pipelines.yml. Da uma olhada nele antes de apontar a pipeline:

trigger: none

parameters:
  - name: selectedScript
    displayName: 'Script K6 a executar ("TODOS" roda a suíte inteira, em sequência)'
    type: string
    default: '02-load-test.js'
    values:
      - 'TODOS'
      - 'fluxo-compras.js'
      - 'login-stress.js'
      - '01-smoke-test.js'
      - '02-load-test.js'
      - '03-stress-test.js'
      - '04-spike-test.js'
      - '05-soak-test.js'
      - '06-throttling-test.js'
      - '07-rabbitmq-api-test.js'
      - '08-rabbitmq-direct-test.js'

  - name: parallelism
    displayName: 'Parallelism (CR TestRun)'
    type: number
    default: 1

  - name: targetNamespace
    displayName: 'Namespace do cluster onde o TestRun roda'
    type: string
    default: 'k6-tests'

pool:
  name: 'MeuPCPool'

variables:
  selectedScript: ${{ parameters.selectedScript }}
  parallelism: ${{ parameters.parallelism }}
  targetNamespace: ${{ parameters.targetNamespace }}

stages:
  - stage: RunK6LoadTest
    displayName: 'Executa teste de carga K6 (GitOps / K6 Operator)'
    jobs:
      - job: RunK6
        displayName: 'Aplica ConfigMap e CR TestRun no cluster'
        steps:
          - script: |
              echo "Script selecionado: $(selectedScript)"
              echo "Parallelism: $(parallelism)"
              echo "Namespace: $(targetNamespace)"
            displayName: 'Mostrar parâmetros selecionados'

          - script: |
              kubectl create namespace $(targetNamespace) --dry-run=client -o yaml | kubectl apply -f -
            displayName: 'Garantir que o namespace alvo existe'

          - script: |
              # Função auxiliar: aplica o TestRun para UM script e espera terminar
              # antes de devolver o controle (execução sequencial - evita que
              # testes de carga concorrentes distorçam as métricas uns dos outros).
              run_test() {
                local script_file="$1"
                local run_name="$2"
                local test_type
                test_type=$(echo "$script_file" | sed -E 's/^[0-9]+-//; s/\.js$//')

                echo "=== Rodando $script_file (test_type=$test_type, TestRun=$run_name) ==="

                kubectl create configmap "cm-$run_name" \
                  --from-file="$(Build.Repository.LocalPath)/scripts/$script_file" \
                  -n $(targetNamespace) \
                  --dry-run=client -o yaml | kubectl apply -f -

                cat <<EOF | kubectl apply -f -
              apiVersion: k6.io/v1alpha1
              kind: TestRun
              metadata:
                name: $run_name
                namespace: $(targetNamespace)
              spec:
                parallelism: $(parallelism)
                script:
                  configMap:
                    name: cm-$run_name
                    file: $script_file
                arguments: --out experimental-prometheus-rw --tag test_type=$test_type
                runner:
                  env:
                    - name: K6_PROMETHEUS_RW_SERVER_URL
                      value: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090/api/v1/write
                    - name: K6_PROMETHEUS_RW_TREND_STATS
                      value: p(95),p(99),min,max,avg
              EOF

                # Espera terminar (ou dar erro), com timeout de segurança de 15min por teste.
                local elapsed=0
                local stage=""
                while [ "$elapsed" -lt 900 ]; do
                  stage=$(kubectl get testrun "$run_name" -n $(targetNamespace) -o jsonpath='{.status.stage}' 2>/dev/null)
                  if [ "$stage" = "finished" ] || [ "$stage" = "error" ]; then
                    break
                  fi
                  sleep 5
                  elapsed=$((elapsed + 5))
                done
                echo "=== $script_file terminou com stage=$stage ==="
              }

              if [ "$(selectedScript)" = "TODOS" ]; then
                SCRIPTS="01-smoke-test.js 02-load-test.js 03-stress-test.js 04-spike-test.js 05-soak-test.js 06-throttling-test.js 07-rabbitmq-api-test.js 08-rabbitmq-direct-test.js"
                i=0
                for s in $SCRIPTS; do
                  i=$((i + 1))
                  run_test "$s" "k6-loadtest-$(Build.BuildId)-$i"
                done
                echo "=== Suíte completa executada (8 testes) ==="
              else
                run_test "$(selectedScript)" "k6-loadtest-$(Build.BuildId)"
              fi
            displayName: 'Rodar teste(s) K6 (um único script, ou a suíte inteira em sequência se TODOS)'

Resumindo o que ele faz:

  • trigger: none: não dispara sozinho em cada commit, só roda manual, na hora você escolhe os parâmetros
  • 3 parâmetros: qual script rodar (ou TODOS pra rodar a suíte inteira em sequência), o parallelism do TestRun e o namespace alvo
  • Roda no pool MeuPCPool, que é o Agent Pool self-hosted que você criou lá em cima
  • Primeiro garante que o namespace existe (kubectl create namespace ... --dry-run=client -o yaml | kubectl apply -f -), de um jeito idempotente, não quebra se já existir
  • A função run_test() faz o trabalho de verdade pra cada script: cria o ConfigMap com o script, aplica o TestRun já configurado pra mandar métrica pro Prometheus, e fica num loop de até 15 minutos esperando o TestRun chegar em stage: finished ou stage: error antes de seguir
  • Quando você escolhe TODOS, ele roda os 8 testes curados um atrás do outro, não em paralelo, justamente pra não misturar a carga de um teste com a métrica de outro

A decisão mais importante: rode o agente como container Linux, não direto no host. Isso isola a pipeline de qualquer particularidade do SO.

docker build -t k6-gitops-agent:latest agent-docker/

docker run -d --name k6-gitops-agent --restart unless-stopped \
  --network SEU-NETWORK-DO-CLUSTER \
  -e AZP_URL="https://SEU_ORG.visualstudio.com" \
  -e AZP_TOKEN="SEU-PAT-AQUI" \
  -e AZP_POOL="MeuPCPool" \
  -e AZP_AGENT_NAME="agente-01" \
  -e KUBECONFIG=/kube/config \
  -v /caminho/para/kubeconfig.yaml:/kube/config:ro \
  k6-gitops-agent:latest

Se você estiver no Windows usando Git Bash pra rodar esse docker run, presta atenção nisso:

  • O Git Bash (MSYS2) reescreve automaticamente argumentos que parecem paths Unix (tipo /kube/config) pra paths Windows
  • Isso corrompe silenciosamente tanto a env var KUBECONFIG quanto o bind mount
  • O kubectl dentro do container acaba tentando falar com localhost:8080 e não vai
  • A solução: prefixa o comando com MSYS_NO_PATHCONV=1 pra desligar essa conversão

No Docker Desktop local, o “Kubernetes” na verdade roda via KIND por baixo dos panos. O kubeconfig padrão aponta pra 127.0.0.1, que só é acessível do host, não de dentro de um container. A saída foi extrair o admin.conf de dentro do container desktop-control-plane e colocar o agente na mesma rede Docker kind.

5 · Segundo cenário: RabbitMQ (opcional)

Só necessário se seu caso de uso realmente tiver uma arquitetura orientada a eventos pra testar.

kubectl create namespace eventos
kubectl apply -f eventos-dotnet/k8s/01-rabbitmq.yaml
kubectl apply -f eventos-dotnet/k8s/02-rabbitmq-servicemonitor.yaml

docker build -t producer-api:latest eventos-dotnet/producer-api
docker build -t worker:latest eventos-dotnet/worker

# só necessário se o cluster for KIND (Docker Desktop): as imagens locais
# não ficam automaticamente visíveis pros nodes (cada um tem seu containerd)
for node in $(docker ps --filter "name=desktop-" --format "{{.Names}}"); do
  docker save producer-api:latest | docker exec -i "$node" ctr -n=k8s.io images import -
  docker save worker:latest       | docker exec -i "$node" ctr -n=k8s.io images import -
done

kubectl apply -f eventos-dotnet/k8s/03-producer-api.yaml
kubectl apply -f eventos-dotnet/k8s/04-worker.yaml

Valide o fluxo ponta a ponta:

kubectl run curltest --image=curlimages/curl:latest --rm -i --restart=Never -n eventos -- \
  curl -s -X POST http://producer-api:8080/api/pedidos \
  -H "Content-Type: application/json" -d '{"produto":"teste","quantidade":1}'

kubectl logs -n eventos -l app=worker --tail=5   # confirme que o worker processou a mensagem

Cuidado com o casing:

  • O corpo HTTP usa camelCase (produto, quantidade)
  • Mas a mensagem que trafega na fila usa PascalCase (Produto, Quantidade), porque o publisher serializa sem opções (preserva os nomes C#)
  • Se publicar com o casing errado, o teste passa sem erro nenhum, só que com todos os campos zerados
  • Isso acontece porque a desserialização por construtor posicional do record usa o valor default quando a propriedade não casa por nome

Atalho: Terraform

Depois de entender o processo manual (ou se preferir ir direto), o mesmo cenário sobe com um único apply:

cd terraform/local
cp terraform.tfvars.example terraform.tfvars
terraform init && terraform apply

Passo a passo em produção na AWS

Em produção muda bastante coisa:

  • O EKS substitui o KIND local
  • O ECR substitui aquele truque de importar imagem manualmente
  • O agente ganha permissão via IAM (IRSA) em vez de um kubeconfig copiado à mão

O resto (k6-operator, Prometheus, Grafana, RabbitMQ) é o mesmo módulo Terraform usado localmente.

Pré-requisito: aws configure (ou SSO) com permissão pra criar VPC, EKS, IAM roles e ECR.

cd terraform/aws-eks
cp terraform.tfvars.example terraform.tfvars   # ajuste região, nome do cluster etc.
terraform init
terraform apply

$(terraform output -raw update_kubeconfig_command)   # atualiza seu ~/.kube/config local

Esse apply cria a VPC, o cluster EKS com node group gerenciado, o ECR, builda e publica as imagens automaticamente, habilita o EBS CSI driver via IRSA (pra persistência real do Prometheus) e sobe o mesmo cenário do ambiente local.

LocalAWSPor quê muda
Docker Desktop / KINDCluster EKS com node group gerenciadoCluster gerenciado, SLA, integração nativa com IAM
ctr images import manualAmazon ECR + docker pushRegistry de verdade, sem truque por node
Agente em container Docker localDeployment dentro do EKS, IAM Role (IRSA)Sem depender da máquina local ligada
kubectl port-forwardIngress + Application Load Balancer + TLSAcesso contínuo pro time
kubeconfig admin do clusterRBAC + IRSA, permissão mínimaSegurança: o agente não deveria poder fazer qualquer coisa

Uma decisão que vale destacar: testes de carga são episódicos, ninguém precisa de cluster ligado 24/7.

  • Rodando 4h por dia, 20 dias úteis por mês, o custo estimado do cluster efêmero fica em torno de US$ 22-25/mês
  • Um cluster permanente sai por US$ 165-250/mês
  • Isso é uns 10x de diferença

O padrão é simples: terraform apply antes da bateria de testes, terraform destroy depois. Detalhes completos de custo (por componente, Spot vs. on-demand, alavancas de otimização) estão na documentação completa.

Problemas reais (e como foram resolvidos)

O valor de um case desse tá tanto no que funcionou quanto no que quebrou primeiro. Esses são bugs genuínos, não hipotéticos. A lista completa (14 no total) tá no repositório, aqui embaixo só os mais instrutivos.

ProblemaCausa raizSolução
Pipeline falhava em toda task script:Conflito de módulos PowerShell (PS7 vs 5.1) no agente WindowsMigrar o agente pra container Docker Linux
kubectl do agente não alcançava o clusterKubeconfig do Docker Desktop só é acessível do host, não de containersExtrair o admin.conf do container desktop-control-plane e usar a rede Docker kind
Imagens não subiam no cluster (ErrImageNeverPull)Nodes do KIND têm containerd próprio, sem o cache do daemon do hostdocker save | docker exec ... ctr images import em cada node
Extensão xk6-amqp travava com múltiplos VUsBug real de concorrência (concurrent map writes) na extensãoAbandonei a extensão, troquei pela API de management HTTP do RabbitMQ (k6 padrão)
Mensagens RabbitMQ processadas com dados zeradosCasing incompatível: correlationId vs CorrelationId, sem lançar exceçãoScript corrigido pro casing exato do publisher real
Agente entrava em loop de restart após reiniciar o Docker Desktop--restart unless-stopped reaproveita o filesystem, então o config.sh rodava de novo sobre um agente já configuradoentrypoint.sh idempotente: pula config.sh se .agent já existe
kubectl caía em localhost:8080 mesmo com kubeconfig certoGit Bash (MSYS2) reescreve paths Unix pra Windows antes de repassar pro DockerMSYS_NO_PATHCONV=1 na frente do docker run

Um padrão se repete em boa parte desses problemas: ferramentas Linux/Unix rodando num host Windows encontram ambiguidade entre runtimes concorrentes (PowerShell vs Git Bash vs WSL, Docker Desktop vs KIND “de verdade”). É uma dor específica de desenvolver localmente no Windows e some completamente em produção, onde tudo roda em Linux nativo (EKS, agente Linux, sem PowerShell/WSL na equação).

O resultado

No final, é isso: a pipeline dispara, o TestRun roda no cluster e as métricas caem direto no dashboard oficial do k6, sem ninguém precisar rodar nada na mão nem ficar catando resultado no terminal.

Dashboard k6 Prometheus no Grafana mostrando o resultado de um teste rodado: gráfico de VUs e requisições por segundo, 1440 requisições HTTP e taxa de transferência ao longo do tempo

# Código aberto

Todos os arquivos necessários pra reproduzir isso do zero (API alvo, scripts k6, pipeline, Dockerfile do agente, manifests do RabbitMQ, dashboards do Grafana e os dois roots do Terraform, local e AWS) estão num repositório público, junto com a documentação técnica completa: arquitetura, decisões, cronologia inteira de problemas, estimativa de custos.