test(charge): mesure l'API sous charge avec k6
Aucun garde-fou de performance n'existait, et le dépôt ne chiffrait aucun temps de réponse. tests/load, quatre scénarios : - smoke : une minute sur chaque route de lecture, joué à chaque PR ; - charge : 50 utilisateurs, 40 sur le tableau de bord au rythme de son rafraîchissement, 10 qui explorent les sites ; - stress : débit croissant jusqu'à la rupture, arrêt au-delà de 10 % d'erreurs ; - limitation-debit : par le proxy, vérifie que nginx répond 429 et jamais 5xx. Seuils : p95 < 500 ms et p99 < 1 s sur les lectures, moins de 1 % d'échecs. k6 tourne en service Compose (profil load) sur le réseau du projet : il vise backend:8000 et mesure l'API plutôt que la limite de 20 req/s par adresse de nginx. Chaque tir écrit un rapport HTML, une synthèse Markdown et le JSON brut dans tests/load/results. make load-smoke, load-test, load-stress et load-limits ; le job E2E enchaîne le smoke et le test de limitation après Playwright. Closes #47
This commit is contained in:
@@ -15,7 +15,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
parcours:
|
parcours:
|
||||||
name: Parcours Playwright
|
name: Parcours Playwright et tirs k6
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -82,6 +82,35 @@ jobs:
|
|||||||
E2E_COMPTES: ${{ runner.temp }}/comptes.json
|
E2E_COMPTES: ${{ runner.temp }}/comptes.json
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
|
|
||||||
|
# Direct sur `backend:8000` : ce tir mesure l'API, pas la limitation de nginx.
|
||||||
|
- name: Tir k6 de fumée sur l'API
|
||||||
|
env:
|
||||||
|
K6_RESUME: /results/resume-smoke.md
|
||||||
|
run: |
|
||||||
|
K6_EMAIL="$(jq -r .lecteur.email "$RUNNER_TEMP/comptes.json")"
|
||||||
|
K6_PASSWORD="$(jq -r .lecteur.password "$RUNNER_TEMP/comptes.json")"
|
||||||
|
echo "::add-mask::$K6_PASSWORD"
|
||||||
|
export K6_EMAIL K6_PASSWORD
|
||||||
|
make load-smoke
|
||||||
|
|
||||||
|
- name: Vérifie par k6 que le proxy limite le débit
|
||||||
|
env:
|
||||||
|
K6_RESUME: /results/resume-limitation.md
|
||||||
|
run: make load-limits
|
||||||
|
|
||||||
|
- name: Publie la synthèse k6
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
run: cat tests/load/results/resume-*.md >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Publie les rapports k6
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: k6-rapports
|
||||||
|
path: tests/load/results/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: Publie le rapport Playwright
|
- name: Publie le rapport Playwright
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost
|
|||||||
E2E_COMPTES ?= $(CURDIR)/$(E2E)/.comptes.json
|
E2E_COMPTES ?= $(CURDIR)/$(E2E)/.comptes.json
|
||||||
E2E_API ?= http://localhost:$(or $(strip $(call env-val,BACKEND_PORT)),8000)
|
E2E_API ?= http://localhost:$(or $(strip $(call env-val,BACKEND_PORT)),8000)
|
||||||
|
|
||||||
|
# Piege : `run` ne demarre que k6, la stack doit deja tourner. `--user` fait ecrire les rapports
|
||||||
|
# de tests/load/results avec l'uid du poste, pas celui de l'image (12345), qui n'y a pas acces.
|
||||||
|
k6-run = mkdir -p tests/load/results && $(COMPOSE_PROD) --profile load run --rm \
|
||||||
|
--user "$$(id -u):$$(id -g)" -e K6_WEB_DASHBOARD=true \
|
||||||
|
-e K6_WEB_DASHBOARD_EXPORT=/results/$(1)-$$(date +%Y%m%dT%H%M%S).html \
|
||||||
|
k6 run /scripts/$(1).js
|
||||||
|
|
||||||
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
|
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
|
||||||
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
|
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
|
||||||
DEMO_NOW ?= 2024-12-31T00:00:00Z
|
DEMO_NOW ?= 2024-12-31T00:00:00Z
|
||||||
@@ -54,7 +61,7 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
|
|||||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
|
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
|
||||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
||||||
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs \
|
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs \
|
||||||
e2e-install e2e-prepare e2e
|
e2e-install e2e-prepare e2e load-smoke load-test load-stress load-limits
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -223,6 +230,18 @@ e2e-prepare: ## Sème le jeu de démonstration et crée les comptes de test sur
|
|||||||
e2e: ## Joue les parcours Playwright. E2E_BASE_URL= optionnel (défaut http://localhost:4200)
|
e2e: ## Joue les parcours Playwright. E2E_BASE_URL= optionnel (défaut http://localhost:4200)
|
||||||
cd $(E2E) && E2E_COMPTES=$(E2E_COMPTES) npx playwright test
|
cd $(E2E) && E2E_COMPTES=$(E2E_COMPTES) npx playwright test
|
||||||
|
|
||||||
|
load-smoke: ## Tir k6 d'une minute. K6_EMAIL= et K6_PASSWORD= d'un lecteur, K6_BASE_URL= optionnel
|
||||||
|
$(call k6-run,smoke)
|
||||||
|
|
||||||
|
load-test: ## Charge nominale k6, 50 utilisateurs pendant 8 minutes. Rapport HTML dans tests/load/results
|
||||||
|
$(call k6-run,charge)
|
||||||
|
|
||||||
|
load-stress: ## Monte le débit jusqu'à la rupture de l'API. Sur la VM, la prod partage la machine
|
||||||
|
$(call k6-run,stress)
|
||||||
|
|
||||||
|
load-limits: ## Vérifie par le proxy que nginx limite le débit d'une même adresse (429)
|
||||||
|
$(call k6-run,limitation-debit)
|
||||||
|
|
||||||
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ services:
|
|||||||
airflow-init:
|
airflow-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
# Pourquoi : sur le réseau du projet, k6 joint `backend:8000` sans passer par nginx, dont la
|
||||||
|
# limite par adresse (20 req/s) fausserait la mesure de l'API. `make load-*` le lance (ADR 0015).
|
||||||
|
k6:
|
||||||
|
image: grafana/k6:2.3.0
|
||||||
|
profiles: ["load"]
|
||||||
|
volumes:
|
||||||
|
- ./tests/load:/scripts:ro
|
||||||
|
- ./tests/load/results:/results
|
||||||
|
environment:
|
||||||
|
K6_BASE_URL: ${K6_BASE_URL:-http://backend:8000}
|
||||||
|
K6_PROXY_URL: ${K6_PROXY_URL:-https://proxy}
|
||||||
|
K6_EMAIL: ${K6_EMAIL:-}
|
||||||
|
K6_PASSWORD: ${K6_PASSWORD:-}
|
||||||
|
K6_RESUME: ${K6_RESUME:-}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
airflow_logs:
|
airflow_logs:
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Tests de charge (k6)
|
||||||
|
|
||||||
|
Scénarios k6 contre l'API EnerVision. Issue #47, décisions dans l'ADR 0015.
|
||||||
|
|
||||||
|
## Scénarios
|
||||||
|
|
||||||
|
| Script | Cible | Profil | Quand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `smoke.js` | API directe | 2 utilisateurs pendant 1 min, chaque route de lecture | Chaque PR (job E2E), `make load-smoke` |
|
||||||
|
| `charge.js` | API directe | 50 utilisateurs simultanés : montée 2 min, plateau 5 min, descente 1 min | `make load-test`, avant une livraison |
|
||||||
|
| `stress.js` | API directe | Débit croissant de 5 à 200 req/s, arrêt au-delà de 10 % d'erreurs | `make load-stress`, pour situer la rupture |
|
||||||
|
| `limitation-debit.js` | Proxy nginx | 80 req/s pendant 15 s depuis une seule adresse | Chaque PR (job E2E), `make load-limits` |
|
||||||
|
|
||||||
|
### Hypothèses de charge
|
||||||
|
|
||||||
|
Le cahier des charges ne chiffre ni volume d'utilisateurs ni temps de réponse. Les hypothèses
|
||||||
|
retenues :
|
||||||
|
|
||||||
|
- **50 utilisateurs simultanés.** C'est un gestionnaire d'énergie par site suivi, plus
|
||||||
|
l'exploitation, avec de la marge.
|
||||||
|
- **40 gardent le tableau de bord ouvert.** Le frontend interroge `/stats/summary` toutes les
|
||||||
|
10 s et `/alerts` toutes les 60 s, et charge `/predictions` une fois (`dashboard.ts`,
|
||||||
|
`alert-feed.ts`).
|
||||||
|
- **10 explorent les sites.** Ils ouvrent la liste, puis un détail avec sa mesure courante, ses
|
||||||
|
relevés sur 24 h et ses recommandations, avec 3 à 8 s de lecture entre deux pages.
|
||||||
|
|
||||||
|
Soit environ 12 req/s en régime établi, avec des pointes au démarrage des sessions.
|
||||||
|
|
||||||
|
### Seuils
|
||||||
|
|
||||||
|
Les seuils sont posés par l'ADR 0015 et partagés par `lib/config.js`.
|
||||||
|
|
||||||
|
| Seuil | Valeur | Raison |
|
||||||
|
|---|---|---|
|
||||||
|
| `http_req_failed` | < 1 % | Aucune erreur attendue en charge nominale |
|
||||||
|
| `checks` | > 99 % | Chaque réponse est un 200 |
|
||||||
|
| Lectures, p95 | < 500 ms | Rafraîchissement du tableau de bord imperceptible |
|
||||||
|
| Lectures, p99 | < 1 s | Borne des cas les plus lents |
|
||||||
|
| `/stats/summary`, p95 | < 500 ms | Route la plus appelée (`DISTINCT ON` sur tout l'historique) |
|
||||||
|
| `/readings`, p95 | < 800 ms | Fenêtre de 24 h, la plus volumineuse |
|
||||||
|
|
||||||
|
Un seuil franchi fait échouer le tir (code de sortie 99). Le test de stress n'a qu'un seuil
|
||||||
|
bloquant, 10 % d'erreurs : il cherche la rupture, pas un niveau de service.
|
||||||
|
|
||||||
|
## Pourquoi k6 ne passe pas par le proxy
|
||||||
|
|
||||||
|
nginx limite chaque adresse IP à 20 req/s, avec une rafale de 40 (ADR 0007). Un tir depuis une
|
||||||
|
seule machine mesurerait cette limite, pas l'API.
|
||||||
|
|
||||||
|
k6 tourne donc en service Compose (profil `load`) sur le réseau du projet, et vise
|
||||||
|
`backend:8000`. Seul `limitation-debit.js` passe par `https://proxy`, précisément pour vérifier
|
||||||
|
que la limite tient : des 429 au-delà du débit autorisé, jamais d'erreur serveur.
|
||||||
|
|
||||||
|
## Lancer un tir
|
||||||
|
|
||||||
|
Il faut une stack démarrée, des données et un compte `lecteur` **déjà activé** (mot de passe
|
||||||
|
définitif).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Poste, stack de `make dev` (API sur l'hôte) : jeu de démonstration et comptes de test
|
||||||
|
make e2e-prepare
|
||||||
|
make load-smoke K6_BASE_URL=http://host.docker.internal:8000 \
|
||||||
|
K6_EMAIL="$(jq -r .lecteur.email tests/e2e/.comptes.json)" \
|
||||||
|
K6_PASSWORD="$(jq -r .lecteur.password tests/e2e/.comptes.json)"
|
||||||
|
|
||||||
|
# Poste, stack conteneurisée (make stack-up) : l'API est joignable en backend:8000
|
||||||
|
make load-test K6_EMAIL=... K6_PASSWORD=...
|
||||||
|
|
||||||
|
# Recette, sur la VM, dans /srv/enervision/rec : compte lecteur créé depuis l'interface
|
||||||
|
make load-test K6_EMAIL=charge@enervision.fr K6_PASSWORD=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recette et prod partagent la VM** (ADR 0009). `make load-test` y reste raisonnable.
|
||||||
|
`make load-stress` sature l'hôte et ralentit la prod : ne le lancer qu'en accord avec l'équipe,
|
||||||
|
hors démonstration.
|
||||||
|
|
||||||
|
`scripts/comptes-test.sh` ne sert qu'aux bases jetables. En recette, un administrateur crée le
|
||||||
|
compte `lecteur` du tir depuis l'interface, puis on s'y connecte une fois pour changer le mot de
|
||||||
|
passe temporaire.
|
||||||
|
|
||||||
|
## Lire les résultats
|
||||||
|
|
||||||
|
Chaque tir écrit dans `tests/load/results/` (ignoré par git) :
|
||||||
|
|
||||||
|
- `<scénario>-<horodatage>.html` : rapport du tableau de bord web de k6 (courbes de débit, de
|
||||||
|
latence et de VUs), à joindre au dossier de preuves ;
|
||||||
|
- `<scénario>-<horodatage>.md` : synthèse et état de chaque seuil, aussi affichée en console et,
|
||||||
|
en CI, dans le résumé du job ;
|
||||||
|
- `<scénario>-<horodatage>.json` : métriques brutes.
|
||||||
|
|
||||||
|
Pendant un tir, le tableau de bord Grafana « API » (profil `monitoring`) montre le débit et les
|
||||||
|
latences vus par l'API elle-même.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Charge nominale : 50 utilisateurs simultanés. 40 gardent le tableau de bord ouvert, qui
|
||||||
|
// interroge l'API au rythme du frontend ; 10 explorent les sites.
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
import { auHasard, consulterSite, lire, preparer } from './lib/api.js';
|
||||||
|
import { SEUILS, STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
const paliers = (cible) => [
|
||||||
|
{ duration: '2m', target: cible },
|
||||||
|
{ duration: '5m', target: cible },
|
||||||
|
{ duration: '1m', target: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
tableau_de_bord: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: paliers(40),
|
||||||
|
exec: 'tableauDeBord',
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
},
|
||||||
|
exploration: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: paliers(10),
|
||||||
|
exec: 'exploration',
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: SEUILS,
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setup = preparer;
|
||||||
|
|
||||||
|
// Une itération vaut une minute d'écran ouvert : `/stats/summary` toutes les 10 s, les alertes
|
||||||
|
// toutes les 60 s, les prévisions une fois (dashboard.ts, alert-feed.ts).
|
||||||
|
export function tableauDeBord(data) {
|
||||||
|
lire('/predictions', 'predictions', data.jeton);
|
||||||
|
lire('/alerts', 'alerts', data.jeton);
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
lire('/stats/summary', 'stats_summary', data.jeton);
|
||||||
|
sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exploration(data) {
|
||||||
|
lire('/sites', 'sites', data.jeton);
|
||||||
|
consulterSite(auHasard(data.sites), data.jeton);
|
||||||
|
sleep(3 + Math.random() * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('charge', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { check, fail } from 'k6';
|
||||||
|
import http from 'k6/http';
|
||||||
|
|
||||||
|
import { API, COMPTE } from './config.js';
|
||||||
|
|
||||||
|
export function connecter() {
|
||||||
|
if (!COMPTE.email || !COMPTE.password) {
|
||||||
|
fail('K6_EMAIL et K6_PASSWORD désignent un compte lecteur déjà activé');
|
||||||
|
}
|
||||||
|
const reponse = http.post(`${API}/auth/login`, JSON.stringify(COMPTE), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
tags: { name: 'auth_login', type: 'auth' },
|
||||||
|
});
|
||||||
|
if (!check(reponse, { 'connexion acceptée': (r) => r.status === 200 })) {
|
||||||
|
fail(`connexion refusée : ${reponse.status} ${reponse.body}`);
|
||||||
|
}
|
||||||
|
return reponse.json('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jeton propre à chaque VU, repris sur un 401 : un tir plus long que le TTL du jeton (15 min)
|
||||||
|
// ne doit pas finir en erreurs. Jamais de refresh, dont la rotation révoquerait la session.
|
||||||
|
let jetonDuVu = null;
|
||||||
|
|
||||||
|
export function lire(chemin, nom, jetonInitial) {
|
||||||
|
const envoyer = () =>
|
||||||
|
http.get(`${API}${chemin}`, {
|
||||||
|
headers: { Authorization: `Bearer ${jetonDuVu || jetonInitial}` },
|
||||||
|
tags: { name: nom, type: 'lecture' },
|
||||||
|
});
|
||||||
|
let reponse = envoyer();
|
||||||
|
if (reponse.status === 401) {
|
||||||
|
jetonDuVu = connecter();
|
||||||
|
reponse = envoyer();
|
||||||
|
}
|
||||||
|
check(reponse, { [`${nom} répond 200`]: (r) => r.status === 200 });
|
||||||
|
return reponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparer() {
|
||||||
|
const jeton = connecter();
|
||||||
|
const sites = lire('/sites', 'sites', jeton).json();
|
||||||
|
if (!Array.isArray(sites) || sites.length === 0) {
|
||||||
|
fail('aucun site en base : semer db/seeds/demo.sql ou importer des relevés avant le tir');
|
||||||
|
}
|
||||||
|
return { jeton, sites: sites.map((site) => site.site_id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auHasard(liste) {
|
||||||
|
return liste[Math.floor(Math.random() * liste.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même fenêtre que le détail d'un site dans le frontend : les 24 heures qui précèdent sa
|
||||||
|
// dernière mesure, pas celles qui précèdent l'instant présent.
|
||||||
|
export function consulterSite(siteId, jeton) {
|
||||||
|
lire(`/sites/${siteId}`, 'site', jeton);
|
||||||
|
const courant = lire(`/sites/${siteId}/current`, 'site_current', jeton);
|
||||||
|
const horodatage = courant.status === 200 ? courant.json('timestamp') : null;
|
||||||
|
const fin = horodatage ? new Date(horodatage) : new Date();
|
||||||
|
const debut = new Date(fin.getTime() - 24 * 3600 * 1000);
|
||||||
|
const fenetre = `start=${encodeURIComponent(debut.toISOString())}&end=${encodeURIComponent(fin.toISOString())}`;
|
||||||
|
lire(`/readings?site_id=${siteId}&${fenetre}`, 'readings', jeton);
|
||||||
|
lire(`/recommendations?site_id=${siteId}`, 'recommendations', jeton);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const BASE_URL = __ENV.K6_BASE_URL || 'http://backend:8000';
|
||||||
|
export const API = `${BASE_URL}/api/v1`;
|
||||||
|
export const PROXY_URL = __ENV.K6_PROXY_URL || 'https://proxy';
|
||||||
|
|
||||||
|
export const COMPTE = {
|
||||||
|
email: __ENV.K6_EMAIL,
|
||||||
|
password: __ENV.K6_PASSWORD,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seuils posés par l'ADR 0015, faute d'exigence chiffrée dans le cahier des charges.
|
||||||
|
export const SEUILS = {
|
||||||
|
http_req_failed: ['rate<0.01'],
|
||||||
|
checks: ['rate>0.99'],
|
||||||
|
'http_req_duration{type:lecture}': ['p(95)<500', 'p(99)<1000'],
|
||||||
|
'http_req_duration{name:stats_summary}': ['p(95)<500'],
|
||||||
|
'http_req_duration{name:readings}': ['p(95)<800'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATISTIQUES = ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'];
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export function rapport(nom, data) {
|
||||||
|
const horodatage = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const markdown = enMarkdown(nom, data);
|
||||||
|
const sorties = {
|
||||||
|
stdout: `${markdown}\n`,
|
||||||
|
[`/results/${nom}-${horodatage}.json`]: JSON.stringify(data, null, 2),
|
||||||
|
[`/results/${nom}-${horodatage}.md`]: markdown,
|
||||||
|
};
|
||||||
|
if (__ENV.K6_RESUME) {
|
||||||
|
sorties[__ENV.K6_RESUME] = markdown;
|
||||||
|
}
|
||||||
|
return sorties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valeur(metrique, statistique, unite) {
|
||||||
|
const brut = metrique?.values?.[statistique];
|
||||||
|
if (brut === undefined) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return unite === '%' ? `${(brut * 100).toFixed(2)} %` : `${brut.toFixed(1)} ${unite}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enMarkdown(nom, data) {
|
||||||
|
const m = data.metrics;
|
||||||
|
const lignes = [
|
||||||
|
`### Tir k6 « ${nom} »`,
|
||||||
|
'',
|
||||||
|
'| Mesure | Valeur |',
|
||||||
|
'|---|---|',
|
||||||
|
`| Requêtes | ${m.http_reqs?.values?.count ?? 0} (${valeur(m.http_reqs, 'rate', 'req/s')}) |`,
|
||||||
|
`| Échecs HTTP | ${valeur(m.http_req_failed, 'rate', '%')} |`,
|
||||||
|
`| Vérifications réussies | ${valeur(m.checks, 'rate', '%')} |`,
|
||||||
|
`| Durée médiane | ${valeur(m.http_req_duration, 'med', 'ms')} |`,
|
||||||
|
`| Durée p95 | ${valeur(m.http_req_duration, 'p(95)', 'ms')} |`,
|
||||||
|
`| Durée p99 | ${valeur(m.http_req_duration, 'p(99)', 'ms')} |`,
|
||||||
|
`| VUs au plus haut | ${m.vus_max?.values?.max ?? '-'} |`,
|
||||||
|
'',
|
||||||
|
'| Seuil | Résultat |',
|
||||||
|
'|---|---|',
|
||||||
|
];
|
||||||
|
for (const [metrique, detail] of Object.entries(m)) {
|
||||||
|
for (const [expression, resultat] of Object.entries(detail.thresholds || {})) {
|
||||||
|
lignes.push(`| \`${metrique}\` ${expression} | ${resultat.ok ? 'tenu' : '**franchi**'} |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lignes.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Vérifie par le proxy que la limitation de nginx tient : au-delà de 20 requêtes par seconde et
|
||||||
|
// de la rafale de 40, une même adresse doit recevoir des 429, et jamais une erreur serveur.
|
||||||
|
import { check } from 'k6';
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { Counter } from 'k6/metrics';
|
||||||
|
|
||||||
|
import { PROXY_URL, STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
const limitees = new Counter('reponses_limitees');
|
||||||
|
|
||||||
|
http.setResponseCallback(http.expectedStatuses(200, 429));
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
rafale: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
rate: 80,
|
||||||
|
timeUnit: '1s',
|
||||||
|
duration: '15s',
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
reponses_limitees: ['count>0'],
|
||||||
|
http_req_failed: ['rate<0.01'],
|
||||||
|
},
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const reponse = http.get(`${PROXY_URL}/api/v1/health/live`, { tags: { name: 'health_live' } });
|
||||||
|
if (reponse.status === 429) {
|
||||||
|
limitees.add(1);
|
||||||
|
}
|
||||||
|
check(reponse, { 'servie ou limitée, jamais en erreur': (r) => [200, 429].includes(r.status) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('limitation-debit', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
import { auHasard, consulterSite, lire, preparer } from './lib/api.js';
|
||||||
|
import { SEUILS, STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
smoke: { executor: 'constant-vus', vus: 2, duration: '1m' },
|
||||||
|
},
|
||||||
|
thresholds: SEUILS,
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setup = preparer;
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
lire('/stats/summary', 'stats_summary', data.jeton);
|
||||||
|
lire('/predictions', 'predictions', data.jeton);
|
||||||
|
lire('/alerts', 'alerts', data.jeton);
|
||||||
|
lire('/sites', 'sites', data.jeton);
|
||||||
|
consulterSite(auHasard(data.sites), data.jeton);
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('smoke', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Monte le débit jusqu'à la rupture pour situer la limite de l'API. S'arrête de lui-même au-delà
|
||||||
|
// de 10 % d'erreurs : le but est de trouver le point de rupture, pas de s'y maintenir.
|
||||||
|
import { auHasard, consulterSite, lire, preparer } from './lib/api.js';
|
||||||
|
import { STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
stress: {
|
||||||
|
executor: 'ramping-arrival-rate',
|
||||||
|
startRate: 5,
|
||||||
|
timeUnit: '1s',
|
||||||
|
preAllocatedVUs: 50,
|
||||||
|
maxVUs: 400,
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 25 },
|
||||||
|
{ duration: '2m', target: 50 },
|
||||||
|
{ duration: '2m', target: 100 },
|
||||||
|
{ duration: '2m', target: 200 },
|
||||||
|
{ duration: '1m', target: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: [{ threshold: 'rate<0.10', abortOnFail: true, delayAbortEval: '30s' }],
|
||||||
|
'http_req_duration{type:lecture}': ['p(95)<2000'],
|
||||||
|
},
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setup = preparer;
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
if (Math.random() < 0.6) {
|
||||||
|
lire('/stats/summary', 'stats_summary', data.jeton);
|
||||||
|
} else {
|
||||||
|
consulterSite(auHasard(data.sites), data.jeton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('stress', data);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user