Learning Platform
Глоссарий Troubleshooting
Урок 17.02 · 22 мин
Начальный
crondebuggingenvironmentPATHlogging

Каноничный вопрос: «почему оно работает в 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-окружении

Это не баги cron — это default безопасность.

python3 из virtualenvне найдётся
rg, fd, jqmodern tools не найдутся
aws, gcloud, terraformcloud CLI обычно не найдутся
alembic, dbt, airflowpip-installed обычно нет

Причина №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 — почти никогда не нужно.

Redirect patterns в cron

Запомни наизусть — экономит часы при дебаге.

>> /var/log/etl.log 2>&1всё в один файл
>> run.log 2>> err.logразделить streams
2>&1 | tee -a /var/log/etl.logпараллельно в файл + stdout
2>&1 | logger -t etl-cronв systemd journal с tag
> /dev/null 2>&1ОПАСНО: потеря логов

Причина №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).

Попробуй сам

  1. Посмотри, какое окружение у cron:
    crontab -e   # добавь:
    # * * * * * env > /tmp/cron-env.log
    # Подожди 1 минуту, потом:
    cat /tmp/cron-env.log
  2. Посмотри cron-логи (что cron запускал):
    journalctl _COMM=cron --since "1 hour ago"
  3. Замерь, отличается ли твой 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
  4. 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 + скрипт.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Скрипт работает в shell, но в cron падает с 'python: command not found'. Причина?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4