Skip to main content

Metering Your Tenants: Build a Kubernetes Metrics Exporter for Railway-Style Usage Showback

10 min readDora NodaDora Noda
Share
On this page

Ask a tenant on your self-hosted platform what their app consumed last month and you will usually get a shrug. Railway can answer that question to the minute: $10 per GB of RAM per month, $20 per vCPU per month, $0.05 per GB of egress, metered continuously and itemized per service. A team running on a platform you built on your own hardware gets... whatever Grafana dashboard someone set up two years ago.

That gap is fixable in an afternoon. Here is the artifact this post builds toward — a per-app monthly usage statement, priced at Railway's public meters, with no bill attached:

AppvCPU avgRAM (working set)EgressAt Railway's meters
api-server0.30 vCPU469 MB22 GB$11.79/mo
worker0.85 vCPU1.29 GB3 GB$30.03/mo
docs-site0.02 vCPU101 MB41 GB$3.46/mo

The rest of this post builds the custom metrics exporter that produces those numbers: a small Go service, following the pattern from the Kubernetes project's July 14, 2026 guide to writing custom metrics exporters, that aggregates per-tenant CPU-seconds, working-set memory, and egress bytes into clean Prometheus metrics your dashboard — or your AI agent — can read. Along the way we will cover the cAdvisor pitfalls that make naive per-container accounting quietly wrong, because every one of them will corrupt your numbers if the exporter's queries don't handle them.

This is showback, not chargeback: usage visibility as a product feature, deliberately decoupled from pricing. Nobody gets invoiced. But everybody finally knows what their app costs.

The Raw Signals Behind Your Exporter's Queries

You do not need to instrument anything to meter tenants. Every kubelet already embeds cAdvisor, which exports per-container resource counters that Prometheus is almost certainly scraping in your cluster today. Your exporter will aggregate three of them:

Billable dimensioncAdvisor metricType
CPUcontainer_cpu_usage_seconds_totalCounter (cumulative CPU-seconds)
Memorycontainer_memory_working_set_bytesGauge (bytes)
Networkcontainer_network_transmit_bytes_total / container_network_receive_bytes_totalCounter (cumulative bytes)

Two of these choices deserve a sentence of justification.

CPU-seconds, not CPU percent. container_cpu_usage_seconds_total is a monotonic counter of cumulative CPU time consumed. One core running flat out accumulates 3,600 CPU-seconds per hour. Counters survive scrape gaps and can be turned into any window you want — per-hour rates for dashboards, 30-day totals for a monthly statement — with rate() and increase().

Working set, not usage. cAdvisor exports both container_memory_usage_bytes and container_memory_working_set_bytes, and they can differ by hundreds of megabytes. usage_bytes includes the page cache — memory the kernel will happily reclaim the moment anyone needs it. working_set_bytes is what the kernel considers actively in use, and it is the number the OOM killer watches. Billing a tenant for reclaimable file cache their app read once at startup is how you lose an argument with a customer. Meter the working set.

Building the Metering Exporter

The Kubernetes blog's exporter guide distills an exporter to its essence: a small HTTP server whose single responsibility is exposing state as text on a /metrics endpoint, which Prometheus scrapes on its own schedule. We will follow its conventions exactly — Go, the official prometheus/client_golang library, snake_case metric names with base units, and metrics collected at scrape time rather than on an internal timer (a Prometheus best practice: exporters should not cache except for genuinely expensive collections).

The architecture is deliberately boring. The exporter does not scrape kubelets itself — Prometheus already did that. At scrape time it runs three aggregation queries against the Prometheus HTTP API, one per billable dimension, and re-exposes the results as per-app series. Assuming the common PaaS layout of one namespace per app (this is how platforms like bex isolate tenant workloads — each deployed app gets its own namespace), aggregation is a sum by (namespace):

promql
# vCPU-hours consumed in the last 30 days, per app
sum by (namespace) (
  increase(container_cpu_usage_seconds_total{container!="",image!=""}[30d])
) / 3600
 
# Average working-set GB over the last 30 days, per app
sum by (namespace) (
  avg_over_time(container_memory_working_set_bytes{container!="",image!=""}[30d])
) / 1e9
 
# Egress bytes in the last 30 days, per app (pod-level series — see pitfalls)
sum by (namespace) (
  increase(container_network_transmit_bytes_total{pod!=""}[30d])
)

The exporter wraps those results in three metrics of its own:

go
var (
    cpuSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
        Name: "paas_app_cpu_seconds_30d",
        Help: "CPU-seconds consumed by the app over the trailing 30 days.",
    }, []string{"app"})
 
    memoryGBHours = prometheus.NewGaugeVec(prometheus.GaugeOpts{
        Name: "paas_app_memory_gb_hours_30d",
        Help: "GB-hours of working-set memory over the trailing 30 days.",
    }, []string{"app"})
 
    egressBytes = prometheus.NewGaugeVec(prometheus.GaugeOpts{
        Name: "paas_app_egress_bytes_30d",
        Help: "Bytes transmitted by the app over the trailing 30 days.",
    }, []string{"app"})
)
 
func main() {
    prometheus.MustRegister(cpuSeconds, memoryGBHours, egressBytes)
    http.Handle("/metrics", promhttp.Handler())
    // refresh() runs the three PromQL queries via the Prometheus API
    // client and sets the gauges before each scrape response.
    log.Fatal(http.ListenAndServe(":9090", nil))
}

A subtlety the guide calls out: these are gauges, not counters, even though the underlying data comes from counters. A trailing-30-day window can go down as old usage ages out, and Prometheus's rule is that anything that can decrease must not be a counter.

