"Tenemos autoscaling" es una de esas frases que no significa nada hasta que preguntas en qué capa. KEDA y Karpenter se meten en el mismo cajón de "autoscaler de Kubernetes" y la gente los usa indistintamente en conversación, lo que genera confusión real en las revisiones de arquitectura: alguien pregunta por qué un pico en una cola no escaló la aplicación, y la respuesta puede ser un problema de KEDA o un problema de Karpenter, y no son el mismo problema en absoluto.
No son competidores. No hacen el mismo trabajo y uno no sustituye al otro. KEDA decide cuántas copias de tu aplicación tienen que estar corriendo. Karpenter decide sobre qué cómputo corren esas copias. La mayoría de clusters de producción que hemos tocado en los últimos dos años terminan usando ambos, y la integración entre ellos es donde viven la mayoría de los casos operativos interesantes.
Dos autoscalers, dos capas completamente distintas
La confusión tiene sentido porque ambas herramientas observan el cluster y ambas reaccionan creando o eliminando algo. Pero lo que observan y lo que crean es distinto en naturaleza, no solo en grado. KEDA opera completamente dentro de la lógica de scheduling de pods del control plane de Kubernetes: cambia el número de réplicas de un Deployment o StatefulSet. No tiene idea de qué es un nodo y no necesita saberlo. Karpenter opera una capa por debajo: observa pods que el scheduler no puede colocar en ningún sitio y aprovisiona capacidad EC2 real para encajarlos. No tiene idea de qué es una cola o un cron schedule y tampoco necesita saberlo.
Una vez separas "cuántos pods" de "dónde corren esos pods", el resto se razona mucho más fácilmente.
Qué hace realmente KEDA
KEDA (Kubernetes Event-Driven Autoscaling) extiende el Horizontal Pod Autoscaler estándar para que pueda escalar en base a métricas que el HPA nunca fue diseñado para entender. De fábrica, el HPA escala en función del uso de CPU y memoria reportado por el metrics server. Eso funciona bien para servidores web limitados por CPU, pero es prácticamente inútil para un worker que procesa mensajes de una cola SQS, consume de un topic de Kafka, o solo necesita correr en un horario concreto.
KEDA cubre ese hueco a través de un custom resource llamado ScaledObject. Apuntas un ScaledObject a un scaleTargetRef — tu Deployment o StatefulSet — y defines uno o varios triggers: profundidad de cola en SQS, longitud de cola en RabbitMQ, lag de un consumer group de Kafka, una query de Prometheus, una expresión cron, o docenas de scalers más que KEDA trae de fábrica. Por debajo, el adaptador de métricas de KEDA expone esos valores externos a través de la custom metrics API de Kubernetes, y gestiona un HPA estándar en tu nombre una vez que el workload tiene más de cero réplicas.
Ese último detalle importa: KEDA puede escalar un workload hasta cero réplicas, algo que el HPA puro no puede hacer porque siempre necesita al menos un pod corriendo del que medir. KEDA resuelve esto haciendo polling directo de la fuente externa — SQS, RabbitMQ, lo que sea el trigger — en un intervalo que tú configuras, y escalando de cero a uno él mismo cuando hay trabajo pendiente, cediendo luego el control al HPA gestionado para todo lo que esté por encima. Para un worker que solo corre unas horas al día, o un consumer de cola que está inactivo la mayor parte del tiempo, eso es una diferencia de coste real, no cosmética.
Qué hace realmente Karpenter
Karpenter opera en la capa de nodos. Sustituyó al antiguo Cluster Autoscaler como forma recomendada de gestionar la capacidad de nodos en EKS, y la diferencia de enfoque es significativa. Cluster Autoscaler trabaja contra node groups fijos — Auto Scaling Groups con tipos de instancia predefinidos — y decide cuándo añadir o quitar nodos de esos grupos. Karpenter no trabaja contra grupos predefinidos en absoluto. Observa directamente el scheduler buscando pods atascados en Pending porque nada en el cluster puede satisfacer sus resource requests, taints o reglas de afinidad, y lanza el tipo de instancia EC2 que realmente encaja con esos pods, aprovisionado justo a tiempo.
Los dos CRDs centrales de Karpenter son NodePool y EC2NodeClass. El NodePool define las restricciones de scheduling dentro de las que Karpenter puede moverse: qué familias y tamaños de instancia entran en juego, qué zonas de disponibilidad, spot frente a on-demand, y los límites de capacidad total aprovisionada. El EC2NodeClass define los detalles específicos de AWS del nodo en sí: la familia de AMI, los selectores de subred y security group, el instance profile, los block device mappings, etc. Un NodePool referencia un EC2NodeClass, y juntos le dicen a Karpenter todo lo que necesita para lanzar un nodo sin que nadie elija un tipo de instancia a mano.
La otra mitad del trabajo de Karpenter es la consolidación, y es la parte que los equipos se saltan configurar y luego se preguntan por qué su número de nodos nunca baja. Karpenter puede evaluar continuamente si los nodos existentes están infrautilizados o vacíos y reempaquetar workloads en menos nodos, mejor ajustados, terminando los que sobran. Ese comportamiento se controla con spec.disruption.consolidationPolicy en el NodePool, con valores como WhenEmptyOrUnderutilized, más un ajuste de consolidateAfter que controla cuánto espera Karpenter antes de actuar sobre un nodo infrautilizado — que resulta ser exactamente el ajuste que interactúa mal con KEDA si nadie lo afina, más sobre esto abajo.
Cómo trabajan juntos: la cadena de escalado de principio a fin
En un cluster que corre ambas herramientas, un pico de tráfico se desarrolla como una reacción en cadena entre dos bucles de control independientes que nunca se hablan directamente, simplemente reaccionan al mismo estado subyacente. Merece la pena recorrerla una vez de principio a fin porque es el modelo mental que hace obvias el resto de decisiones de integración.
Llega un pico de eventos — digamos que se acumula un backlog en una cola SQS de la que consume un Deployment worker. El scaler de SQS de KEDA hace polling de la cola, ve que la profundidad cruza el umbral definido en los metadatos del trigger del ScaledObject, y escala el Deployment de, digamos, 2 réplicas a 40. El scheduler de Kubernetes intenta colocar esos 38 pods nuevos de inmediato. Si los nodos existentes no tienen CPU y memoria libres para encajarlos — cosa que, en un cluster cuidadoso con el coste, normalmente no tienen — esos pods se quedan en estado Pending. El controlador de Karpenter está observando exactamente esa condición: pods no planificables. Lee sus resource requests agregadas, las compara con las restricciones del NodePool disponible, y lanza cuantas instancias EC2 del tamaño que haga falta para encajarlos, en una decisión por lotes en vez de nodo a nodo. En un par de minutos — limitado sobre todo por el tiempo de arranque de EC2 y el tiempo de unión del kubelet — los nodos nuevos se registran, el scheduler asigna los pods pendientes a ellos, y las 40 réplicas están realmente corriendo y procesando el backlog.
Ninguna de las dos herramientas sabe que la otra existe. A KEDA no le importa ni sabe si hay capacidad para correr las réplicas que está pidiendo. A Karpenter no le importa ni sabe por qué de repente hay 38 pods no planificables. La coordinación ocurre enteramente a través de los primitivos estándar de scheduling de Kubernetes — número de réplicas y resource requests de los pods — que es exactamente por lo que encajan de forma limpia sin necesitar ningún código de integración explícito entre ambas.
KEDA vs Karpenter, lado a lado
Deployment o StatefulSet. Karpenter escala nodos — las instancias EC2 que sostienen el cluster.ScaledObject (y ScaledJob para workloads por lotes). Karpenter se configura mediante NodePool y EC2NodeClass.HorizontalPodAutoscaler puro para workloads orientados a eventos. Karpenter sustituye al Cluster Autoscaler para el aprovisionamiento de nodos en EKS.Desplegar ambos de forma declarativa con GitOps
En todos los clusters donde hemos montado esto, ambas herramientas se instalan igual: un Helm chart, envuelto en el controlador de GitOps que use el cluster — una Application de ArgoCD apuntando al repositorio Helm de KEDA o Karpenter, o un HelmRelease de Flux haciendo lo mismo. Ninguna de las dos necesita nada exótico más allá de eso; la complejidad no está en instalarlas, está en secuenciarlas correctamente frente a los custom resources que dependen de ellas.
Ese problema de secuenciación es real y suele morder a los equipos en su primer bootstrap: un manifiesto de ScaledObject o NodePool no se puede aplicar antes de que exista el CRD correspondiente en el cluster, porque el API server no tiene dónde validarlo ni almacenarlo. Si tu herramienta de GitOps intenta sincronizar el Helm release de KEDA y un ScaledObject en la misma wave, obtienes un fallo de huevo y gallina en un cluster recién creado, aunque los mismos manifiestos exactos se apliquen sin problema en una segunda sincronización una vez que los CRDs ya están ahí.
ArgoCD resuelve esto con sync waves: anotas el Helm release (o los propios CRDs, si los instalas por separado) con un argocd.argoproj.io/sync-wave menor que el de los recursos ScaledObject y NodePool que dependen de ellos, y ArgoCD secuencia la sincronización para que los CRDs lleguen primero. Flux resuelve el mismo problema con dependsOn en el HelmRelease o Kustomization que define los custom resources, apuntando al release que instala KEDA o Karpenter. En ambos casos, el principio es el mismo: CRDs antes que CRs, forzado explícitamente en vez de dejarlo al azar del orden de sincronización.
Para arrancar un cluster completamente nuevo, el patrón que mejor nos ha funcionado es app-of-apps: una Application raíz de ArgoCD (o una Kustomization de Flux de nivel superior) que se despliega en aplicaciones hijas para el Helm release de KEDA, el Helm release de Karpenter, y una aplicación separada para los manifiestos reales de ScaledObject/NodePool/EC2NodeClass, con el orden de dependencia declarado mediante sync waves o dependsOn en lugar de asumido. Así un cluster nuevo arranca en el orden correcto siempre, sin que nadie tenga que ejecutar un kubectl apply manual de los CRDs primero cruzando los dedos.
Un ejemplo concreto de lo que realmente se despliega
Un ScaledObject típico respaldado por SQS fija spec.scaleTargetRef.name al Deployment worker, spec.minReplicaCount: 0 y un maxReplicaCount razonable, y un único trigger de type: aws-sqs-queue con el queueURL y un objetivo de queueLength que define cuántos mensajes por réplica marcan el umbral de escalado — más un pollingInterval y un cooldownPeriod que controlan cuán nervioso es el escalado hacia abajo.
Por el lado de Karpenter, un NodePool base fija spec.template.spec.requirements para restringir familias de instancia y tipo de capacidad (spot, on-demand, o ambos), referencia un EC2NodeClass mediante spec.template.spec.nodeClassRef para los detalles de AMI y networking, y fija spec.disruption.consolidationPolicy: WhenEmptyOrUnderutilized con un valor de consolidateAfter — algo como 1m para optimización de coste agresiva, o más largo si quieres evitar que los nodos se muevan durante carga intermitente. Eso es, genuinamente, la mayor parte de lo que necesita una configuración funcional; el resto es afinar los números al workload.
Errores habituales al correr ambos juntos
El modo de fallo que más vemos es la latencia de arranque en frío durante un pico brusco. KEDA puede escalar un Deployment de cero a decenas de réplicas en un solo intervalo de polling, que puede ser cuestión de segundos. Karpenter no puede aprovisionar capacidad EC2 tan rápido — lanzar una instancia, esperar a que arranque, unirse al cluster y descargar las imágenes realistamente lleva de uno a tres minutos según el tipo de instancia y el tamaño de la imagen. Durante esa ventana, los pods recién creados simplemente se quedan en Pending, y si el trigger es algo sensible a la latencia como una cola HTTP en vez de una cola de jobs asíncronos, ese hueco es directamente visible para los usuarios. La mitigación no es hacer que Karpenter sea más rápido de lo que la física permite — es mantener una pequeña base de réplicas calientes y nodos calientes (un minReplicaCount distinto de cero, o una pequeña reserva de NodePool siempre activa) para workloads donde un arranque en frío de un minuto o más no es aceptable, y reservar el scale-to-zero real para workloads que puedan tolerar el retraso.
El segundo error es más sutil y se manifiesta como nodos que parecen oscilar sin parar: el timing de consolidación de Karpenter peleándose con el timing de scale-down de KEDA. Si el cooldownPeriod de KEDA escala un workload hacia abajo bastante antes de que haya pasado la ventana de consolidateAfter de Karpenter, terminas con Karpenter consolidando un nodo, y KEDA escalando de nuevo hacia arriba momentos después, disparando un ciclo de aprovisionamiento completamente nuevo — repetidamente, para workloads con tráfico intermitente pero frecuente. Lo hemos arreglado en más de un cluster de cliente simplemente alineando los dos temporizadores: fijando el consolidateAfter de Karpenter para que sea notablemente más largo que el cooldownPeriod de KEDA para los workloads que comparten ese node pool, de forma que un nodo no se elimine justo antes de que fuera a hacer falta otra vez. Ninguna de las dos herramientas te avisa de este desajuste; solo se manifiesta como actividad de lanzamiento de EC2 y coste que no coincide con tu patrón de tráfico real.
Conclusión
KEDA y Karpenter resuelven problemas distintos y la integración entre ambos no necesita código de unión personalizado — encajan a través del mismo mecanismo que Kubernetes siempre ha usado para conectar la demanda de pods con la oferta de nodos: resource requests y el scheduler. El trabajo que realmente importa es acertar el orden de bootstrap en GitOps para que los CRDs lleguen antes que los custom resources que los necesitan, y afinar los temporizadores de ambas herramientas para que no se peleen durante el scale-down. Acertando esas dos cosas, la cadena desde "pico de eventos" hasta "pods corriendo sobre capacidad recién aprovisionada" ocurre sin que nadie reciba una alerta a las 3 de la madrugada.
¿Tu cluster escala pods pero no nodos (o al revés)?
Te ayudamos a integrar KEDA y Karpenter, afinar sus temporizadores para que no se peleen entre sí, y desplegar ambos de forma limpia con GitOps.
Hablar con un experto →