Meu nome é Elton Minetto

Teste de carga usando o k6

No post anterior vimos que existem diferentes tipos de testes de carga e quais são seus objetivos e características. Neste texto vamos ver como implementá-los usando uma ferramenta chamada k6.

O k6 é uma ferramenta open source mantida pela Grafana Labs, mesma organização por trás de grandes projetos como o Grafana, Prometheus, Mimir e Loki. A ferramenta é escrita em Go e tem embutida um motor de processamento de scripts JavaScript, que é a linguagem usada para a definição dos testes de carga.

Sem mais delongas, vamos colocar a mão na massa.

O primeiro passo é instalar a ferramenta.

No macOS bastou o comando:

brew install k6

Na documentação oficial é possível visualizar como instalá-la nos demais sistemas operacionais.

Antes de executar o primeiro teste, vamos definir qual vai ser a aplicação que vamos usar como alvo. O k6 disponibiliza uma área para testes, a https://test.k6.io/ que possui algumas APIs que podem ser usadas para demonstração. Mas para este caso resolvi fazer uma pequena API em Go, apenas para fins didáticos. O código da aplicação é:

package main

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

func main() {
	r := chi.NewRouter()
	r.Use(middleware.Logger)
	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("welcome"))
	})
	http.ListenAndServe(":3000", r)
}

O código é realmente bem simples, pois não é o foco principal deste texto e sim os testes de carga com o k6.

Vamos começar a criar nossos testes, de acordo com os tipos descritos no post anterior.

Smoke testing

Criei o esqueleto do primeiro teste usando o comando:

k6 new tests/smoke-test.js

Removi alguns comentários gerados pelo comando acima e o código do nosso primeiro teste ficou assim:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  vus: 1,
  duration: '5s',
}

export default function () {
  http.get('http://192.168.68.108:3000');
  sleep(1);
}

Na variável options é feita a configuração do teste. Neste caso vamos simular 1 usuário, ou vu (Virtual User) acessando por 5 segundos. Na função default é onde definimos as ações que esse usuário vai realizar. Essa função vai ser executada para cada usuário, durante a duração do teste. Neste caso vamos fazer uma requisição do tipo get para a máquina onde está executando a nossa API Go. Estou usando uma máquina para executar os testes e outra para hospedar a aplicação. O comando sleep serve para ajudar a simular o comportamento de um usuário, pois na vida real a pessoa precisa de algum tempo entre as operações, por exemplo para mover o mouse entre links, etc. Essa é uma característica importante, sempre tentarmos emular o comportamento de um usuário e não de um bot.

Agora podemos executar o teste com o comando:

k6 run tests/smoke-test.js

E o resultado foi:

❯ k6 run tests/smoke-test.js

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  ()  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: tests/smoke-test.js
     output: -

  scenarios: (100.00%) 1 scenario, 1 max VUs, 35s max duration (incl. graceful stop):
           * default: 1 looping VUs for 5s (gracefulStop: 30s)


     data_received..................: 615 B 117 B/s
     data_sent......................: 425 B 81 B/s
     http_req_blocked...............: avg=8.59ms  min=10µs    med=12µs    max=42.9ms  p(90)=25.74ms p(95)=34.32ms
     http_req_connecting............: avg=8.57ms  min=0s      med=0s      max=42.87ms p(90)=25.72ms p(95)=34.29ms
     http_req_duration..............: avg=37.9ms  min=12.31ms med=45.33ms max=48.94ms p(90)=48.7ms  p(95)=48.82ms
       { expected_response:true }...: avg=37.9ms  min=12.31ms med=45.33ms max=48.94ms p(90)=48.7ms  p(95)=48.82ms
     http_req_failed................: 0.00% ✓ 05
     http_req_receiving.............: avg=133.2µs min=56µs    med=168µs   max=184µs   p(90)=182.8µs p(95)=183.4µs
     http_req_sending...............: avg=52µs    min=42µs    med=53µs    max=62µs    p(90)=59.6µs  p(95)=60.8µs
     http_req_tls_handshaking.......: avg=0s      min=0s      med=0s      max=0s      p(90)=0s      p(95)=0s
     http_req_waiting...............: avg=37.72ms min=12.21ms med=45.1ms  max=48.7ms  p(90)=48.46ms p(95)=48.58ms
     http_reqs......................: 5     0.953716/s
     iteration_duration.............: avg=1.04s   min=1.03s   med=1.04s   max=1.05s   p(90)=1.05s   p(95)=1.05s
     iterations.....................: 5     0.953716/s
     vus............................: 1     min=1      max=1
     vus_max........................: 1     min=1      max=1


