Programar Trabajos en Kubernetes con Spring Scheduler
Si desplegás una aplicación Spring Boot en Kubernetes con tareas programadas usando @Scheduled, cada replica ejecuta el mismo job independientemente, causando duplicación de ejecuciones. Un equipo de focused.io descubrió esto a la mala: configuraron dos trabajos diarios para enviar emails a las 11:00 AM, pero cuando revisaron los logs en producción, cada job estaba corriendo en cada pod. Los usuarios recibieron emails duplicados. El problema: Spring Scheduler no tiene cluster-awareness nativo. La solución requiere ShedLock, Kubernetes CronJob, o Quartz Scheduler.
En 30 segundos
@Scheduledejecuta el job en CADA pod cuando usás Spring Boot en Kubernetes, causando duplicación- ShedLock usa una tabla de BD para garantizar ejecución única; mínimo cambio de código (solo anotaciones)
- Kubernetes CronJob offloada tareas fuera de la aplicación; mejor para jobs independientes
- Quartz Scheduler es más robusto si necesitás cluster-awareness avanzada y múltiples tipos de jobs
- La mejor solución depende de duración del job, frecuencia y complejidad operacional que toleres
Spring Scheduler en Kubernetes es uno de esos problemas que parecen simples hasta que los deployás a producción. Ponele que tuviste una aplicación funcionando bien en una sola máquina, los jobs programados se ejecutaban a la hora exacta, sin duplicados, todo bien. Después escalás a Kubernetes con múltiples replicas, el YAML tiene 3-5 pods, y de repente todo se rompe. No por un bug de tu código, sino porque Spring Scheduler no sabe que existe más de una instancia de tu aplicación corriendo.
El problema: duplicación de trabajos en Kubernetes
Cada pod en Kubernetes es una instancia completamente independiente de tu aplicación. Si tu código tiene un @Scheduled(cron = "0 11 * * *") para ejecutar un job a las 11:00 AM, cada réplica interpreta ese cron independientemente. Si tenés 3 pods, el job corre 3 veces (o 5 veces si escalaste a 5).
El caso real de focused.io es instructivo. Implementaron dos trabajos diarios: uno a las 11:00 AM y otro más tarde. El objetivo era enviar emails a un set de usuarios. El código se veía correcto en local. Lo pusharon a producción en Kubernetes con 3 replicas, y en las primeras 24 horas cada usuario recibió 3 copias de cada email. No era un error de lógica; era que el scheduler corría en paralelo en 3 pods diferentes.
El problema se agrava si el job tiene efectos secundarios: enviar emails (como en este caso), actualizar registros en BD, procesar pagos, registrar datos en sistemas externos. Triplicar la ejecución no es una pequeña molestia; es un bug de producción grave.
Por qué ocurren los problemas de timing
Hay dos capas de problema: una es la duplicación (que acabamos de ver), la otra es el timing. Los schedulers en Kubernetes pueden desincronizarse por varias razones.
Primero: cada pod tiene su reloj, y los relojes del sistema no siempre están perfectamente sincronizados. Si tenés 3 pods con desvío de 5 segundos cada uno (cosa que puede pasar si los nodos están en datacenters diferentes), un job que debería ejecutarse a las 11:00:00 podría ejecutarse a las 10:59:55 en un pod y a las 11:00:08 en otro. Ojo con esto, porque algunos jobs tienen dependencias temporales. Para más detalles técnicos, mirá ejecutar agentes sin conectar a APIs externas.
Segundo: la duración variable del job causa que se superpongdan ejecutiones. El artículo de focused.io menciona esto explícitamente: “los jobs tienen duraciones variables y generalmente corren en el orden de minutos”. Si un job demora 7 minutos y está configurado para ejecutarse cada 5 minutos, en la próxima ejecución programada el job anterior todavía está corriendo. En una sola instancia, Spring Scheduler lo detiene con una excepción o lo salta. En Kubernetes con múltiples pods, pasá lo que pasá: dos o más instancias ejecutándose simultáneamente.
Tercero: el threading. Spring Scheduler por defecto usa un thread pool compartido. Si tus otros jobs también usan ese pool y consumen recursos, tu scheduler se retrasa. En Kubernetes, donde los recursos están compartidos y las restricciones de CPU son dinámicas, esto es predecible que ocurra.
Solución 1: ShedLock con base de datos
ShedLock es una librería que mantiene un lock en la base de datos para garantizar que un scheduled job se ejecute solo una vez en todo el cluster. Funciona así: antes de ejecutar el job, ShedLock inserta o actualiza un registro en una tabla con el nombre del job y un timestamp de lock. Si otro pod intenta ejecutar el mismo job en el mismo momento, encuentra que el lock ya existe y salta la ejecución.
La implementación es mínima. Agregás una anotación:
@Scheduled(cron = "0 11 * * *")
@SchedulerLock(name = "emailJob", lockAtMostFor = "10m", lockAtLeastFor = "5m")
public void sendDailyEmails() { ... }
Listo. ShedLock soporta MySQL, PostgreSQL, MongoDB, Redis y otros. La tabla que necesitás es mínima: nombre del job, timestamp, quién tiene el lock.
Las ventajas: mínimo cambio de código (anotación + config de BD), funciona con cualquier BD que ya tengas, simple de debuggear. Las desventajas: agrégale overhead de BD (hay que hacer una query antes de cada ejecución), si tu BD falla, los jobs no corren, y necesitás mantener un reloj sincronizado entre pods (aunque menos crítico que sin ShedLock).
Solución 2: Kubernetes CronJob
En lugar de que tu aplicación administre los scheduled jobs, offloadealos a Kubernetes. Un Kubernetes CronJob es un objeto nativo que ejecuta un contenedor (u otro recurso) en un horario específico.
En lugar de tener el job dentro de Spring, creás un manifest YAML:
apiVersion: batch/v1 Cubrimos ese tema en detalle en elegir plataformas seguras para tu pipeline.
kind: CronJob
metadata:
name: send-daily-emails
spec:
schedule: "0 11 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: emailer
image: my-app:latest
command: ["java", "-jar", "app.jar", "send-emails"]
Kubernetes garantiza que el job se ejecuta exactamente una vez en el horario especificado (o lo más próximo posible, considerando la sincronización de reloj del cluster). No hay duplicación porque Kubernetes es el único que decide si la ejecución debe correr.
Ventajas: Kubernetes maneja el scheduling nativamente, separación clara entre la aplicación y las tareas operacionales, escalabilidad garantizada (no importa cuántos pods tengas; el job corre una sola vez). Desventajas: los jobs necesitan ser stateless y autocontenidos (no pueden compartir sesión HTTP ni contexto de la aplicación fácilmente), más complejo operacionalmente (necesitás mantener manifests de CronJob por cada tarea).
Solución 3: Quartz Scheduler
Quartz es más robusto que Spring Scheduler: tiene cluster awareness integrada. Mantiene una BD compartida con información de triggers y executions, y cada nodo consulta la BD para determinar si debe ejecutar el job o no.
La configuración es más compleja que ShedLock, pero Quartz es el estándar industrial para job scheduling en Java. Si necesitás flexibilidad avanzada (ejecutar jobs solo en ciertos nodos, re-intentos automáticos, persistencia de estado del job), Quartz es la apuesta correcta.
El trade-off es mayor complejidad. Quartz necesita su propia tabla de datos, su propio thread pool, y tenés que mantener configuración de cluster. Para jobs simples, es overkill. Para sistemas complejos con múltiples tipos de tareas, es el camino.
Comparativa: cuándo usar cada solución
| Solución | Complejidad | Duración típica del job | Número de pods | Mejor para… |
|---|---|---|---|---|
| Spring @Scheduled solo | Muy baja | <1 minuto | 1 pod | Desarrollo local, NO producción multi-replica |
| ShedLock | Baja | 1-30 minutos | 2-10 pods | Jobs rápidos-moderados, mínimo cambio de código |
| Kubernetes CronJob | Media | Variable | N/A (no afecta) | Jobs independientes, separación clara ops/app |
| Quartz | Alta | >30 minutos | 10+ pods | Jobs complejos, múltiples tipos, estados persistentes |

