Chargeback y showback de GPU en multi-tenancy: cómo repartir el coste del cluster entre equipos

Notación: importes en euros (N €), decimales con coma. No se usa el símbolo de dólar (en este sitio es delimitador de fórmula).

TL;DR

Un cluster de 4×H100 SXM a ~1,40 €/GPU-hora (capex amortizado + energía) compartido entre tres equipos con distinta ocupación produce un informe de chargeback mensual de 3 filas y una fila adicional de idle que nadie reclamó. Sin herramientas, ese coste ocioso desaparece diluido en el total. Con OpenCost + LiteLLM + Kueue, la atribución opera en tres planos ortogonales: el hierro (OpenCost, €/GPU-hora por namespace/label), el consumo de tokens (LiteLLM, €/token por clave/equipo/modelo) y la cuota de scheduler (Kueue, GPUs reservadas por ClusterQueue). El cruce de los tres produce el número que va a finanzas: equipo B consumió X millones de tokens a Y €/1M tok, la GPU le costó Z €, y todavía tiene 2 GPUs prestadas del cohort que le costarán W € si las retiene el mes que viene.


Showback vs chargeback: definición FinOps Foundation

La distinción no es de tecnología sino de formalidad contable (FinOps Foundation — Invoicing & Chargeback, FinOps Foundation — Data Analysis and Showback):

ConceptoDefiniciónMueve dineroRequiere
Showbackvisibilidad del consumo y su coste por equipo; el informe llega, el presupuesto no cambiaNométricas + atribución
Chargebackel coste se transfiere al P&L del equipo o producto como gasto realshowback + política contable + sistema financiero

Dos puntos del framework que conviene fijar:

  1. Showback es requisito de cualquier práctica FinOps; chargeback es opcional y depende de que la política contable de la organización soporte la transferencia entre centros de coste.
  2. Ninguno es “más maduro” que el otro. La narrativa de que chargeback es la versión “adulta” es falsa según el propio framework. La secuencia natural es: showback → confianza en los datos → chargeback si la política lo permite.

Cuándo usar cada uno

SituaciónModalidad recomendada
Primer ciclo de atribución; los equipos aún no confían en los datosShowback
Equipos con presupuesto propio en sistema financieroChargeback
Cluster compartido sin separación de P&L por equipoShowback con coste de idle visible
Equipos con SLA de disponibilidad de GPU comprometidoChargeback (reserva real)
Workloads de investigación/experimentación sin presupuesto formalShowback + alerta de umbral

Atribución del coste GPU con OpenCost

Allocation API: parámetros de atribución

La API /allocation de OpenCost es la pieza que transforma las métricas de Prometheus en un informe de coste por dimensión (OpenCost — API):

ParámetroValoresUso en multi-tenancy
windowtoday, 7d, lastmonth, rango RFC3339ventana del informe mensual
aggregatenamespace, label:LABEL, annotation:NAME, pod, controllerdimensión de atribución
includeIdletrue / falseañade fila __idle__ al informe
shareIdletrue / falsedistribuye el idle entre las asignaciones no ociosas (proporcional a coste no idle)
idleByNodetrue / falsecalcula idle por nodo en lugar de por cluster
filternamespace:"llm-prod", label:equipo:"datos"limitar a un equipo concreto

Consulta de informe mensual por equipo, con idle visible como fila separada:

curl -G http://localhost:9003/allocation \
  -d window=lastmonth \
  -d aggregate=label:equipo \
  -d includeIdle=true \
  -d shareIdle=false \
  -d resolution=10m

Con shareIdle=false (el default), OpenCost devuelve el idle como __idle__ separado — lo que hace visible cuánto se pagó por capacidad sin usar. Con shareIdle=true, ese idle se distribuye entre los tenants en proporción a su coste no-idle: cada uno absorbe su parte del desperdicio, lo que es correcto para un chargeback que penalice al responsable del idle.

Etiquetado de pods de inferencia

Para que aggregate=label:equipo funcione, los pods de inferencia necesitan el label correcto. Dos métodos:

Label en el pod spec (el más directo):

