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

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.