---
title: "El maître que solo te sienta si cabéis en una mesa: CPU, Memory y Topology Manager en RKE2"
url: https://blog.lo0.es/posts/kubelet-resource-managers-rke2-numa/
markdown: https://blog.lo0.es/posts/kubelet-resource-managers-rke2-numa/index.md
lang: es
date: 2026-06-06
lastmod: 2026-06-12
author: "José Manuel Román"
description: "Pinnear NUMA a mano con numactl no escala a un cluster. El kubelet lo automatiza con tres componentes —CPU Manager, Memory Manager y Topology Manager— que, en política single-numa-node, solo admiten un pod si sus CPUs, su memoria y su GPU caben en el mismo dominio NUMA, y si no, lo rechazan. Este post explica cómo funcionan los Hint Providers, cómo se configura en RKE2 vía kubelet-arg, los 10 knobs donde tocar y los gotchas (cpu_manager_state, QoS Guaranteed, reserved-cpus) que rompen el pinning en silencio."
tags: ["kubernetes","rke2","kubelet","topology-manager","cpu-manager","numa","gpu","on-premise","fundamentos"]
translations:
  en: https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/index.md
---

# El maître que solo te sienta si cabéis en una mesa: CPU, Memory y Topology Manager en RKE2


> Cierre de la serie "por debajo del motor". Vimos el [cable entre GPUs](https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/index.md) y el [host: NUMA, hugepages y aislamiento de CPU](https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/index.md) hecho a mano. Aquí está la pieza que lo hace **declarativo y a escala**: cómo el kubelet de RKE2 pinnea cada pod de vLLM al NUMA node correcto sin un solo script.

## TL;DR

Pinnear NUMA con `numactl`/`isolcpus`/`taskset` —lo del post anterior— **no escala** a un cluster donde los pods nacen y mueren y hay decenas de nodos. El **kubelet** lo automatiza con tres componentes que funcionan como *Hint Providers* de un coordinador central, el **Topology Manager**: el **CPU Manager** (asigna CPUs **exclusivas** a contenedores de pods `Guaranteed` con CPU entera), el **Memory Manager** (memoria y hugepages NUMA-local) y el **Device Manager**/plugin de GPU (sabe qué GPU está en qué NUMA node). Con la política **`single-numa-node`**, el Topology Manager solo **admite** el pod si sus CPUs, su memoria y su GPU caben en el **mismo** dominio NUMA; si no caben, **rechaza** el pod —admisión estricta, como el maître que no sienta a un grupo de ocho si no hay mesa de ocho. En RKE2 todo esto se configura con `kubelet-arg` en `/etc/rancher/rke2/config.yaml`. Este post explica el mecanismo, da los 10 knobs y desmonta los gotchas que rompen el pinning **en silencio**: el fichero `cpu_manager_state` que hay que borrar al cambiar de política, la QoS que tiene que ser exactamente `Guaranteed`, y el `reserved-cpus` que debe casar con el `isolcpus` del host. Sobre un cluster genérico RKE2 con nodos 4×H100 SXM.

## Dónde estás: la orquestación que materializa el host

