Files
gongxue-base/scripts/run-docker-4c16g-benchmark.js
2026-07-01 06:33:39 +08:00

423 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const apiBase = process.env.PERF_API_BASE || 'http://127.0.0.1:8787';
const outputDir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports');
const dbContainerName = process.env.BENCHMARK_DB_CONTAINER || 'supabase_db_tiku-saas-local';
const shouldLimitDb = process.env.BENCHMARK_LIMIT_DB_RESOURCES === 'true';
const shouldKeepDbLimit = process.env.BENCHMARK_KEEP_DB_LIMIT === 'true';
const dbLimit = {
cpus: process.env.BENCHMARK_DB_CPUS || '2.0',
memory: process.env.BENCHMARK_DB_MEMORY || '8g',
};
const matrix = [
{ name: 'read-30', duration: 120, concurrency: 30, ramp: 15, writes: false, ratio: 0 },
{ name: 'mixed-50', duration: 60, concurrency: 50, ramp: 10, writes: true, ratio: 0.1 },
{ name: 'mixed-100', duration: 60, concurrency: 100, ramp: 15, writes: true, ratio: 0.08 },
{ name: 'mixed-150', duration: 60, concurrency: 150, ramp: 20, writes: true, ratio: 0.06 },
];
function baseEnv(extra = {}) {
return {
...process.env,
DATABASE_URL: databaseUrl,
AUTH_SESSION_SECRET: process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me',
DB_POOL_MAX: process.env.DB_POOL_MAX || '10',
BENCHMARK_API_CPUS: process.env.BENCHMARK_API_CPUS || '2.0',
BENCHMARK_API_MEMORY: process.env.BENCHMARK_API_MEMORY || '4g',
...extra,
};
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
env: baseEnv(options.env),
});
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
}
}
function runCapture(command, args, options = {}) {
return spawnSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
env: baseEnv(options.env),
});
}
function dockerJson(args) {
const result = runCapture('docker', args);
if (result.status !== 0) {
return { unavailable: true, message: `${result.stderr || result.stdout || `docker ${args.join(' ')} failed`}`.trim() };
}
try {
return JSON.parse(result.stdout || '{}');
} catch (error) {
return { unavailable: true, message: error?.message || String(error), raw: result.stdout };
}
}
function dockerText(args) {
const result = runCapture('docker', args);
if (result.status !== 0) return '';
return String(result.stdout || '').trim();
}
function redactUrl(value) {
return String(value || '').replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return value === 0 ? 'unlimited' : '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let next = value;
let unitIndex = 0;
while (next >= 1024 && unitIndex < units.length - 1) {
next /= 1024;
unitIndex += 1;
}
return `${next >= 10 ? next.toFixed(0) : next.toFixed(1)}${units[unitIndex]}`;
}
function shanghaiStamp(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
}
function dockerInfoSummary() {
const info = dockerJson(['info', '--format', '{{json .}}']);
if (info.unavailable) return info;
return {
operatingSystem: info.OperatingSystem,
serverVersion: info.ServerVersion,
ncpu: info.NCPU,
memoryBytes: info.MemTotal,
memory: formatBytes(info.MemTotal),
cgroupVersion: info.CgroupVersion,
context: info.ClientInfo?.Context,
};
}
function dockerHostRestoreLimit() {
const info = dockerInfoSummary();
const ncpu = Number(info.ncpu || 0);
const memoryBytes = Number(info.memoryBytes || 0);
return {
cpus: ncpu > 0 ? String(ncpu) : '0',
memory: memoryBytes > 0 ? String(Math.floor(memoryBytes * 0.98)) : '0',
memorySwap: memoryBytes > 0 ? String(Math.floor(memoryBytes * 0.98)) : '-1',
note: ncpu > 0 && memoryBytes > 0
? 'Original DB container was unlimited; Docker Desktop may not clear live memory limits with docker update, so restore uses the Docker Desktop host limit.'
: 'Original DB container was unlimited but Docker host resources were unavailable; attempted docker update unlimited restore.',
};
}
function resourceSummaryFromHostConfig(hostConfig = {}) {
const nanoCpus = Number(hostConfig.NanoCpus || 0);
const memoryBytes = Number(hostConfig.Memory || 0);
const memorySwapBytes = Number(hostConfig.MemorySwap || 0);
return {
cpus: nanoCpus > 0 ? Number((nanoCpus / 1_000_000_000).toFixed(2)) : null,
memoryBytes,
memory: formatBytes(memoryBytes),
memorySwapBytes,
memorySwap: memorySwapBytes === -1 ? 'unlimited' : formatBytes(memorySwapBytes),
cpuQuota: hostConfig.CpuQuota || 0,
cpuPeriod: hostConfig.CpuPeriod || 0,
raw: {
nanoCpus,
memory: memoryBytes,
memorySwap: memorySwapBytes,
},
};
}
function inspectContainer(container) {
if (!container) return { unavailable: true, message: 'container name/id is empty' };
const inspect = dockerJson(['inspect', container]);
if (inspect.unavailable) return inspect;
const item = Array.isArray(inspect) ? inspect[0] : inspect;
if (!item) return { unavailable: true, message: `container ${container} not found` };
return {
id: item.Id,
name: String(item.Name || '').replace(/^\//, ''),
image: item.Config?.Image || '',
state: item.State?.Status || '',
resources: resourceSummaryFromHostConfig(item.HostConfig),
};
}
function composeServiceContainerId(service) {
return dockerText(['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'ps', '-q', service])
.split(/\r?\n/)
.map(item => item.trim())
.filter(Boolean)[0] || '';
}
function updateContainerResources(container, resources) {
const args = ['update', '--cpus', resources.cpus, '--memory', resources.memory, '--memory-swap', resources.memorySwap, container];
const result = runCapture('docker', args);
if (result.status !== 0) {
throw new Error(`docker ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
}
}
function maybeApplyDbLimit(resourceEvidence) {
resourceEvidence.database = {
containerName: dbContainerName,
limitRequested: shouldLimitDb,
requestedLimit: {
cpus: shouldLimitDb ? dbLimit.cpus : null,
memory: shouldLimitDb ? dbLimit.memory : null,
},
before: inspectContainer(dbContainerName),
};
if (!shouldLimitDb) {
resourceEvidence.database.note = 'DB container resources were not changed. Local Supabase/PostgreSQL may still use Docker Desktop global resources.';
return null;
}
if (resourceEvidence.database.before.unavailable) {
resourceEvidence.database.limitApplied = false;
resourceEvidence.database.limitError = resourceEvidence.database.before.message;
return null;
}
const beforeRaw = resourceEvidence.database.before.resources.raw;
const restore = beforeRaw.nanoCpus > 0 || beforeRaw.memory > 0
? {
cpus: beforeRaw.nanoCpus > 0 ? String(beforeRaw.nanoCpus / 1_000_000_000) : '0',
memory: String(beforeRaw.memory || 0),
memorySwap: String(beforeRaw.memorySwap || 0),
note: 'Restoring DB container resource limits captured before benchmark.',
}
: dockerHostRestoreLimit();
try {
updateContainerResources(dbContainerName, {
cpus: dbLimit.cpus,
memory: dbLimit.memory,
memorySwap: dbLimit.memory,
});
resourceEvidence.database.limitApplied = true;
resourceEvidence.database.afterLimit = inspectContainer(dbContainerName);
resourceEvidence.database.restorePlanned = !shouldKeepDbLimit;
return restore;
} catch (error) {
resourceEvidence.database.limitApplied = false;
resourceEvidence.database.limitError = error?.message || String(error);
return null;
}
}
function restoreDbLimit(restore, resourceEvidence) {
if (!restore || shouldKeepDbLimit) return;
try {
updateContainerResources(dbContainerName, restore);
resourceEvidence.database.restored = true;
resourceEvidence.database.restoreLimit = restore;
resourceEvidence.database.afterRestore = inspectContainer(dbContainerName);
} catch (error) {
resourceEvidence.database.restored = false;
resourceEvidence.database.restoreError = error?.message || String(error);
}
}
function latestBenchmarkJson(before) {
const files = fs.existsSync(outputDir)
? fs.readdirSync(outputDir)
.filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name))
.map(name => path.join(outputDir, name))
.filter(file => fs.statSync(file).mtimeMs >= before)
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
: [];
if (!files[0]) throw new Error('Benchmark report JSON was not generated.');
return path.relative(repoRoot, files[0]).replaceAll('\\', '/');
}
function readBenchmarkSummary(relativePath) {
const absolutePath = path.join(repoRoot, relativePath);
const report = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
return {
startedAt: report.startedAt,
finishedAt: report.finishedAt,
config: report.config,
summary: report.summary,
};
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForHealth(timeoutMs = 120_000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`${apiBase}/health`);
const payload = await response.json().catch(() => ({}));
if (response.ok && payload.ok) return;
lastError = new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`);
} catch (error) {
lastError = error;
}
await sleep(1000);
}
throw new Error(`API did not become healthy at ${apiBase}. ${lastError?.message || ''}`);
}
async function main() {
const resourceEvidence = {
generatedAt: new Date().toISOString(),
benchmarkKind: 'docker-api-4c16g-observation',
databaseUrl: redactUrl(databaseUrl),
apiBase,
docker: dockerInfoSummary(),
api: {
requestedLimit: {
cpus: process.env.BENCHMARK_API_CPUS || '2.0',
memory: process.env.BENCHMARK_API_MEMORY || '4g',
},
dbPoolMax: process.env.DB_POOL_MAX || '10',
},
matrix,
reports: [],
};
const generated = [];
const dbRestore = maybeApplyDbLimit(resourceEvidence);
let status = 'pass';
let failure = null;
try {
run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']);
await waitForHealth();
resourceEvidence.api.container = inspectContainer(composeServiceContainerId('api'));
for (const item of matrix) {
const before = Date.now();
run('node', ['scripts/api-performance-benchmark.js'], {
env: {
PERF_START_SERVER: 'false',
PERF_API_BASE: apiBase,
PERF_AUTH_MODE: 'app_session',
PERF_DURATION_SECONDS: String(item.duration),
PERF_CONCURRENCY: String(item.concurrency),
PERF_RAMP_SECONDS: String(item.ramp),
PERF_INCLUDE_WRITES: item.writes ? 'true' : 'false',
PERF_PRACTICE_FLOW_RATIO: String(item.ratio),
PERF_PRACTICE_FLOW_ANSWERS: '3',
},
});
const report = latestBenchmarkJson(before);
const summary = readBenchmarkSummary(report);
generated.push({ scenario: item.name, report, summary: summary.summary });
resourceEvidence.reports.push({ scenario: item.name, report, ...summary });
}
} catch (error) {
status = 'fail';
failure = error;
resourceEvidence.error = error?.message || String(error);
throw error;
} finally {
restoreDbLimit(dbRestore, resourceEvidence);
resourceEvidence.finishedAt = new Date().toISOString();
resourceEvidence.status = status;
fs.mkdirSync(outputDir, { recursive: true });
const resourceJson = path.join(outputDir, `docker-4c16g-resource-evidence-${shanghaiStamp()}.json`);
const resourceMd = resourceJson.replace(/\.json$/, '.md');
fs.writeFileSync(resourceJson, `${JSON.stringify(resourceEvidence, null, 2)}\n`, 'utf8');
fs.writeFileSync(resourceMd, resourceEvidenceMarkdown(resourceEvidence), 'utf8');
if (failure) console.error(`[benchmark] resource evidence written before failure: ${path.relative(repoRoot, resourceJson)}`);
else {
console.log(JSON.stringify({
status: 'pass',
apiBase,
resourceEvidence: path.relative(repoRoot, resourceJson).replaceAll('\\', '/'),
reports: generated,
}, null, 2));
}
if (process.env.PERF_KEEP_DOCKER_API !== 'true') {
spawnSync('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'down'], {
cwd: repoRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
});
}
}
}
function resourceEvidenceMarkdown(evidence) {
const lines = [
'# Docker 4c16g 压测资源证据',
'',
`生成时间:${new Date(evidence.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
'',
`状态:${evidence.status || 'running'}`,
'',
`API${evidence.apiBase}`,
'',
`数据库:${evidence.databaseUrl}`,
'',
'## Docker Desktop',
'',
`- 系统:${evidence.docker.operatingSystem || 'unknown'}`,
`- Docker${evidence.docker.serverVersion || 'unknown'}`,
`- CPU${evidence.docker.ncpu || 'unknown'}`,
`- 内存:${evidence.docker.memory || 'unknown'}`,
'',
'## 资源限制',
'',
`- API 请求限制:${evidence.api.requestedLimit.cpus} CPU / ${evidence.api.requestedLimit.memory} / DB_POOL_MAX=${evidence.api.dbPoolMax}`,
`- API 实际限制:${evidence.api.container?.resources?.cpus ?? 'unlimited'} CPU / ${evidence.api.container?.resources?.memory || 'unknown'}`,
`- DB 容器:${evidence.database?.containerName || 'unknown'}`,
`- DB 限制请求:${evidence.database?.limitRequested ? `${evidence.database.requestedLimit.cpus} CPU / ${evidence.database.requestedLimit.memory}` : '未请求'}`,
`- DB 压测前限制:${evidence.database?.before?.resources?.cpus ?? 'unlimited'} CPU / ${evidence.database?.before?.resources?.memory || 'unknown'}`,
];
if (evidence.database?.afterLimit) {
lines.push(`- DB 限制后:${evidence.database.afterLimit.resources.cpus ?? 'unlimited'} CPU / ${evidence.database.afterLimit.resources.memory}`);
}
if (evidence.database?.afterRestore) {
lines.push(`- DB 恢复后:${evidence.database.afterRestore.resources.cpus ?? 'unlimited'} CPU / ${evidence.database.afterRestore.resources.memory}`);
}
if (evidence.database?.restoreLimit?.note) lines.push(`- DB 恢复策略:${evidence.database.restoreLimit.note}`);
if (evidence.database?.note) lines.push(`- 说明:${evidence.database.note}`);
if (evidence.database?.limitError) lines.push(`- DB 限制错误:${evidence.database.limitError}`);
if (evidence.database?.restoreError) lines.push(`- DB 恢复错误:${evidence.database.restoreError}`);
lines.push('', '## 压测摘要', '');
lines.push('| 场景 | 并发 | 时长 | 写入 | 请求 | 错误率 | RPS | P95 | P99 |');
lines.push('| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: |');
for (const report of evidence.reports || []) {
const summary = report.summary || {};
const latency = summary.latencyAll || {};
lines.push(
`| ${report.scenario} | ${report.config?.concurrency ?? '-'} | ${report.config?.durationSeconds ?? '-'}s | ${report.config?.includeWrites ? '是' : '否'} | ${summary.requests ?? '-'} | ${((summary.errorRate || 0) * 100).toFixed(2)}% | ${summary.throughputRps ?? '-'} | ${latency.p95Ms ?? '-'}ms | ${latency.p99Ms ?? '-'}ms |`,
);
}
lines.push('', '说明:这份文件只记录本地 Docker 资源上下文,不能替代目标云服务器正式压测证据。');
return `${lines.join('\n')}\n`;
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});