# Deployment de vLLM para equipo datos
metadata:
  labels:
    equipo: datos
    producto: rag-prod
    modelo: llama-70b
    entorno: prod

Annotation (cuando el label ya está ocupado por otra convención):

# agrega como annotation
aggregate=annotation:finops.io/equipo

OpenCost soporta aggregate=label:KEY y aggregate=annotation:KEY con la misma sintaxis; la elección entre uno u otro depende de la convención de etiquetado del cluster.

Costes compartidos e idle: los tres modos

Modo APIComportamientoCuándo
includeIdle=false (default)Ignora el idle; el coste total parece menor de lo que esNunca en producción
includeIdle=true, shareIdle=falseIdle en fila __idle__ separadaShowback: visibilidad del desperdicio
includeIdle=true, shareIdle=trueIdle distribuido entre tenants proporcionalmenteChargeback: el equipo paga su parte del idle
shareIdle=true, idleByNode=trueIdle distribuido por nodo (más granular si hay nodos dedicados)Chargeback con nodos heterogéneos

Para infraestructura con nodos dedicados por equipo, idleByNode=true es más justo: el idle de un nodo dedicado al equipo A no se transfiere al equipo B.


LiteLLM como punto de atribución por consumo de tokens

Virtual keys y equipos

LiteLLM materializa el chargeback por tokens con cuatro mecanismos (LiteLLM — Virtual Keys, LiteLLM — Setting Team Budgets, LiteLLM — Spend Tracking):

MecanismoQué hace
Virtual key con max_budgetpresupuesto mensual por clave; la key se bloquea al agotarlo
budget_durationventana de reset: 30d, 7d, 24h; el proxy corre un cron diario que resetea según la duración
team_idagrupa claves; el gasto se acumula en LiteLLM_TeamTable por equipo
tagsetiquetan cada request; permiten presupuestos por tag (centro de coste)
Spend logsuna fila por petición con team_id, model, prompt_tokens, completion_tokens, response_cost

Configuración de equipos con presupuesto mensual

# litellm-config.yaml — presupuestos por equipo con coste on-prem declarado
model_list:
  - model_name: llama-3-70b-onprem
    litellm_params:
      model: openai/llama-3-70b
      api_base: http://vllm-svc:8000/v1
      input_cost_per_token:  0.00000140   # 1,40 €/1M tok (€/GPU-hora ÷ throughput)
      output_cost_per_token: 0.00000140
  - model_name: llama-3-8b-onprem
    litellm_params:
      model: openai/llama-3-8b
      api_base: http://vllm-8b-svc:8000/v1
      input_cost_per_token:  0.00000035   # 0,35 €/1M tok (1 GPU, mayor throughput)
      output_cost_per_token: 0.00000035

litellm_settings:
  success_callback: ["langfuse"]   # o cualquier logger externo

# Equipos (vía API /team/new o config)
# POST /team/new
# {
#   "team_alias": "equipo-datos",
#   "max_budget": 800,           # 800 € al mes
#   "budget_duration": "30d",
#   "tpm_limit": 500000,         # tokens/min máx
#   "rpm_limit": 1000            # requests/min máx
# }

La clave input_cost_per_token sale de la identidad del artículo A4: el €/GPU-hora de OpenCost dividido por el throughput del benchmark. Con ese valor, el response_cost de cada petición refleja el coste real del hierro on-prem para ese modelo.

Endpoints de consulta de gasto por equipo

# Gasto acumulado del equipo en el periodo
GET /team/info?team_id=equipo-datos

# Gasto por modelo del equipo (tabla spend_logs agregada)
GET /global/spend/logs?team_id=equipo-datos&start_date=2026-06-01&end_date=2026-06-30

# Gasto total por todos los equipos
GET /global/spend/teams

La respuesta incluye spend (coste acumulado en la divisa configurada), total_tokens, prompt_tokens, completion_tokens y desglose por modelo — los campos directos para el informe de chargeback mensual.

Unir €/GPU-hora (hierro) con tokens por equipo