> **Diagrama:** Estás en la orquestación: el kubelet materializa el pinning del host — [verlo en la versión HTML](https://blog.lo0.es/posts/kubelet-resource-managers-rke2-numa/)

## La analogía: el maître de un restaurante con mesas que no se juntan

Un restaurante tiene mesas de distintos tamaños, y —regla de la casa— **las mesas no se juntan**. Llega un grupo de ocho. El maître mira si hay **una sola mesa** donde quepan los ocho. Si la hay, los sienta; si solo quedan mesas de cuatro, **no los acepta** —prefiere rechazar la reserva a sentar al grupo partido en dos mesas separadas, porque sabe que la cena partida va mal.

Ese maître es el **Topology Manager** en política `single-numa-node`. El "grupo" es un pod de inferencia que pide CPUs, memoria y una GPU. La "mesa" es un **NUMA node**. El maître pregunta a tres ayudantes —¿hay CPUs libres en algún node? (CPU Manager), ¿hay memoria libre? (Memory Manager), ¿hay GPU libre? (Device Manager)— y solo **admite** el pod si los tres recursos caben en **el mismo** node. Si no, lo **rechaza** (el pod queda en `Failed` con `TopologyAffinityError`), y el scheduler probará otro nodo.

La diferencia con el post anterior: allí **tú** eras el maître, sentando a mano a cada proceso con `numactl`. Aquí el maître es el kubelet, y lo hace para **cada pod, en cada nodo, automáticamente, y rechazando lo que no cabe**. Eso es lo que convierte el pinning artesanal en una propiedad declarativa del cluster.

## El mecanismo: Hint Providers y el coordinador

El Topology Manager no asigna recursos; **coordina** a los que sí lo hacen. El flujo, cuando un pod `Guaranteed` llega a un nodo:

> **Diagrama:** Topology Manager coordina CPU, Memory y Device Manager — [verlo en la versión HTML](https://blog.lo0.es/posts/kubelet-resource-managers-rke2-numa/)

Los tres managers son **Hint Providers**: cada uno le dice al Topology Manager en qué NUMA node(s) podría satisfacer su parte. El Topology Manager calcula la **intersección** y, según la política, decide:

- **`none`** (default): no coordina; cada manager hace lo suyo sin alinear. Sin garantía NUMA.
- **`best-effort`**: intenta alinear en un node; si no puede, **admite igual** (en el node que sea). Mejor que nada, sin garantía.
- **`restricted`**: si no logra alinear, **rechaza** el pod. Estricto, pero permite afinidad multi-node si la intersección lo da.
- **`single-numa-node`**: exige que **todo** quepa en **un único** NUMA node, o rechaza. El más estricto y el que de verdad garantiza la localidad del post anterior.

Y dos **precondiciones** sin las cuales nada de esto se activa:

1. El pod tiene que ser **QoS `Guaranteed`**: `requests == limits` en CPU y memoria, y **CPU entera** (no `500m`). Solo así el CPU Manager asigna CPUs exclusivas.
2. El **CPU Manager** tiene que estar en política **`static`** (no `none`).

Sin esas dos, el Topology Manager no tiene nada que alinear y el pinning **no ocurre** —aunque la política esté puesta. Es el gotcha nº1.

## Cómo se configura en RKE2

RKE2 pasa argumentos al kubelet con la clave **`kubelet-arg`** en `/etc/rancher/rke2/config.yaml`. La configuración de referencia para nodos GPU de inferencia:

```yaml
# /etc/rancher/rke2/config.yaml  (en cada nodo agent con GPUs)
kubelet-arg:
  - "cpu-manager-policy=static"
  - "topology-manager-policy=single-numa-node"
  - "topology-manager-scope=pod"
  - "memory-manager-policy=Static"
  - "reserved-cpus=0-1,64-65"            # housekeeping; debe casar con el host
  - "system-reserved=memory=8Gi"
  - "kube-reserved=memory=4Gi"
  - "reserved-memory=0:memory=4Gi;1:memory=4Gi"   # requerido por Memory Manager Static
node-label:
  - "fibercli.local/numa-pinned=true"
```

Tras desplegarlo: `systemctl restart rke2-agent`. **Gotcha crítico**: si el nodo ya corrió con `cpu-manager-policy=none`, hay un fichero de estado `/var/lib/kubelet/cpu_manager_state` que **fija la política antigua**; cambiar el arg sin borrar ese fichero hace que el kubelet **falle al arrancar** o ignore la nueva política. Hay que: parar el agent, `rm /var/lib/kubelet/cpu_manager_state`, arrancar. (Lo mismo aplica a `memory_manager_state`).

Y el pod de vLLM, para ser elegible, **Guaranteed con CPU entera**:

```yaml
resources:
  requests:
    cpu: "16"            # entero, no "16000m" fraccionado raro
    memory: "200Gi"
    nvidia.com/gpu: "2"  # TP=2 → 2 GPUs del mismo NUMA node
    hugepages-1Gi: "16Gi"
  limits:
    cpu: "16"            # == requests  → QoS Guaranteed
    memory: "200Gi"
    nvidia.com/gpu: "2"
    hugepages-1Gi: "16Gi"
```

Con esto, en un nodo con la config de arriba, el kubelet asigna 16 CPUs exclusivas del NUMA node donde están las 2 GPUs pedidas, su memoria local y las hugepages —o rechaza el pod si no caben juntas. El pinning artesanal del post anterior, ahora declarativo.

## Los 10 knobs donde tocar

Ordenados por dependencia (los primeros son precondición de los siguientes). La referencia canónica es la [doc del Topology Manager de Kubernetes](https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/) y la [config de RKE2](https://docs.rke2.io/install/configuration).

### Knob 1 — `cpu-manager-policy=static`: el cimiento

Sin esto, no hay CPUs exclusivas y nada de lo demás se activa.

```yaml
kubelet-arg: [ "cpu-manager-policy=static" ]
```

**Gotcha**: cambiarlo requiere borrar `/var/lib/kubelet/cpu_manager_state` y reiniciar el kubelet, o el arranque falla. Es la causa nº1 de "puse la política y no pinnea".

### Knob 2 — QoS `Guaranteed` + CPU entera: la precondición del pod

No es config de nodo, es del **pod**, pero sin ella el knob 1 no hace nada para ese pod. `requests == limits` en CPU y memoria, y CPU **entera**. Un `cpu: 500m` o un `requests != limits` degrada el pod a `Burstable` y pierde el pinning. Mucha gente pone la política de nodo y olvida la QoS del pod.

### Knob 3 — `topology-manager-policy=single-numa-node`: admisión estricta

```yaml
kubelet-arg: [ "topology-manager-policy=single-numa-node" ]
```

El maître estricto. Para inferencia con GPU, es la política correcta: garantiza que CPU+memoria+GPU comparten node. `best-effort` no garantiza (admite desalineado); `restricted` permite afinidad multi-node. Empieza por `single-numa-node` y baja a `restricted` solo si tienes problemas de admisión.

### Knob 4 — `topology-manager-scope=pod`: agrupar el pod entero

```yaml
kubelet-arg: [ "topology-manager-scope=pod" ]
```

Con scope `container` (default), cada contenedor se alinea por separado; con scope `pod`, **todo el pod** va al mismo node. Para un pod de vLLM con sidecars (métricas, proxy), scope `pod` evita que el sidecar arrastre el contenedor principal a otro node. Recomendado para inferencia.

### Knob 5 — `memory-manager-policy=Static` + `reserved-memory`: memoria NUMA-local

```yaml
kubelet-arg:
  - "memory-manager-policy=Static"
  - "reserved-memory=0:memory=4Gi;1:memory=4Gi"
```

El Memory Manager Static fuerza `cpuset.mems` para que la memoria del pod salga del node correcto (y las hugepages). **Requiere** declarar `reserved-memory` por node, o el kubelet no arranca. Es el equivalente declarativo del `--membind` del post anterior.

### Knob 6 — `reserved-cpus`: los cores housekeeping (debe casar con `isolcpus`)

```yaml
kubelet-arg: [ "reserved-cpus=0-1,64-65" ]
```

Reserva cores para el sistema y los daemons; el resto quedan para pods exclusivos. **Clave de la serie**: estos `reserved-cpus` deben ser **los mismos** cores que dejaste fuera de `isolcpus` en el host (post anterior). Si el host aísla 2-31 pero RKE2 reserva 0-3, hay un desajuste: cores aislados que el kubelet asigna a pods sin que estén realmente quietos. Coordina las dos capas.

### Knob 7 — Plugin de GPU con topología NUMA (NVIDIA GPU Operator)

El Device Manager solo puede dar un hint NUMA correcto si el **plugin de GPU expone en qué node está cada GPU**. El NVIDIA device plugin / GPU Operator lo hace, pero hay que verificar que la información de topología llega (en algunas versiones requiere flags). Sin hint de GPU, el Topology Manager alinea CPU y memoria pero **no la GPU** —y la localidad GPU es justo la que más importa.

### Knob 8 — hugepages como recurso del pod

```yaml
resources:
  limits:
    hugepages-1Gi: "16Gi"   # el nodo debe tenerlas pre-reservadas (post 2, knob 4)
```

Las hugepages que reservaste en el arranque del host (post anterior) se piden como recurso. El Memory Manager las asigna NUMA-local. Si las pides sin haberlas reservado en el nodo, el pod no se programa.

### Knob 9 — `system-reserved` / `kube-reserved`: no sobre-suscribir

```yaml
kubelet-arg:
  - "system-reserved=cpu=500m,memory=8Gi"
  - "kube-reserved=cpu=500m,memory=4Gi"
```

Reserva recursos para el sistema y los componentes de K8s para que el nodo no se quede sin aire bajo carga. Mal calibrado, o el nodo se ahoga (poco reservado) o desperdicias capacidad (demasiado). Debe ser coherente con `reserved-cpus`.

### Knob 10 — Labels + taints: que vLLM caiga aquí y lo demás no

```yaml
# nodo GPU: taint para repeler lo que no necesita GPU
node-taint: [ "nvidia.com/gpu=present:NoSchedule" ]
node-label: [ "fibercli.local/numa-pinned=true" ]
```

Mantén los nodos NUMA-pinned para inferencia y echa de ahí lo que no la necesita (bases de datos, [el backend de Langfuse](https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/index.md), runners). Un ClickHouse robando ancho de banda de memoria a un pod de vLLM cuidadosamente pinneado tira por tierra todo el trabajo de los nueve knobs anteriores. El aislamiento de workloads es el cierre.

### Tabla resumen

| # | Knob | Dónde | Función |
|---|---|---|---|
| 1 | `cpu-manager-policy=static` | kubelet-arg | CPUs exclusivas (cimiento) |
| 2 | QoS `Guaranteed` + CPU entera | pod spec | precondición del pinning |
| 3 | `topology-manager-policy=single-numa-node` | kubelet-arg | admisión estricta NUMA |
| 4 | `topology-manager-scope=pod` | kubelet-arg | agrupar pod entero |
| 5 | `memory-manager-policy=Static` | kubelet-arg | memoria/hugepages NUMA-local |
| 6 | `reserved-cpus` | kubelet-arg | housekeeping (casar con isolcpus) |
| 7 | plugin GPU con topología | GPU Operator | hint NUMA de la GPU |
| 8 | `hugepages-1Gi` | pod spec | hugepages como recurso |
| 9 | `system/kube-reserved` | kubelet-arg | no sobre-suscribir |
| 10 | taints + labels | config nodo | aislar workloads GPU |

## Verificar que el pinning de verdad ocurrió

No te fíes de que la config "esté puesta". Comprueba:

```bash
# ¿La política activa es la que pusiste?
cat /var/lib/kubelet/cpu_manager_state | jq .policyName    # "static"

# ¿Qué CPUs exclusivas tiene el contenedor?
kubectl exec <pod> -- cat /sys/fs/cgroup/cpuset.cpus.effective

# Dentro del pod: ¿la GPU asignada es local a esos cores?
kubectl exec <pod> -- nvidia-smi topo -m

# ¿Hubo rechazos por topología?
kubectl describe pod <pod> | grep -i TopologyAffinityError
```

Un pod en `Failed` con `TopologyAffinityError` no es un bug: es el maître **haciendo su trabajo** —ese nodo no tenía una mesa donde cupieran CPU+memoria+GPU juntas. La respuesta es revisar el sizing del pod o del nodo, no relajar la política a la ligera.

## Cómo se conecta con el resto del stack

**Con el host (post anterior).** Este post es la **automatización declarativa** de aquel. `cpu-manager-policy=static` materializa el `taskset`; `memory-manager-policy=Static` materializa el `--membind`; `reserved-cpus` debe casar con el `isolcpus`. Las dos capas son **una sola decisión** vista desde dos sitios: el host la ejecuta, el kubelet la declara. Descoordinarlas (isolcpus 2-31 vs reserved-cpus 0-3) rompe ambas.

**Con el interconnect (post 1).** El Topology Manager pinnea la **GPU correcta** al pod, pero si pides 2 GPUs para TP=2, querrás que esas dos compartan NVLink. La política NUMA garantiza que están en el mismo socket; que estén NVLink-conectadas lo garantiza el hardware del baseboard (post 1, knob 1). Las dos cosas juntas son lo que hace que `TP=2` rinda.

**Con el autoscaling.** Cuando [KEDA escala pods de vLLM](https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/index.md), cada réplica nueva pasa por la admisión del Topology Manager. Si el nodo no tiene una "mesa" libre, el pod queda pendiente —el autoscaling de pods y el de nodos (cluster-autoscaler) tienen que contar con la granularidad NUMA, no solo con CPU/memoria agregada.

**Con capacity planning.** El [dimensionado](https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/index.md) cambia: no es "128 vCPU por nodo", es "128 menos los reserved-cpus, en bloques que quepan por NUMA node". Un nodo de 2 sockets × 64 cores no sirve un pod que pida 80 cores en single-numa-node: no caben en una mesa. El planning tiene que razonar por node, no por nodo.

**Con la convivencia de servicios.** El taint del knob 10 es lo que mantiene a [Langfuse](https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/index.md), bases de datos y runners **fuera** de los nodos de inferencia. Sin esa frontera, todo el pinning fino se lo come un vecino ruidoso. La observabilidad va en sus nodos; la inferencia, pinneada, en los suyos.

## Trampas y cosas que no son lo que parecen

**Política puesta, QoS olvidada.** El error más común: `cpu-manager-policy=static` en el nodo pero el pod es `Burstable` (`requests != limits` o CPU fraccionada). El pinning **no ocurre** y nadie avisa. La QoS `Guaranteed` con CPU entera es condición necesaria.

**`cpu_manager_state` fosilizado.** Cambiar de política sin borrar `/var/lib/kubelet/cpu_manager_state` (y `memory_manager_state`) hace que el kubelet falle o ignore el cambio. Parar agent → borrar fichero → arrancar.

**`reserved-cpus` ≠ `isolcpus`.** Si el host aísla unos cores y RKE2 reserva otros, los managers asignan a pods cores que no están realmente quietos, o dejan idle cores aislados. Las dos listas tienen que ser coherentes. Es el fallo de coordinación entre el post anterior y este.

**Plugin de GPU sin topología NUMA.** Si el device plugin no expone el NUMA node de cada GPU, el Topology Manager alinea CPU y memoria pero deja la GPU al azar —y la localidad de la GPU es la que más pesa. Verifica que el GPU Operator publica la topología.

**`single-numa-node` que rechaza demasiado.** Si los pods piden más recursos de los que caben en un node (p. ej. más cores que los de un socket), el rechazo es constante. La respuesta no es bajar a `best-effort` (que silencia el problema sirviendo desalineado), sino **dimensionar el pod para que quepa en una mesa**, o aceptar `restricted` con conocimiento de causa.

**Creer que `best-effort` "es casi igual".** `best-effort` admite el pod aunque no logre alinear: te da la falsa sensación de NUMA-awareness mientras sirves desde el socket equivocado. Para inferencia con SLO de cola, `single-numa-node` o `restricted`; `best-effort` solo si la alternativa es no programar nada.

## Conclusión

El post anterior pinneaba a mano; este lo hace a escala y con una garantía que el `numactl` artesanal no daba: **admisión estricta**. El kubelet, vía CPU Manager, Memory Manager y Topology Manager, actúa como un maître que solo sienta al pod si sus CPUs, su memoria y su GPU caben en la misma mesa NUMA, y que **rechaza** lo que no cabe en vez de servir una cena partida. De los diez knobs, los dos primeros —**`cpu-manager-policy=static`** y **QoS `Guaranteed` con CPU entera**— son la precondición sin la cual los otros ocho no hacen nada, y son justo los que más se olvidan; el resto afina la política, la memoria, las hugepages y la convivencia. El hilo que cierra la serie: el rendimiento de inferencia que parecía un problema del motor (vLLM lento) o del modelo (cuantización) es, demasiadas veces, un problema del **cable** (NVLink no usado), del **host** (NUMA remoto, jitter) o de la **orquestación** (pinning que no ocurrió porque la QoS estaba mal). Bajar de nivel no es esnobismo de infraestructura: es donde están las causas raíz que ningún dashboard de la capa de aplicación te va a señalar.

## Ver también

- [Compartir una GPU entre varias cargas: time-slicing, MPS y MIG](https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/index.md) — repartir la GPU (time-slicing/MPS/MIG) es el equivalente en el acelerador a lo que estos managers hacen en CPU y memoria.

- [NUMA, hugepages y aislamiento de CPU](https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/index.md) — el post anterior; la capa cruda (numactl, isolcpus, membind) que este automatiza de forma declarativa. `reserved-cpus` aquí debe casar con `isolcpus` allí.
- [NVLink, NVSwitch y NCCL](https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/index.md) — el primero de la serie; la política NUMA pinnea la GPU correcta, pero que las GPUs de un TP compartan NVLink lo decide el hardware del baseboard.
- [El stack de inferencia LLM on-premise en siete capas](https://blog.lo0.es/posts/siete-capas-stack-inferencia-llm-on-premise/index.md) — el edificio completo; la orquestación es la capa de control plane que sostiene a la inferencia.
- [Autoescalado de LLMs en Kubernetes con KEDA](https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/index.md) — cada réplica que KEDA crea pasa por la admisión del Topology Manager; el autoscaling tiene que contar con la granularidad NUMA.
- [Capacity planning de inferencia on-premise](https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/index.md) — por qué el sizing pasa a razonarse por NUMA node, no por nodo: un pod no cabe si pide más que una mesa.
- [Langfuse por dentro: arquitectura v3 y los 10 knobs de backend](https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/index.md) — el tipo de workload que los taints del knob 10 mantienen **fuera** de los nodos de inferencia pinneados.
- [Observabilidad GPU con DCGM](https://blog.lo0.es/posts/observabilidad-gpu-dcgm-llm/index.md) — cómo confirmar, métrica en mano, que el pinning se traduce en GPU saturada y sin burbujas.
- [La puerta de la cocina que el maître no miró: NUMA de red, Cilium eBPF y DRANET](https://blog.lo0.es/posts/cilium-ebpf-dranet-numa-de-red-inferencia/index.md) — la coda: el Topology Manager pinnea CPU+memoria+GPU pero **no la NIC**; esa cuarta pata (localidad de red, afinidad de IRQ, DRA/DRANET) es la que aquí quedaba fuera del censo.
- [Del disco a la HBM: cold start y carga del modelo](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) — el NVMe también cuelga de un PCIe root bajo un socket; su localidad NUMA es la quinta lista a alinear, y el cold start es el techo real de la elasticidad que este pinning sostiene.

## Referencias

- Kubernetes, *Control Topology Management Policies on a node*: <https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/>.
- Kubernetes, *Control CPU Management Policies on the Node*: <https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/>.
- Kubernetes, *Control Memory Management Policies on a Node*: <https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/>.
- RKE2, *Configuration Options* (kubelet-arg en config.yaml): <https://docs.rke2.io/install/configuration>.
- RKE2, *Advanced Options and Configuration*: <https://docs.rke2.io/advanced>.
- rancher/rke2, discusión #3034 *CPU Management Policies for RKE2* (el gotcha de cpu_manager_state): <https://github.com/rancher/rke2/discussions/3034>.