running (05.2s), 0/1 VUs, 5 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  5s

Vamos analisar as informações mais importantes deste report.

  • http_request_duration: mostra o tempo total entre o envio da requisição e a sua resposta.
  • http_req_failed: mostra o percentual de requisições que falharam
  • http_reqs: o número de requisições que foram atendidas

O report mostra mais informações, mas por enquanto vamos focar nestas principais.

Um ponto importante antes de continuarmos. Algumas métricas são mostrada em diferentes formas: média (avg), mínimo (min), mediana (med), valor máximo (max), percentil 90 (p90) e percentil 95 (p95). Os primeiros valores são auto-explicativos, enquanto que p90 significa que 90% das requests foram respondidas em 48.7ms ou mais, no caso da http_request_duration . E p95 nos diz que 95% das requests foram respondidas em 48.82ms ou mais. Analisar os valores de p90 e p95 são boas práticas em testes de carga, pois contam uma história mais completa do que a média ou mediana, por exemplo. Vamos usar bastante estes valores nas nossas análises.

Load testing

Vamos usar o k6 para criar um teste que implemente o seguinte cenário:

LoadTest

Para isso vamos usar uma feature do k6 chamada stages. O código do nosso novo teste ficou assim:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    stages: [
        {
            duration: '10s',
            target: 100
        },
        {
            duration: '30s',
            target: 100
        },
        {
            duration: '10s',
            target: 0
        }
    ]
}

export default function () {
    http.get('http://192.168.68.108:3000');
    sleep(1);
}

Com este teste conseguimos simular o cenário de um ramp-up, com o primeiro stage. O teste vai executar durante 10 segundos aumentando gradualmente o número de usuários até atingir 100. Depois vai ficar executando acessos com estes 100 usuários por 30 segundos e, finalmente, vai levar mais 10 segundos para diminuir gradualmente o número, até zerar.

O resultado da execução foi:

❯ k6 run tests/load-test.js

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  ()  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: tests/load-test.js
     output: -

  scenarios: (100.00%) 1 scenario, 100 max VUs, 1m20s max duration (incl. graceful stop):
           * default: Up to 100 looping VUs for 50s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)


     data_received..................: 458 kB 9.0 kB/s
     data_sent......................: 316 kB 6.2 kB/s
     http_req_blocked...............: avg=667.11µs min=1µs    med=4µs    max=105.83ms p(90)=10µs     p(95)=13µs
     http_req_connecting............: avg=658.34µs min=0s     med=0s     max=105.68ms p(90)=0s       p(95)=0s
     http_req_duration..............: avg=91.44ms  min=5.94ms med=92.4ms max=409.05ms p(90)=207.7ms  p(95)=230.13ms
       { expected_response:true }...: avg=91.44ms  min=5.94ms med=92.4ms max=409.05ms p(90)=207.7ms  p(95)=230.13ms
     http_req_failed................: 0.00%  ✓ 03720
     http_req_receiving.............: avg=44.72µs  min=13µs   med=36µs   max=682µs    p(90)=71µs     p(95)=103µs
     http_req_sending...............: avg=50.59µs  min=5µs    med=14µs   max=2.23ms   p(90)=78.1µs   p(95)=227.04µs
     http_req_tls_handshaking.......: avg=0s       min=0s     med=0s     max=0s       p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=91.34ms  min=5.87ms med=92.2ms max=409ms    p(90)=207.51ms p(95)=230.08ms
     http_reqs......................: 3720   73.335688/s
     iteration_duration.............: avg=1.09s    min=1s     med=1.09s  max=1.4s     p(90)=1.2s     p(95)=1.23s
     iterations.....................: 3720   73.335688/s
     vus............................: 7      min=7       max=100
     vus_max........................: 100    min=100     max=100


running (0m50.7s), 000/100 VUs, 3720 complete and 0 interrupted iterations
default ✓ [======================================] 000/100 VUs  50s

Analisando os resultados podemos observar:

  • http_request_duration: p(90)=207.7ms e p(95)=230.13ms
  • http_req_failed: 0%
  • http_reqs: 3720 ou seja, 73.335688/s

De acordo com a definição deste tipo de teste, esse é o comportamento esperado para nossa API. Estes são os valores mínimos que esperamos que nossa aplicação responda. Nos próximos tópicos vamos testar os limites da aplicação.

Stress testing

Vamos agora colocar mais pressão sob nossa aplicação, simulando o cenário:

StressTest

Vamos colocar o dobro de carga para analisar o comportamento da API:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    stages: [
        {
            duration: '10s',
            target: 200
        },
        {
            duration: '30s',
            target: 200
        },
        {
            duration: '10s',
            target: 0
        }
    ]
}

