Runners de CI efímeros en Nomad: Temporal como planificador sin estado
El autor resuelve las tareas de CI que GitHub no puede ejecutar fuera del clúster con runners self-hosted que solo existen durante un job, despachados por un workflow de Temporal.

Un desarrollador ha publicado el patrón que usa para lanzar runners de CI efímeros en un clúster de Nomad, con Temporal haciendo de planificador. Responde a un problema concreto: hay tareas de CI que GitHub no puede ejecutar por él —empujar imágenes al registro Docker, desplegar jobs de Nomad o aplicar Terragrunt— porque necesitan llegar al clúster y, por tanto, correr dentro.
La alternativa clásica es tener runners self-hosted esperando en Nomad. En una cuenta personal eso se vuelve incómodo rápido: fuera de una organización, un runner solo se puede registrar a un repositorio. Seis repos son seis runners, cada uno registrado a mano con su token, ocupando recursos reales del clúster casi todo el día sin hacer nada.
Su solución reutiliza Temporal, que ya usaba para copias nocturnas y mantenimiento. Un schedule cada 30 segundos dispara un workflow PollAndDispatch que carga la configuración por repositorio desde Consul KV, lista los jobs self-hosted en cola y cuenta los runners ya pendientes o en ejecución. Por cada runner que falta lanza un hijo HandleRunner, que acuña un token de registro, despacha el job de Nomad y espera a que termine la asignación.
Un runner por ejecución
El runner es un job batch parametrizado. Cada despacho crea uno efímero que se registra, toma exactamente un job, se da de baja y sale. Los reintentos y el reschedule están apagados a propósito: un runner terminado debe quedarse terminado.
El job lleva metadatos obligatorios (repo_url y runner_token) y opcionales (labels). Corre en Docker sobre una imagen amd64, así que va restringido a nodos de esa arquitectura, y usa identidades de workload para cambiar un token de Vault por uno de Nomad con alcance limitado, que necesita para validar y planificar jobs. La reserva de recursos es fija, porque al ser one-shot no hay coste por tenerlo ocioso.
La comunicación con Nomad pasa por un wrapper sobre el cliente oficial en Go, github.com/hashicorp/nomad/api. Despachar un runner es una llamada a Jobs().Dispatch que devuelve el ID del job concreto, y reapear uno es un Deregister con purge, para que los jobs terminados no se acumulen en el estado de Nomad. El conteo de runners en vuelo no necesita estado propio: cada runner despachado es un child job llamado /dispatch-…, y su meta conserva el repo y las labels, así que la reconciliación se resuelve contando esos slots contra los jobs en cola en lugar de seguir un runner por job_id.
El código y la configuración están en un repositorio junto con el montaje completo del clúster, aunque está pensado para un homelab. Aun así, el patrón —planificador que acuña tokens, job parametrizado con un runner por ejecución y reconciliación sin estado— se traslada a cualquier despliegue de Nomad donde el CI tenga que entrar en el clúster.

