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 y el host: NUMA, hugepages y aislamiento de CPU 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
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:
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:
- El pod tiene que ser QoS
Guaranteed:requests == limitsen CPU y memoria, y CPU entera (no500m). Solo así el CPU Manager asigna CPUs exclusivas. - El CPU Manager tiene que estar en política
static(nonone).
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:
# /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:
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 y la config de RKE2.
Knob 1 — cpu-manager-policy=static: el cimiento
Sin esto, no hay CPUs exclusivas y nada de lo demás se activa.
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
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
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
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)
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
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
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
# 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, 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:
# ¿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, 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 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, 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
- NUMA, hugepages y aislamiento de CPU — el post anterior; la capa cruda (numactl, isolcpus, membind) que este automatiza de forma declarativa.
reserved-cpusaquí debe casar conisolcpusallí. - NVLink, NVSwitch y NCCL — 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 — el edificio completo; la orquestación es la capa de control plane que sostiene a la inferencia.
- Autoescalado de LLMs en Kubernetes con KEDA — 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 — 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 — el tipo de workload que los taints del knob 10 mantienen fuera de los nodos de inferencia pinneados.
- Observabilidad GPU con DCGM — cómo confirmar, métrica en mano, que el pinning se traduce en GPU saturada y sin burbujas.
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.