Запуск cron внутри Docker-контейнера
Так уж вышло, что запуск cron в Docker-контейнере — дело весьма специфическое, если не сказать сложное. В сети полно решений и идей на эту тему. Вот один из самых популярных (и простых) способов запуска:
cron -f
Но такое решение (и большинство других тоже) обладает рядом недостатков, которые сходу обойти достаточно сложно:
- неудобство просмотра логов (команда docker logs не работает)
- cron использует свой собственный Environment (переменные окружения, переданные при запуске контейнера, не видимы для cron заданий)
- невозможно нормально (gracefully) остановить контейнер командой docker stop (в конце концов в контейнер прилетает SIGKILL)
- контейнер останавливается с ненулевым кодом ошибки
Logs
Проблему просмотра логов с использованием стандартных средств Docker устранить сравнительно легко. Для этого достаточно принять решение о том, в какой файл будут писать свои логи cron-задания. Предположим, что это /var/log/cron.log:
* * * * * www-data task.sh >> /var/log/cron.log 2>&1
Запуская после этого контейнер при помощи команды:
cron && tail -F /var/log/cron.log
мы всегда сможем видеть результаты выполнения заданий при помощи «docker logs».
Аналогичного эффекта можно добиться воспользовавшись перенаправлением /var/log/cron.log в стандартный вывод контейнера:
ln -sf /dev/stdout /var/log/cron.log
Если cron-задания пишут логи в разные файлы, то, скорее всего, предпочтительнее будет вариант с использованием tail, который может «следить» за несколькими логами одновременно:
cron && tail -F /var/log/task1.log /var/log/task2.log
Environment variables
Изучая информацию на тему назначения переменных окружения для задач cron, выяснил, что последний может использовать так называемые подключаемые модули аутентификации (PAM). Что на первый взгляд является не относящимся к сабжу теме фактом. Но у PAM есть возможность определять и переопределять любые переменные окружения для служб, которые его (точнее их, модули аутентификации) используют, в том числе и для cron. Вся настройка производится в файле /etc/security/pam_env.conf (в случае Debian/Ubuntu). То есть любая переменная, описанная в этом файле, автоматически попадает в Environment всех cron-заданий.
Но есть одна проблема, точнее даже две. Синтаксис файла (его описание) при первом взгляде может ввести в ступор обескуражить. Вторая проблема — это как при запуске контейнера перенести переменные окружения внутрь pam_env.conf.
Опытные Docker-пользователи насчет второй проблемы наверняка сразу скажут, что можно воспользоваться лайфхаком под названием docker-entrypoint.sh и будут правы. Суть этого лайфхака заключается в написании специального скрипта, запускаемого в момент старта контейнера, и являющегося входной точкой для параметров, перечисленных в CMD или переданных в командной строке. Скрипт можно прописать внутри Dockerfile, например, так:
ENTRYPOINT ["/docker-entrypoint.sh"]
А его код при этом должен быть написан специальным образом:
#!/usr/bin/env bash
set -e
# код переноса переменных окружения в /etc/security/pam_env.conf
exec "$@"
Вернемся к переносу переменных окружения немного позже, а пока остановимся на синтаксисе файла pam_env.conf. При описании любой переменной в этом файле значение можно указать c помощью двух директив: DEFAULT и OVERRIDE. Первая позволяет указать значение переменной по умолчанию (если та вообще не определена в текущем окружении), а вторая позволяет значение переменной переопределить (если значение этой переменной в текущем окружении есть). Помимо этих двух кейсов, в файле в качестве примера описаны более сложные кейсы, но нас по большому счету интересует только DEFAULT. Итого, чтобы определить значение для какой-нибудь переменной окружения, которая затем будет использовать в cron, можно воспользоваться таким примером:
VAR DEFAULT="value"
Обратите внимание на то, что value в данном случае не должно содержать названий переменных (например, $VAR), потому как контекст файла выполняется внутри целевого Environment, где указанные переменные отсутствуют (либо имеют другое значение).
Но можно поступить еще проще (и такой способ почему-то не описан в примерах pam_env.conf). Если вас устраивает, что переменная в целевом Environment будут иметь указанное значение, независимо от того, определена она уже в этом окружении или нет, то вместо вышеупомянутой строки можно записать просто:
VAR="value"
Тут следует предупредить о том, что $PWD, $USER и $PATH вы не сможете заменить для cron-заданий при любом желании, потому как cron назначает значения этих переменных исходя из своих собственных убеждений. Можно, конечно, воспользоваться различными хаками, среди которых есть и рабочие, но это уже на ваше усмотрение.
Ну и наконец, если нужно перенести все текущие переменные в окружение cron-заданий, то в этом случае можно использовать такой скрипт:
#!/usr/bin/env bash
set -e
# переносим значения переменных из текущего окружения
env | while read -r LINE; do # читаем результат команды 'env' построчно
# делим строку на две части, используя в качестве разделителя "=" (см. IFS)
IFS="=" read VAR VAL <<< ${LINE}
# удаляем все предыдущие упоминания о переменной, игнорируя код возврата
sed --in-place "/^${VAR}/d" /etc/security/pam_env.conf || true
# добавляем определение новой переменной в конец файла
echo "${VAR} DEFAULT=\"${VAL}\"" >> /etc/security/pam_env.conf
done
exec "$@"
Поместив скрипт «print_env» в папку /etc/cron.d внутри образа и запустив контейнер (см. Dockerfile), мы сможем убедиться в работоспособности этого решения:
* * * * * www-data env >> /var/log/cron.log 2>&1
FROM debian:jessie
RUN apt-get clean && apt-get update && apt-get install -y rsyslog
RUN rm -rf /var/lib/apt/lists/*
RUN touch /var/log/cron.log \
&& chown www-data:www-data /var/log/cron.log
COPY docker-entrypoint.sh /
COPY print_env /etc/cron.d
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/bin/bash", "-c", "cron && tail -f /var/log/cron.log"]
docker build --tag cron_test .
docker run --detach --name cron --env "CUSTOM_ENV=custom_value" cron_test
docker logs -f cron # нужно подождать минуту
Graceful shutdown
Говоря о причине невозможности нормального завершения описанного контейнера с cron, следует упомянуть о способе общения демона Docker с запущенной внутри него службой. Любая такая служба (процесс) запускается с PID=1, и только с этим PID Docker умеет работать. То есть каждый раз, когда Docker посылает управляющий сигнал в контейнер, он адресует его процессу с PID=1. В случае с «docker stop» это SIGTERM и, если процесс продолжает работу, через 10 секунд SIGKILL. Так как для запуска используется »/bin/bash -c» (в случае с «CMD cron && tail -f /var/log/cron.log» Docker все равно использует »/bin/bash -c», просто неявно), то PID=1 получает процесс /bin/bash, а cron и tail уже получают другие PID, предугадать значения которых не представляется возможным по очевидным причинам.
Вот и выходит, что когда мы выполняем команду «docker stop cron» SIGTERM получает процесс »/bin/bash -с», а он в этом режиме игнорирует любой полученный сигнал (кроме SIGKILL, разумеется).
Первая мысль в этом случае обычно — надо как-то «кильнуть» процесс tail. Ну это сделать достаточно легко:
docker exec cron killall -HUP tail
Круто, контейнер тут же прекращает работу. Правда насчет graceful тут есть некоторые сомнения. Да и код ошибки по прежнему отличен от нуля. В общем, я не смог продвинуться в решении проблемы, следуя этим путем.
Кстати, запуск контейнера при помощи команды cron -f также не дает нужного результата, cron в этом случае просто отказывается реагировать на какие-либо сигналы.
True graceful shutdown with zero exit code
Остается только одно — написать отдельный скрипт запуска демона cron, умеющий при этом правильно реагировать на управляющие сигналы. Относительно легко, даже если раньше на bash’е писать не приходилось, можно найти информацию о том, что в нем есть возможность запрограммировать обработку сигналов (при помощи команды trap). Вот как, к примеру, мог бы выглядеть такой скрипт:
#!/usr/bin/env bash
# перенаправляем /var/log/cron.log в стандартный вывод
ln -sf /dev/stdout /var/log/cron.log
# запускаем syslog и cron
service rsyslog start
service cron start
# ловим SIGINT или SIGTERM и выходим
trap "service cron stop; service rsyslog stop; exit" SIGINT SIGTERM
если бы мы могли каким-то образом заставить этот скрипт работать бесконечно (до получения сигнала). И тут на помощь приходит еще один лайфхак, подсмотренный тут, а именно — добавление в конец нашего скрипта таких строчек:
# запускаем в фоне процесс "tail -f" и ждем его завершения
tail -f /dev/null & wait $!
Или, если cron-задания пишут логи в разные файлы:
# запускаем в фоне процесс "tail -F" и ждем его завершения
tail -F /var/log/task1.log /var/log/task2.log & wait $!
Заключение
В итоге получилось эффективное решение для запуска cron внутри Docker-контейнера, обходящее ограничения первого и соблюдающее правила второго, с возможностью нормальной остановки и перезапуска контейнера.
В конце привожу полные листинги Dockerfile и start-cron, которыми я пользуюсь сейчас.
#!/usr/bin/env bash
# переносим значения переменных из текущего окружения
env | while read -r LINE; do # читаем результат команды 'env' построчно
# делим строку на две части, используя в качестве разделителя "=" (см. IFS)
IFS="=" read VAR VAL <<< ${LINE}
# удаляем все предыдущие упоминания о переменной, игнорируя код возврата
sed --in-place "/^${VAR}/d" /etc/security/pam_env.conf || true
# добавляем определение новой переменной в конец файла
echo "${VAR} DEFAULT=\"${VAL}\"" >> /etc/security/pam_env.conf
done
# запускаем syslog и cron
service rsyslog start
service cron start
# ловим SIGINT или SIGTERM и выходим
trap "service cron stop; service rsyslog stop; exit" SIGINT SIGTERM
# запускаем в фоне процесс "tail -f /dev/null" и ждем его завершения
tail -f /dev/null & wait $!
FROM debian:jessie
RUN apt-get clean && apt-get update && apt-get install -y rsyslog
RUN rm -rf /var/lib/apt/lists/*
RUN touch /var/log/cron.log \
&& chown www-data:www-data /var/log/cron.log \
&& ln -sf /dev/stdout /var/log/cron.log
COPY start-cron /usr/sbin
COPY cron.d /etc
CMD start-cron
Комментарии (5)
12 июля 2016 в 14:19
+2↑
↓
Для ленивых, сделаю краткую выжимку: используйте свой entrypoint.sh и делайте там все, что захотите.12 июля 2016 в 14:27
+2↑
↓
docker контейнер и запуск более чем одного приложения в нем, ну это как бы даже не соответствует документации по docker, там написано что в одном контейнере лучше запускать не более одного приложения. Если у вас в контейнере надо делать крон, то это уже говорит о том что выбрали неправильный инструмент для решения задачи. Возьмите тогда LXC и будет счастье.12 июля 2016 в 15:05
+1↑
↓
Честно говоря, я с трудом представляю юзкейсы, где такое надо (про велосипеды молчим). Мне видится более удобным вариантом запускать контейнер системным кроном. Возможно на выделенной тачке.
12 июля 2016 в 15:19
+1↑
↓
Это как раз тот случай, когда докер пытаются запихать туда, где его быть не должно.12 июля 2016 в 15:26
+2↑
↓
думаю предадут анафеме, но в нынешнем виде докер почти не нужен в проде…
Он или много не умеет и нужны костыли или, что еще хуже, его пытаются запихнуть туда где он в принципе не нужен, но тупо моден сейчас