Как запускать cron jobs для docker-контейнеров?

Следуя хорошим практикам работе с Docker, в каждом контейнере должно быть запущено одно и только одно приложение. Как только приложение завершает работу — контейнер останавливается. Эти ограничения заставляют посмотреть на работу с cron задачами по-другому. Конечно, законами физики не запрещено в каждый контейнер устанавливать более одного приложения и запускать крон прямо изнутри контейнера. Однако, это может привести к сложностям и проблемам, особенно когда ваши приложения разрастутся на более чем один сервер.

Если у вас в распоряжении кластер из нескольких серверов с docker-контейнерами, то скорее всего вы используете какой-либо оркестратор, например Kubernetes, который содержит модуль CronJob для запуска задач по расписанию. В других оркестраторах это может называться Scheduler.

Как запустить cron задачу для уже запущенного docker-контейнера

Но в этой статье рассмотрим как организовать запуск cron задач по расписанию в рамках одного единственного сервера без установки дополнительных инструментов. Наиболее простое решение — записать все задачи в crontab хоста в таком формате:

* * * * * /usr/bin/docker-compose -f /application/docker-compose.yml exec -T php '/usr/bin/php /var/www/app/current/run.php' >> /var/log/docker-application.log 2>&1

Обратите внимание, все пути, как в контейнере, так и в хостовой системе — абсолютные. С помощью опции —file (-f) указываем путь до docker-compose.yml файла, в рамках сервисов которого нужно выполнить задачу.

Далее обратите внимание на опцию -T, это специфичный параметр для подкоманды exec. Disable pseudo-tty allocation. By default `docker-compose exec` allocates a TTY. Без добавления этого параметра при попытке запустить задачу из крона будет ошибка: the input device is not a TTY. Однако, вне контекста крона команда будет корректно отрабатывать и без опции -T.

Затем указано имя сервиса и команда которую нужно запустить внутри него. Для облегчения чтения она окружена в апострофы, но для работы они не обязательны.

Если по какой-либо причине задачи не выполняются, а в логах этому нет объяснения, то можно «подебажить» задачу таким образом:

echo '/usr/bin/docker-compose -f /application/docker-compose.yml exec php /usr/bin/php /var/www/app/current/run.php' | sh

И на экране увидите ошибки, если таковые присутствуют.

Создание docker-контейнера и запуск cron задачи

Предыдущий вариант хорош тем, что использует уже запущенный контейнер, благодаря чему, выполнение задачи будет максимально быстрым. Однако, есть ложка дёгтя. Даже две. Если контейнер по какой-либо причине не запущен или остановлен, то задача не выполнится и возвратится ошибка: ERROR: No container found for php_1

Вторая проблема в том, что если во время экзекуции задачи из крона будет остановлен контейнер либо главный процесс контейнера завершится, не важно успешно или с ошибкой, то задача внезапно прервётся со всеми вытекающими последствиями. Такой кейс возникает когда время жизни контейнера меньше времени, требуемого на выполнение задачи.

Поэтому лучше все задачи из крона выполнять на выделенных специально для этой цели контейнеров, время жизни которых будет соответствовать работе задач из крона.

/usr/bin/docker-compose -f /application/docker-compose.yml run --rm --no-deps php /usr/bin/php /var/www/app/current/run.ph

В данном случае используется подкоманда run, которая создаёт новый контейнер на каждый вызов команды. Обратите внимание на опцию —rm, которая удаляет контейнер после завершения задачи. Опция —no-deps немного ускорит запуск нового контейнера в случае если для сервиса в рамках которого запускается задача указана директива depends_on с контейнерами от которых зависит сервер в docker-compose.yml файле. Используйте её если уверены, что все контейнеры, от которых зависит выполнение задачи будут уже запущены.

С опцией —rm есть небольшой нюанс. Если в docker-compose.yml указать restart: always для сервиса, а при запуске задачи из крона не использовать опцию —rm, то контейнер будет автоматически перезапускаться сразу после завершения. При этом cron каждый указанный интервал будет запускать ещё один экземпляр контейнера и к чему-то хорошему это вряд ли приведёт.

Как с помощью docker-compose постоянно выполнять задачу

Из предыдущего пункта вытекает следующий кейс. Если нужно какую-то задачу держать постоянно запущенной: при завершении или факапе как можно скорее перезапускать, то укажите в docker-compose.yml опцию restart: always, и один раз запустите :

/usr/bin/docker-compose -f /application/docker-compose.yml run --no-deps --name='super-job' php /usr/bin/php /var/www/app/current/run.php

Примечания и комментарии

Все приведённые выше примеры отлично работают на версиях Docker:

Client:
 Version: 18.03.0-ce
 API version: 1.37
 Go version: go1.9.4
 Git commit: 0520e24
 Built: Wed Mar 21 23:09:15 2018
 OS/Arch: linux/amd64
 Experimental: false
 Orchestrator: swarm
Server:
 Engine:
 Version: 18.03.0-ce
 API version: 1.37 (minimum version 1.12)
 Go version: go1.9.4
 Git commit: 0520e24
 Built: Wed Mar 21 23:13:03 2018
 OS/Arch: linux/amd64
 Experimental: false
docker-compose version 1.20.1, build 5d8c71b
docker-py version: 3.1.4
CPython version: 3.6.4
OpenSSL version: OpenSSL 1.0.1t 3 May 2016