Files
progcode/web/scripts/upgrade-2024-10.mjs
T
huncode 1b5ff34e2b
Build and deploy / deploy (push) Successful in 17s
revise October 2024 capacity cost articles
2026-07-31 16:43:48 +03:00

744 lines
80 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.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'Kubernetes documentation: Resource requests and limits, immutable snapshot 06.05.2024',
url: 'https://github.com/kubernetes/website/blob/0666011822b3ebbec179d6b3a2e6832977daa3bc/content/en/docs/concepts/configuration/manage-resources-containers.md',
note: 'Первичный неизменяемый снимок документации Kubernetes. Он подтверждает, что scheduler учитывает request, а CPU/memory limit имеют отдельную семантику исполнения. Он не задаёт цену ресурса, модель счёта или удачный размер instance.',
},
{
title: 'Kubernetes documentation: ResourceQuota, immutable snapshot 30.05.2024',
url: 'https://github.com/kubernetes/website/blob/c87b19193d8b1e96de03d76ec7eece02262580f0/content/en/docs/concepts/policy/resource-quotas.md',
note: 'Первичный неизменяемый снимок документации Kubernetes. Он подтверждает абсолютные quota для requests и limits; сумма quota может превышать ёмкость кластера, поэтому quota не является обещанием доступной capacity. Он не связывает quota с облачным invoice и не доказывает, что quota снижает расход.',
},
{
title: 'Kubernetes documentation: LimitRange, immutable snapshot 14.03.2024',
url: 'https://github.com/kubernetes/website/blob/c889d9b2510bba78dbb527a79b8e2099b16f3d67/content/en/docs/concepts/policy/limit-range.md',
note: 'Первичный неизменяемый снимок документации Kubernetes. Он описывает default, minimum, maximum и ratio request/limit на admission stage. Он не является прогнозом saturation, цены или поведения уже запущенного workload.',
},
{
title: 'Google Cloud Billing Catalog v1 proto, immutable snapshot 18.06.2024',
url: 'https://github.com/googleapis/googleapis/blob/0fe653872eca3ece391a0b4ec2f8ace64634419f/google/cloud/billing/v1/cloud_catalog.proto',
note: 'Первичный неизменяемый API-contract. В нём определены SKU, pricing expression, usage unit, base unit и tiered rates. Он объясняет форму billing formula, но не сообщает тариф, валюту, скидку или счёт конкретного читателя.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_VERSION = 'synthetic-capacity-cost-model/2024-10-v1';
const FIXTURE_SCOPE = 'synthetic-capacity-cost-2024-10';
const FIXTURE_MODE = 'fixed-memory-only';
const FIXTURE_LIMIT = 'versioned-fixed-synthetic-js-literals-in-memory-only; no filesystem, git, cloud account, billing export, telemetry, CI, network, production state or live change';
const INPUT_KEYS = Object.freeze(['kind', 'synthetic', 'scope', 'modelVersion', 'mode', 'caseId']);
const REPORT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'scope', 'caseId',
'card', 'cost', 'signals', 'decision', 'stopCondition', 'nextHumanQuestion', 'evidenceBoundary',
'productionEffect',
]);
const DRAFT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'sourceReport',
'decision', 'actions', 'returnPoint', 'realCloudBilling', 'cluster', 'telemetry', 'ci', 'network',
'productionEffect',
]);
const ROLLBACK_KEYS = Object.freeze([
'kind', 'drafted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'fromCaseId',
'returnPoint', 'realCloudBilling', 'cluster', 'telemetry', 'ci', 'network', 'productionEffect',
]);
function isPlainRecord(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function hasExactKeys(value, expected) {
if (!isPlainRecord(value)) return false;
const keys = Object.keys(value);
return keys.length === expected.length
&& expected.every((key) => Object.hasOwn(value, key))
&& keys.every((key) => expected.includes(key));
}
function hasDenseArray(value) {
if (!Array.isArray(value)) return false;
const ownKeys = Reflect.ownKeys(value);
return ownKeys.length === value.length + 1
&& ownKeys.includes('length')
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
}
function canonicalJson(value) {
const visited = new WeakSet();
function visit(current) {
if (current === null) return 'null';
if (typeof current === 'string' || typeof current === 'boolean') return JSON.stringify(current);
if (typeof current === 'number') return Number.isFinite(current) ? JSON.stringify(current) : null;
if (Array.isArray(current)) {
if (!hasDenseArray(current) || visited.has(current)) return null;
visited.add(current);
const values = current.map((item) => visit(item));
visited.delete(current);
return values.every((item) => item !== null) ? '[' + values.join(',') + ']' : null;
}
if (!isPlainRecord(current) || visited.has(current)) return null;
visited.add(current);
const keys = Object.keys(current).sort();
const pairs = keys.map((key) => {
const nested = visit(current[key]);
return nested === null ? null : JSON.stringify(key) + ':' + nested;
});
visited.delete(current);
return pairs.every((item) => item !== null) ? '{' + pairs.join(',') + '}' : null;
}
return visit(value);
}
function hasSameCanonicalJson(left, right) {
const leftJson = canonicalJson(left);
const rightJson = canonicalJson(right);
return leftJson !== null && rightJson !== null && leftJson === rightJson;
}
function freezeJson(value) {
if (Array.isArray(value)) return Object.freeze(value.map((item) => freezeJson(item)));
if (isPlainRecord(value)) {
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, freezeJson(item)])));
}
return value;
}
function fixedCard(card) {
return freezeJson(card);
}
function decimal(value) {
return Number(value.toFixed(6));
}
const fixedCards = Object.freeze({
'steady-slo-cost-drift': fixedCard({
kind: 'synthetic-capacity-card/v1',
cardVersion: MODEL_VERSION,
label: 'SLO is met while the fixed model cost grows with reserved capacity',
intervalHours: 24,
workload: { baselineRequestsPerSecond: 120, peakRequestsPerSecond: 180, p95Milliseconds: 235, sloP95Milliseconds: 300 },
resource: { cpuRequestCores: 1.2, cpuLimitCores: 1.6, memoryRequestGiB: 2, memoryLimitGiB: 3, quotaCpuCores: 6 },
billing: { fixedBaseUnitsPerHour: 0.36, cpuUnitsPerCoreHour: 0.08, memoryUnitsPerGiBHour: 0.01, unitLabel: 'synthetic-unit' },
signals: { observedCpuPercent: 58, observedMemoryPercent: 61, saturation: 'none', operationalRisk: 'low' },
nextLimit: { cpuRequestCores: 1.4, cpuLimitCores: 1.8, returnCpuRequestCores: 1.2, returnCpuLimitCores: 1.6 },
}),
'next-limit-balanced': fixedCard({
kind: 'synthetic-capacity-card/v1',
cardVersion: MODEL_VERSION,
label: 'A small next limit remains inside the synthetic SLO and cost boundary',
intervalHours: 24,
workload: { baselineRequestsPerSecond: 150, peakRequestsPerSecond: 205, p95Milliseconds: 248, sloP95Milliseconds: 300 },
resource: { cpuRequestCores: 1.4, cpuLimitCores: 1.8, memoryRequestGiB: 2.2, memoryLimitGiB: 3.2, quotaCpuCores: 6 },
billing: { fixedBaseUnitsPerHour: 0.36, cpuUnitsPerCoreHour: 0.08, memoryUnitsPerGiBHour: 0.01, unitLabel: 'synthetic-unit' },
signals: { observedCpuPercent: 67, observedMemoryPercent: 66, saturation: 'watch', operationalRisk: 'low' },
nextLimit: { cpuRequestCores: 1.6, cpuLimitCores: 2, returnCpuRequestCores: 1.4, returnCpuLimitCores: 1.8 },
}),
'saturation-stop': fixedCard({
kind: 'synthetic-capacity-card/v1',
cardVersion: MODEL_VERSION,
label: 'The synthetic limit reaches a stop condition before any larger allocation is selected',
intervalHours: 24,
workload: { baselineRequestsPerSecond: 172, peakRequestsPerSecond: 228, p95Milliseconds: 296, sloP95Milliseconds: 300 },
resource: { cpuRequestCores: 1.6, cpuLimitCores: 2, memoryRequestGiB: 2.4, memoryLimitGiB: 3.4, quotaCpuCores: 6 },
billing: { fixedBaseUnitsPerHour: 0.36, cpuUnitsPerCoreHour: 0.08, memoryUnitsPerGiBHour: 0.01, unitLabel: 'synthetic-unit' },
signals: { observedCpuPercent: 79, observedMemoryPercent: 74, saturation: 'queue-growth', operationalRisk: 'medium' },
nextLimit: { cpuRequestCores: 1.8, cpuLimitCores: 2.2, returnCpuRequestCores: 1.6, returnCpuLimitCores: 2 },
}),
'large-instance-unjustified': fixedCard({
kind: 'synthetic-capacity-card/v1',
cardVersion: MODEL_VERSION,
label: 'A large synthetic allocation improves one latency value but breaches the chosen marginal-cost boundary',
intervalHours: 24,
workload: { baselineRequestsPerSecond: 172, peakRequestsPerSecond: 228, p95Milliseconds: 208, sloP95Milliseconds: 300 },
resource: { cpuRequestCores: 4, cpuLimitCores: 4.5, memoryRequestGiB: 6, memoryLimitGiB: 7, quotaCpuCores: 6 },
billing: { fixedBaseUnitsPerHour: 0.36, cpuUnitsPerCoreHour: 0.08, memoryUnitsPerGiBHour: 0.01, unitLabel: 'synthetic-unit' },
signals: { observedCpuPercent: 31, observedMemoryPercent: 38, saturation: 'none', operationalRisk: 'medium' },
nextLimit: { cpuRequestCores: 4.2, cpuLimitCores: 4.7, returnCpuRequestCores: 4, returnCpuLimitCores: 4.5 },
}),
});
function createCost(card) {
const fixedPerHour = card.billing.fixedBaseUnitsPerHour;
const variableCpuPerHour = decimal(card.resource.cpuRequestCores * card.billing.cpuUnitsPerCoreHour);
const variableMemoryPerHour = decimal(card.resource.memoryRequestGiB * card.billing.memoryUnitsPerGiBHour);
const totalPerHour = decimal(fixedPerHour + variableCpuPerHour + variableMemoryPerHour);
const totalForInterval = decimal(totalPerHour * card.intervalHours);
const requestCount = card.workload.baselineRequestsPerSecond * 3600 * card.intervalHours;
return freezeJson({
fixedPerHour,
variableCpuPerHour,
variableMemoryPerHour,
totalPerHour,
totalForInterval,
requestsForInterval: requestCount,
unitsPerThousandRequests: decimal(totalForInterval / (requestCount / 1000)),
unitLabel: card.billing.unitLabel,
});
}
function createSignals(card, cost) {
const p95HeadroomMilliseconds = card.workload.sloP95Milliseconds - card.workload.p95Milliseconds;
const requestedCpuRatioToQuota = card.resource.cpuRequestCores / card.resource.quotaCpuCores;
const nextCpuCostDeltaForInterval = decimal((card.nextLimit.cpuRequestCores - card.resource.cpuRequestCores)
* card.billing.cpuUnitsPerCoreHour * card.intervalHours);
const isNearSlo = p95HeadroomMilliseconds <= 15;
const saturated = card.signals.saturation === 'queue-growth';
const largeUnusedReserve = card.resource.cpuRequestCores >= 4 && card.signals.observedCpuPercent < 40;
return freezeJson({
p95HeadroomMilliseconds,
requestedCpuRatioToQuota,
nextCpuCostDeltaForInterval,
saturationObserved: saturated,
isNearSlo,
largeUnusedReserve,
observedUtilisationPercent: card.signals.observedCpuPercent,
modelCostPerHour: cost.totalPerHour,
});
}
function rejectInspection(reason) {
return freezeJson({
kind: 'synthetic-capacity-cost-report/v1',
accepted: false,
syntheticOnly: true,
reason,
modelVersion: MODEL_VERSION,
modelLimit: FIXTURE_LIMIT,
scope: FIXTURE_SCOPE,
caseId: 'not-accepted',
card: 'not-read',
cost: 'not-calculated',
signals: 'not-calculated',
decision: 'stop-before-human-review',
stopCondition: 'input-not-accepted',
nextHumanQuestion: 'which fixed synthetic input field failed the contract?',
evidenceBoundary: freezeJson({
fixedLiterals: 'not-read',
cloudBilling: 'not-read',
cluster: 'not-read',
telemetry: 'not-read',
ci: 'not-run',
filesystem: 'not-read',
network: 'not-used',
production: 'not-read',
}),
productionEffect: 'not-attempted',
});
}
export function createFixedSyntheticCapacityInput(caseId) {
return freezeJson({
kind: 'synthetic-capacity-cost-input/v1',
synthetic: true,
scope: FIXTURE_SCOPE,
modelVersion: MODEL_VERSION,
mode: FIXTURE_MODE,
caseId,
});
}
function calculateDecision(card, signals) {
if (signals.isNearSlo || signals.saturationObserved) {
return freezeJson({
decision: 'stop-and-review-synthetic-limit',
stopCondition: signals.isNearSlo
? 'p95-headroom-at-or-below-15ms'
: 'saturation-signal-present',
nextHumanQuestion: 'Does an authorised owner have a separate measurement plan for the observed queue or p95 boundary before comparing another limit?',
});
}
if (signals.largeUnusedReserve) {
return freezeJson({
decision: 'stop-and-compare-smaller-synthetic-limit',
stopCondition: 'large-reserve-with-low-observed-utilisation',
nextHumanQuestion: 'Which smaller fixed allocation explains the workload-to-resource relation before a larger instance is considered?',
});
}
return freezeJson({
decision: 'compare-next-synthetic-limit',
stopCondition: 'none-inside-fixed-model',
nextHumanQuestion: 'Can an authorised reviewer compare one next fixed limit against a separately collected real workload, billing rule and rollback policy?',
});
}
/**
* Inspects one embedded capacity card. The function accepts only a closed,
* fixed in-memory input shape and does not read any file, Git data, billing
* export, cluster, telemetry, CI result, network endpoint or production state.
*/
export function inspectSyntheticCapacityCost(input) {
if (!input || input.kind !== 'synthetic-capacity-cost-input/v1' || input.synthetic !== true) {
return rejectInspection('synthetic-input-required');
}
if (!hasExactKeys(input, INPUT_KEYS)) return rejectInspection('unexpected-input-field');
if (input.scope !== FIXTURE_SCOPE) return rejectInspection('unexpected-synthetic-scope');
if (input.modelVersion !== MODEL_VERSION) return rejectInspection('unexpected-model-version');
if (input.mode !== FIXTURE_MODE) return rejectInspection('fixed-memory-mode-required');
if (!Object.hasOwn(fixedCards, input.caseId)) return rejectInspection('unknown-fixed-synthetic-case');
const card = fixedCards[input.caseId];
const cost = createCost(card);
const signals = createSignals(card, cost);
const decision = calculateDecision(card, signals);
return freezeJson({
kind: 'synthetic-capacity-cost-report/v1',
accepted: true,
syntheticOnly: true,
reason: 'fixed-versioned-synthetic-card-inspected',
modelVersion: MODEL_VERSION,
modelLimit: FIXTURE_LIMIT,
scope: FIXTURE_SCOPE,
caseId: input.caseId,
card,
cost,
signals,
decision: decision.decision,
stopCondition: decision.stopCondition,
nextHumanQuestion: decision.nextHumanQuestion,
evidenceBoundary: freezeJson({
fixedLiterals: 'embedded-versioned-js-objects-only',
cloudBilling: 'not-read',
cluster: 'not-read',
telemetry: 'not-read',
ci: 'not-run',
filesystem: 'not-read',
network: 'not-used',
production: 'not-read',
}),
productionEffect: 'not-attempted',
});
}
function canonicalReportFor(caseId) {
return inspectSyntheticCapacityCost(createFixedSyntheticCapacityInput(caseId));
}
/**
* Builds a teaching draft only after a closed report matches the canonical
* object for the same fixed case. It does not select, resize, purchase,
* change, observe, approve or roll back any real infrastructure.
*/
export function planSyntheticCapacityDecision(report) {
if (!report || report.kind !== 'synthetic-capacity-cost-report/v1' || report.accepted !== true || report.syntheticOnly !== true) {
return freezeJson({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelLimit: FIXTURE_LIMIT });
}
if (!hasExactKeys(report, REPORT_KEYS)) {
return freezeJson({ accepted: false, syntheticOnly: true, reason: 'unexpected-report-field', modelLimit: FIXTURE_LIMIT });
}
const canonical = canonicalReportFor(report.caseId);
if (!hasSameCanonicalJson(canonical, report)) {
return freezeJson({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-canonical-fixed-card', modelLimit: FIXTURE_LIMIT });
}
const actions = canonical.decision === 'compare-next-synthetic-limit'
? [
'keep-workload-resource-unit-and-stop-condition-in-one-review-card',
'ask-for-authorised-separate-evidence-before-a-live-change',
'record-a-human-owned-return-policy-before-comparing-the-next-limit',
]
: [
'stop-before-selecting-another-synthetic-limit',
'name-the-saturation-or-cost-boundary-in-the-review-card',
'ask-an-authorised-owner-for-a-separate-measurement-or-policy-check',
];
return freezeJson({
kind: 'synthetic-capacity-decision-draft/v1',
accepted: true,
syntheticOnly: true,
reason: 'canonical-fixed-synthetic-capacity-decision-draft',
modelVersion: MODEL_VERSION,
modelLimit: FIXTURE_LIMIT,
sourceReport: canonical,
decision: canonical.decision,
actions,
returnPoint: freezeJson({
fromCpuRequestCores: canonical.card.resource.cpuRequestCores,
fromCpuLimitCores: canonical.card.resource.cpuLimitCores,
toCpuRequestCores: canonical.card.nextLimit.returnCpuRequestCores,
toCpuLimitCores: canonical.card.nextLimit.returnCpuLimitCores,
state: 'teaching-record-not-executed',
boundary: 'does-not-change-a-workload-or-prove-a-real-rollback',
}),
realCloudBilling: 'not-read-or-changed',
cluster: 'not-read-or-changed',
telemetry: 'not-read',
ci: 'not-run',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
/**
* Returns a fixed teaching return card for the one non-stop branch. It is not
* a rollback command: no workload, manifest, quota, billing setting, traffic
* or production object is read or modified.
*/
export function draftSyntheticCapacityRollback(plan) {
if (!plan || plan.kind !== 'synthetic-capacity-decision-draft/v1' || plan.accepted !== true || plan.syntheticOnly !== true || !plan.sourceReport) {
return freezeJson({ drafted: false, syntheticOnly: true, reason: 'accepted-synthetic-decision-draft-required' });
}
if (!hasExactKeys(plan, DRAFT_KEYS)) {
return freezeJson({ drafted: false, syntheticOnly: true, reason: 'unexpected-draft-field' });
}
const canonicalPlan = planSyntheticCapacityDecision(plan.sourceReport);
if (canonicalPlan.accepted !== true || !hasSameCanonicalJson(canonicalPlan, plan)) {
return freezeJson({ drafted: false, syntheticOnly: true, reason: 'draft-does-not-match-canonical-fixed-card' });
}
if (canonicalPlan.decision !== 'compare-next-synthetic-limit') {
return freezeJson({ drafted: false, syntheticOnly: true, reason: 'stop-condition-requires-separate-human-review' });
}
return freezeJson({
kind: 'synthetic-capacity-return-card/v1',
drafted: true,
syntheticOnly: true,
reason: 'fixed-return-point-recorded-in-memory',
modelVersion: MODEL_VERSION,
modelLimit: FIXTURE_LIMIT,
fromCaseId: canonicalPlan.sourceReport.caseId,
returnPoint: canonicalPlan.returnPoint,
realCloudBilling: 'not-read-or-changed',
cluster: 'not-read-or-changed',
telemetry: 'not-read',
ci: 'not-run',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
export function runCapacityCostFixture() {
const steady = inspectSyntheticCapacityCost(createFixedSyntheticCapacityInput('steady-slo-cost-drift'));
const next = inspectSyntheticCapacityCost(createFixedSyntheticCapacityInput('next-limit-balanced'));
const stop = inspectSyntheticCapacityCost(createFixedSyntheticCapacityInput('saturation-stop'));
const large = inspectSyntheticCapacityCost(createFixedSyntheticCapacityInput('large-instance-unjustified'));
const missingSynthetic = inspectSyntheticCapacityCost({ ...createFixedSyntheticCapacityInput('steady-slo-cost-drift'), synthetic: false });
const extraCloudBill = inspectSyntheticCapacityCost({ ...createFixedSyntheticCapacityInput('steady-slo-cost-drift'), cloudBill: 'not-read' });
const extraFileField = inspectSyntheticCapacityCost({ ...createFixedSyntheticCapacityInput('steady-slo-cost-drift'), manifestPath: 'not-read' });
const wrongScope = inspectSyntheticCapacityCost({ ...createFixedSyntheticCapacityInput('steady-slo-cost-drift'), scope: 'other-scope' });
const wrongVersion = inspectSyntheticCapacityCost({ ...createFixedSyntheticCapacityInput('steady-slo-cost-drift'), modelVersion: 'other-version' });
const wrongMode = inspectSyntheticCapacityCost({ ...createFixedSyntheticCapacityInput('steady-slo-cost-drift'), mode: 'read-real-cloud-billing' });
const unknown = inspectSyntheticCapacityCost(createFixedSyntheticCapacityInput('invented-card'));
const steadyPlan = planSyntheticCapacityDecision(steady);
const stopPlan = planSyntheticCapacityDecision(stop);
const forgedCost = planSyntheticCapacityDecision({ ...steady, cost: { ...steady.cost, totalForInterval: 0 } });
const extraReportField = planSyntheticCapacityDecision({ ...steady, billingExport: 'not-read' });
const sparseResourceArray = [{ key: 'one' }, { key: 'two' }];
delete sparseResourceArray[1];
const sparseReport = planSyntheticCapacityDecision({ ...steady, signals: { ...steady.signals, evidence: sparseResourceArray } });
const cyclicReport = { ...steady, signals: { ...steady.signals } };
cyclicReport.signals.loop = cyclicReport;
const cyclicPlanFromReport = planSyntheticCapacityDecision(cyclicReport);
const forgedPlan = draftSyntheticCapacityRollback({ ...steadyPlan, actions: ['invented-live-change'] });
const extraDraftField = draftSyntheticCapacityRollback({ ...steadyPlan, cloudCommand: 'not-read' });
const cyclicDraft = { ...steadyPlan, actions: [...steadyPlan.actions] };
cyclicDraft.actions.push(cyclicDraft);
const cyclicRollback = draftSyntheticCapacityRollback(cyclicDraft);
const returnCard = draftSyntheticCapacityRollback(steadyPlan);
const returnFromStop = draftSyntheticCapacityRollback(stopPlan);
const reportAsDraft = draftSyntheticCapacityRollback(steady);
const reorderedOne = { beta: [2, 3], alpha: { yes: true } };
const reorderedTwo = { alpha: { yes: true }, beta: [2, 3] };
return freezeJson({
assertions: freezeJson({
steadyAccepted: steady.accepted === true && steady.decision === 'compare-next-synthetic-limit',
fixedCostHasFixedAndVariableParts: steady.cost.fixedPerHour === 0.36 && steady.cost.variableCpuPerHour === 0.096 && steady.cost.variableMemoryPerHour === 0.02,
fixedCostUsesIntervalLiteral: steady.cost.totalForInterval === 11.424,
steadySloHeadroomIsExplicit: steady.signals.p95HeadroomMilliseconds === 65,
nextCaseStaysReviewable: next.accepted === true && next.decision === 'compare-next-synthetic-limit' && next.signals.nextCpuCostDeltaForInterval === 0.384,
saturationStopsBeforeAnotherLimit: stop.accepted === true && stop.decision === 'stop-and-review-synthetic-limit' && stop.stopCondition === 'p95-headroom-at-or-below-15ms',
largeReserveStopsForComparison: large.accepted === true && large.decision === 'stop-and-compare-smaller-synthetic-limit' && large.stopCondition === 'large-reserve-with-low-observed-utilisation',
rejectsMissingSyntheticMarker: missingSynthetic.accepted === false && missingSynthetic.reason === 'synthetic-input-required',
rejectsCloudBillLikeInput: extraCloudBill.accepted === false && extraCloudBill.reason === 'unexpected-input-field',
rejectsFileLikeInput: extraFileField.accepted === false && extraFileField.reason === 'unexpected-input-field',
rejectsWrongScope: wrongScope.accepted === false && wrongScope.reason === 'unexpected-synthetic-scope',
rejectsWrongVersion: wrongVersion.accepted === false && wrongVersion.reason === 'unexpected-model-version',
rejectsWrongMode: wrongMode.accepted === false && wrongMode.reason === 'fixed-memory-mode-required',
rejectsUnknownCase: unknown.accepted === false && unknown.reason === 'unknown-fixed-synthetic-case',
reportHasClosedContract: hasExactKeys(steady, REPORT_KEYS),
inputHasClosedContract: hasExactKeys(createFixedSyntheticCapacityInput('steady-slo-cost-drift'), INPUT_KEYS),
acceptsCanonicalReport: steadyPlan.accepted === true && steadyPlan.decision === 'compare-next-synthetic-limit',
draftHasClosedContract: hasExactKeys(steadyPlan, DRAFT_KEYS),
rejectsForgedCost: forgedCost.accepted === false && forgedCost.reason === 'report-does-not-match-canonical-fixed-card',
rejectsUnknownReportField: extraReportField.accepted === false && extraReportField.reason === 'unexpected-report-field',
denseArrayAcceptsNormalArray: hasDenseArray([1, 2, 3]) === true,
denseArrayRejectsSparseArray: hasDenseArray(sparseResourceArray) === false,
rejectsSparseNestedArray: sparseReport.accepted === false && sparseReport.reason === 'report-does-not-match-canonical-fixed-card',
canonicalCompareIgnoresObjectKeyOrder: hasSameCanonicalJson(reorderedOne, reorderedTwo) === true,
canonicalCompareRejectsCycle: canonicalJson(cyclicReport) === null,
rejectsCyclicReport: cyclicPlanFromReport.accepted === false && cyclicPlanFromReport.reason === 'report-does-not-match-canonical-fixed-card',
rejectsForgedDraft: forgedPlan.drafted === false && forgedPlan.reason === 'draft-does-not-match-canonical-fixed-card',
rejectsUnknownDraftField: extraDraftField.drafted === false && extraDraftField.reason === 'unexpected-draft-field',
rejectsCyclicDraft: cyclicRollback.drafted === false && cyclicRollback.reason === 'draft-does-not-match-canonical-fixed-card',
returnCardUsesMemoryOnlyBoundary: returnCard.drafted === true && returnCard.cluster === 'not-read-or-changed' && returnCard.productionEffect === 'not-attempted',
stopPlanCannotBecomeReturnCard: returnFromStop.drafted === false && returnFromStop.reason === 'stop-condition-requires-separate-human-review',
reportCannotBeRollbackDraft: reportAsDraft.drafted === false && reportAsDraft.reason === 'accepted-synthetic-decision-draft-required',
rollbackHasClosedContract: hasExactKeys(returnCard, ROLLBACK_KEYS),
}),
samples: freezeJson({
steady,
next,
stop,
large,
steadyPlan,
returnCard,
}),
});
}
const practiceExample = [
"import { createFixedSyntheticCapacityInput, inspectSyntheticCapacityCost } from './upgrade-2024-10.mjs';",
'',
"const report = inspectSyntheticCapacityCost(",
" createFixedSyntheticCapacityInput('steady-slo-cost-drift'),",
');',
'',
"console.log({",
' p95HeadroomMs: report.signals.p95HeadroomMilliseconds,',
' fixedPerHour: report.cost.fixedPerHour,',
' variableCpuPerHour: report.cost.variableCpuPerHour,',
' totalFor24h: report.cost.totalForInterval,',
' nextQuestion: report.nextHumanQuestion,',
'});',
].join('\n');
const mechanismExample = [
"import { createFixedSyntheticCapacityInput, inspectSyntheticCapacityCost } from './upgrade-2024-10.mjs';",
'',
"const report = inspectSyntheticCapacityCost(",
" createFixedSyntheticCapacityInput('large-instance-unjustified'),",
');',
'',
"console.log({",
' utilisation: report.signals.observedUtilisationPercent,',
' modelCostPerHour: report.signals.modelCostPerHour,',
' decision: report.decision,',
' stopCondition: report.stopCondition,',
'});',
].join('\n');
const fieldExample = [
"import {",
' createFixedSyntheticCapacityInput,',
' inspectSyntheticCapacityCost,',
' planSyntheticCapacityDecision,',
' draftSyntheticCapacityRollback,',
"} from './upgrade-2024-10.mjs';",
'',
"const report = inspectSyntheticCapacityCost(",
" createFixedSyntheticCapacityInput('next-limit-balanced'),",
');',
'const draft = planSyntheticCapacityDecision(report);',
'const returnCard = draftSyntheticCapacityRollback(draft);',
'',
'console.log({ decision: draft.decision, returnCard: returnCard.reason });',
].join('\n');
const practice = revision({
slug: 'editorial-2024-10-practice-capacity-cost',
title: 'Ёмкость и стоимость: SLO выполняется, а расход растёт',
excerpt: 'Как собрать одну synthetic capacity-card, связать workload, выделенный ресурс и billing unit, а затем остановить разговор о стоимости на проверяемом пределе.',
cover: '/assets/editorial/2024/capacity-cost-2024-load-resource-cost.svg',
readingMinutes: 10,
}, [
p('В учебной карточке <code>steady-slo-cost-drift</code> p95 равен 235 ms при synthetic SLO 300 ms. Симптом выглядит спокойным: latency проходит условие. Цена ошибки появляется рядом: модельная стоимость за 24 часа уже равна 11,424 synthetic-unit, потому что в ней есть fixed base и выделенные CPU/memory. Если назвать это «здоровой системой» только по SLO, команда не заметит, какую связь она перестала проверять: нагрузка выросла, reservation остался без владельца, а следующий лимит выбирают по привычке. Это не счёт и не описание чьей-либо среды; это фиксированный object literal из fixture.'),
p('Причина не в том, что SLO плох. SLO отвечает на отдельный вопрос о latency, а не на вопрос о том, какая billing unit участвует в модели. Проверка должна разложить карточку на четыре поля: <code>workload → resource → unit cost → limit</code>. Действие тоже маленькое: для одного scope записать период, request, limit, квоту, unit и stop condition, а затем сравнить только два synthetic варианта. Такой порядок не обещает экономию. Он не даёт произвольному проценту загрузки выдать себя за доказательство цены.'),
h2('Одна capacity-card вместо трёх несвязанных графиков'),
p('Capacity-card — не новый формат отчётности. Это короткий contract для одного обсуждения. В ней workload хранит baseline и peak request rate, а также p95 и выбранный SLO. Resource хранит request и limit CPU/memory, а quota показывает доступную границу namespace. Billing хранит fixed base, CPU-hour и GiB-hour как <em>synthetic-unit</em>. Последнее поле — limit — называет, что останавливает сравнение: близость p95 к SLO, saturation signal или слишком большая зарезервированная ёмкость при низком observed utilisation. Если хотя бы одно поле отсутствует, фраза «дорого» ещё не диагностическая.'),
figure('/assets/editorial/2024/capacity-cost-2024-load-resource-cost.svg', 'Capacity-card из четырёх колонок: workload 120 request/s, resource request и limit, synthetic billing units и предел SLO/cost/risk. Нижняя подпись отделяет учебную модель от cloud bill и production telemetry.', 'Схема показывает направление проверки: нагрузка задаёт контекст, ресурс описывает reservation, billing formula задаёт единицу, а предел останавливает выбор. Она не показывает реальный кластер, тариф или расход.'),
p('Документация Kubernetes в снимке мая 2024 помогает не смешать request с limit. Scheduler использует request при размещении, а limit имеет отдельную семантику выполнения. Поэтому в карточке нельзя написать «1,2 CPU» и считать, что это одновременно фактический usage, гарантированная производительность и стоимость. Также нельзя вывести цену из quota: ResourceQuota выражается в абсолютных единицах namespace, а сумма quota может превышать ёмкость кластера. Она не доказывает доступную capacity. Здесь quota нужна только как дисциплина полей. Какая именно единица оплачивается, задаёт отдельный billing contract, а не Pod spec.'),
h2('Связь workload → resource → unit cost → предел'),
p('Начинаем с workload. В примере baseline равен 120 request/s, peak — 180 request/s. Эти значения не умножаются на цену напрямую: они помогают проверить, с каким потоком связаны p95 и reservation. Затем берём resource: request 1,2 CPU и 2 GiB, limit 1,6 CPU и 3 GiB. Условная формула считает CPU по request, а не по observed 58%. Это намеренный выбор модели, чтобы показать вопрос: какую величину считает provider или внутренний allocation rule? В настоящем контуре ответ берут из разрешённого billing contract, а не угадывают по графику utilisation.'),
p('Третье поле — unit cost. В fixture fixed base равен 0,36 u/h, CPU равен 0,08 u/core-hour, memory — 0,01 u/GiB-hour. У формулы есть период 24 часа. Она возвращает 0,476 u/h и 11,424 u за период. Никакое число не является валютой, ценой provider или прогнозом. Важна структура: fixed часть не меняется от observed CPU, variable CPU и memory меняются от выбранного allocation, а workload нужен, чтобы посчитать условный cost per thousand requests и сравнить равные интервалы. Если период или scope разный, сравнение завершается до арифметики.'),
table('Три fixed synthetic card для одного периода 24 часа', ['Case', 'Workload и p95', 'Reservation', 'Модельная стоимость', 'Предел/следующий вопрос'], [
['<code>steady-slo-cost-drift</code>', '120/180 req/s; p95 235 ms при SLO 300 ms', '1,2 CPU; 2 GiB; limit 1,6 CPU', '11,424 u; SLO выполнен', 'Нет stop; сравнить один следующий limit с отдельным evidence.'],
['<code>next-limit-balanced</code>', '150/205 req/s; p95 248 ms', '1,4 CPU; 2,2 GiB; limit 1,8 CPU', '11,856 u', 'Нет stop; delta до 1,6 CPU записана как 0,384 u/24h.'],
['<code>saturation-stop</code>', '172/228 req/s; p95 296 ms', '1,6 CPU; 2,4 GiB; limit 2 CPU', '12,288 u', 'Stop: p95 headroom 4 ms; не выбирать следующий limit внутри fixture.'],
]),
p('Таблица не выбирает победителя. Она защищает границу сравнения. Первый case показывает, почему «SLO зелёный» и «cost понятен» — разные утверждения. Второй case делает marginal delta явной, но не превращает её в разрешение на изменение. Третий case показывает stop раньше попытки «дать ещё CPU»: p95 почти касается заданной границы, поэтому fixture меняет вопрос с арифметики на отдельный human review. Когда эти три условия записаны рядом, больший instance перестаёт быть рефлекторным ответом.'),
h2('Компактный воспроизводимый расчёт'),
p('Команда ниже запускает только модуль этой ревизии. Вход — закрытый case id, а report строится из embedded literals. Функция не читает manifest, cloud account, invoice, Prometheus, CI и network. Полезный результат запуска — увидеть отдельные fixed и variable части, а не получить рекомендацию к изменению ресурса. Изменение <code>totalFor24h</code> или добавление поля с export в fixture приводит к отрицательной проверке, потому что карточка должна оставаться учебной и детерминированной.'),
code(practiceExample),
p('Внутри расчёта есть ещё один осторожный показатель: <code>unitsPerThousandRequests</code>. Он делит synthetic cost периода на число synthetic requests периода. Это удобно только потому, что обе величины принадлежат одной карте, одному периоду и одному fixed literal. Он не становится unit economics продукта, пока не известны реальные billing rule, retries, cache, network, commitments и границы shared infrastructure. Хорошая проверка здесь звучит так: «мы можем повторить деление по той же карточке», а не «мы получили стоимость запроса».'),
h2('Что SLO не показывает о росте reservation'),
p('SLO не обязан отвечать за стоимость. При p95 235 ms есть как минимум три разных объяснения растущей model cost: был повышен request, изменилась memory reservation или в модели существует fixed base. Observed CPU 58% не отменяет ни одно из них. Он лишь ставит вопрос, соответствует ли allocation характеру нагрузки. И наоборот, высокий процент не устанавливает цену: он может быть следствием маленького request, длинного временного окна, burst или saturation. Поэтому порядок «процент → стоимость» в статье запрещён; нужен промежуточный resource и explicit billing unit.'),
p('Quota и LimitRange помогают назвать другой класс границ. Quota может потребовать request/limit и ограничить aggregate value; LimitRange может задать default, minimum, maximum или ratio на admission. Они полезны для договорённости о допустимых значениях, но не являются ценовым калькулятором и не меняют уже запущенные Pod задним числом. В capacity-card мы отмечаем их для того, чтобы не предложить next limit, который уже не пройдёт admission. Сначала проверяется допустимость формы, затем отдельная модель, затем — только при полном разрешённом контексте — изменение.'),
h2('Маршрут проверки без ложной автоматизации'),
ol([
'<strong>Назовите один scope и период.</strong> Запишите workload, owner, интервал и SLO. Не складывайте в одну строку разные сервисы, регионы или периоды.',
'<strong>Разделите resource и usage.</strong> Отдельно зафиксируйте request, limit, quota и observed signal. Если какая-то величина неизвестна, не подставляйте процент загрузки вместо неё.',
'<strong>Назовите billing unit.</strong> Укажите fixed/variable часть, базовую единицу и правило tier. Без этого «стоимость растёт» остаётся симптомом, а не расчётом.',
'<strong>Сравните только одинаковые cards.</strong> Период, scope и formula должны совпасть. Затем сравните один следующий limit, а не каталог instance.',
'<strong>Запишите stop condition и return boundary.</strong> Если p95 близок к SLO или есть saturation, остановите подбор. Return card в fixture — запись для review, не rollback command.',
'<strong>Передайте открытый вопрос владельцу.</strong> Реальные billing, telemetry и policy требуют отдельного разрешённого evidence. Положительный fixture не подменяет его.',
]),
h2('Ограничения карточки и конкретный следующий шаг'),
p('Эта revision не читает облачный счёт, каталог SKU, commitment, скидку, метрики, лог, cluster state или deployment. Она не утверждает, что request оплачивается в конкретном provider, что 0,36 u/h существует как тариф или что меньшая reservation безопаснее. Kubernetes snapshots в конце фиксируют роль request, limit, quota и admission; Google Cloud Billing proto фиксирует форму usage unit и tiered rates. Ни один источник не переносит собственную семантику на synthetic literals.'),
p('Следующий проверяемый шаг — провести tabletop review с одной пустой capacity-card. Один человек вписывает вымышленные workload, resource, unit и stop condition; второй пытается найти пропущенную связь, не видя никакой настоящей среды. Успех — не «нашли экономию», а точный ответ: какая величина фиксирована, какая variable, чем подтверждается billing unit, какой предел останавливает сравнение и кто владеет следующим источником evidence. Только после этого форму можно сопоставить с разрешённым процессом организации.'),
h2('Историческая граница октября 2024'),
p('В октябре 2024 уже существовали закреплённые выше documentation snapshots Kubernetes и Google API contract. Из них взяты узкие технические различия: request/limit/quota относятся к ресурсной модели Kubernetes, а billing expression может иметь usage unit, base unit и tiered rate. Остальное — авторская fixed synthetic модель версии <code>synthetic-capacity-cost-model/2024-10-v1</code>. Она объясняет, как держать четыре поля в одной карте, но не измеряет реальную инфраструктуру и не даёт production advice.'),
]);
const mechanism = revision({
slug: 'editorial-2024-10-mechanism-capacity-cost',
title: 'Ёмкость и стоимость: почему процент загрузки не равен цене',
excerpt: 'Механизм для разбора fixed и variable resource, billing unit, quota/limit, saturation и marginal cost без подмены модели настоящим облачным счётом.',
cover: '/assets/editorial/2024/capacity-cost-2024-scenario-matrix.svg',
readingMinutes: 10,
}, [
p('В synthetic case <code>large-instance-unjustified</code> observed CPU равен 31%, p95 равен 208 ms при SLO 300 ms, а модельная стоимость выше, чем у соседнего case с меньшим reservation. Симптом легко описать неверно: «низкая загрузка — значит инстанс лишний». Цена этой ошибки — другой неверный вывод в следующей встрече: процент загрузки начинают использовать как цену, а фактор fixed base, billing unit или quota исчезает из обсуждения. В этой статье нет настоящего bill и нет рекомендации уменьшать instance. Есть замкнутая модель, которая показывает, каких промежуточных величин не хватает до честного вопроса.'),
p('Причина проста, но её часто прячут в один dashboard. Utilisation — наблюдение относительно выбранной базы. Cost — результат formula для allocation и единиц. Quota и limit — разные ограничения ресурса. Saturation — сигнал, что часть системы перестаёт обслуживать поток с нужным запасом. Проверка обязана вынести эти слова в отдельные поля. Действие — сравнить их причинно: usage может подсказать проверку allocation, но нельзя провести стрелку от 31% прямо к «дёшево» или «дорого».'),
h2('Пять объектов, которые нельзя склеивать'),
p('<strong>Fixed resource</strong> в модели — база 0,36 u/h: она остаётся, пока существует выбранный synthetic scope. <strong>Variable resource</strong> — CPU request и memory request, умноженные на fixed literals за core-hour и GiB-hour. <strong>Billing unit</strong> — единица, которой принадлежит formula; у внешнего API она может иметь usage unit, base unit и tiered rates. <strong>Quota/limit</strong> — ограничения допустимой и исполняемой формы ресурса. <strong>Saturation</strong> — наблюдаемое условие очереди, p95 или retry risk. Эти пять объектов могут быть связаны, но одинаковыми они не становятся.'),
p('Полезно отдельно сказать, чего не делает каждый объект. Fixed base не объясняет latency. CPU percent не сообщает, какова base unit. Quota не доказывает оплату ресурса. Limit не является forecast. Saturation не превращает уравнение в price list. Именно здесь поздний автор 2024 года должен удержать цену решения: чем меньше терминов он смешал, тем меньше будет необратимых действий в ответ на один график. Нужна не большая теория, а контроль над направлением стрелок.'),
figure('/assets/editorial/2024/capacity-cost-2024-scenario-matrix.svg', 'Причинная схема разделяет observed CPU/memory usage, allocated resource request/limit/quota, saturation, billing expression и итоговую synthetic model cost. Стрелки показывают, что utilisation не ведёт прямо к цене.', 'Схема — причинная дисциплина для модели. Она не отображает provider invoice, реальные SKU, telemetry или действие над cluster.'),
h2('Причинная диаграмма: что влияет на что'),
p('В верхней части схемы observed usage связан с allocated resource: процент появляется только относительно allocation. Но эта стрелка не означает, что usage выставляет цену. Allocation попадает в billing expression вместе с fixed base, unit и возможным tier. Только после этого fixture строит model cost. В нижней части saturation связан с workload и allocation: очередь или малая p95 headroom требуют остановки и отдельной проверки. Они не пересчитывают billing formula. Такая диаграмма полезна, когда один график CPU пытаются использовать и как тест производительности, и как аргумент о бюджете.'),
p('Kubernetes snapshot о request и limits создаёт техническую опору для половины схемы. Request участвует в scheduling, limit имеет свою enforcement semantics. ResourceQuota фиксирует aggregate границы, но сумма quota может превышать total capacity cluster; LimitRange может подставить default на admission. Это делает сравнительную карточку точнее: к resource принадлежат как указанные в spec значения, так и правила, от которых они зависят. Но ни один из этих документов не утверждает, какая строка будет в счёте. Внешняя billing formula — отдельный contract, и именно поэтому она стоит в другой колонке.'),
h2('Таблица различий: вопрос, поле, неправильный вывод'),
table('Разделение терминов в capacity/cost review', ['Объект', 'Вопрос, на который он отвечает', 'Пример fixed literal', 'Что он не доказывает'], [
['Observed utilisation', 'Каков usage относительно выбранной базы?', '<code>CPU 31%</code>', 'Не задаёт invoice и не доказывает, что allocation лишний.'],
['Fixed resource', 'Какая часть model cost не зависит от CPU percent?', '<code>base 0,36 u/h</code>', 'Не объясняет p95 и не является тарифом.'],
['Variable resource', 'Что formula умножает на unit за период?', '<code>4 CPU × 0,08 u/core-h</code>', 'Не равен фактическому consumption в любой платформе.'],
['Quota / limit', 'Какие значения разрешены или исполняются?', '<code>quota 6 CPU; limit 4,5 CPU</code>', 'Не являются ценовой шкалой и не меняют прошлый расход.'],
['Saturation', 'Нужна ли stop condition до следующего шага?', '<code>queue-growth</code>', 'Не говорит, какой instance покупать или возвращать.'],
['Marginal cost', 'Как меняется formula между двумя сравнимыми cards?', '<code>+0,384 u/24h</code>', 'Не является savings claim или approval.'],
]),
p('В таблице намеренно нет строки «лучший размер». Размер становится результатом другого, authorised процесса, когда известны рабочая нагрузка, реальные правила provider, availability, ownership, rollback policy и последствия ошибки. В fixture есть только smaller question: что изменится между текущим и следующим фиксированным пределом? Если для него нет единицы, периода и return boundary, модель предпочитает stop. Это честнее, чем выводить перспективу на production из процента utilisation.'),
h2('Fixed и variable части в одной формуле'),
p('В <code>large-instance-unjustified</code> formula берёт base 0,36 u/h, CPU request 4 × 0,08 u/core-hour и memory request 6 × 0,01 u/GiB-hour. Получается 0,74 u/h. Для <code>saturation-stop</code> формула с 1,6 CPU и 2,4 GiB даёт 0,512 u/h. Разница не доказывает, что второй case выгоднее: у него p95 296 ms и signal <code>queue-growth</code>. Зато пример не позволяет спрятать цену большого reservation за 31% utilisation. Fixed/variable decomposition делает visible то, что нужен отдельный reason для каждого шага.'),
p('Marginal cost в этой модели — разность formula между соседними cards при одинаковой единице и периоде. Например, переход с request 1,4 CPU на 1,6 CPU добавляет 0,384 u за 24 часа: <code>(1,6 − 1,4) × 0,08 × 24</code>. Это не ответ «надо увеличить». Это размер вопроса, который должен лежать рядом с ожидаемым performance effect и operational risk. Если новая card меняет ещё memory, region, commitment или shared base, разность перестаёт быть одной переменной. Тогда нельзя обозначать её как marginal cost одного limit.'),
h2('Исполнимая negative check вместо имитации billing'),
p('Ниже запускается case, где p95 лучше SLO, но reservation намеренно большой. Fixture возвращает <code>stop-and-compare-smaller-synthetic-limit</code>. Она не меняет параметр и не может открыть счёт, чтобы проверить, прав ли автор. В этом и смысл negative branch: она проверяет, что classroom logic не превращает хороший latency в разрешение тратить или плохой percent utilisation — в разрешение сокращать. Вход закрыт: другой case id, extra field похожий на bill, file path или network mode отвергается до расчёта.'),
code(mechanismExample),
p('В fixture проверяется и более неприятный случай: report с подменённой model cost, sparse array внутри nested signal, cyclic object и draft с неизвестным field не проходят canonical compare. Отрицательная проверка не защищает реальный API. Она защищает границу примера: если вход нельзя сверить с fixed literal, пример должен остановиться, а не использовать красивые числа caller-а. Это особенно важно для темы стоимости, где очень легко перепутать учебную арифметику с данными access-controlled billing system.'),
h2('Saturation и quota говорят о разном'),
p('Saturation — не тот же limit. В case <code>saturation-stop</code> CPU observed 79%, memory 74%, p95 296 ms, а <code>queue-growth</code> уже задан как signal. Fixture выбирает stop по p95 headroom четыре миллисекунды, до попытки выдать следующий limit. Quota 6 CPU остаётся доступной математической границей, но она не отвечает, будет ли следующий allocation безопасным. Это полезный контрпример для обсуждения: «ещё есть quota» не является причиной увеличивать request, если performance boundary уже требует другого измерения.'),
p('Limit тоже не решает вопрос сам по себе. Kubernetes documentation описывает CPU и memory limits как разные механизмы; LimitRange накладывает условия на admission и не меняет существующие Pod. Поэтому строка <code>limit: 4,5 CPU</code> в fixed card не должна читаться как «теперь сервис выдержит 4,5». Она только называет верхнюю конструкцию данного synthetic scenario. При реальном решении понадобятся версия платформы, runtime, request shape, scheduling, data path и способ наблюдать degradation. В статье намеренно нет этого claim, потому что fixture ничего из перечисленного не видит.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Назовите один наблюдаемый факт: SLO выполняется, model cost растёт, utilisation низкий или p95 близок к границе. Не объединяйте их в один вывод.',
'<strong>Причина-кандидат.</strong> Отметьте fixed base, allocation, billing unit, quota/limit или saturation. У каждого кандидата должна быть собственная колонка.',
'<strong>Проверка формы.</strong> Сверьте scope, период, unit, request, limit и quota. Если values принадлежат разным interval, stop раньше вычисления.',
'<strong>Проверка причинной стрелки.</strong> Для «дорого» потребуйте billing expression; для «медленно» — workload и saturation evidence; для «не пройдёт» — admission/quota rule.',
'<strong>Действие.</strong> Сравните только next synthetic limit либо остановите выбор. Сохраните human question и return boundary, не executor command.',
'<strong>Ограничение.</strong> Назовите, какие реальные источники отсутствуют. Отсутствие настоящего bill — повод не делать production conclusion, а не повод подставить synthetic unit вместо него.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Модель не читает usage export, invoice, SKU catalog, commitment, tax, network charge, storage, trace, metrics, cluster, policy, manifest или CI. Она не содержит реальных ставок, имён продуктов, клиентских объёмов, интервью или actual savings. Source из Google API фиксирует, что pricing expression может содержать usage/base unit и tiered rates; он не даёт rights, чтобы запросить чужой catalog. Kubernetes sources фиксируют ресурсные термины, не оплачиваемый usage. Любое перенесение числа за пределы fixture нарушит её модельный предел.'),
p('Следующий проверяемый шаг — нарисовать ту же причинную схему для вымышленной card без чисел: один человек ставит стрелки между workload, allocation, billing unit и saturation, второй убирает хотя бы одну недопустимую стрелку, например utilisation → price. Затем оба добавляют один stop condition и один вопрос владельцу. Если схема остаётся понятной без слова «оптимизация», она готова к сопоставлению с разрешённым процессом. Если нет, сначала уточняют границы, а не выбирают instance.'),
h2('Историческая граница октября 2024'),
p('Снимки Kubernetes марта–мая 2024 и Google Cloud Billing Catalog proto июня 2024 существуют до конца октября 2024. Они поддерживают узкий словарь этой статьи: request/limit/quota, admission defaults, usage unit, base unit и tiered rate. Fixed/variable split, cases, percentages, p95, model cost и decisions — авторские versioned synthetic literals. Они не доказывают свойства облака, приложения или команды и не разрешают изменение инфраструктуры.'),
]);
const field = revision({
slug: 'editorial-2024-10-field-capacity-cost',
title: 'Ёмкость и стоимость: выбрать следующий предел, а не самый большой инстанс',
excerpt: 'Полевой разбор трёх synthetic сценариев: сравнить performance, model cost и operational risk, зафиксировать stop condition и вернуть обсуждение к маленькому следующему пределу.',
cover: '/assets/editorial/2024/capacity-cost-2024-limit-curve.svg',
readingMinutes: 10,
}, [
p('В одном tabletop-разборе лежат три fixed synthetic cards. Первая держит 1,4 CPU, p95 248 ms и low operational risk. Вторая поднимается до 1,6 CPU, но p95 уже 296 ms и signal <code>queue-growth</code>. Третья сразу выбирает 4 CPU, получает p95 208 ms и observed CPU 31%. Симптом встречи знакомый: участники хотят перейти от второй карточки к третьей, потому что она «самая быстрая». Цена ошибки — пропустить stop condition, перепутать performance с безопасностью и назвать большой reservation единственным способом убрать тревогу.'),
p('Полевая задача уже: не подобрать instance, а выбрать следующий предел, который можно объяснить тремя осями — performance, model cost и operational risk. Причина выбора по максимальному размеру в том, что одна хорошая цифра p95 заслоняет resource contract и return boundary. Проверка должна сравнить cases по одинаковому scope и периоду, назвать stop condition до действия и отделить teaching return card от реального rollback. Действие будет либо <code>compare-next-synthetic-limit</code>, либо stop и human review; fixture не создаёт третьего пути.'),
h2('Три сценария как материал полевого разбора'),
p('<strong>Первый сценарий — next limit.</strong> <code>next-limit-balanced</code> содержит 1,4 CPU request, 1,8 CPU limit, 2,2 GiB memory и p95 248 ms при SLO 300 ms. Его model cost за 24 часа равен 11,856 u. CPU observed 67%, saturation помечена как <code>watch</code>, а operational risk — low. Fixture позволяет сравнить следующий fixed limit 1,6 CPU и фиксирует delta 0,384 u/24h. Это не рекомендация увеличить request: это единственная карточка, где учебная логика ещё не встретила stop.'),
p('<strong>Второй сценарий — stop.</strong> <code>saturation-stop</code> использует 1,6 CPU request и 2 CPU limit, однако p95 оставляет лишь четыре миллисекунды headroom, а signal уже равен <code>queue-growth</code>. Cost равна 12,288 u/24h, но разница цены не главный факт. Модель останавливается по p95 boundary и просит отдельный review. Ошибка была бы в том, чтобы назвать quota 6 CPU запасом, который автоматически лечит очередь. Quota здесь только допустимая абсолютная граница, а не доказательство поведения следующего limit.'),
p('<strong>Третий сценарий — большой reservation.</strong> <code>large-instance-unjustified</code> задаёт 4 CPU request и 6 GiB memory, поэтому p95 падает до 208 ms. Но observed CPU 31%, model cost равна 17,76 u/24h, а operational risk остаётся medium: в модели не доказано, какой change был безопасен и как его вернуть. Fixture не говорит «уменьшить до 1,4». Она возвращает <code>stop-and-compare-smaller-synthetic-limit</code>. Достаточно этого отрицательного результата: большой instance не получает статус базовой стратегии только потому, что одна latency-точка лучше.'),
figure('/assets/editorial/2024/capacity-cost-2024-limit-curve.svg', 'Кривая трёх fixed synthetic точек: current 1,2 CPU, next limit 1,4 CPU и large instance 4 CPU. Линии показывают раздельные performance, model cost и operational risk, а пунктир отмечает stop condition.', 'Диаграмма не прогнозирует production. Она заставляет спросить, на какой оси принято решение и где модель обязана остановить сравнение.'),
h2('Матрица выбора: не один лучший показатель'),
table('Сравнение three fixed synthetic scenarios', ['Scenario', 'Performance', 'Model cost / 24h', 'Operational risk', 'Fixture outcome'], [
['<code>next-limit-balanced</code>', 'p95 248 ms; headroom 52 ms', '11,856 u; next CPU delta 0,384 u', 'low; saturation <code>watch</code>', '<code>compare-next-synthetic-limit</code>'],
['<code>saturation-stop</code>', 'p95 296 ms; headroom 4 ms; <code>queue-growth</code>', '12,288 u', 'medium; performance boundary unclear', '<code>stop-and-review-synthetic-limit</code>'],
['<code>large-instance-unjustified</code>', 'p95 208 ms; observed CPU 31%', '17,76 u', 'medium; return safety not proven', '<code>stop-and-compare-smaller-synthetic-limit</code>'],
]),
p('Матрица не складывает три колонки в score. Это было бы ещё одной скрытой formula без owner-а. Её задача проще: не позволить одной колонке отменить другую. У третьего case лучше p95, но он не объясняет marginal effect между 1,6 и 4 CPU и не имеет доказанного operational path. У второго case нет самого большого cost, но его p95/risk condition уже достаточна для stop. У первого нет успеха; есть только ограниченное право продолжить учить модель на следующем fixed limit и перенести real decision владельцу.'),
h2('Сначала stop condition, потом следующий предел'),
p('Stop condition должна быть записана до сравнения вариантов. В fixture есть три вида. <code>p95-headroom-at-or-below-15ms</code> останавливает scenario, где latency почти касается SLO. <code>saturation-signal-present</code> остановил бы case с очередью даже при другом запасе p95. <code>large-reserve-with-low-observed-utilisation</code> не говорит, что ресурс точно лишний; он запрещает использовать большой reservation как ответ без сравнения меньшего fixed step. Эти условия не равны production policy. Они намеренно узкие и понятные как unit-test reasoning.'),
p('Затем формулируется next limit. Он должен отличаться одним основным контролируемым полем и иметь return point. В <code>next-limit-balanced</code> следующая карта меняет CPU request с 1,4 до 1,6 и CPU limit с 1,8 до 2; return point хранит исходные 1,4 и 1,8 как teaching record. Memory, period и unit здесь фиксированы. Если в реальном обсуждении вместе с CPU меняют class instance, network, concurrency, storage и commitment, это уже не «следующий предел». Это несколько решений, для которых одной линейной карточки недостаточно.'),
h2('Компактный прогон decision и return boundary'),
p('Пример ниже строит report, затем decision draft и return card для <code>next-limit-balanced</code>. Return card существует только после canonical report и только для branch без stop. Она содержит фразу <code>does-not-change-a-workload-or-prove-a-real-rollback</code>. Такая формулировка нужна, чтобы во время напряжённого разбора никто не перепутал зафиксированную точку возврата с командой к API. Файл не импортирует cloud client и не знает имя cluster, manifest, namespace, account или deployment.'),
code(fieldExample),
p('Проверка fixture покрывает не только happy case. Если в report изменить cost, добавить <code>billingExport</code>, передать sparse nested array или получить cyclic object, plan отвергается. Если в draft изменить actions или добавить field, похожий на cloud command, return card тоже не создаётся. Эти assertions не делают rollback безопасным в production. Они доказывают лишь узкое правило: учебный record не должен принять мутировавший object как разрешение на действие. Для темы capacity это важнее красивого примера с одной зелёной веткой.'),
h2('Роль quota и limit в полевом маршруте'),
p('Quota и limit находятся рядом с выбором, но не заменяют его. Kubernetes ResourceQuota позволяет ограничивать aggregate requests и limits в namespace, но не превращает разрешённый aggregate в доступную ёмкость кластера. LimitRange может применить default/min/max на admission и не пересобирает уже существующие workload. Поэтому field reviewer сначала спрашивает: пройдёт ли новая форма policy? Затем: какой resource она резервирует? Потом: какую billing formula мы вообще собираемся сопоставить? И только после этого — какое performance evidence допустимо сравнить. Перестановка вопроса «а quota позволяет?» на конец обсуждения создаёт лишний риск уже на стадии формы.'),
p('В synthetic cards quota 6 CPU одна и та же. Это сделано специально: она не становится объяснением разницы между 1,4 и 4 CPU. Если держать quota одинаковой, видно, что performance, cost и operational risk меняются не из-за самой quota. В реальном контуре quota может быть важной policy, но она не превращает capacity change в harmless change. У неё собственный owner, admission semantics и blast radius. Статья не выдаёт учебный literal за готовое правило namespace.'),
h2('Маршрутизированный чек-лист полевого разбора'),
ol([
'<strong>Старт: зафиксируйте case.</strong> Выберите один workload, один период и одну formula. Если карточка смешивает разные units или scope, маршрут заканчивается до сравнения.',
'<strong>Ветка performance.</strong> Запишите p95, SLO и headroom. При заданном stop condition не переходите к размеру instance; сформулируйте недостающий authorised measurement.',
'<strong>Ветка resource policy.</strong> Проверьте request, limit, quota и admission constraint. Ответ «quota ещё есть» не закрывает performance или cost branch.',
'<strong>Ветка cost model.</strong> Отделите fixed base, variable allocation, unit и period. Если unit не подтверждена источником, не вычисляйте marginal effect.',
'<strong>Ветка operational risk.</strong> Назовите owner, допустимый scope и return boundary. Если return означает непонятное действие над workload, останьтесь в review.',
'<strong>Финиш: выберите outcome.</strong> Только <code>compare-next-synthetic-limit</code> ведёт к новой учебной карточке. Любой stop ведёт к отдельному human question, а не к большому instance.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Ни один case не является нагрузочным тестом, cloud quote, счётом, telemetry snapshot, анализом scheduler, интервью или incident report. Все request, limit, quota, rates, request/s, p95, risk labels и return points — fixed versioned JS literals. Модель не знает, как реальный runtime использует CPU, как provider применяет commitment/tier/discount, какие dependencies находятся за latency и кто уполномочен менять capacity. Поэтому она не заявляет cost saving, не выбирает provider и не советует реальный rollback.'),
p('Следующий проверяемый шаг — пройти таблицу втроём на бумаге. Первый читает только performance column, второй — только cost model, третий — только resource/operational column. Каждый обязан назвать одну stop condition или недостающий источник. Затем группа выбирает один из трёх outcomes fixture и сверяет, нет ли решения, основанного на единственной колонке. Успех — короткая доказуемая причина остановки или следующего synthetic limit, а не согласие на самый большой instance.'),
h2('Историческая граница октября 2024'),
p('Источники в конце закреплены датами марта–июня 2024 и существовали до конца октября 2024. Kubernetes docs поддерживают различение request, limit, quota и admission constraints. Google API proto поддерживает различение usage/base unit и tiered rate в billing expression. Три scenarios, cost values, stop conditions, decisions и return cards принадлежат только <code>synthetic-capacity-cost-model/2024-10-v1</code>. Они не описывают неизвестный production и не исполняют никаких действий.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runCapacityCostFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length > 0) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
const count = Object.keys(report.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');