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% ✓ 0 ✗ 5
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 falharamhttp_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:
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% ✓ 0 ✗ 3720
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
ep(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:
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% ✓ 0 ✗ 7431
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
ep(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:
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% ✓ 295 ✗ 345070
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
ep(95)=1.14s
http_req_failed
:0.08%
, com295
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:
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:
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.