Los dos planos de atribución — OpenCost (hierro) y LiteLLM (tokens) — se cruzan con una clave: el model_name y el namespace. El join se hace fuera de las herramientas (en un pipeline de BI o una consulta SQL sobre la base de datos de LiteLLM y los datos exportados de OpenCost):

coste_por_token_real = OpenCost.GPU_cost_namespace / LiteLLM.total_tokens_team

Cuando el coste on-prem está bien declarado en input_cost_per_token, LiteLLM ya hace este cálculo internamente y el response_cost es correcto. El join explícito sirve para validar: si la suma de response_cost de LiteLLM por equipo no coincide con el coste GPU asignado por OpenCost al namespace, hay una deriva en el input_cost_per_token que hay que corregir (throughput cambiado, optimización nueva, más réplicas).


Kueue como mecanismo de presupuesto/cuota de GPU

ClusterQueue, LocalQueue y cohorts

Kueue introduce la capa de scheduling con cuotas sobre Kubernetes (Kueue — Cluster Queue, Kueue — Cohort, Kueue — Fair Sharing):

ObjetoAlcanceQué hace
ResourceFlavorclustermapea recursos a un grupo de nodos (ej.: nodos H100)
ClusterQueueclusterdefine nominalQuota, borrowingLimit, lendingLimit por recurso/flavor
LocalQueuenamespacepunto de entrada para los workloads del equipo; apunta a un ClusterQueue
Cohortclusteragrupa ClusterQueues que pueden prestarse quota entre sí

Conceptos de quota

  • nominalQuota: GPUs garantizadas al ClusterQueue en todo momento.
  • borrowingLimit: GPUs adicionales máximas que puede tomar prestadas del cohort cuando otros no las usan.
  • lendingLimit: GPUs de su nominalQuota que permite prestar a otros (si no se especifica, puede prestar todas las no usadas).
  • Fair Sharing: mecanismo que ordena los workloads pendientes por uso histórico de recursos de su LocalQueue, dando preferencia a quien ha consumido menos. Compatible con cohorts jerárquicos desde Kueue v0.11.

YAML de ejemplo: 3 equipos en cohort llm-platform

# ResourceFlavor que mapea a los nodos H100 SXM
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
  name: h100-sxm
spec:
  nodeLabels:
    accelerator: h100-sxm
---
# ClusterQueue equipo datos — 2 GPUs garantizadas, puede pedir hasta 2 prestadas
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
  name: cq-datos
spec:
  cohort: llm-platform
  namespaceSelector:
    matchLabels:
      kubernetes.io/metadata.name: ns-datos
  queueingStrategy: BestEffortFIFO
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu"]
      flavors:
        - name: h100-sxm
          resources:
            - name: "nvidia.com/gpu"
              nominalQuota: 2
              borrowingLimit: 2    # puede usar hasta 4 GPUs en total
  preemption:
    reclaimWithinCohort: LowerPriority
    withinClusterQueue: LowerPriority
---
# ClusterQueue equipo ia — 1 GPU garantizada, presta lo que no usa
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
  name: cq-ia
spec:
  cohort: llm-platform
  namespaceSelector:
    matchLabels:
      kubernetes.io/metadata.name: ns-ia
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu"]
      flavors:
        - name: h100-sxm
          resources:
            - name: "nvidia.com/gpu"
              nominalQuota: 1
              lendingLimit: 1     # presta su GPU cuando no la usa
---
# ClusterQueue equipo plataforma — 1 GPU garantizada, prioridad baja
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
  name: cq-plataforma
spec:
  cohort: llm-platform
  namespaceSelector:
    matchLabels:
      kubernetes.io/metadata.name: ns-plataforma
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu"]
      flavors:
        - name: h100-sxm
          resources:
            - name: "nvidia.com/gpu"
              nominalQuota: 1
              borrowingLimit: 3   # puede usar las 4 si el resto está libre
---
# LocalQueue en cada namespace — punto de entrada para los workloads
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
  name: lq-datos
  namespace: ns-datos
spec:
  clusterQueue: cq-datos

Cómo Kueue materializa el “presupuesto de GPU”

