¿Por qué no se ejecutó mi tarea cron? Una guía de campo para fallos silenciosos
«No se ejecutó» esconde tres fallos distintos: nunca se disparó, se disparó pero murió en la primera línea, o funcionó bien donde no puedes verlo. Esta guía los distingue, empezando por el log de cron.
La programación se ve bien. La has releído campo por campo, quizá la pegaste en una herramienta para asegurarte, y dice justo lo que querías. Pero lo que debía hacer no ocurrió: ningún archivo, ningún correo, ninguna fila nueva en la tabla. Así que la tarea «no se ejecutó».
Ahí, en esa frase, es donde la depuración se tuerce, porque nombra un síntoma y, de paso, da por sentada una causa. «No se ejecutó» se trata como un solo problema con un solo arreglo, cuando en realidad son tres fallos muy distintos vestidos con la misma frase:
- Nunca se disparó. Cron no llegó a invocar tu comando: la línea no está donde crees, el demonio no corre, o esta máquina no tiene cron.
- Se disparó, pero el comando murió enseguida. Cron ejecutó la línea a su hora, pero el comando chocó con un error en su primer paso y salió, casi siempre porque el entorno de cron no se parece en nada a la shell donde lo probaste.
- Funcionó bien, y no ves la prueba. El comando hizo exactamente lo que debía. Su salida fue a parar a un sitio donde no estás mirando, así que, desde donde tú estás, parece que no pasó nada.
Estos tres no tienen casi nada en común. Sospechar de la programación cuando el problema real es un PATH que falta, o reescribir el comando cuando cron nunca lo disparó: así se esfuma una hora. Sobre estos tres hay además dos variantes más sutiles —una tarea que se ejecuta casi todos los días y un día falla, y otra que se ejecuta a una hora que no esperabas—, y llegaremos a ambas al final. Pero el primer movimiento no es teorizar sobre nada de esto. Es averiguar cuál de los casos tienes entre manos. Un solo sitio te lo dice.
Una nota sobre el alcance antes de los pasos. Esta guía trata sobre todo del crontab clásico de Linux —el cron al estilo Cronie/Vixie que traen la mayoría de las distribuciones—, con los casos de macOS, systemd, contenedores y plataformas gestionadas señalados a medida que aparecen. El razonamiento sirve en todas partes; los detalles, no. Si tu tarea vive en GitHub Actions, Cloudflare o un CronJob de Kubernetes, comprueba si se disparó en el propio historial de ejecución de esa plataforma, no en un log del sistema, y toma la documentación de cada una como la autoridad.
Empieza por el log: parte el problema en dos
Antes de tocar nada, pregúntale a cron qué hizo. El demonio de cron suele registrar una línea cada vez que invoca una tarea —normalmente (user) CMD (el comando exacto)—, así que el log también confirma que estaba leyendo la línea que tú crees. (Suele, no siempre: Cronie permite que una línea del crontab empiece por - para suprimir su entrada en syslog, y los backends de logs varían; una razón más para que la ausencia de una entrada no sea prueba por sí sola.) Ese único hecho parte los casos en dos. Dónde vive el log depende del sistema:
grep CRON /var/log/syslog # Debian / Ubuntu
journalctl -u cron # Debian / Ubuntu (systemd)
journalctl -u crond # RHEL / Fedora / Alma (systemd)
cat /var/log/cron # RHEL / CentOS
Busca una línea, en el minuto en que debía correr, que nombre tu comando. Lo que veas decide adónde ir:
- Hay una entrada de log para tu comando. Cron lo disparó. La programación está bien; deja de sospechar de ella. El problema está en el comando o en cómo registras su salida: el comando murió (siguiente sección) o corrió y escondió su salida (la de después). Es el caso más común, y elimina la mitad de las conjeturas con un solo comando.
- No hay ninguna entrada a la hora en que debía correr. Lo más probable es que cron nunca invocara la línea, pero antes confirma que estás leyendo el log correcto (mira el aviso de abajo) y luego salta a Nunca se disparó.
Dos cosas que conviene tener claras. Primera: una entrada CMD (…) que coincide demuestra que cron invocó la tarea, y solo eso. Es una invocación, no un código de salida, así que te lleva de «¿se disparó?» a «¿qué pasó después?», que es justo la bifurcación que quieres. La ausencia de una entrada es una prueba más débil: según la distribución, la configuración del log, la imagen del contenedor y los permisos, puede que cron simplemente no esté escribiendo donde miras. Así que, antes de concluir «nunca se disparó», confirma que estás consultando el demonio y el backend de logs que este host usa de verdad. Segunda: en una máquina recortada, sin demonio de syslog, estas líneas solo viven en el journal de systemd, así que tira de journalctl en lugar de un archivo de log. (macOS enruta cron por el log unificado del sistema; log show --predicate 'process == "cron"' --last 1h es el equivalente aproximado, aunque en un Mac la respuesta más probable es que cron no sea la herramienta adecuada, como se ve más abajo.)
Cuando no puedas fiarte del log en absoluto —un host muy restringido, una distro que no conoces, un contenedor—, sáltatelo con una sonda que escriba en un archivo tuyo, y mira si crece:
* * * * * date >> /tmp/cron-probe.log 2>&1
Una línea nueva cada minuto significa que el demonio está vivo y leyendo tu crontab; un archivo que nunca aparece, o que deja de crecer, significa que no. Responde a «¿está cron ejecutando mi crontab siquiera?» sin depender del log del sistema. Quítala en cuanto tengas la respuesta.
Se disparó, pero el comando murió: cron no es tu shell
Es la razón más común de que una línea correcta no produzca nada, y se reduce a una idea que vale la pena retener: el comando que funciona cuando lo escribes tú no corre en el mismo entorno cuando lo corre cron. Cron no arranca tu shell. Ejecuta tu línea como /bin/sh -c '<comando>': un proceso mínimo y no interactivo, con un puñado escaso de variables, donde no ocurre nada de la preparación que tu shell interactiva hace por ti.
Un puñado de consecuencias explican la mayoría de los fallos:
El PATH es diminuto. Cron suele correr con un PATH que es poco más que /usr/bin:/bin. Así que una línea que llama a node, python3, docker, aws, psql o a tu propio script por su nombre a secas funciona en tu prompt —donde el PATH es rico— y falla bajo cron con «command not found», algo que ni siquiera ves, porque cron envía el error por correo a un sitio donde no estás mirando (siguiente sección). Todo lo instalado en /usr/local/bin, en un gestor de versiones de un lenguaje o en el node_modules/.bin de un proyecto queda invisible.
Tus dotfiles no se leen nunca. La shell de cron no es de login ni interactiva, así que ~/.bashrc, ~/.bash_profile y ~/.profile no se cargan con source. Todo lo que esos archivos preparan desaparece: los añadidos al PATH de nvm, pyenv, rbenv, asdf o Homebrew; los secretos y la configuración que exportas; un virtualenv o un entorno Conda activados. El comando que «funciona sin más» en tu terminal a menudo funciona gracias a una línea de un dotfile que ya ni recuerdas tener.
El directorio de trabajo es $HOME y la shell es /bin/sh. Cron corre desde tu directorio home, así que cualquier ruta relativa (./data, logs/out.txt, config.yml) parte del lugar equivocado. Y, salvo que la fijes, la shell es /bin/sh, que en Debian y Ubuntu es dash, no bash. Entonces la sintaxis exclusiva de bash ([[ … ]], arrays, source) falla con un error críptico.
Puede que ni siquiera sea tu cuenta. Un crontab del sistema corre como root o como un usuario de servicio con nombre, así que $HOME —y con él las claves de ~/.ssh, el known_hosts, ~/.aws, un kubeconfig, una sesión de gcloud— apunta al home de ese usuario, no al tuyo. Un git, un ssh, un rsync o una CLI de nube que se autentican sin problema desde tu terminal fallan bajo cron porque las credenciales que buscan no están en el home que se está usando.
El arreglo es dejar de depender de nada interactivo. Usa rutas absolutas tanto para el intérprete como para los archivos, fija el entorno de forma explícita al principio del crontab y clava la shell:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * cd /srv/app && /usr/local/bin/node scripts/nightly.js
Para cualquier cosa que pase de un comando, mételo en un script: un shebang absoluto (#!/bin/bash, no #!/usr/bin/env bash —env todavía se apoya en el PATH para encontrar bash, justo lo que aquí no es de fiar—), set -euo pipefail arriba y rutas absolutas dentro; luego programa el script. La línea del crontab se queda trivial y toda la preparación frágil vive en un único archivo que puedes probar. Y puedes aproximar el entorno recortado de cron antes de desplegar, lo que convierte el «a mí me funciona» en una prueba de verdad:
env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c 'cd "$HOME" && exec /srv/app/scripts/nightly.sh'
El cd "$HOME" importa: cron arranca cada tarea desde el home del propietario, así que omitirlo deja que un script que depende de una ruta relativa pase tu prueba y aun así falle bajo cron (o al revés). Hazlo con el home del usuario que de verdad es dueño de la tarea —a menudo root o una cuenta de servicio, no tu login—, porque es su $HOME y sus dotfiles los que cron usa. No es cron exactamente (cron también fija LOGNAME, USER y SHELL, y aplica las variables del principio del crontab), pero reproduce lo que suele romperse. Si falla en tu propio prompt, también habría fallado bajo cron; ahora ves por qué.
Aunque el entorno esté bien, el script en sí puede negarse a correr. Un entorno recortado no es la única forma de que una línea muera en su primer paso. Hay tres más que hacen fallar un comando en cuanto cron llega a él, y todas quedan invisibles hasta que capturas el error (siguiente sección):
- El script no es ejecutable. Programa
/srv/app/job.shsinchmod +xy cron obtienepermission denied. Márcalo como ejecutable o invoca el intérprete de forma explícita:/bin/bash /srv/app/job.sh. - Saltos de línea de Windows. Un script guardado con saltos de línea CRLF convierte el shebang en
#!/bin/bash\r, y cron reportabad interpreter: /bin/bash^M: no such file or directory. Es el fallo clásico de «editado en Windows, desplegado en Linux».dos2unix job.shlo arregla. - No hay a quién preguntar. Cron no tiene terminal, así que cualquier cosa que se detenga a preguntar —un
sshconfirmando una clave de host desconocida, una passphrase, unsudopidiendo contraseña, un «¿seguro?» de una CLI— se cuelga o falla en seco. Dale una forma no interactiva:ssh -o BatchMode=yespara que una credencial que falta falle al instante en vez de colgarse, una clave de automatización dedicada y con privilegios mínimos con una entrada real enknown_hosts, y una reglasudoersacotada en lugar de una contraseña. Una clave sin passphrase puede servir, pero como una decisión meditada, no como opción por defecto.
Funcionó bien; simplemente no lo ves
A veces cron disparó la línea y el comando tuvo éxito, y aun así parece que no pasó nada, porque estás juzgando el «¿se ejecutó?» por un resultado que no has configurado para poder comprobar. La versión clásica es un comando cuya única salida es texto por stdout. Cron captura esa salida y, por tradición, la envía por correo al propietario de la tarea. En un servidor sin agente de transporte de correo configurado —como tantos hoy—, ese correo no tiene adónde ir y se descarta en silencio. Tu tarea imprimió «Done», y ese «Done» se evaporó.
Así que no te fíes del correo de cron. Manda la salida a un sitio que controles y captura los errores con ella:
0 * * * * /srv/app/hourly.sh >> /var/log/hourly.log 2>&1
El orden importa: >> file 2>&1 apunta stdout al archivo y luego apunta stderr al mismo sitio. Si escribes 2>&1 >> file, stderr sigue yendo al destino de antes. Con las dos corrientes en un log que puedes leer, «no se ejecutó» suele convertirse en un error concreto que arreglas en un minuto.
Hay una trampa que vive en la propia cadena del comando y produce justo este síntoma de «corrió pero no hizo nada útil»: un % sin escapar en un comando de crontab no es un signo de porcentaje literal. Cron traduce % a un salto de línea, y todo lo que va después del primer % se convierte en la entrada estándar del comando en vez de formar parte de él. Así que esta línea no hace lo que parece:
0 0 * * * pg_dump mydb > /backup/db-$(date +%F).sql
Cron corta el comando en el %, así que lo que ejecuta es pg_dump mydb > /backup/db-$(date + —un comando roto al que le mete F).sql como entrada—. La fecha nunca se expande y la copia sale vacía o no sale. Escapa con una barra invertida cada % que quieras literal:
0 0 * * * pg_dump mydb > /backup/db-$(date +\%F).sql
Nunca se disparó: el crontab no es el que crees
Si el log no registró nada a la hora prevista, cron no llegó a tu línea. La línea existe, pero no donde un demonio de cron en marcha la está leyendo.
Algo que casi seguro no necesitas de entrada: un reinicio. Tras un crontab -e normal, Cronie nota el cambio por su cuenta vigilando la hora de modificación del spool, así que el reflejo de systemctl restart cron suele ser costumbre, no arreglo. No es una regla absoluta —casos límite, como un crontab al que se llega a través de un enlace simbólico, pueden burlar esa comprobación de fecha—, pero si una edición normal no surte efecto, mira las causas de abajo antes de reiniciar.
Editaste un archivo, no el crontab. Los crontab de usuario se gestionan con crontab -e; cron los lee de su propio directorio spool, no de un archivo que creaste y guardaste por ahí. Si escribiste tu línea en un crontab.txt cualquiera y nunca lo instalaste, nada la programa. Confirma lo que cron tiene de verdad con crontab -l, e instala un archivo con crontab ruta/al/archivo.
Los crontab del sistema llevan un campo de más, y es fácil equivocarse. Un crontab de usuario (crontab -e) son cinco campos de tiempo y luego el comando. Pero los archivos del sistema —/etc/crontab y cualquier cosa dentro de /etc/cron.d/— insertan un campo de usuario entre la programación y el comando:
# /etc/cron.d/backup — ojo al campo root antes del comando
0 3 * * * root /srv/app/backup.sh
Mete una línea normal de cinco campos en /etc/cron.d/ y cron lee la primera palabra de tu comando como el nombre de usuario: la tarea no corre y te llevas un error en el log sobre un usuario desconocido, no el resultado que querías. El error también va al revés, con otro síntoma: pegar ese mismo campo root en crontab -e no selecciona ningún usuario. Se convierte en la primera palabra del comando, así que la shell suele fallar intentando ejecutar root (command not found).
Algunas trampas de instalación más calladas:
- Sin salto de línea final. Algunas implementaciones de cron ignoran la última línea de un crontab si el archivo no termina en un salto de línea.
crontab -esuele encargarse de esto; un archivo que sueltas a mano en/etc/cron.d/puede que no. - Un nombre de archivo que cron ignora. Esto es un comportamiento de
run-parts, no una regla universal de cron: en Debian/Ubuntu y otros sistemas que manejan/etc/cron.daily,/etc/cron.hourlyy compañía conrun-parts, un archivo cuyo nombre contiene un punto se ignora por defecto, así que esebackup.shnunca corre mientras quebackupsí. Los sistemas que no usanrun-partsaquí no tienen esa restricción. - El demonio no está corriendo, o no lo estará tras un reinicio. Un contenedor mínimo o una máquina recién aprovisionada pueden no tener cron arrancado siquiera. Comprueba que corre ahora con
systemctl status cron(ocrond); pero comostatussolo refleja el estado actual, confirma que también arranca al inicio consystemctl is-enabled cron, y enciende ambas cosas consystemctl enable --now cronsi no. - No se te permitía instalar el crontab.
/etc/cron.allowy/etc/cron.denygobiernan quién puede usar el comandocrontab, no si una tarea ya instalada corre. Así que estas reglas solo importan si el usuario no pudo instalar o reemplazar el crontab de entrada; no impiden que un crontab de usuario ya instalado se dispare. Sicrontab -ete dio antes un error de permisos, por eso tu línea nunca se guardó.
Aquí no hay ningún cron, o el planificador es otro
A veces la programación nunca se disparó porque lo que tienes en la cabeza sencillamente no está. Y esta es, cada vez más, la respuesta de verdad.
Los contenedores no ejecutan cron por sí solos. Una imagen base de Docker no tiene un demonio de cron en marcha. Añadir un crontab a la imagen no hace nada a menos que también instales y arranques cron en ese contenedor; y, aun así, heredas una segunda copia del problema del entorno, porque el PATH y las herramientas instaladas del contenedor difieren de los del host. Para trabajo programado, lanzar la tarea desde el host o el orquestador suele ser más limpio que cuidar de un demonio de cron dentro de un contenedor.
En macOS, cron no debería ser la opción predeterminada. cron aún existe en un Mac, pero Apple recomienda launchd en su lugar, y hay una razón práctica para cambiar: si el Mac está dormido en el minuto previsto, cron no ejecuta la tarea perdida cuando la máquina despierta —simplemente la salta—. Un agente de launchd con StartCalendarInterval sí ejecuta al despertar una tarea que se perdió mientras la máquina estaba dormida. Fíjate en el límite: ese recobro cubre el sueño, no el apagado; una tarea perdida mientras el Mac estaba del todo apagado no se ejecuta después. Así que un cron nocturno en un portátil que se cierra por la noche puede no dispararse nunca, y launchd arregla el caso de dormido, pero no el de apagado.
El Linux moderno usa timers a menudo, no cron. Muchas distribuciones programan con timers de systemd en vez de —o junto a— cron. Si una tarea está definida como timer, no aparecerá en ningún crontab. Lístalos con systemctl list-timers, y ten en cuenta que un timer con Persistent=true recuperará una ejecución perdida mientras la máquina estaba apagada, algo que el cron normal nunca hace.
Los planificadores gestionados y serverless solo corren lo que les despliegas. Un schedule: de GitHub Actions, un cron trigger de Cloudflare Workers o un CronJob de Kubernetes corren en el planificador de esa plataforma, no en el crontab de ninguna máquina. Así que la línea tiene que estar de verdad desplegada allí; y cada uno tiene su forma de no correr en silencio: un CronJob de Kubernetes puede estar en suspend: true o saltarse ejecuciones por concurrencyPolicy y startingDeadlineSeconds; GitHub Actions corre las programaciones en modo best-effort y puede retrasarlas o descartarlas bajo carga. Y Windows no tiene cron en absoluto: ese mundo usa el Programador de tareas o schtasks.
Se ejecuta casi todos los días, y un día falla
Un caso más sutil: la tarea corre la mayoría de las veces y luego falla una. Dos mecanismos lo provocan.
Las ejecuciones se solapan y se apilan. Si una ejecución tarda más que el hueco hasta la siguiente, cron arranca la siguiente igualmente: nunca espera a que termine la anterior. Una tarea que suele tardar 20 segundos pero de vez en cuando corre tres minutos, programada cada minuto, acaba con varias copias peleándose por el mismo archivo o el mismo lock. Prevénlo negándote a empezar mientras la ejecución anterior siga en marcha. En Linux tienes flock (una herramienta de util-linux):
* * * * * /usr/bin/flock -n /var/lock/myapp/sync.lock /srv/app/sync.sh
flock -n toma el lock o sale de inmediato. Así que, si una ejecución anterior aún tiene el lock, la nueva ejecución programada se omite en vez de solaparse con la que sigue en marcha: la lenta continúa intacta; la que se salta es la del siguiente disparo. Pon el archivo de lock en un sitio donde el propio usuario de la tarea pueda escribir y que no sea de escritura para todos: un directorio dedicado como /var/lock/myapp/ en lugar del /tmp compartido, cuyos permisos y limpieza lo hacen mal candidato para un servicio de producción.
La ventana pasó con la máquina apagada. El cron normal no tiene memoria. Si la máquina estaba apagada o dormida a las 3:00, cuando tocaba la tarea nocturna, cron no la ejecuta más tarde: esa ocasión sencillamente se pierde. Es el mismo hueco de recobro que anacron se construyó para cerrar en tareas diarias, semanales y mensuales, y que los timers de systemd cierran con Persistent=true. En un servidor siempre encendido rara vez muerde; en cualquier cosa que se duerma, es una causa frecuente del «ayer corrió y hoy no».
Cuando sí se disparó, solo que a la hora equivocada
Queda un último caso al que el log te llevará: aparece una entrada, pero en un minuto de reloj que no esperabas. Que haya entrada significa que la línea se disparó, así que esto no es el entorno ni la instalación. Es la programación en sí: la expresión, o la zona horaria en la que corre.
Las trampas de la expresión están cubiertas de cabo a rabo en Cómo leer una expresión cron: un paso como */35 que no divide su rango de forma exacta, la regla del «O» entre día del mes y día de la semana, y el hecho de que una expresión cron no lleva zona horaria propia, de modo que la zona del servidor y los saltos del horario de verano deciden cuándo se ejecuta de verdad. La forma más rápida de ver lo que una línea debería hacer es soltarla en el lector de expresiones cron: imprime la programación en lenguaje claro, desglosa cada campo y lista las próximas ejecuciones en la zona horaria que elijas, de modo que una ejecución desplazada o saltada se vuelve visible.
Una vez que lo has acotado a un desfase de zona —la tarea se disparó en el instante correcto pero con el reloj de pared equivocado—, un conversor de zonas horarias alinea la zona del servidor con la tuya, y UTC, GMT, ISO 8601 y la hora Unix desenreda las capas de debajo.
Una lista de comprobación para depurar
Cuando una tarea cron «no se ejecutó», recórrela en este orden:
- Lee primero el log de cron.
grep CRON /var/log/syslogojournalctl -u cron/crond. Una entrada en el minuto correcto demuestra que se disparó, y el problema está en el comando o su salida. Sin entrada, lo más probable es que nunca se disparara, una vez que has confirmado que lees el log que este host escribe de verdad. - Si se disparó pero no hizo nada: revisa el entorno y el script. Rutas absolutas, un
PATHy unSHELLexplícitos al principio del crontab, no depender de dotfiles, el directorio de trabajo y el usuario correctos; luego confirma que el script es ejecutable, tiene saltos de línea Unix y no se queda esperando entrada. Reprodúcelo conenv -i. - Si se disparó pero no viste salida: redirígela. Añade
>> /path/log 2>&1y deja de fiarte del correo de cron. Escapa cualquier%literal como\%. - Si nunca se disparó: revisa la instalación.
crontab -lpara tareas de usuario, el campo usuario de más en las de/etc/cron.d/, el salto de línea final, y si el demonio siquiera está corriendo. - Si aquí no hay cron: usa el planificador correcto. Un contenedor, un Mac, una máquina con systemd o una plataforma gestionada programan cada uno a su manera; y una máquina que se duerme necesita uno que recupere lo perdido.
- Si se disparó a la hora equivocada: es la programación, no la configuración. Vuelve al lector y a la guía de lectura para las trampas del paso, el campo de día y la zona horaria.
Hazlo y «no se ejecutó» deja de ser un misterio y se convierte en un acotamiento corto y ordenado: de «¿llegó a dispararse?» hasta la única razón concreta por la que no produjo nada.