export default function () {
    http.get('http://192.168.68.108:3000');
    sleep(1);
}

O resultado da execução foi:

❯ k6 run tests/stress-test.js

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  ()  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: tests/stress-test.js
     output: -

  scenarios: (100.00%) 1 scenario, 200 max VUs, 1m20s max duration (incl. graceful stop):
           * default: Up to 200 looping VUs for 50s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)


     data_received..................: 914 kB 18 kB/s
     data_sent......................: 632 kB 13 kB/s
     http_req_blocked...............: avg=709.98µs min=1µs    med=4µs     max=590.31ms p(90)=8µs      p(95)=11µs
     http_req_connecting............: avg=701.63µs min=0s     med=0s      max=590.23ms p(90)=0s       p(95)=0s
     http_req_duration..............: avg=91.79ms  min=4.69ms med=54.47ms max=1.02s    p(90)=215.15ms p(95)=240.84ms
       { expected_response:true }...: avg=91.79ms  min=4.69ms med=54.47ms max=1.02s    p(90)=215.15ms p(95)=240.84ms
     http_req_failed................: 0.00%  ✓ 07431
     http_req_receiving.............: avg=41.07µs  min=10µs   med=36µs    max=577µs    p(90)=63µs     p(95)=82µs
     http_req_sending...............: avg=45.08µs  min=4µs    med=14µs    max=2.6ms    p(90)=59µs     p(95)=166.49µs
     http_req_tls_handshaking.......: avg=0s       min=0s     med=0s      max=0s       p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=91.71ms  min=4.61ms med=54.42ms max=1.02s    p(90)=215.09ms p(95)=240.79ms
     http_reqs......................: 7431   147.939696/s
     iteration_duration.............: avg=1.09s    min=1s     med=1.05s   max=2.03s    p(90)=1.21s    p(95)=1.24s
     iterations.....................: 7431   147.939696/s
     vus............................: 18     min=18       max=200
     vus_max........................: 200    min=200      max=200


running (0m50.2s), 000/200 VUs, 7431 complete and 0 interrupted iterations
default ✓ [======================================] 000/200 VUs  50s

Analisando os resultados podemos observar:

  • http_request_duration: p(90)=215.15ms e p(95)=240.84ms
  • http_req_failed: 0%
  • http_reqs: 7431 ou seja, 147.939696/s

Podemos agora facilmente fazer simulações aumentando a carga para o triplo de acessos, ou outro múltiplo que faça sentido.

Spike testing

Neste cenário vamos emular um pico inesperado de acessos:

SpikeTest

O teste ficou da seguinte forma:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    stages: [
        {
            duration: '1m',
            target: 10000
        },
        {
            duration: '30s',
            target: 0
        }
    ]
}

export default function () {
    http.get('http://192.168.68.108:3000');
    sleep(1);
}

E o resultado:

❯ k6 run tests/spike-test.js

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  ()  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: tests/spike-test.js
     output: -

  scenarios: (100.00%) 1 scenario, 10000 max VUs, 2m0s max duration (incl. graceful stop):
           * default: Up to 10000 looping VUs for 1m30s over 2 stages (gracefulRampDown: 30s, gracefulStop: 30s)

WARN[0050] Request Failed     error="Get \"http://192.168.68.108:3000\": read tcp 192.168.68.106:64758->192.168.68.108:3000: read: connection reset by peer"
WARN[0050] Request Failed     error="Get \"http://192.168.68.108:3000\": read tcp 192.168.68.106:64758->192.168.68.108:3000: read: connection reset by peer"

     data_received..................: 42 MB  393 kB/s
     data_sent......................: 29 MB  272 kB/s
     http_req_blocked...............: avg=31.54ms  min=0s     med=2µs      max=14.25s  p(90)=4µs      p(95)=7µs
     http_req_connecting............: avg=31.54ms  min=0s     med=0s       max=14.25s  p(90)=0s       p(95)=0s
     http_req_duration..............: avg=296.14ms min=0s     med=103.91ms max=47.07s  p(90)=594.6ms  p(95)=1.14s
       { expected_response:true }...: avg=295.5ms  min=5.05ms med=103.79ms max=47.07s  p(90)=592.84ms p(95)=1.13s
     http_req_failed................: 0.08%  ✓ 295345070
     http_req_receiving.............: avg=20.58µs  min=0s     med=15µs     max=9.21ms  p(90)=29µs     p(95)=40µs
     http_req_sending...............: avg=35.69µs  min=0s     med=6µs      max=16.99ms p(90)=39µs     p(95)=113µs
     http_req_tls_handshaking.......: avg=0s       min=0s     med=0s       max=0s      p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=296.08ms min=0s     med=103.84ms max=47.07s  p(90)=594.57ms p(95)=1.14s
     http_reqs......................: 345365 3200.769108/s
     iteration_duration.............: avg=1.32s    min=1s     med=1.1s     max=48.07s  p(90)=1.64s    p(95)=2.39s
     iterations.....................: 345365 3200.769108/s
     vus............................: 1      min=1         max=10000
     vus_max........................: 10000  min=10000     max=10000