Concepto FinOpsMecanismo Kueue
Presupuesto garantizadonominalQuota: el equipo siempre tiene estas GPUs disponibles
Límite de gasto máximonominalQuota + borrowingLimit: techo absoluto de GPUs admisibles
Préstamo de capacidad ociosacohort + lendingLimit: otros toman lo que este no usa
Fair sharing entre equiposFair Sharing + WorkloadPriorityClass: los que más han consumido esperan más
Recuperar la cuota propiapreemption.reclaimWithinCohort: LowerPriority: el dueño de la GPU prestada la recupera expulsando workloads de menor prioridad

El nominalQuota es la expresión del presupuesto en GPUs: si el equipo tiene 2 GPUs nominales y el precio es 1,40 €/GPU-hora, su gasto máximo garantizado es

[ \text{gasto máximo nominal} = 2 \times 1{,}40 \times 720 = 2,016 \text{ €/mes} ]

El borrowingLimit fija el sobrecoste potencial si toma prestado del cohort (y ese préstamo se puede imputar con la misma fórmula multiplicando las horas de uso prestado por 1,40 €/GPU-hora).


Modelo de coste: ejemplo con 4×H100 SXM y 3 equipos

Precio del nodo

Hardware genérico: servidor con 4×H100 SXM 80 GB. Precios de mercado 2026: una H100 SXM individual cuesta entre 27.000 y 40.000 € según fuente (GMI Cloud — H100 GPU Pricing 2026); un servidor 4×H100 se sitúa en el rango 120.000–180.000 € incluyendo chasis, fuentes y NVLink. Usamos 140.000 € como supuesto genérico para el servidor completo (4 GPUs + infraestructura).

ComponenteCálculo€/hora nodo
Capex amortizado (140.000 €, 4 años, 90 % disponibilidad)140.000 ÷ (4 × 8.760 × 0,9)~4,43
Energía (4 × 700 W TDP × PUE 1,4 × 0,12 €/kWh)4 × 0,7 × 1,4 × 0,12~0,47
Operación / red / mantenimientoestimación~0,70
Total nodo 4×H100~5,60 €/h
Por GPU5,60 ÷ 4~1,40 €/GPU-hora

Fórmula del coste por token para el modelo de 70B (throughput sostenido ~2.000 tok/s en TP=4):

$$\text{€/1M tokens} = \frac{5{,}60 \text{ €/h}}{2{,}000 \text{ tok/s} \times 3,600 \text{ s/h}} \times 10^6 \approx 0{,}78 \text{ €/1M tokens}$$

Escenario mensual: 3 equipos, utilización heterogénea

Mes de referencia (720 horas). Las 4 GPUs están nominalmente asignadas: 2 a Datos, 1 a IA, 1 a Plataforma. La ocupación real varía:

EquipoGPUs nominalesOcupación mediaGPU-horas usadasGPU-horas ociosasCoste GPU (€)
Datos275 %1.080360~1.512
IA160 %432288~605
Plataforma135 %252468~353
Idle cluster1.116~1.562
Total nodo41.7641.116~4.032

Coste total del nodo al mes: 5,60 €/h × 720 h = 4.032 €. Coste por GPU-hora usada: 5,60 ÷ 4 = 1,40 €.

Con shareIdle=false (showback), el idle de 1.562 € aparece como fila separada en el informe y nadie lo paga directamente. Con shareIdle=true (chargeback proporcional), se reparte entre los tres en proporción a su coste no-idle: Datos absorbe ~857 €, IA ~333 €, Plataforma ~192 € de idle adicional.

Consumo de tokens por equipo (LiteLLM spend logs)

EquipoModeloTokens/mesCoste/tokenCoste tokens (€)
Datosllama-70b (TP=4)380 M0,78 €/1M~296
IAllama-8b (1 GPU)210 M0,35 €/1M~74
Plataformallama-8b + batch80 M0,35 €/1M~28

Informe de chargeback mensual (showback + tokens)

EquipoCoste GPU hierro (€)Coste idle asignado (€)Coste tokens LiteLLM (€)Total imputable (€)
Datos1.5128572962.665
IA605333741.012
Plataforma35319228573
Idle (sin shareIdle)1.562visible, no imputado

