Ejecutar tareas cron para apps Dockerizadas
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Tu app corre bien en Docker, pero la parte que no sobrevive la mudanza es cron. No hay un demonio cron dentro de una imagen de app típica, y aunque instalaras uno, un reinicio del contenedor borra cualquier estado que llevara. Necesitas una limpieza nocturna, un reporte que sale cada lunes, o un recalentamiento de caché cada 15 minutos, y necesitas que sobreviva a despliegues, reinicios, y al hecho de que tu app corre como un contenedor de propósito único, no como una máquina Linux completa donde cron ya está corriendo.
Esta guía monta un contenedor planificador dedicado, ofelia, que lee etiquetas de Docker en tus contenedores existentes y ejecuta comandos contra ellos según un horario. Al final tendrás un docker-compose.yml funcional que ejecuta un comando dentro de tu contenedor de app según un horario cron, con logs que puedes revisar y una forma segura de manejar una tarea que falla.
Qué necesitas
- Un servidor con Docker y el plugin de Docker Compose instalados. Un Hetzner CX23 (2 vCPU, 4 GB de RAM, 40 GB NVMe, 5,99 € al mes sin IVA) alcanza para correr una app pequeña junto con el planificador.
- Tu app ya corriendo como contenedor, idealmente vía
docker-compose.yml. - No se necesita ningún dominio para el planificador en sí. Si tu app sirve HTTP, el proxy inverso y la configuración HTTPS que ya tienes para ella no se ven afectados por nada en esta guía.
- Acceso SSH al servidor.
Paso 1: entender por qué cron no funciona solo aquí
Una imagen de Docker normalmente corre un solo proceso, tu app, como PID 1. No hay nada vigilando un crontab, porque no hay crontab. Hay tres formas honestas de resolver esto:
- Integrar
cron(ocrondde Alpine) directamente en la imagen de tu app y correrlo junto a tu proceso con algo comosupervisord. Esto funciona, pero ahora tu imagen de aplicación es dueña de la programación, y cada tarea sin relación necesita un cambio de código y una reconstrucción para ajustarse. - Correr un contenedor separado que, según un horario, ejecuta un comando dentro de tu contenedor de app en ejecución. Sin cambios a la imagen de tu app.
- Correr un contenedor separado que, según un horario, arranca un nuevo contenedor efímero desde una imagen, ejecuta un comando puntual, y luego desecha el contenedor.
Las opciones 2 y 3 las maneja la misma herramienta: ofelia, un pequeño planificador en Go construido específicamente para Docker. Vigila el socket de Docker, lee definiciones de tareas desde etiquetas en tus contenedores (o desde un archivo de configuración), y las ejecuta según un horario cron. Esta guía usa la opción 2, job-exec, porque es el caso común: ya tienes un contenedor de app corriendo y quieres que un comando de gestión corra dentro periódicamente.
Paso 2: etiquetar el contenedor que debe ejecutar la tarea
Las tareas job-exec de ofelia se declaran como etiquetas en el contenedor contra el que quieres ejecutar comandos. Aquí un servicio de app con una tarea de limpieza nocturna adjunta:
services:
web:
image: ghcr.io/tucuenta/tuapp:latest
restart: unless-stopped
ports:
- "3000:3000"
labels:
ofelia.enabled: "true"
ofelia.job-exec.nightly-cleanup.schedule: "30 3 * * *"
ofelia.job-exec.nightly-cleanup.command: "bin/cleanup"
ofelia.enabled: "true" le dice al planificador que este contenedor tiene permitido que se ejecuten tareas contra él, así un contenedor que olvidaste etiquetar nunca se toma en cuenta silenciosamente. El patrón de la etiqueta es ofelia.<tipo-de-tarea>.<nombre-de-tarea>.<parámetro>, así que nightly-cleanup aquí es solo un nombre que elegiste, no tiene ningún significado especial para ofelia. El horario 30 3 * * * es una expresión cron estándar de 5 campos (minuto, hora, día del mes, mes, día de la semana), que se lee cada noche a las 3:30. command es lo que normalmente escribirías dentro del contenedor, aquí un script de gestión que tu imagen ya trae.
Paso 3: agregar el servicio planificador
Agrega ofelia mismo como un segundo servicio en el mismo docker-compose.yml:
services:
web:
image: ghcr.io/tucuenta/tuapp:latest
restart: unless-stopped
ports:
- "3000:3000"
labels:
ofelia.enabled: "true"
ofelia.job-exec.nightly-cleanup.schedule: "30 3 * * *"
ofelia.job-exec.nightly-cleanup.command: "bin/cleanup"
scheduler:
image: mcuadros/ofelia:a573727
restart: unless-stopped
depends_on:
- web
command: daemon --docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
mcuadros/ofelia actualmente no publica tags de versión semántica en Docker Hub, solo latest, development, y tags nombrados según hashes cortos de commits de git. a573727 es uno de esos tags de hash y está confirmado presente en Docker Hub hoy; fija el tag de hash actual que encuentres ahí en lugar de flotar en latest, para que una actualización del planificador sea algo que eliges, no algo que pasa en un reinicio de rutina. El comando daemon --docker le dice a ofelia que vigile la API de Docker buscando contenedores etiquetados en lugar de leer un archivo INI estático.
Paso 4: montar el socket de Docker, y saber qué otorga eso
La línea /var/run/docker.sock:/var/run/docker.sock:ro es lo que permite a ofelia ver tus contenedores y ejecutar comandos dentro de ellos. También es, funcionalmente, acceso root al host: un proceso con acceso al socket de Docker puede arrancar un nuevo contenedor con el sistema de archivos del host montado dentro, que es acceso root con otro nombre. La bandera :ro impide que ofelia escriba en el archivo del socket mismo, pero no restringe qué llamadas de la API de Docker puede hacer a través de ese socket, así que esto no es un sandbox.
Mantén ese contenedor lejos de cualquier imagen que no controles, no expongas el contenedor de ofelia a nada más en la red, y trata el servidor donde corre como un solo límite de confianza, no como una máquina multi-inquilino. Si ese compromiso no te convence, las tareas job-run (que lanzan un contenedor desechable desde una imagen especificada en lugar de ejecutar dentro de uno de larga duración) reducen algo el radio de impacto, ya que ofelia solo necesita permiso para ejecutar esa única imagen, no alcanzar contenedores arbitrarios en ejecución.
Paso 5: desplegar y verificar la primera ejecución
Levanta la pila:
docker compose up -d
Ofelia registra cada tarea que ejecuta, incluidas las que fallan, en el stdout de su propio contenedor:
docker compose logs -f scheduler
Deberías ver una línea como job "nightly-cleanup" started la primera vez que se dispara el horario, seguida de job "nightly-cleanup" finished con el código de salida. Si no quieres esperar hasta las 3:30, agrega temporalmente una segunda etiqueta con un horario a pocos minutos, confirma que se ejecuta, y luego quita la etiqueta de prueba y vuelve a desplegar.
Paso 6: manejar fallos sin perder la salida
Una tarea que falla no reinicia tu contenedor ni alerta a nadie por sí sola. El código de salida y la salida del comando viven en docker compose logs scheduler, y eso es todo a menos que conectes algo más. Ofelia soporta una sección de configuración [global] para notificaciones por correo, Slack, o log guardado, pero eso solo aplica cuando ofelia corre contra un archivo de configuración en lugar de puras etiquetas de Docker, ya que las etiquetas no tienen dónde poner credenciales SMTP. Si necesitas alertas, la opción práctica es hacer que el comando de tu tarea reporte el fallo él mismo (una llamada a un webhook de Slack, un error a tu monitoreo existente) en lugar de depender de que el planificador lo note.
Para tareas donde una ejecución perdida importa, mantén el comando mismo idempotente, para que si una ejecución falla a mitad de camino, la siguiente ejecución programada pueda simplemente reintentar en lugar de necesitar que una persona intervenga.
Paso 7: actualizar el planificador y respaldar su configuración
Ofelia mismo no guarda ningún estado propio más allá de lo que está en tus etiquetas o archivo de configuración, así que no hay nada que respaldar del lado del planificador más allá de lo que ya guardas en control de versiones para docker-compose.yml. Actualizar es bajar un tag de hash más reciente y recrear ese único servicio:
docker compose pull scheduler
docker compose up -d scheduler
Tu contenedor de app sigue corriendo sin afectarse, ya que docker compose up -d solo toca servicios cuya imagen o configuración realmente cambió.
El atajo: hazlo con Railyard
Si despliegas la app misma en Railyard en lugar de armar Compose a mano, el problema de programación desaparece como pieza de infraestructura separada. La función Cron de Railyard ejecuta un comando dentro de la imagen de tu propia app según un horario que defines, con historial de ejecución guardado por tarea, así que 30 3 * * * se convierte en una línea en el panel en lugar de una etiqueta y un segundo contenedor. No hay socket de Docker que montar ni una segunda imagen que mantener parchada, porque el comando corre usando la misma imagen que tu app ya despliega.
La misma pestaña Workers también detecta cualquier proceso en segundo plano que tu repositorio ya declare, una línea worker o clock en un Procfile, o un ejecutor como Sidekiq, Solid Queue, GoodJob, o un worker de cola de Laravel encontrado en tus dependencias, y te deja agregarlo como su propio proceso escalado de forma independiente con un clic. Ve Workers y tareas programadas para el panorama completo de ambos, y Desplegar desde un repositorio para cómo funciona el despliegue inicial en sí.
Solución de problemas
ofelia.enabled está puesto pero la tarea nunca se ejecuta. Verifica que la etiqueta esté en el contenedor objetivo (el que quieres que ejecute el comando), no solo en el planificador. Para job-exec, el par de etiquetas vive en el contenedor de la app; solo job-local y las entradas de configuración global viven en el planificador mismo.
El contenedor planificador no puede alcanzar el socket de Docker. Confirma que la ruta del montaje de volumen coincide con la ubicación real del socket en tu host, /var/run/docker.sock es estándar en la mayoría de las distribuciones Linux, pero una configuración de Docker Desktop o Docker en modo rootless a veces lo coloca en otro lugar. docker compose logs scheduler mostrará un error de permisos o conexión al arrancar si esto está mal.
La sintaxis cron se ve bien pero la tarea se dispara a la hora equivocada. Ofelia corre con el reloj propio del contenedor en UTC por defecto a menos que hayas definido TZ en el contenedor planificador; 30 3 * * * significa 3:30 UTC, no 3:30 en tu zona horaria local, a menos que hayas configurado explícitamente lo contrario.
Una tarea que toca archivos falla con un error de permisos. Esto usualmente significa que el comando intenta escribir en algún lugar donde el usuario del propio contenedor de la app no puede, independientemente de ofelia. Prueba el mismo comando con docker compose exec web <comando> directamente para aislar problemas del planificador de problemas de permisos.
El servidor se queda sin memoria después de agregar el planificador. Ofelia mismo es pequeño (alrededor de 12 MB comprimido), así que casi siempre es el comando de la tarea misma, no el planificador, el que consume memoria. Revisa el uso de recursos de la tarea de la misma forma que revisarías cualquier otro proceso en la máquina.
Preguntas frecuentes
¿Necesito cron instalado en algún lugar del host? No. Ofelia reemplaza por completo el crontab del host; nada en esta configuración toca /etc/crontab ni los timers de systemd.
¿Puedo programar una tarea que no necesite un contenedor existente en ejecución? Sí, eso es job-run: arranca un nuevo contenedor desde una imagen que especifiques, ejecuta el comando, y elimina el contenedor después, lo cual se parece más a una tarea batch tradicional de “levantar, hacer el trabajo, apagar”.
¿Qué pasa si dos ejecuciones programadas se superponen, porque una todavía está terminando cuando la siguiente arranca? Ofelia no serializa las ejecuciones job-exec contra el mismo contenedor por defecto, así que una tarea larga y un intervalo corto pueden superponerse. Mantén el comando rápido en relación con su intervalo, o seguro de ejecutar de forma concurrente consigo mismo.
¿Funciona igual en Docker Swarm o Kubernetes? Ofelia también soporta job-service-run para servicios Swarm. En Kubernetes, un recurso CronJob es el equivalente nativo y no necesita ofelia para nada.
¿Hay una interfaz web para ver el historial de tareas? No en la imagen de código abierto por sí sola; el historial de tareas vive en lo que sea que configures de salida de logs y retención para el contenedor scheduler.
Un contenedor planificador y un par de etiquetas es poca infraestructura para lo que necesita una tarea cron, y es infraestructura que ahora tienes que parchar, monitorear, y explicarle a la siguiente persona que abra este repo. Ese compromiso vale la pena cuando ya corres tu propia pila de Compose a mano. Cuando no, la tarea misma sigue siendo exactamente igual de portable, ya que es solo un comando que tu imagen ya puede ejecutar.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.