running (1m47.9s), 00000/10000 VUs, 345365 complete and 4 interrupted iterations
default ✓ [======================================] 00000/10000 VUs  1m30s

Aqui a situação ficou mais interessante. A mensagem a seguir ocorreu diversas vezes no resultado e eu cortei para não ocupar muito espaço no post.

WARN[0050] Request Failed     error="Get \"http://192.168.68.108:3000\": read tcp 192.168.68.106:64758->192.168.68.108:3000: read: connection reset by peer"

Analisando os principais dados:

  • http_request_duration: p(90)=594.6ms e p(95)=1.14s
  • http_req_failed: 0.08%, com 295 falhas.
  • http_reqs: 345365 ou seja, 3200.769108/s

Foi possível observar que a aplicação conseguiu responder ao pico de acessos, com degradação da performance, mas algumas requisições falharam. Essa informação mostra importantes insights sobre possíveis melhorias na aplicação ou na infraestrutura. Ou mesmo para tomar a decisão se estas 0.08% de falhas estão dentro de um patamar aceitável e nada precisa ser feito.

Breakpoint testing

Vamos agora tentar observar qual é o limite máximo que a aplicação suporta. Para isso criamos o teste para simular o cenário:

BreakTest

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    stages: [
        {
            duration: '2h',
            target: 100000
        }
    ]
}

export default function () {
    http.get('http://192.168.68.108:3000');
    sleep(1);
}

Esse é um dos testes que é complexo de se observar apenas com o k6. É preciso analisar como o servidor e a aplicação em si está se comportando para entender quando ela para de responder. Para isso é importante que a aplicação tenha aplicado os importantes conceitos de observabilidade. Com observação dos detalhes internos da aplicação vamos poder entender se o ponto de ruptura é a memória do servidor/cluster, se é o sistema de arquivos, o banco de dados, etc. Não vou ter dados para mostrar no resultado deste teste, pois todos estes aspectos são necessários na aplicação e infraestrutura, e isso está fora do escopo deste post.

Soak testing

A ideia deste tipo de teste é observar como a aplicação se comporta sob pressão constante durante um grande período de tempo:

Soaktest

Podemos representar este cenário com o seguinte teste:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    stages: [
        {
            duration: '5m',
            target: 1000
        },
        {
            duration: '24h',
            target: 1000
        },
        {
            duration: '5m',
            target: 0
        }
    ]
}

export default function () {
    http.get('http://192.168.68.108:3000');
    sleep(1);
}

Da mesma forma que o Breakpoint test, neste caso também precisamos fazer uso de observabilidade para podermos entender onde os problemas começam a aparecer depois de algum tempo sob essa pressão constante. Podem acontecer problemas de vazamento de memória, esgotamento de recursos como memória, disco, banco e dados e até mesmo rede.

Conclusões

Este post tinha por objetivo materializar os conceitos que foram apresentados na primeira parte, demonstrando como criar testes usando o k6. Espero que com estes primeiros exemplos seja possível que o leitor aplique em seus ambientes para detectar possíveis falhas e estar preparado para picos de acesso como black friday, campanhas publicitárias ou mesmo ataques inesperados de segurança.

Como leitura complementar eu sugiro:

  • O k6 permite que sejam criados checks que vão falhar de acordo com parâmetros como tempo de requisição, número de falhas, etc. Isso permite que o teste do k6 funcione como uma validação de performance/disponibilidade que pode ser adicionada em esteiras de CI/CD.
  • Além da solução open source mostrada aqui existe uma versão Cloud, fornecida pela Grafana Labs. Com ela é possível executar os testes de origens distintas e na nuvem, garantindo um teste mais avançado e completo. Além disso é possível integrar os resultados dos testes em dashboards usando a dupla Prometheus + Grafana. Essa solução é paga, mas vale muito a análise para casos mais críticos.
  • Recomendo a leitura da documentação oficial, pois contém mais detalhes de customização tanto dos testes quanto dos resultados e verificações.