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.
- Repositório Git: scripts k6 versionados +
azure-pipelines.yml. - Pipeline Azure DevOps: dispara o job no agente self-hosted, com parâmetros (qual teste, VUs, namespace).
- Agente self-hosted: container Linux com
kubectl, aplica oTestRunno cluster. - k6-operator: observa o
TestRune orquestra os pods que geram a carga. - Alvo do teste: API síncrona (HTTP) ou cenário assíncrono (RabbitMQ + producer/worker).
- 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
kubectlhelmv3+.NET 10 SDKgit
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

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 emGET /api/produtose confere se a resposta foi200. Osthresholdsfazem 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 recursoTestRundo k6-operator. Ele não contém o teste, só aponta proConfigMap(k6-script-teste) onde o02-load-test.jsfoi carregado (passo anterior,kubectl create configmap) e diz qual arquivo rodar (file: 02-load-test.js). É essekind: TestRunque o operator observa e transforma em pods reais. É o “GitOps” do título: em vez de rodark6 run script.jsna 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

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 numConfigMapcom o label certo.k6-execucoes-dashboard.json: customizado, tabela com nome, tipo, início, fim, duração e total de requisições de cadaTestRun, 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"

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):

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
TODOSpra rodar a suíte inteira em sequência), oparallelismdoTestRune 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 oConfigMapcom o script, aplica oTestRunjá configurado pra mandar métrica pro Prometheus, e fica num loop de até 15 minutos esperando oTestRunchegar emstage: finishedoustage: errorantes 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
KUBECONFIGquanto o bind mount - O
kubectldentro do container acaba tentando falar comlocalhost:8080e não vai - A solução: prefixa o comando com
MSYS_NO_PATHCONV=1pra 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.
| Local | AWS | Por quê muda |
|---|---|---|
| Docker Desktop / KIND | Cluster EKS com node group gerenciado | Cluster gerenciado, SLA, integração nativa com IAM |
ctr images import manual | Amazon ECR + docker push | Registry de verdade, sem truque por node |
| Agente em container Docker local | Deployment dentro do EKS, IAM Role (IRSA) | Sem depender da máquina local ligada |
kubectl port-forward | Ingress + Application Load Balancer + TLS | Acesso contínuo pro time |
| kubeconfig admin do cluster | RBAC + IRSA, permissão mínima | Seguranç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.
| Problema | Causa raiz | Solução |
|---|---|---|
Pipeline falhava em toda task script: | Conflito de módulos PowerShell (PS7 vs 5.1) no agente Windows | Migrar o agente pra container Docker Linux |
kubectl do agente não alcançava o cluster | Kubeconfig do Docker Desktop só é acessível do host, não de containers | Extrair 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 host | docker save | docker exec ... ctr images import em cada node |
Extensão xk6-amqp travava com múltiplos VUs | Bug real de concorrência (concurrent map writes) na extensão | Abandonei a extensão, troquei pela API de management HTTP do RabbitMQ (k6 padrão) |
| Mensagens RabbitMQ processadas com dados zerados | Casing incompatível: correlationId vs CorrelationId, sem lançar exceção | Script 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á configurado | entrypoint.sh idempotente: pula config.sh se .agent já existe |
kubectl caía em localhost:8080 mesmo com kubeconfig certo | Git Bash (MSYS2) reescreve paths Unix pra Windows antes de repassar pro Docker | MSYS_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.

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