Problema
Los pipelines de CI/CD que usan self‑hosted GitHub Actions runners dentro de un clúster EKS pueden quedar en estado “Waiting for a runner” o consumir recursos inesperados sin que aparezca ningún error en los logs. El síntoma típico es que los pods del runner aparecen “Running”, reportan “Connected to GitHub”, pero los jobs nunca se asignan. En otros casos los nodos se provisionan con el tipo de instancia equivocado (on‑demand en lugar de Spot) o la infraestructura completa se vuelve inestable cuando el clúster escala a cero. Estos fallos son “silenciosos”: el control plane muestra verde, pero la causa real está oculta en configuraciones de IAM, Helm, taints o en la interacción entre Karpenter y Terraform.
Causa
1. Roles y permisos de AWS Service‑Linked
Karpenter necesita que exista el service‑linked role AWSServiceRoleForEC2Spot para crear Spot fleets. Si el rol no está presente, cada intento de lanzar una instancia Spot falla y Karpenter recurre a on‑demand sin registrar el error. El clúster sigue funcionando, pero el coste se dispara.
2. Merging superficial de listas en Helm
Helm solo realiza deep merge en mapas; las listas se sustituyen por completo. Cuando se sobrescribe containers[0].image o containers[0].resources en values.yaml, se pierden campos implícitos como command: ["/home/runner/run.sh"]. El pod arranca sin el script de arranque, termina inmediatamente y el controlador lo recrea en bucle.
3. Imágenes de runner “fijas” (pinned tags)
GitHub descontinúa versiones de runner y responde con 403 a los agentes que usan imágenes obsoletas. Si el controlador está configurado con DisableUpdate: true, la imagen nunca se actualiza y el runner deja de aceptar jobs, aunque el resto del clúster siga operativo.
4. Taints que bloquean pods críticos
Aplicar un taint a los nodos on‑demand para forzar que los runners solo usen Spot funciona mientras haya Spot disponibles. Cuando el clúster escala a cero y Karpenter elimina los Spot, el nodo taintado queda como único recurso. Si componentes esenciales como CoreDNS no toleran ese taint, el DNS interno se rompe y todo el clúster pierde conectividad.
5. Destrucción de infraestructura con Terraform
Los nodos creados por Karpenter no aparecen en el estado de Terraform. Al ejecutar terraform destroy, los recursos Spot que siguen activos conservan sus ENI, provocando DependencyViolation al intentar eliminar la VPC. El proceso se queda colgado hasta que los nodos se drenan manualmente.
Solución
A. Verificar y crear el service‑linked role para Spot
Antes de habilitar Karpenter con spot como prioridad, asegúrate de que el rol exista. Si no, créalo manualmente o permite que Karpenter lo haga con los permisos adecuados.
B. Usar valores de Helm que respeten la estructura de listas
En vez de sobrescribir directamente containers[0], emplea la sintaxis de patch con merge o define el command explícitamente en values.yaml. Otra opción es usar helm upgrade --reuse-values y aplicar un strategic merge patch con kubectl patch.
C. Mantener runners actualizados
Configura el controlador con DisableUpdate: false y usa la etiqueta latest o una política de actualización automática (imagePullPolicy: Always). Si necesitas reproducibilidad, guarda la versión del runner en un lockfile y programa actualizaciones periódicas mediante un cron job que revise la API de GitHub.
D. Gestionar taints y tolerancias de forma segura
- Aplica el taint solo a los nodos que no alojan pods críticos.
- Añade tolerancias a los DaemonSets de CoreDNS y a cualquier otro pod del sistema.
- Configura Karpenter con
consolidation: truey un node pool de fallback on‑demand sin taints, de modo que siempre exista al menos un nodo “limpio”.
E. Orquestar el ciclo de vida con Terraform y Karpenter
Antes de terraform destroy:
- Elimina los
NodePoolyNodeClaimde Karpenter (kubectl delete crd nodepools.karpenter.sho el recurso correspondiente). - Ejecuta
kubectl drainen los nodos Spot activos y espera a que se terminen de desasignar. - Sólo entonces corre
terraform destroy. Esto garantiza que los ENI se liberen y la VPC se elimine sin bloqueos.
F. Monitoreo y alertas específicas
- Añade una regla de CloudWatch que detecte
CreateFleetfallidos con códigoInsufficientInstanceCapacity. - Configura Prometheus para observar
runner_controller_reconcile_errors_totalykarpenter_node_claims_pending. - Usa un
Alertmanagerque notifique cuando el número de podsrunnerenPendingsupere un umbral.
Cuándo aplicar esta solución
- Síntomas: jobs en GitHub Actions quedan en “Waiting for a runner”, pods de runner se reinician constantemente, coste de EC2 inesperado, DNS interno no responde, o
terraform destroyse cuelga. - Entorno: clúster EKS con Karpenter (Spot/On‑Demand), Helm chart de ARC (Action Runner Controller), infraestructura gestionada por Terraform.
- No aplica: entornos donde los runners se ejecutan en máquinas físicas o en otro proveedor de nube, o cuando la arquitectura no usa Karpenter ni Helm.
Código
# 1. Verificar existencia del service‑linked role
aws iam get-role --role-name AWSServiceRoleForEC2Spot || \
aws iam create-service-linked-role --aws-service-name ec2spot.amazonaws.com
# 2. Patch de Helm values para preservar command
cat > values-patch.yaml <<'EOF'
containers:
- name: runner
image: myrepo/runner:2.312.0
resources:
limits:
cpu: "2"
memory: "4Gi"
command: ["/home/runner/run.sh"]
EOF
helm upgrade my-runner ./arc-chart \
-f values.yaml \
-f values-patch.yaml \
--reuse-values
# 3. Añadir tolerancia a CoreDNS para el taint spot-only
kubectl patch ds coredns -n kube-system \
-p '{"spec":{"template":{"spec":{"tolerations":[{"key":"spot-only","operator":"Exists","effect":"NoSchedule"}]}}}}'
# 4. Draining y eliminación segura antes de terraform destroy
kubectl get nodes -l karpenter.sh/provisioner-name=spot -o name | xargs -r -I{} kubectl drain {} --ignore-daemonsets --delete-emptydir-data
kubectl delete crd nodepools.karpenter.sh nodeclaims.karpenter.sh
terraform destroy
Verificación
-
Spot provisioning
- Ejecuta
kubectl get nodes -o widey verifica que la columnaINSTANCE-LIFECYCLEmuestrespot. - En CloudWatch, busca eventos
CreateFleetsin errores.
- Ejecuta
-
Runner operatividad
- En GitHub, dispara un workflow sencillo que imprima
echo ok. - Confirma que el job se asigna rápidamente y que el pod muestra
Connected to GitHubseguido de la salida del script.
- En GitHub, dispara un workflow sencillo que imprima
-
DNS interno
kubectl exec -n kube-system <coredns-pod> -- nslookup kubernetes.defaultdebe resolver sin tiempo de espera.
-
Terraform teardown
- Después de
terraform destroy,aws ec2 describe-instancesno debe listar ENI vinculados al VPC objetivo. - La VPC debe desaparecer sin errores de
DependencyViolation.
- Después de
Notas adicionales
- Cold start vs warm runners: en entornos con baja frecuencia de builds, un runner “warm” (mínimo 1‑2 pods siempre activos) reduce el tiempo de espera en ~30 s. El coste extra suele ser marginal comparado con la pérdida de productividad.
- Spot interruptions: GitHub Actions reintenta automáticamente los jobs fallidos. Para builds largos, habilita
continue-on-errory usatimeout-minutesmayor; también puedes dividir el pipeline en etapas más pequeñas. - Docker dentro de runners: Kaniko o BuildKit son preferibles a
docker-in-dockerporque no requieren privilegios deCAP_SYS_ADMIN. En runners sin acceso a la red pública, monta un cache de imágenes en un ECR privado y configura Kaniko con--cache=true. - Helm list merging: siempre revisa el resultado de
helm templateantes de aplicar cambios críticos. Un simplehelm diff upgradepuede evitar sobrescrituras inesperadas. - Karpenter consolidación: configura
consolidation: truey defineconsolidationPolicy: "WhenEmpty"para que los nodos on‑demand no se eliminen si son los únicos disponibles. Esto protege los pods del sistema cuando el clúster pasa a cero.