Blog de Amazon Web Services (AWS)
Cómo sennder ahorró un 30% migrando Amazon EKS a AWS Graviton
Por Miguel Fontanilla, Platform Engineering Lead, y Leandro Pretel, Senior Platform Engineer en sennder, y Guillermo Ruiz, Senior Specialist Solutions Architect en AWS.
Introducción
En sennder, empresa europea de logística digital, el crecimiento de la plataforma también estaba incrementando los costes de cómputo. El equipo de Plataforma ya había aplicado distintas medidas de optimización, como apagar los entornos de desarrollo fuera del horario laboral y utilizar instancias Amazon EC2 Spot para cargas de trabajo tolerantes a interrupciones. Sin embargo, una parte significativa de la infraestructura seguía ejecutándose en instancias bajo demanda basadas en procesadores x86.
Para reducir estos costes, sennder evaluó la migración de sus cargas de trabajo en Amazon Elastic Kubernetes Service (Amazon EKS) a instancias basadas en procesadores AWS Graviton. El reto no consistía únicamente en ejecutar los contenedores sobre la arquitectura arm64. La migración debía realizarse sin requerir cambios en los manifiestos de Kubernetes mantenidos por los equipos de producto y sin comprometer la compatibilidad de las cargas existentes.
En este post mostramos cómo sennder abordó esta migración mediante una combinación de imágenes de contenedor multiplataforma, Karpenter y Kyverno. Karpenter proporciona la capacidad de cómputo arm64 necesaria en los clústeres de Amazon EKS, mientras que las políticas de Kyverno determinan durante el proceso de admisión qué cargas pueden ejecutarse sobre AWS Graviton. Este enfoque permite mantener amd64 como alternativa para aquellas cargas cuya compatibilidad con arm64 no ha sido validada.
La plataforma de sennder, sennOS, conecta a empresas que necesitan transportar mercancías con una red de transportistas por carretera en Europa. Su infraestructura utiliza Amazon EKS desde 2020, con nodos aprovisionados mediante Karpenter y un Helm chart común mantenido por el equipo de Plataforma. Esta estandarización proporcionó un punto de partida para introducir AWS Graviton de forma centralizada, sin trasladar la complejidad de la migración a cada equipo de producto.
Antes de iniciar la migración, el equipo definió cuatro requisitos:
- Transparencia: los equipos de producto no debían modificar sus Deployment, añadir un nodeSelector ni cambiar la imagen base de sus Dockerfile.
- Compatibilidad verificada: las imágenes de terceros quedaban fuera del pipeline centralizado de sennder, por lo que su compatibilidad con arm64 debía verificarse antes de ejecutarlas sobre AWS Graviton.
- Reversibilidad: si una carga presentaba problemas sobre arm64, debía poder volver a ejecutarse sobre amd64 sin intervención manual en cada clúster.
- Trazabilidad: el equipo necesitaba medir el progreso de la migración e identificar incompatibilidades antes de que afectaran a los servicios.
A partir de estos requisitos, sennder diseñó un mecanismo que separa la compatibilidad de las imágenes, el aprovisionamiento de capacidad y la selección de arquitectura. En las siguientes secciones analizamos cada componente y cómo interactúan durante el despliegue de una carga de trabajo.
| Nota: En este post se usa arm64 y amd64 para referirse a las plataformas de imágenes y nodos, y AArch64 y x86-64 solo al hablar de la arquitectura de un binario. |
Visión general de la solución
El diseño separa dos decisiones: qué variantes de una imagen son válidas, que se decide en el pipeline, y dónde se ejecuta cada carga, que se decide durante la admisión en el clúster. Karpenter aprovisiona la capacidad que piden los Pods. Para aplicar este patrón se necesita un clúster EKS con Karpenter v1, Kyverno con permisos de lectura en el registro, builders nativos de BuildKit (el motor de compilación de imágenes de contenedor que ejecuta las instrucciones de un Dockerfile) para amd64 y arm64, un registro OCI (en el caso de sennder, Amazon Elastic Container Registry) y un mecanismo común de despliegue que permita introducir cambios sin tocar los repositorios de cada equipo.