Tabla de dimensiones de atribución × política

Dimensión de atribuciónHerramientaShowbackChargeback
namespaceOpenCost aggregate=namespace/allocation?aggregate=namespace&includeIdle=trueshareIdle=true + transfer a P&L
label de podOpenCost aggregate=label:equipoinforme por labelidem con shareIdle
virtual key / teamLiteLLM /global/spend/teamsdashboard de tokensmax_budget duro por clave
ClusterQueue (GPU reservada)Kueue nominalQuotaobservar uso vs quotapresupuesto en GPUs comprometido
tag de requestLiteLLM tag_budgetspor centro de costepresupuesto por tag
annotation de podOpenCost aggregate=annotation:KEYinforme por annotationidem con shareIdle

Flujo de datos end-to-end

Pod vLLMlabel:equipo=datosOpenCost (hierro)€/GPU-hora × namespace/labelLiteLLM (tokens)€/token × team_id/key/modelKueue (scheduler)nominalQuota / cohort / fair-shareInforme mensual€/equipo (hierro + idle + tokens)GPUs prestadas · presupuesto restanteShowbackoChargebackLos tres planos son ortogonales: OpenCost ve el hierro, LiteLLM ve los tokens, Kueue ve la cuota del scheduler.El cruce de los tres da el número completo: €/equipo con idle, tokens y GPUs prestadas.

Fair sharing y preemption: cómo Kueue recupera la cuota

Cuando el equipo Datos ha agotado sus 2 GPUs nominales y toma prestadas las de Plataforma (que no las usa), Kueue registra ese préstamo. En cuanto Plataforma lanza un workload nuevo:

  1. Kueue detecta que cq-plataforma está por debajo de su nominalQuota.
  2. Con preemption.reclaimWithinCohort: LowerPriority, Kueue expulsa el workload de Datos que estaba en la GPU prestada, si su prioridad es menor.
  3. El workload expulsado vuelve a la cola de cq-datos y se readmite cuando se libere cuota.

El Fair Sharing ordena la cola de pendientes por uso histórico acumulado de la LocalQueue: si Datos ha consumido más GPU-horas que IA en el periodo reciente, los próximos workloads de IA tienen prioridad de admisión antes que los nuevos de Datos. Esto implementa un reparto equitativo sin bloquear a nadie permanentemente.


Configuración de OpenCost para el precio del nodo

Para que los números de la sección anterior sean reproducibles, el precio del nodo hay que declararlo explícitamente (el valor por defecto subestima GPU on-prem, issue #3781):

# values.yaml Helm de OpenCost — nodo 4×H100 on-prem, 5,60 €/h
opencost:
  customPricing:
    enabled: true
    provider: custom
    costModel:
      description: "Nodo 4xH100 SXM on-prem"
      CPU: "0.025"      # €/CPU-hora (componente menor)
      RAM: "0.003"      # €/GB-hora
      GPU: "1.40"       # €/GPU-hora  ← el que mueve el reparto
      storage: "0.0002" # €/GB-hora

Verificar tras configurar:

# Comprobar precio resuelto por nodo
curl http://localhost:9003/allNodePricing

# Consulta de atribución por label de equipo, último mes, idle separado
curl -G http://localhost:9003/allocation \
  -d window=lastmonth \
  -d aggregate=label:equipo \
  -d includeIdle=true \
  -d shareIdle=false \
  -d accumulate=true

PromQL de referencia para paneles de chargeback

# Coste GPU asignado por equipo (label:equipo) — €/hora
sum by (label_equipo) (
  container_gpu_allocation
  * on(node) group_left node_gpu_hourly_cost
)

# GPU-horas ociosas por nodo (idle > 15 min)
sum by (node) (
  (1 - avg_over_time(DCGM_FI_DEV_GPU_UTIL[15m]) / 100)
  * on(node) group_left node_gpu_hourly_cost
)

# Coste acumulado del mes por equipo (suma sobre ventana)
sum_over_time(
  sum by (label_equipo) (
    container_gpu_allocation * on(node) group_left node_gpu_hourly_cost
  )[30d:1h]
)

Fuentes

Ver también