forked from wangziqi/gongxue-base
test: record docker benchmark resource evidence
This commit is contained in:
@@ -575,7 +575,7 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
|
|||||||
npm run perf:api:docker-4c16g
|
npm run perf:api:docker-4c16g
|
||||||
```
|
```
|
||||||
|
|
||||||
注意:这个入口只限制 API 容器资源,本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。
|
注意:这个入口默认只限制 API 容器资源,并会生成 `docker-4c16g-resource-evidence-*.json/md` 记录 Docker Desktop、API 容器和 Supabase DB 容器的资源上下文;本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。需要更贴近本地 4C16G 模拟时,可以显式设置 `BENCHMARK_LIMIT_DB_RESOURCES=true`、`BENCHMARK_DB_CPUS` 和 `BENCHMARK_DB_MEMORY`,脚本默认会在结束后恢复 DB 容器限制。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。
|
||||||
|
|
||||||
PostgreSQL 调参与运行证据采集:
|
PostgreSQL 调参与运行证据采集:
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,24 @@ Remove-Item Env:\DB_POOL_MAX
|
|||||||
|
|
||||||
这个入口模拟的是“4 核 16G shared-host 中 API 容器的资源约束”,不是完整云服务器复刻。当前本地 Supabase/PostgreSQL 仍跑在 Docker Desktop 的 Supabase stack 中,除非额外手工限制 DB 容器资源,否则数据库容器仍可能使用 Docker Desktop 的全局资源。正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、真实对象存储/CDN 和真实网络下复跑。
|
这个入口模拟的是“4 核 16G shared-host 中 API 容器的资源约束”,不是完整云服务器复刻。当前本地 Supabase/PostgreSQL 仍跑在 Docker Desktop 的 Supabase stack 中,除非额外手工限制 DB 容器资源,否则数据库容器仍可能使用 Docker Desktop 的全局资源。正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、真实对象存储/CDN 和真实网络下复跑。
|
||||||
|
|
||||||
|
每次运行都会额外生成 `docker-4c16g-resource-evidence-*.json/md`,记录 Docker Desktop 总 CPU/内存、API 容器实际 CPU/内存限制、DB 容器是否被限制、`DB_POOL_MAX` 和每个压测报告的摘要。该文件位于已忽略的 `docs/refactor/performance-reports/`,用于防止后续把“只限制 API 容器”的本地结果误写成完整 4 核 16G 生产容量;它不能替代目标云服务器正式压测证据。
|
||||||
|
|
||||||
|
如需要在本地显式限制 Supabase PostgreSQL 容器资源,可以打开下面开关。脚本默认会在结束后恢复 DB 容器原始限制;如果你希望保留限制用于排查,才设置 `BENCHMARK_KEEP_DB_LIMIT=true`。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:BENCHMARK_LIMIT_DB_RESOURCES="true"
|
||||||
|
$env:BENCHMARK_DB_CONTAINER="supabase_db_tiku-saas-local"
|
||||||
|
$env:BENCHMARK_DB_CPUS="2.0"
|
||||||
|
$env:BENCHMARK_DB_MEMORY="8g"
|
||||||
|
npm run perf:api:docker-4c16g
|
||||||
|
Remove-Item Env:\BENCHMARK_LIMIT_DB_RESOURCES
|
||||||
|
Remove-Item Env:\BENCHMARK_DB_CONTAINER
|
||||||
|
Remove-Item Env:\BENCHMARK_DB_CPUS
|
||||||
|
Remove-Item Env:\BENCHMARK_DB_MEMORY
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:Docker `update` 只能限制已有容器的 cgroup 资源,不会替代 PostgreSQL 参数调优。限制 DB 容器后仍必须跑 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json` 检查 `shared_buffers`、`work_mem`、超时、JIT、`pg_stat_statements` 和 `pending_restart`。
|
||||||
|
|
||||||
如需保留 API 容器便于排查:
|
如需保留 API 容器便于排查:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||||
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
||||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
||||||
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
||||||
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
||||||
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
||||||
|
|||||||
32
scripts/docker-benchmark-resource-evidence-test.js
Normal file
32
scripts/docker-benchmark-resource-evidence-test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const scriptPath = path.join(repoRoot, 'scripts', 'run-docker-4c16g-benchmark.js');
|
||||||
|
const runbookPath = path.join(repoRoot, 'docs', 'refactor', 'performance-benchmark-runbook.md');
|
||||||
|
const readmePath = path.join(repoRoot, 'README.md');
|
||||||
|
|
||||||
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||||
|
const runbook = fs.readFileSync(runbookPath, 'utf8');
|
||||||
|
const readme = fs.readFileSync(readmePath, 'utf8');
|
||||||
|
|
||||||
|
assert.match(script, /docker-4c16g-resource-evidence-\$\{shanghaiStamp\(\)\}\.json/, 'benchmark script should write resource evidence JSON');
|
||||||
|
assert.match(script, /BENCHMARK_LIMIT_DB_RESOURCES/, 'benchmark script should expose explicit DB resource limiting');
|
||||||
|
assert.match(script, /BENCHMARK_KEEP_DB_LIMIT/, 'benchmark script should support keeping DB limits only when requested');
|
||||||
|
assert.match(script, /dockerJson\(\['info', '--format', '\{\{json \.\}\}'\]\)/, 'benchmark script should record Docker Desktop resources');
|
||||||
|
assert.match(script, /inspectContainer\(composeServiceContainerId\('api'\)\)/, 'benchmark script should inspect the API container after compose starts');
|
||||||
|
assert.match(script, /inspectContainer\(dbContainerName\)/, 'benchmark script should inspect the Supabase DB container');
|
||||||
|
assert.match(script, /restoreDbLimit\(dbRestore, resourceEvidence\)/, 'benchmark script should restore DB resource limits by default');
|
||||||
|
assert.match(script, /readBenchmarkSummary\(report\)/, 'benchmark script should attach benchmark summaries to resource evidence');
|
||||||
|
|
||||||
|
assert.match(runbook, /docker-4c16g-resource-evidence-\*\.json\/md/, 'runbook should document the resource evidence artifact');
|
||||||
|
assert.match(runbook, /BENCHMARK_LIMIT_DB_RESOURCES="true"/, 'runbook should document explicit DB resource limiting');
|
||||||
|
assert.match(runbook, /默认会在结束后恢复 DB 容器原始限制/, 'runbook should document DB limit restoration');
|
||||||
|
assert.match(runbook, /不能替代目标云服务器正式压测证据/, 'runbook should keep local benchmark scope explicit');
|
||||||
|
|
||||||
|
assert.match(readme, /docker-4c16g-resource-evidence-\*\.json\/md/, 'README should mention resource evidence artifacts');
|
||||||
|
assert.match(readme, /BENCHMARK_LIMIT_DB_RESOURCES=true/, 'README should mention optional DB resource limiting');
|
||||||
|
assert.match(readme, /正式容量承诺仍要在目标 4 核 16G 云服务器复跑/, 'README should avoid overstating local Docker capacity');
|
||||||
|
|
||||||
|
console.log('[PASS] docker benchmark resource evidence guardrails');
|
||||||
@@ -5,6 +5,14 @@ import path from 'node:path';
|
|||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
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 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 = [
|
const matrix = [
|
||||||
{ name: 'read-30', duration: 120, concurrency: 30, ramp: 15, writes: false, ratio: 0 },
|
{ 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-50', duration: 60, concurrency: 50, ramp: 10, writes: true, ratio: 0.1 },
|
||||||
@@ -12,32 +20,217 @@ const matrix = [
|
|||||||
{ name: 'mixed-150', duration: 60, concurrency: 150, ramp: 20, writes: true, ratio: 0.06 },
|
{ 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 = {}) {
|
function run(command, args, options = {}) {
|
||||||
const result = spawnSync(command, args, {
|
const result = spawnSync(command, args, {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: process.platform === 'win32',
|
shell: process.platform === 'win32',
|
||||||
env: {
|
env: baseEnv(options.env),
|
||||||
...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',
|
|
||||||
...options.env,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
|
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',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
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 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 = {
|
||||||
|
cpus: beforeRaw.nanoCpus > 0 ? String(beforeRaw.nanoCpus / 1_000_000_000) : '0',
|
||||||
|
memory: String(beforeRaw.memory || 0),
|
||||||
|
memorySwap: String(beforeRaw.memorySwap || 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
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.afterRestore = inspectContainer(dbContainerName);
|
||||||
|
} catch (error) {
|
||||||
|
resourceEvidence.database.restored = false;
|
||||||
|
resourceEvidence.database.restoreError = error?.message || String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function latestBenchmarkJson(before) {
|
function latestBenchmarkJson(before) {
|
||||||
const dir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports');
|
const files = fs.existsSync(outputDir)
|
||||||
const files = fs.existsSync(dir)
|
? fs.readdirSync(outputDir)
|
||||||
? fs.readdirSync(dir)
|
|
||||||
.filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name))
|
.filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name))
|
||||||
.map(name => path.join(dir, name))
|
.map(name => path.join(outputDir, name))
|
||||||
.filter(file => fs.statSync(file).mtimeMs >= before)
|
.filter(file => fs.statSync(file).mtimeMs >= before)
|
||||||
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
||||||
: [];
|
: [];
|
||||||
@@ -45,6 +238,17 @@ function latestBenchmarkJson(before) {
|
|||||||
return path.relative(repoRoot, files[0]).replaceAll('\\', '/');
|
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) {
|
function sleep(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
@@ -67,10 +271,30 @@ async function waitForHealth(timeoutMs = 120_000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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 generated = [];
|
||||||
|
const dbRestore = maybeApplyDbLimit(resourceEvidence);
|
||||||
|
let status = 'pass';
|
||||||
|
let failure = null;
|
||||||
try {
|
try {
|
||||||
run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']);
|
run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']);
|
||||||
await waitForHealth();
|
await waitForHealth();
|
||||||
|
resourceEvidence.api.container = inspectContainer(composeServiceContainerId('api'));
|
||||||
for (const item of matrix) {
|
for (const item of matrix) {
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
run('node', ['scripts/api-performance-benchmark.js'], {
|
run('node', ['scripts/api-performance-benchmark.js'], {
|
||||||
@@ -86,10 +310,34 @@ async function main() {
|
|||||||
PERF_PRACTICE_FLOW_ANSWERS: '3',
|
PERF_PRACTICE_FLOW_ANSWERS: '3',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
generated.push({ scenario: item.name, report: latestBenchmarkJson(before) });
|
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 });
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify({ status: 'pass', apiBase, reports: generated }, null, 2));
|
} catch (error) {
|
||||||
|
status = 'fail';
|
||||||
|
failure = error;
|
||||||
|
resourceEvidence.error = error?.message || String(error);
|
||||||
|
throw error;
|
||||||
} finally {
|
} 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') {
|
if (process.env.PERF_KEEP_DOCKER_API !== 'true') {
|
||||||
spawnSync('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'down'], {
|
spawnSync('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'down'], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
@@ -100,6 +348,56 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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?.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 => {
|
main().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user