Запуск 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"]

А його код при цьому повинен бути написаний спеціальним чином:
docker-entrypoint.sh
#!/usr/bin/env bash
set -e

# код перенесення змінних оточення в /etc/security/pam_env.conf

exec "$@"


Повернемося до перенесення змінних оточення трохи пізніше, а поки зупинимося на синтаксисі файлу pam_env.conf. При описі будь-якої змінної в цьому файлі значення можна вказати за допомогою двох директив: DEFAULT і OVERRIDE. Перша дозволяє вказати значення змінної за замовчуванням (якщо така взагалі не визначена в поточному оточенні), а друга дозволяє значення змінної перевизначити (якщо значення цієї змінної в поточному оточенні є). Крім цих двох кейсів, у файлі в якості прикладу описані більш складні кейси, але нас за великим рахунком цікавить тільки DEFAULT. Отже, щоб визначити значення для якої-небудь змінної оточення, яка потім буде використовувати в cron, можна скористатися таким прикладом:
VAR DEFAULT="value"

Зверніть увагу на те, що value в даному випадку не повинно містити назв змінних (наприклад, $VAR), тому як контекст файлу виконується всередині цільового Environment, де зазначені змінні відсутні (або мають інше значення).

Але можна поступити ще простіше (і такий спосіб чомусь не описаний в прикладах pam_env.conf). Якщо вас влаштовує, що змінна в цільовому Environment матимуть вказане значення, незалежно від того, визначено вона вже в цьому оточенні або немає, то замість вищезгаданої рядка можна просто записати:
VAR="value"

Тут слід попередити про те, що $PWD, $USER і $PATH ви не зможете замінити для cron-завдань при будь-якому бажанні, бо як cron призначає значення цих змінних виходячи зі своїх власних переконань. Можна, звичайно, скористатися різними хакамі, серед яких є і робітники, але це вже на ваш розсуд.

Ну і нарешті, якщо потрібно перенести всі поточні змінні оточення cron-завдань, то в цьому випадку можна використовувати такий скрипт:
docker-entrypoint.sh
#!/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), ми зможемо переконатися в працездатності цього рішення:
print_env
* * * * * www-data env >> /var/log/cron.log 2>&1


Dockerfile
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", "крок && 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). Ось як, до прикладу, міг би виглядати такий скрипт:
start-cron
#!/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, якими я користуюся зараз.
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 $!


Dockerfile
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


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.