Figura 1. Arquitectura de la solución: validación en el pipeline, decisión en la admisión y aprovisionamiento con Karpenter.
El flujo tiene cinco pasos:
- El pipeline construye las variantes amd64 y arm64 en builders nativos de BuildKit.
- Un análisis de los binarios ELF de la variante arm64 decide si esa variante se publica. El registro almacena las variantes publicadas y una etiqueta con las plataformas validadas.
- Cuando se crea o actualiza un Deployment o StatefulSet, una política de Kyverno lee esa etiqueta en el registro.
- Si arm64 está validada, Kyverno añade a la plantilla del Pod un nodeSelector y una toleration para nodos Graviton. En caso contrario, la plantilla no cambia.
- Karpenter aprovisiona nodos de la arquitectura que piden los Pods pendientes.
Kyverno modifica la plantilla, no los Pods en ejecución. El cambio de arquitectura ocurre cuando el controlador sustituye los Pods según la estrategia de actualización del recurso, normalmente en el siguiente despliegue.
Construir y validar las imágenes
La librería centralizada de CI/CD de sennder ya utilizaba Docker Buildx y BuildKit, así que añadir arm64 fue relativamente sencillo. El equipo mantiene backends de BuildKit nativos en nodos amd64 y arm64, y Buildx envía cada plataforma al builder correspondiente. Así se construyen ambas variantes en paralelo, sin depender de la emulación entre arquitecturas. Los siguientes comandos crean un builder con dos nodos remotos, uno por plataforma, protegiendo la conexión con TLS:
docker buildx create \
--name multiarch \
--driver remote \
--driver-opt cacert=/certs/ca.pem,cert=/certs/cert.pem,key=/certs/key.pem \
--node amd64 \
--platform linux/amd64 \
tcp://buildkit-amd64:1234
docker buildx create \
--name multiarch \
--append \
--driver remote \
--driver-opt cacert=/certs/ca.pem,cert=/certs/cert.pem,key=/certs/key.pem \
--node arm64 \
--platform linux/arm64 \
tcp://buildkit-arm64:1234
Detectar binarios de otra arquitectura
Construir una imagen para arm64 no garantiza que todos sus binarios sean AArch64. Un Dockerfile puede descargar un ejecutable precompilado para x86-64 y el build termina sin errores; el fallo aparece al arrancar el contenedor como exec format error. Por eso el equipo añadió al job de build un paso que analiza los binarios ELF antes de publicar la variante arm64. ELF (Executable and Linkable Format) es el formato de la mayoría de ejecutables en Linux, y su cabecera indica la arquitectura para la que se compiló cada binario.
Si el análisis falla, el pipeline no publica la variante arm64. Si la supera, publica ambas variantes con una etiqueta que lista las plataformas validadas, por ejemplo example.com/platforms=linux/amd64,linux/arm64. Kyverno lee esa etiqueta durante la admisión. La etiqueta se añade con el flag –label en el propio comando docker buildx build, de modo que siempre refleja el resultado real de esa build.
# La lista de plataformas refleja solo las variantes que superaron la validación
docker buildx build \
--builder multiarch \
--platform linux/amd64,linux/arm64 \
--label "example.com/platforms=linux/amd64,linux/arm64" \
-t <registro>/:<tag> \
-t <registro>/:<tag> \
--push .
| Importante: Este análisis detecta binarios empaquetados para otra arquitectura, pero no cubre los que se descargan en tiempo de ejecución ni las diferencias de comportamiento o rendimiento. No sustituye las pruebas funcionales sobre Graviton. La guía técnica de AWS Graviton incluye recomendaciones por lenguaje y para contenedores, y Porting Advisor for Graviton analiza el código fuente en busca de dependencias incompatibles. |
Reservar capacidad Graviton con Karpenter
El equipo de Plataforma creó NodePools y EC2NodeClasses específicos para arm64. La configuración apenas cambió respecto a la de amd64: se seleccionaron familias de instancias Graviton y se añadió un taint NoSchedule. Con ese taint, ninguna carga llega por accidente a un nodo arm64; solo lo hacen los Pods que reciben a la vez el nodeSelector y la toleration correspondientes.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: arm64
spec:
template:
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: arm64
requirements:
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: kubernetes.io/arch
operator: In
values: ["arm64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["6"]
- key: karpenter.k8s.aws/instance-cpu
operator: In
values: ["8", "16"]
taints:
- key: example.com/arch
value: arm64
effect: NoSchedule
Con estos requisitos, Karpenter elige instancias bajo demanda de las familias C y M de generación 7 o posterior (M7g, C7g, M9g, C9g) con 8 o 16 vCPU. El equipo eligió un nodeSelector obligatorio en lugar de una afinidad preferente, porque la preferencia resultaba demasiado débil: la penalización del scheduler por tolerar el taint, sumada a la localidad de imagen, mantenía Pods multiplataforma en amd64 incluso con capacidad arm64 libre. Con el selector obligatorio, Karpenter recibe la señal y crea el nodo en cuanto hay demanda.
Centralizar la decisión con Kyverno
Con imágenes y nodos preparados, la decisión de scheduling la toma Kyverno, como admission controller, cuando se crea o actualiza un Deployment o StatefulSet. La política consulta en el registro la etiqueta de plataformas validadas y, si incluye arm64, añade el nodeSelector y la toleration:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: arm64-routing
spec:
failurePolicy: Ignore
rules:
- name: route-validated-images-to-arm64
match:
any:
- resources:
kinds: [Deployment, StatefulSet]
operations: [CREATE, UPDATE]
context:
- name: platforms
imageRegistry:
reference: "{{ request.object.spec.template.spec.containers[0].image }}"
jmesPath: "configData.config.Labels.\"example.com/platforms\" || ''"
preconditions:
all:
# Solo plantillas con un único contenedor y sin init containers
- key: "{{ length(request.object.spec.template.spec.containers) }}"
operator: Equals
value: 1
- key: "{{ length(request.object.spec.template.spec.initContainers || `[]`) }}"
operator: Equals
value: 0
# La variante arm64 superó la validación del pipeline
- key: "{{ contains(platforms, 'arm64') }}"
operator: Equals
value: true
# Evita duplicar la toleration en actualizaciones posteriores
- key: "{{ request.object.spec.template.spec.nodeSelector.\"kubernetes.io/arch\" || '' }}"
operator: NotEquals
value: arm64
mutate:
patchStrategicMerge:
spec:
template:
spec:
nodeSelector:
kubernetes.io/arch: arm64
tolerations: '{{ [request.object.spec.template.spec.tolerations || `[]`, `[{"key":"example.com/arch","operator":"Equal","value":"arm64","effect":"NoSchedule"}]`][] }}'
Algunos aspectos clave de esta política: actuar sobre plantillas con un único contenedor sin init containers garantiza que la consulta cubre la única imagen relevante. La precondición final evita añadir la toleration de nuevo en despliegues posteriores. Con failurePolicy: Ignore, si el registro no responde la carga se admite sin mutar y se queda en amd64, por lo que un fallo de consulta nunca bloquea un despliegue.
Para sidecars inyectados por webhooks (service mesh, agentes de observabilidad) y DaemonSets con tolerations amplias, asegúrate de que esas imágenes sean multiplataforma antes de crear el primer nodo Graviton, ya que la política no las cubre.
Mantener la disponibilidad durante la transición
La compatibilidad de las imágenes no garantiza por sí sola una transición sin impacto. sennder realizó el despliegue entorno a entorno: primero los clusters de herramientas dev y UAT, después dev-microservices, luego UAT en dos fases (capacidad arm64 añadida primero sin forzar nada, enrutado activado después), y por último prod y prod-microservices.
Si aplicas este patrón, se recomienda:
- RollingUpdate con maxUnavailable: 0 y maxSurge: 1 en Deployments con varias réplicas. Karpenter aprovisiona la capacidad adicional temporal en el NodePool arm64.
- Readiness probes que reflejen la capacidad real de atender tráfico y una terminación ordenada de las conexiones.
- PodDisruptionBudgets para que la consolidación de Karpenter y el drenado de nodos amd64 mantengan un mínimo de réplicas.
- Empezar en entornos no productivos, definir umbrales de latencia y errores, y ensayar la vuelta a amd64 antes de reducir esa capacidad.
Medir el avance
El equipo instrumentó cuatro métricas principales: porcentaje de Pods en arm64 por entorno y servicio, fallos del análisis ELF asociados al repositorio responsable, reinicios con exec format error en los logs de los contenedores, y latencia, tasa de errores y coste por unidad de trabajo de cada servicio migrado.
Si usas Prometheus con kube-state-metrics, esta consulta calcula el porcentaje de Pods programados en nodos arm64 (requiere exponer la etiqueta de arquitectura con –metric-labels-allowlist=nodes=[kubernetes.io/arch]):
100 * sum(
kube_pod_info{node!=""}
* on (node) group_left (label_kubernetes_io_arch)
kube_node_labels{label_kubernetes_io_arch="arm64"}
) / sum(kube_pod_info{node!=""})

Figura 2. Incremento del porcentaje de Pods en nodos arm64 en producción.
La gráfica muestra una adopción gradual durante las primeras horas, un salto hasta el 80 % y un último incremento por encima del 90 %. En el clúster de producción, la política de Kyverno se habilitó el 24 de agosto. Para acelerar la migración, el equipo realizó un reinicio controlado namespace por namespace, en lugar de esperar a que cada equipo desplegara nuevas versiones; los servicios críticos con mayor volumen de tráfico durante el horario de oficina se reiniciaron al final de la jornada, lo que explica el último salto.
Resultados
Hoy, la mayor parte de las cargas de trabajo de sennder se ejecuta sobre Graviton sin que los equipos de desarrollo hayan tenido que modificar la configuración de sus despliegues. La arquitectura amd64 sigue disponible para los servicios cuya compatibilidad no se ha podido confirmar.
«El paradigma de ingeniería de plataforma resultó especialmente útil para propagar el cambio con la mínima intervención de los equipos de desarrollo. Las librerías de CI/CD centralizadas y los Helm charts unificados permitieron cambiar la arquitectura de gran parte de la flota sin tocar los repositorios de cada equipo.»
— Miguel Fontanilla, Platform Engineering Lead, sennder
Tras varias semanas de estabilización, el ahorro en costes de computación de las cargas migradas se situó cerca del 30 %, por encima del 20 % de referencia que AWS indica para Graviton frente a x86. La diferencia se explica porque la migración también mejoró la eficiencia de los clústeres: las instancias de novena generación como C9g y M9g admiten hasta 118 pods por nodo (frente a 58 en una c7g.2xlarge) gracias a su mayor capacidad de direcciones IPv4 por ENI. Eso permitió consolidar cargas en menos nodos y reducir el overhead de los DaemonSets.

Figura 3. Gasto diario en instancias de Amazon EC2 en AWS Cost Explorer. La reducción se concentra en el gasto bajo demanda; el gasto de Savings Plans se mantiene estable.
El resultado más interesante no fue simplemente sustituir instancias amd64 por arm64 más económicas, sino aprovechar el cambio de arquitectura para mejorar la eficiencia global de la infraestructura. Para evaluar el propio resultado, se recomienda comparar periodos equivalentes usando una métrica como el coste por petición o por trabajo completado. El AWS Graviton Savings Dashboard puede ayudar a estimar el potencial antes de empezar.
Lecciones aprendidas y próximos pasos
Tres aprendizajes clave de esta migración:
- El paradigma de ingeniería de plataforma es el habilitador. Una librería de CI/CD y un Helm chart compartidos permitieron cambiar la arquitectura de toda la flota sin tocar los repositorios de cada equipo.
- Construir para arm64 no es lo mismo que validar. Analizar los binarios ELF fue un control barato que detectó problemas antes de que llegaran a producción.
- Diseña para el fallo seguro. Los casos que importan aparecen con tráfico real y no siempre se pueden reproducir en un entorno de pruebas; lo que sí puede hacerse es que, cuando aparezcan, el fallo caiga hacia el lado seguro.
Si tu plataforma ya centraliza la construcción y el despliegue, puedes empezar con un servicio representativo:
- Construir y probar su variante arm64.
- Ejecutar una actualización controlada en un entorno no productivo y verificar su comportamiento.
- Ensayar la vuelta a amd64.
- Incorporar esas comprobaciones a la plataforma y ampliar la migración por grupos de servicios.
Para más información, consulta la Guía técnica de AWS Graviton en GitHub, las buenas prácticas de Karpenter en la guía de Amazon EKS y el Programa AWS Graviton Fast Start.
Sobre los autores
![]() |
Miguel Fontanilla Platform Engineering Lead, sennder | AWS Community BuilderMiguel Fontanilla es Platform Engineering Lead en sennder y AWS Community Builder. Está al mando del equipo de Plataforma, centrándose en mejorar la eficiencia de los equipos de desarrollo ofreciendo abstracciones sobre AWS. |
![]() |
Leandro Pretel Senior Platform Engineer, sennderLeandro Pretel es Senior Platform Engineer en sennder y forma parte del equipo de Cloud Platform and Security. Lideró técnicamente la migración a Graviton descrita en este artículo, desde el enrutado por arquitectura en Kyverno hasta el aprovisionamiento de capacidad con Karpenter. |
![]() |
Guillermo Ruiz Senior Specialist Solutions Architect, AWS EMEAGuillermo Ruiz es Senior Specialist Solutions Architect en AWS EMEA y forma parte del equipo de Efficient Compute. Trabaja con clientes para mejorar la eficiencia de sus cargas con AWS Graviton, Instancias Spot de Amazon EC2 y Karpenter. |


