// Нагрузочный smoke по запущенному образу/серверу (autocannon). // node scripts/perf/load.mjs http://127.0.0.1:8080 [container-name] // Бюджеты (env): LOAD_MAX_P99_MS (150), LOAD_MIN_RPS (500), LOAD_MAX_RSS_MB (200), LOAD_DURATION_S (15) import { execFileSync } from 'node:child_process' import autocannon from 'autocannon' const base = process.argv[2] ?? 'http://127.0.0.1:8080' const container = process.argv[3] const duration = Number(process.env.LOAD_DURATION_S ?? 15) const maxP99 = Number(process.env.LOAD_MAX_P99_MS ?? 150) const minRps = Number(process.env.LOAD_MIN_RPS ?? 500) const maxRss = Number(process.env.LOAD_MAX_RSS_MB ?? 200) const email = process.env.LOAD_EMAIL ?? 'admin@smoke.local' const password = process.env.LOAD_PASSWORD async function token() { if (!password) return null const res = await fetch(`${base}/api/v1/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }) if (!res.ok) throw new Error(`login ${res.status}`) return (await res.json()).access_token } function run(title, opts) { return new Promise((resolve, reject) => { autocannon({ url: base, connections: 50, duration, ...opts }, (err, r) => err ? reject(err) : resolve({ title, r }), ) }) } function rssMb() { if (!container) return null const out = execFileSync('docker', ['stats', '--no-stream', '--format', '{{.MemUsage}}', container]).toString() const m = /^([\d.]+)(KiB|MiB|GiB)/.exec(out.trim()) if (!m) return null const v = Number(m[1]) return m[2] === 'GiB' ? v * 1024 : m[2] === 'KiB' ? v / 1024 : v } const jwt = await token() const scenarios = [{ title: 'GET /health', opts: { url: `${base}/health` } }] if (jwt) { scenarios.push({ title: 'GET /api/v1/auth/me', opts: { url: `${base}/api/v1/auth/me`, headers: { authorization: `Bearer ${jwt}` } }, }) } const failures = [] for (const s of scenarios) { const { r } = await run(s.title, s.opts) const non2xx = r.non2xx console.log( `${s.title}: ${Math.round(r.requests.average)} rps, p99 ${r.latency.p99} ms, non2xx ${non2xx}, errors ${r.errors}`, ) if (r.latency.p99 > maxP99) failures.push(`${s.title}: p99 ${r.latency.p99} > ${maxP99} ms`) if (r.requests.average < minRps) failures.push(`${s.title}: ${Math.round(r.requests.average)} rps < ${minRps}`) if (non2xx > 0 || r.errors > 0) failures.push(`${s.title}: ${non2xx} non2xx, ${r.errors} errors`) } const rss = rssMb() if (rss != null) { console.log(`RSS после нагрузки: ${Math.round(rss)} MiB`) if (rss > maxRss) failures.push(`RSS ${Math.round(rss)} MiB > ${maxRss} MiB`) } if (failures.length) { console.error('LOAD BUDGET FAILED:\n - ' + failures.join('\n - ')) process.exit(1) } console.log('LOAD OK')