Allocating Shared Infrastructure Cost per Tenant
You cannot price isolation tiers, spot an unprofitable customer, or justify a promotion without knowing what a tenant actually costs โ and on shared infrastructure that number has to be constructed. This page is a focused part of Cost vs Security Tradeoff Analysis: choosing drivers, measuring them, and allocating the shared remainder honestly.
Problem Framing
Dedicated resources are easy: a tenant with its own database instance costs what that instance costs. Shared resources are the hard part, and on a pooled platform they are most of the bill. One Postgres cluster, one Kubernetes cluster, one object-storage bucket and one observability platform serve thousands of tenants, and the invoice arrives as a handful of line items with no tenant dimension at all.
Two failure modes are common. Even splitting โ total cost divided by tenant count โ is trivially wrong when usage spans four orders of magnitude, and it makes the free tier look expensive and the largest customers look cheap. Over-engineering โ attempting to attribute every millisecond of CPU โ costs more to build and run than the decisions it informs, and the extra precision changes no outcome.
The workable middle is a small set of measurable drivers that genuinely correlate with spend: storage bytes, query time, request count, and egress. Attribute direct costs precisely, allocate shared costs by driver, and report the platform overhead separately rather than smearing it across tenants in a way nobody can explain.
Step-by-Step Guide
1. Measure storage per tenant directly
-- Row-level storage attribution on a shared table, sampled nightly.
SELECT tenant_id,
sum(pg_column_size(t.*))::bigint AS approx_bytes,
count(*) AS rows
FROM invoice t
GROUP BY tenant_id;
-- Object storage: prefix-level totals from the inventory report, not a live listing.
-- s3://tenant-files/{tenant_id}/... โ bytes per prefix, delivered daily.
Use the provider's storage inventory rather than listing objects live: listing a bucket with a hundred million objects costs real money and takes hours, while the inventory report is a file you read.
2. Attribute query time using pg_stat_statements plus a tenant tag
-- Tag every statement so time can be grouped afterwards.
-- The application sets this once per transaction, alongside the RLS scope.
SET LOCAL application_name = 'tenant:acme';
-- Then attribute: total execution time by tenant over the window.
SELECT split_part(a.application_name, ':', 2) AS tenant,
sum(s.total_exec_time) AS ms,
sum(s.calls) AS calls
FROM pg_stat_statements s
JOIN pg_stat_activity a ON a.datid = s.dbid
GROUP BY 1 ORDER BY ms DESC;
Query time is the best available proxy for the database's share of compute, and it is far cheaper to collect than true CPU attribution. It is an approximation โ it ignores background work like autovacuum โ and that is acceptable, because the decisions it drives are "which tenants are disproportionately expensive", not "bill this customer exactly".
3. Allocate each shared cost by its own driver
# allocate.py
DRIVERS = {
"rds": "query_ms", # database compute follows execution time
"rds_storage":"db_bytes",
"s3": "object_bytes",
"cloudfront": "egress_bytes",
"eks": "request_count", # application compute follows request volume
}
def allocate(costs: dict[str, float], usage: dict[str, dict[str, float]]) -> dict[str, float]:
"""costs: service -> monthly spend. usage: tenant -> driver -> value."""
out = {t: 0.0 for t in usage}
for service, spend in costs.items():
driver = DRIVERS[service]
total = sum(u.get(driver, 0.0) for u in usage.values())
if total <= 0:
continue
for tenant, u in usage.items():
out[tenant] += spend * (u.get(driver, 0.0) / total)
return out
One driver per service, chosen because it plausibly causes the spend. Resist blended weights: a formula nobody can explain produces a number nobody trusts, and the first time someone questions an allocation the whole model is discarded.
4. Keep platform overhead out of the per-tenant number
# report.py
def unit_economics(month: str) -> list[dict]:
costs = load_costs(month) # from the cost-and-usage export
usage = load_usage(month) # from steps 1 and 2
direct = load_direct_costs(month) # dedicated instances, per-tenant KMS keys
shared = {k: v for k, v in costs.items() if k in DRIVERS}
overhead = sum(costs.values()) - sum(shared.values()) - sum(direct.values())
allocated = allocate(shared, usage)
rows = []
for tenant, alloc in allocated.items():
cost = alloc + direct.get(tenant, 0.0)
rev = revenue(tenant, month)
rows.append({
"tenant": tenant, "cost": round(cost, 2), "revenue": rev,
"gross_margin_pct": round(100 * (rev - cost) / rev, 1) if rev else None,
})
return rows + [{"tenant": "__platform_overhead__", "cost": round(overhead, 2)}]
Reporting overhead as its own row rather than dividing it across tenants keeps the per-tenant figure defensible. Overhead is a business cost that scales with the platform, not with any customer, and spreading it makes small tenants look unprofitable for reasons they did not cause.
5. Report margin, and act on the tails
-- Tenants whose cost exceeds a meaningful share of their revenue.
SELECT tenant_id, revenue_cents, cost_cents,
round(100.0 * (revenue_cents - cost_cents) / nullif(revenue_cents, 0), 1) AS margin_pct
FROM tenant_unit_economics
WHERE month = $1
ORDER BY margin_pct NULLS FIRST
LIMIT 25;
Verification
The model's credibility rests on one property: allocated cost plus overhead must equal the bill.
# test_allocation.py
def test_allocation_reconciles_to_the_bill(month):
rows = unit_economics(month)
allocated = sum(r["cost"] for r in rows if r["tenant"] != "__platform_overhead__")
overhead = next(r["cost"] for r in rows if r["tenant"] == "__platform_overhead__")
assert abs((allocated + overhead) - total_bill(month)) < 1.00 # within a dollar
def test_a_tenant_with_no_usage_is_allocated_nothing(month):
rows = {r["tenant"]: r for r in unit_economics(month)}
assert rows["dormant-tenant"]["cost"] == 0.0
def test_doubling_one_tenants_storage_roughly_doubles_its_storage_share(month):
before = allocate({"s3": 1000.0}, {"a": {"object_bytes": 10}, "b": {"object_bytes": 10}})
after = allocate({"s3": 1000.0}, {"a": {"object_bytes": 20}, "b": {"object_bytes": 10}})
assert after["a"] > before["a"] and after["b"] < before["b"]
-- Sanity: the top ten tenants by cost should look like the top ten by usage.
SELECT tenant_id, cost_cents, query_ms, db_bytes, request_count
FROM tenant_unit_economics
WHERE month = $1 ORDER BY cost_cents DESC LIMIT 10;
-- a tenant high in cost but low on every driver means a driver is missing
That last check is the one that finds modelling gaps. A tenant that is expensive without being heavy on any measured driver is consuming something you are not measuring โ usually egress, or a support-heavy workload that shows up in a service outside the model.
Failure Modes & Gotchas
-
Symptom: every tenant appears to cost the same. Cause: cost is divided by tenant count rather than allocated by usage. Fix: allocate by driver; even one driver, storage, is dramatically better than an even split.
-
Symptom: the free tier looks catastrophically unprofitable. Cause: platform overhead is smeared across all tenants including free ones. Fix: report overhead separately, and treat free-tier cost as acquisition spend rather than negative margin.
-
Symptom: allocations do not sum to the bill. Cause: a service was added to the account without a driver mapping. Fix: assert reconciliation in a test, and let unmapped services land visibly in overhead rather than being dropped.
-
Symptom: query-time attribution is wildly wrong for one tenant. Cause: a background job runs without the
application_nametag, so its time is unattributed or attributed to whoever ran last. Fix: set the tag in the same place that sets the tenant scope, including in every worker.
FAQ
How precise does per-tenant cost need to be? Within roughly ten percent is enough for every decision it informs: which tenants to promote to a dedicated tier, how to price that tier, which customers are unprofitable, and whether an efficiency project is worth doing. Chasing precision beyond that means per-query CPU accounting, which costs more to operate than the savings it identifies. Be explicit that the number is an allocation, not an invoice.
Should tenants ever see their own cost figure? Rarely, and never as "your cost". Showing usage โ storage consumed, requests made, against their plan limits โ is genuinely useful and is what customers ask for. Showing your internal cost invites a negotiation about margin and exposes information about your infrastructure that has no upside. The exception is a cost-plus contract, where the model must then be documented and auditable.
Does this change under a dedicated-database model? It gets simpler and more expensive to interpret. Direct attribution becomes exact โ that instance, that backup, that key โ but the fixed instance floor dominates, so a small tenant on a dedicated database has a high cost regardless of usage. That is precisely the number needed to price the tier correctly, and it is the input to the promotion decision described in Hybrid & Tiered Isolation Models.
How should the model handle a tenant that is expensive for one month only? Report the month rather than smoothing it away. A one-off migration, a bulk import or a large export genuinely consumed the resources, and averaging it across the year hides both the event and the customer conversation it might justify. Where a recurring pattern emerges โ every tenant spikes at month end, say โ that belongs in capacity planning rather than in the allocation, because it affects how much headroom the platform needs rather than what any one customer costs.