La mayoría de los casos en Latinoamérica (startups, PMEs con apps web moderadas) caen en la categoría de ShedLock. Es el sweet spot: suficientemente robusto para multi-pod, mínimo overhead operacional.
Mejores prácticas y consideraciones de performance
Primero: idempotencia. Tu job DEBE ser seguro de ejecutar dos o más veces si algo falla. Si usa ShedLock, si la BD se cae entre el lock y la ejecución, el job podría ejecutarse de nuevo. Si tenés Kubernetes CronJob y el job falla, Kubernetes lo reintentará. Diseñá tus jobs asumiendo que podrían ejecutarse múltiples veces. En herramientas complementarias para tu stack profundizamos sobre esto.
Segundo: monitoreo. Registrá el comienzo y fin de cada ejecución, la duración, si fue exitosa o falló. En Kubernetes podés usar el logging estándar (stdout/stderr), que se captura en los logs del pod. Para alertas, integrá con Prometheus o tu sistema de observabilidad.
Tercero: sincronización de reloj. Si usás ShedLock o Quartz que dependen de timestamps, asegurate que todos los nodos tengan NTP activado. Una diferencia de 5-10 minutos puede causar que los locks se consideren expirados prematuramente.
Cuarto: testing en multi-pod. Antes de deployar a producción, probá tu job configuración en un ambiente local con múltiples replicas. Herramientas como Docker Compose o Kubernetes en local (Kind, Minikube) te permiten simular esto. Ejecutá el job y verificá que corra exactamente una vez, no más.
Errores comunes
Error 1: creer que @Scheduled es thread-safe en Kubernetes
Spring Scheduler por defecto es single-threaded. Muchos desarrolladores asumen que “si corre en un solo thread, no hay duplicación”. Error. En Kubernetes, cada pod corre su propio Spring Scheduler con su propio thread. La thread-safety no previene la duplicación entre pods. Necesitás un mecanismo de lock distribuido (ShedLock, Quartz, o CronJob).
Error 2: no medir la duración real del job
Medís el job en local y demora 30 segundos. Lo configurás para correr cada minuto. En producción, con datos reales, demora 45 segundos. Los logs se quejan de “previous execution did not complete”. Medí siempre la duración en condiciones realistas: con la BD completa, con la carga esperada, con la latencia de red real.
Error 3: deployar a Kubernetes sin testear multi-replica
Todo funciona en la rama main (un solo pod de testing). Lo pasás a producción con 3-5 replicas, y descubrís el problema cuando los usuarios se quejan de duplicación. Testá con multi-replica en staging antes de tocar producción. Ya lo cubrimos antes en comparar plataformas para orquestación.
Preguntas Frecuentes
¿Por qué mi trabajo programado se ejecuta varias veces en Kubernetes?
Porque cada pod independiente ejecuta el mismo @Scheduled sin coordinación. Kubernetes no sabe que múltiples pods están intentando correr la misma tarea. Necesitás un mecanismo de lock (ShedLock, Quartz) o delegarle el scheduling a Kubernetes mediante CronJob.
¿Cómo implementar tareas programadas en Spring Boot con múltiples pods?
Opción 1: ShedLock, agregá la anotación @SchedulerLock y una tabla de locks en BD. Opción 2: Kubernetes CronJob, saca el scheduling de la aplicación y delegalo a Kubernetes. Opción 3: Quartz, si necesitás más control y complejidad. ShedLock es la más rápida de implementar.
¿Cuál es la mejor solución: ShedLock o Kubernetes CronJob?
Depende. ShedLock si el job necesita contexto de la aplicación (acceso a beans Spring, transacciones, inyección de dependencias). CronJob si el job es independiente y querés separación clara entre la orquestación (Kubernetes) y la lógica (aplicación). La mayoría de los casos usan ShedLock porque es menos disruptivo.
¿Cómo usar ShedLock para prevenir duplicados?
Agregás @SchedulerLock(name = "jobName", lockAtMostFor = "10m", lockAtLeastFor = "5m") encima del método @Scheduled. ShedLock insertará un lock en la BD antes de ejecutar; si otro pod intenta ejecutar el mismo job, verá que el lock existe y saltará la ejecución. lockAtMostFor es el tiempo máximo que el lock se mantiene (si el job falla y cuelga, el lock se libera después de este tiempo). lockAtLeastFor es el tiempo mínimo entre ejecutiones.
¿Qué diferencia hay entre @Scheduled y Kubernetes CronJob?
@Scheduled corre DENTRO de la aplicación Spring (cada pod independientemente). CronJob corre FUERA, orquestado por Kubernetes (solo una vez por cluster). @Scheduled es más simple si el job necesita estado de la aplicación. CronJob es más robusto si el job es standalone. En Kubernetes, CronJob es el patrón nativo.
Qué está confirmado, qué no
Confirmado:
- Spring @Scheduled ejecuta en cada pod independientemente en Kubernetes (caso real: focused.io con emails duplicados)
- ShedLock con BD distribuida previene duplicación con mínimo cambio de código
- Kubernetes CronJob es el patrón nativo para scheduling en K8s
- Quartz tiene cluster-awareness integrada y es estándar industrial en Java
Pendiente (requiere testing en tu contexto específico):
- Performance overhead exacto de ShedLock en tu BD y volumen de jobs
- Comportamiento de locks si la latencia de BD es muy alta (>500ms)
- Integración de CronJob con tu sistema de observabilidad específico
Conclusión
Spring @Scheduled es perfecto en una sola máquina. En Kubernetes con múltiples replicas, es un accidente esperando ocurrir. La buena noticia es que las soluciones son simples y maduras. Si querés seguir usando Spring Scheduler, ShedLock es rápido de implementar (una anotación, una tabla de BD). Si preferís limpiar la arquitectura, Kubernetes CronJob es el camino nativo. En ambos casos, testá en multi-pod antes de producción y diseñá tus jobs idempotentes para cuando algo falle. La duplicación de tareas programadas es un problema que la comunidad resolvió hace años; no necesitás reinventar la rueda.
Fuentes
- A Tight Schedule: Spring, Kubernetes, and Scheduled Jobs – focused.io
- ShedLock – Spring scheduling in a distributed environment – Baeldung
- Kubernetes CronJob Official Documentation
- Rethinking Java Scheduled Tasks in Kubernetes – The New Stack
- A Better Way to Handle Scheduled Jobs in Spring Boot with Kubernetes CronJobs – Medium






