Каноничный вопрос: «почему оно работает в shell, но не в cron?»
Каждый DE рано или поздно сталкивается с этой ситуацией: ETL-скрипт идеально работает, когда ты запускаешь его руками ./run.sh в своём shell, но cron 0 6 * * * /opt/etl/run.sh молча падает каждое утро. Логи пустые, alert приходит только когда менеджер замечает, что данные не пришли.
Этот урок — про все причины такого расхождения и как их диагностировать. Если ты освоишь содержание этого урока — 80% cron-проблем будешь решать за 5 минут.
Причина №1: PATH в cron минимальный
В shell у тебя обычно PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:/home/levo/.local/bin:/opt/etl/venv/bin:.... В cron — PATH=/usr/bin:/bin (на Ubuntu/Debian).
Что не работает из-за этого:
# В shell работает (потому что PATH содержит /opt/etl/venv/bin):
$ python my_etl.py
# OK
# В cron — нет:
0 6 * * * python /opt/etl/my_etl.py
# python: command not found
Решение №1: задавать PATH в crontab:
PATH=/opt/etl/venv/bin:/usr/local/bin:/usr/bin:/bin
0 6 * * * python /opt/etl/my_etl.py
Решение №2 (предпочтительное): использовать абсолютные пути:
0 6 * * * /opt/etl/venv/bin/python /opt/etl/my_etl.py
Это надёжнее: команда работает независимо от PATH, легче read для следующего человека.
Это не баги cron — это default безопасность.
Причина №2: нет .bashrc, нет alias-ов
В .bashrc ты завёл:
alias deploy='/opt/de-tools/deploy.sh'
export AWS_PROFILE=prod
Это всё не работает в cron. cron запускает команду через /bin/sh -c "COMMAND" — не interactive shell, .bashrc не источниваются.
Что не работает:
- alias-ы;
- shell functions из dotfiles;
- export-нутые переменные из
.bashrc/.profile/.zshrc; - pyenv / nvm / rbenv shims (зависят от инициализации в shell).
Решение: всё необходимое — явно в скрипте.
#!/usr/bin/env bash
# /opt/etl/run.sh
set -euo pipefail
# Явно указать что нужно — не полагаться на shell init
export AWS_PROFILE=prod
export PYTHONPATH=/opt/etl/lib
export PATH=/opt/etl/venv/bin:/usr/local/bin:/usr/bin:/bin
cd /opt/etl
python -m orders_etl
Cron запускает этот скрипт, скрипт сам настраивает окружение — workable.
Причина №3: SHELL=/bin/sh, а не bash
На Ubuntu/Debian /bin/sh — это dash (минимальная POSIX shell), не bash. В cron-команде /bin/sh -c "..." — это dash, который не поддерживает многие bash-features:
[[ ... ]](используй[ ... ]);(( ... ))(используй$(( ... ))илиexpr);- массивы (
arr=(a b c)— нет); <()process substitution;==в test (используй=);&>для перенаправления (используй>...2>&1).
Решение:
SHELL=/bin/bash
0 6 * * * /opt/etl/run.sh
Или явно в команде:
0 6 * * * /bin/bash -c '/opt/etl/run.sh'
Или в shebang скрипта #!/usr/bin/env bash — тогда cron вызывает /bin/sh, тот вызывает скрипт через execve, и kernel читает shebang и запускает bash. Самое надёжное — #!/usr/bin/env bash в скрипте.
Подробнее про shebang — модуль 16-bash-scripting-basics.
Причина №4: cwd не там
Cron запускает команду в $HOME пользователя (часто /root для root или /home/levo). Это не то же самое, что ты привык, делая cd /opt/etl && ./run.sh.
Если в скрипте есть относительные пути:
# В Python:
with open("config.yaml") as f: # ищет в /home/levo/config.yaml, не /opt/etl/config.yaml
...
Файл не найдётся.
Решение: cd в начале crontab-команды или в самом скрипте.
# В crontab:
0 6 * * * cd /opt/etl && /opt/etl/venv/bin/python -m orders_etl
Или в скрипте:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")" # cd в директорию самого скрипта
python -m orders_etl
$(dirname "$0") — портабельный паттерн «директория этого скрипта».
Причина №5: stdout/stderr теряются
По умолчанию cron шлёт вывод email-ом. Email обычно не настроен -> вывод в /var/mail/USERNAME (текстовый файл), который никто не смотрит. Симптом: скрипт упал, alert не пришёл, никаких логов.
Решение: redirect.
# Самый простой: append в файл, stderr туда же:
0 6 * * * /opt/etl/run.sh >> /var/log/etl.log 2>&1
# Если нужно различать stdout и stderr:
0 6 * * * /opt/etl/run.sh >> /var/log/etl.log 2>> /var/log/etl.err
# Если совсем не нужен вывод:
0 6 * * * /opt/etl/run.sh > /dev/null 2>&1
КРИТИЧЕСКАЯ ОШИБКА: > /dev/null 2>&1 теряет всю диагностическую информацию. Используй только когда уверен, что вывод не нужен. Для DE — почти никогда не нужно.
Запомни наизусть — экономит часы при дебаге.
Причина №6: символ % в crontab
В crontab % — special: всё после первого % (не экранированного) считается stdin для команды. Это часто ломает date-форматы:
# НЕ работает! %d, %m, %Y интерпретируются как stdin separator:
0 6 * * * /opt/backup/dump.sh > /var/backup/dump-$(date +%Y-%m-%d).sql
Cron исполнит:
/opt/backup/dump.sh > /var/backup/dump-$(date +
# и подаст на stdin:
Y-%m-%d).sql
Что не то. Решение: экранировать %:
0 6 * * * /opt/backup/dump.sh > /var/backup/dump-$(date +\%Y-\%m-\%d).sql
Каждый % в команде должен стать \%. Это одна из самых неочевидных проблем cron — теряешь час на дебаг.
Альтернатива: вынести логику в bash-скрипт, в crontab указывать только /opt/backup/run.sh. В скрипте % уже не special.
Причина №7: разные user, разные права
# В user crontab (crontab -e от user 'levo'):
0 6 * * * /opt/etl/run.sh
vs
# В /etc/cron.d/orders-etl (системный, root управляет):
0 6 * * * etl /opt/etl/run.sh
В первом случае скрипт работает с правами levo, во втором — с правами etl. Если скрипту нужно писать в /var/log/etl/ (owned etl:etl) — первый вариант упадёт с Permission denied.
Решение: задумайся, под каким user должно запускаться. Для production обычно создаётся system user (useradd -r etl) и задача в /etc/cron.d/. Для personal/dev-скриптов — user crontab.
Причина №8: lock file проблема
Что если cron запускает задачу каждые 5 минут, а сама задача иногда работает 15 минут? Будет 3 экземпляра одновременно. Это может:
- Загрузить базу;
- Привести к race condition при записи в один файл;
- Дублировать данные в pipeline.
Решение: flock или другой механизм lock.
# flock — встроенный в util-linux. -n = nonblocking, выйдет если lock занят:
*/5 * * * * /usr/bin/flock -n /var/run/etl.lock /opt/etl/run.sh
flock создаёт lock-файл; пока скрипт работает — следующий запуск увидит lock и выйдет (с -n). Без флага -n — будет ждать.
Альтернатива: проверять PID-файл в начале скрипта.
#!/usr/bin/env bash
LOCKFILE=/var/run/etl.lock
if ! flock -n 9; then
echo "Already running, exiting"
exit 0
fi 9>"$LOCKFILE"
# ... основная работа
Debugging cron-задачи
Dockerfile ENV и окружение контейнера — те же граблиСистемный подход:
Шаг 1: проверить, что cron вообще запустил
# На современной Ubuntu (с systemd):
$ journalctl _COMM=cron --since "1 hour ago"
May 13 06:00:01 prod-vm CRON[12345]: (etl) CMD (/opt/etl/run.sh >> /var/log/etl.log 2>&1)
# Или (legacy):
$ grep CRON /var/log/syslog | tail
Если в логах задача есть — cron запускал. Если нет — проверь crontab (crontab -l), расписание (минута / час).
Шаг 2: посмотреть логи задачи
$ tail -50 /var/log/etl.log
Если файл пустой или содержит ошибки — это твой путь к решению.
Шаг 3: повторить запуск в окружении cron
Самый надёжный debug: запусти команду как cron бы запустил.
# Способ 1: env -i — пустое окружение, минимум подстановок:
$ env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c '/opt/etl/run.sh'
# Способ 2: подставь cron-окружение явно и запусти:
$ HOME=/home/etl PATH=/usr/bin:/bin SHELL=/bin/sh /opt/etl/run.sh
Если падает так же — найдёшь причину. Если работает — значит, в crontab синтаксис не тот.
Шаг 4: бытовой trick — записать env в файл
# Один раз добавь:
* * * * * env > /tmp/cron-env.log
Через минуту:
$ cat /tmp/cron-env.log
HOME=/home/etl
PATH=/usr/bin:/bin
SHELL=/bin/sh
...
Видишь точно, что у cron в окружении. Затем удали эту строку из crontab.
Шаг 5: добавь explicit logging в скрипт
#!/usr/bin/env bash
exec >> /var/log/etl/debug.log 2>&1
echo "[$(date -Iseconds)] script started, PID=$$"
echo "[$(date -Iseconds)] PATH=$PATH"
echo "[$(date -Iseconds)] cwd=$(pwd)"
echo "[$(date -Iseconds)] user=$(whoami)"
set -x # echo every command
# ... остальная работа
exec >> file 2>&1 — перенаправляет stdout/stderr всего скрипта в файл. set -x — печатает каждую команду перед выполнением (xtrace). Это сильный debug.
DE-сценарий: правильный production cron-job
Собираем всё вместе. Скрипт /opt/orders-etl/run.sh:
#!/usr/bin/env bash
set -euo pipefail
# Самозащита: где этот скрипт лежит, туда и cd
cd "$(dirname "$0")"
# Логирование: всё (stdout + stderr) в файл
LOGFILE=/var/log/orders-etl/run.log
mkdir -p "$(dirname "$LOGFILE")"
exec >> "$LOGFILE" 2>&1
# Заголовок
echo
echo "[$(date -Iseconds)] === ETL run started ==="
# Подгрузка env
if [[ -f /etc/orders-etl/env ]]; then
set -a
. /etc/orders-etl/env
set +a
fi
# Запуск
/opt/orders-etl/venv/bin/python -m orders_etl
# Итог
echo "[$(date -Iseconds)] === ETL run completed, exit=$? ==="
crontab (/etc/cron.d/orders-etl):
SHELL=/bin/bash
[email protected]
PATH=/usr/local/bin:/usr/bin:/bin
# Daily orders ETL at 06:00 UTC. Logs to /var/log/orders-etl/run.log via script.
# flock prevents concurrent runs if previous one stuck.
0 6 * * * etl /usr/bin/flock -n /var/run/orders-etl.lock /opt/orders-etl/run.sh
Что мы сделали правильно:
- абсолютные пути везде;
- shell explicit (
SHELL=/bin/bash); - run user (
etl); - flock для предотвращения concurrent runs;
- скрипт сам redirect-ит вывод и
cd-нится; set -euo pipefailв скрипте — падает на первой ошибке (модуль17-bash-scripting-advanced).
Попробуй сам
- Посмотри, какое окружение у cron:
crontab -e # добавь: # * * * * * env > /tmp/cron-env.log # Подожди 1 минуту, потом: cat /tmp/cron-env.log - Посмотри cron-логи (что cron запускал):
journalctl _COMM=cron --since "1 hour ago" - Замерь, отличается ли твой PATH от cron PATH:
echo "Shell PATH: $PATH" > /tmp/path-compare.log echo "Cron PATH:" >> /tmp/path-compare.log crontab -l | grep -E '^PATH=' >> /tmp/path-compare.log cat /tmp/path-compare.log - Test command в cron-окружении вручную:
env -i HOME=$HOME PATH=/usr/bin:/bin /bin/sh -c 'which python3'
Главное
- PATH в cron минимальный — задавай свой через
PATH=...в crontab или абсолютные пути. .bashrcне выполняется — никаких alias-ов, export-ов из dotfiles. Всё необходимое — в самом скрипте.- SHELL=/bin/sh (dash на Ubuntu) — не bash.
[[ ]],(( )), массивы не работают. ЗадавайSHELL=/bin/bashили используй#!/usr/bin/env bashв скрипте. - cwd =
$HOME, не где ты ожидаешь.cdв начале или абсолютные пути. - stdout/stderr идут email (часто никому). Redirect:
>> /var/log/script.log 2>&1. %в crontab — special. Экранируй\%или вынеси логику в скрипт.- Lock против concurrent:
flock -n /var/run/svc.lock COMMAND. - Debug workflow:
journalctl _COMM=cron-> логи скрипта -> cron-окружение черезenv -i->set -xв скрипте. - Production paттерн: bash-скрипт с
set -euo pipefail, exec redirect, явная подгрузка env. crontab указывает только flock + скрипт.