Cardinality stays bounded by construction: one series per app per dimension, labeled only by app. Resist the urge to add pod or container labels here — the whole point of the exporter is that it owns the aggregation, so the dashboard consuming it never has to re-derive it. Per the exporter guide, ship it as a one-replica Deployment with a ServiceAccount whose RBAC grants nothing beyond reach of the Prometheus service — the exporter never touches the Kubernetes API itself.

Why not just use Prometheus recording rules and skip the exporter? Because a platform product needs a stable interface, not a query convention. The exporter gives you one endpoint your billing page, CLI (bex usage, say), and MCP-connected agents can consume without knowing your Prometheus topology; it is where monthly rollups, calendar-month boundaries, and per-tenant API auth will live as the feature grows. Recording rules are an implementation detail; the exporter is a contract.

What the Exporter Reports: A Worked Showback Statement

Take a representative tenant app — a mid-traffic API server — and read its three meters for a 30-day (720-hour) month:

  • CPU: paas_app_cpu_seconds_30d = 777,600 → 216 vCPU-hours → an average of 0.30 vCPU
  • Memory: paas_app_memory_gb_hours_30d = 338 GB-hours → an average working set of 0.47 GB (469 MB)
  • Egress: paas_app_egress_bytes_30d = 22 GB transmitted

Now price it at Railway's published meters:

DimensionUsageRailway meterMonthly
CPU0.30 vCPU avg$20 / vCPU / mo$6.00
Memory0.47 GB avg working set$10 / GB / mo$4.69
Egress22 GB$0.05 / GB$1.10
Total$11.79 / mo

That $11.79 line is the product. The tenant sees what their app actually consumes, denominated in a public price everyone can verify — without your platform sending anyone a bill.

Sensitivity: the average hides the shape, and the shape moves the number ~3x. That 0.30 vCPU average could be a steady 0.3 vCPU all day, or an app that idles at 0.05 vCPU and spikes to 2 vCPU during business hours. Usage metering prices both identically — $6.00 — because increase() integrates over the whole window. But a reservation-based view of the same spiky app (its Kubernetes CPU request has to cover the spikes, say 1 full vCPU) would price it at $20. Which number you show tenants is a product decision: usage rewards bursty apps, reservations reflect what the scheduler actually sets aside. Show usage, but show the peak alongside it — tenants with a 6x peak-to-average ratio should know they are the reason your nodes have headroom.

Five Ways Naive Accounting Lies to Your Exporter

Every query above carries filters that look decorative and are not. Remove them and the statement above becomes fiction. These are the cAdvisor/kubelet pitfalls that make naive per-container accounting wrong:

1. Parent cgroup series double your CPU and memory. cAdvisor exports series for every level of the cgroup hierarchy, including pod-level aggregates with an empty container="" label. Sum without a filter and every CPU-second is counted twice — once in the container's series, once in its parent's. The container!="" filter in the CPU and memory queries is load-bearing.

2. Pause containers pad the count. Each pod's infrastructure ("pause") container shows up as container="POD" on some runtimes, with its own (tiny) CPU and memory series. The image!="" filter (or an explicit container!="POD") keeps infrastructure overhead out of tenant meters.

3. Counter resets break subtraction — use increase(), never last-minus-first. When a container restarts, its counters reset to zero. A naive value_now - value_30d_ago on an app that restarted mid-month yields a small or negative number, silently under-billing the noisiest (most-restarted) apps. increase() and rate() detect resets and compensate. If you ever compute usage outside PromQL, you have to reimplement that reset logic yourself.

4. usage_bytes bills tenants for the kernel's cache. Covered above, but it is the single most common metering bug in the wild: dashboards built on container_memory_usage_bytes show a log-heavy app "using" gigabytes that are just page cache. Working set only.

5. Pod-level network counters can't tell the internet from the cluster. Note the network query filters on pod!="" and not container!="" — network counters are measured at the pod's network namespace (attributed to the pause container's netns), so the container-level filter that fixes CPU would return nothing here. The deeper caveat: container_network_transmit_bytes_total counts every byte leaving the pod, including traffic to the database one namespace over. True internet-egress metering — what Railway's $0.05/GB actually bills — needs flow-level attribution from your CNI (Cilium's Hubble exports exactly this) or accounting at the cluster edge. Label the meter honestly ("network transmit") until you have it, and treat it as an upper bound on egress.

Each of these belongs inside the exporter's queries, which is the quiet argument for the exporter pattern itself: fix the filter once, in one place, and every consumer downstream inherits correct numbers.

Showback Is a Product Feature, Not a Billing System

If what you actually need is cost accounting — cloud bills allocated across teams, GPU amortization, idle-cost attribution — adopt OpenCost, the CNCF's vendor-neutral cost-allocation project, rather than growing this exporter into one. It ingests real pricing (cloud billing APIs, or custom pricing for on-prem hardware) and allocates by pod, namespace, and label, exposing both an API and Prometheus metrics. It is the right tool when finance is the audience.

The exporter built here is a different, smaller thing, and that is its virtue: three metrics, one label, a public reference price, aimed at tenants rather than accountants. On a self-hosted platform, that reframing matters. Railway meters usage because usage is the bill. When you run the hardware, usage visibility becomes pure product surface — a reason tenants trust the platform — while the marginal price of a vCPU-hour on machines you already own rounds to electricity. Showing Railway's meter next to actual consumption ("this app would cost $11.79/month on Railway; here it runs on hardware you own") is the cheapest self-hosting advocacy you will ever ship.

There is one more consumer worth designing for: agents. A per-app usage endpoint with stable, machine-readable semantics is exactly the kind of platform state an AI operator can act on — flag the app whose egress tripled week-over-week, right-size the request that sits at 6x its actual usage. Metering built for humans to read turns out to be metering agents can operate on.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with platform state agents can read and act on. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex