Cron в docker контейнере
Feb 26, 2018 08:57 · 1246 words · 6 minute read
Как мы уже упоминали ранее, иногда есть смысл собрать отдельный docker-контейнер для запуска периодических задач. Давайте разберемся!
В моем случае создаваемый контейнер с cron’ом должен уметь запускать периодические задачи не только в отдельных контейнерах на docker-хостах, но и в сервисах, запущенных в кластере docker swarm. Отдельным требованием было перенаправление всех результатов работы кронтасок в централизованное хранилище логов под управлением graylog2.
Начнем с Dockerfile, который выглядит следующим образом:
FROM library/docker:stable
ARG APP_ENV=dev
ENV HOME_DIR=/opt/crontab
RUN apk add --no-cache --virtual .run-deps bash jq \
&& mkdir -p ${HOME_DIR}/jobs ${HOME_DIR}/projects \
&& adduser -S docker -D \
&& sed -i "s/999/99/" /etc/group
COPY config.json.${APP_ENV} ${HOME_DIR}/config.json
COPY graylogger.sh /bin/graylogger.sh
COPY docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["crond","-f"]
Из особенностей - при сборке docker-образа я передаю дополнительный аргумент, указывающий окружение, на котором будет запускаться контейнер, например --build-arg APP_ENV=prod
(по умолчанию значение установлено на окружение разработчика - APP_ENV=dev
). В зависимости от данного значения в образ будет скопирован соответствующий файл config.json.${APP_ENV}
, из которого позже будет сформирован crontab
.
В процессе тестирования выяснилось, что на некоторых docker-хостах у группы docker id=999 (номер группы ping внутри контейнера), из-за чего контейнер не мог запуститься (сваливался на шаге # Create docker group using correct gid from host
в entrypoint-скрипте). Для устранения данной ошибки в Dockerfile пришлось добавить строку sed -i "s/999/99/" /etc/group
.
Последняя особенность - добавление в контейнер скрипта graylogger.sh
, который и перенаправляет результаты работы периодических задач в graylog2. Сам скрипт выглядит так:
#!/bin/sh
# Первый аргумент для скрипта graylogger.sh - команда, которая запустилась, например (docker exec nginx pwd)
COMMAND=${1}
# Тэг, с которым вывод команды будет записан в лог это команда (см. выше) без пробелов/спецсимволов, например dockerexecnginxpwd
TAG=$(echo ${COMMAND//[-._ ]/} | tr -d '/')
# В переменную MESSAGE запишем результат выполнения команды без подсветки синтаксиса
MESSAGE=$( ${COMMAND} 2>&1 | sed 's/\[[0-9;]*[mGKF]//g' )
# Служебные переменные для полей в graylog. Берутся из .env при запуске контейнера, если значения нет или оно пустое - берется значение по умолчанию (установлено после `:-`)
FACILITY=crontask_output
SOURCE=${HOST_SOURCE:-crontab_container}
# ip-адрес и порт graylog
GRAYLOG_ADDR=${GRAYLOG_ADDR:-127.0.0.1}
GRAYLOG_PORT=${GRAYLOG_PORT:-12202}
# Отправляем в graylog с помощью nc
echo -e {\"application_name\":\"${TAG}\", \"facility\":\"${FACILITY}\", \"host\":\"${SOURCE}\", \"short_message\":\"${MESSAGE}\", \"full_message\":\"${MESSAGE}\", \"level\":5 }'\'0 | nc -w 1 ${GRAYLOG_ADDR} ${GRAYLOG_PORT}
Здесь, думаю, пояснения излишни, но если возникнут вопросы - буду рад ответить на них в комментариях.
Основной, самый важный скрипт - docker-entrypoint.sh
- который и формирует корректный crontab
из файла config.json
, выставляет все необходимые права, а также меняет id группе docker внутри контейнера выглядит следующим образом:
#!/usr/bin/env bash
set -e
if [ -z "$DOCKER_HOST" -a "$DOCKER_PORT_2375_TCP" ]; then
export DOCKER_HOST='tcp://docker:2375'
fi
if [ "${LOG_FILE}" == "" ]; then
LOG_DIR=/var/log/crontab
LOG_FILE=${LOG_DIR}/jobs.log
mkdir -p ${LOG_DIR}
touch ${LOG_FILE}
fi
GRAYLOG=/bin/graylogger.sh
CONFIG=${HOME_DIR}/config.json
DOCKER_SOCK=/var/run/docker.sock
CRONTAB_FILE=/etc/crontabs/docker
# Ensure dir exist - in case of volume mapping
mkdir -p ${HOME_DIR}/jobs ${HOME_DIR}/projects
# Create docker group using correct gid from host, and add docker user to it
if ! grep -q "^docker:" /etc/group; then
DOCKER_GID=$(stat -c '%g' ${DOCKER_SOCK})
addgroup -g ${DOCKER_GID} docker
adduser docker docker
fi
slugify() {
echo "$@" | iconv -t ascii | sed -r s/[~\^]+//g | sed -r s/[^a-zA-Z0-9]+/-/g | sed -r s/^-+\|-+$//g | tr A-Z a-z
}
make_image_cmd() {
DOCKERARGS=$(echo ${1} | jq -r .dockerargs)
if [ "${DOCKERARGS}" == "null" ]; then DOCKERARGS=; fi
IMAGE=$(echo ${1} | jq -r .image)
TMP_COMMAND=$(echo ${1} | jq -r .command)
echo "${GRAYLOG} 'docker run ${DOCKERARGS} ${IMAGE} ${TMP_COMMAND}'"
}
make_container_cmd() {
DOCKERARGS=$(echo ${1} | jq -r .dockerargs)
if [ "${DOCKERARGS}" == "null" ]; then DOCKERARGS=; fi
SCRIPT_NAME=$(echo ${1} | jq -r .name)
PROJECT=$(echo ${1} | jq -r .project)
CONTAINER=$(echo ${1} | jq -r .container)
TMP_COMMAND=$(echo ${1} | jq -r .command)
if [ "${PROJECT}" != "null" ]; then
# create bash script to detect all running containers
if [ "${SCRIPT_NAME}" == "null" ]; then
SCRIPT_NAME=$(cat /proc/sys/kernel/random/uuid)
fi
cat << EOF > ${HOME_DIR}/projects/${SCRIPT_NAME}.sh
#!/usr/bin/env bash
set -e
# Execute command in EVERY replicated container in stack
#CONTAINERS=\$(docker ps --format '{{.Names}}' | grep -E "^${PROJECT}_${CONTAINER}.[0-9]+")
# Execute command in ONE container in stack
CONTAINERS=\$(docker ps --format '{{.Names}}' | grep -E "^${PROJECT}_${CONTAINER}.[0-9]+" | head -n 1)
for CONTAINER_NAME in \$CONTAINERS; do
${GRAYLOG} "docker exec ${DOCKERARGS} \${CONTAINER_NAME} ${TMP_COMMAND}"
done
EOF
echo "/bin/bash ${HOME_DIR}/projects/${SCRIPT_NAME}.sh"
else
echo "${GRAYLOG} 'docker exec ${DOCKERARGS} ${CONTAINER} ${TMP_COMMAND}'"
fi
}
make_cmd() {
if [ "$(echo ${1} | jq -r .image)" != "null" ]; then
make_image_cmd "$1"
elif [ "$(echo ${1} | jq -r .container)" != "null" ]; then
make_container_cmd "$1"
else
echo ${1} | jq -r .command
fi
}
parse_schedule() {
case $1 in
"@yearly")
echo "0 0 1 1 *"
;;
"@annually")
echo "0 0 1 1 *"
;;
"@monthly")
echo "0 0 1 * *"
;;
"@weekly")
echo "0 0 * * 0"
;;
"@daily")
echo "0 0 * * *"
;;
"@midnight")
echo "0 0 * * *"
;;
"@hourly")
echo "0 * * * *"
;;
"@every")
TIME=$2
TOTAL=0
M=$(echo $TIME | grep -o '[0-9]\+m')
H=$(echo $TIME | grep -o '[0-9]\+h')
D=$(echo $TIME | grep -o '[0-9]\+d')
if [ -n "${M}" ]; then
TOTAL=$(($TOTAL + ${M::-1}))
fi
if [ -n "${H}" ]; then
TOTAL=$(($TOTAL + ${H::-1} * 60))
fi
if [ -n "${D}" ]; then
TOTAL=$(($TOTAL + ${D::-1} * 60 * 24))
fi
echo "*/${TOTAL} * * * *"
;;
*)
echo "${@}"
;;
esac
}
function build_crontab() {
rm -rf ${CRONTAB_FILE}
ONSTART=()
while read i ; do
SCHEDULE=$(jq -r .[$i].schedule ${CONFIG} | sed 's/\*/\\*/g')
if [ "${SCHEDULE}" == "null" ]; then
echo "Schedule Missing: $(jq -r .[$i].schedule ${CONFIG})"
continue
fi
SCHEDULE=$(parse_schedule ${SCHEDULE} | sed 's/\\//g')
if [ "$(jq -r .[$i].command ${CONFIG})" == "null" ]; then
echo "Command Missing: $(jq -r .[$i].command ${CONFIG})"
continue
fi
COMMENT=$(jq -r .[$i].comment ${CONFIG})
if [ "${COMMENT}" != "null" ]; then
echo "# ${COMMENT}" >> ${CRONTAB_FILE}
fi
SCRIPT_NAME=$(jq -r .[$i].name ${CONFIG})
SCRIPT_NAME=$(slugify $SCRIPT_NAME)
if [ "${SCRIPT_NAME}" == "null" ]; then
SCRIPT_NAME=$(cat /proc/sys/kernel/random/uuid)
fi
COMMAND="/bin/bash ${HOME_DIR}/jobs/${SCRIPT_NAME}.sh"
cat << EOF > ${HOME_DIR}/jobs/${SCRIPT_NAME}.sh
#!/usr/bin/env bash
set -e
echo "Start Cronjob **${SCRIPT_NAME}** ${COMMENT}"
$(make_cmd "$(jq -c .[$i] ${CONFIG})")
EOF
if [ "$(jq -r .[$i].trigger ${CONFIG})" != "null" ]; then
while read j ; do
if [ "$(jq .[$i].trigger[$j].command ${CONFIG})" == "null" ]; then
echo "Command Missing: $(jq -r .[$i].trigger[$j].command ${CONFIG})"
continue
fi
echo "$(make_cmd "$(jq -c .[$i].trigger[$j] ${CONFIG})")" >> ${HOME_DIR}/jobs/${SCRIPT_NAME}.sh
done < <(jq -r '.['$i'].trigger|keys[]' ${CONFIG})
fi
echo "echo \"End Cronjob **${SCRIPT_NAME}** ${COMMENT}\"" >> ${HOME_DIR}/jobs/${SCRIPT_NAME}.sh
echo "${SCHEDULE} ${COMMAND}" >> ${CRONTAB_FILE}
if [ "$(jq -r .[$i].onstart ${CONFIG})" == "true" ]; then
ONSTART+=("${COMMAND}")
fi
done < <(jq -r '.|keys[]' ${CONFIG})
echo "##### crontab generation complete #####"
cat ${CRONTAB_FILE}
echo "##### run commands with onstart #####"
for COMMAND in "${ONSTART[@]}"; do
echo "${COMMAND}"
${COMMAND} &
done
}
if [ "$1" = "crond" ]; then
if [ -f ${CONFIG} ]; then
build_crontab
else
echo "Unable to find ${HOME_DIR}/config.json"
fi
fi
echo "$@"
exec "$@"
Пример файла config.json
:
[{
"comment":"DISABLED",
"schedule":"# */5 * * * *",
"command":"hostname -f"
},{
"comment":"Run command in separate container",
"schedule":"@every 10m",
"command":"pwd",
"container":"websocket"
},{
"comment":"Run command in docker swarm service container",
"schedule":"@every 5m",
"command":"indexer --config /etc/sphinxsearch/sphinx.conf --rotate --all",
"dockerargs":"--user www-data",
"project":"ed",
"container":"sphinxsearch"
}]
Находясь в каталоге с Dockerfile запускаем сборку docker-образа командой:
docker build -t crontab:latest .
Запустить docker-контейнер из собранного docker-образа можно так:
docker run -d \
--env HOST_SOURCE=crontab_container \
--env GRAYLOG_HOST=graylog.lc \
--env GRAYLOG_PORT=12202 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v $(pwd)/config.json:/opt/crontab/config.json:rw \
crontab:latest
Или добавить новый сервис в файл docker-compose.yml
, например так (вариант с монтированием config.json
в docker-контейнер с машины разработчика):
...
### Crontab Container #######################################
crond:
container_name: crond
image: crontab:latest
environment:
- HOST_SOURCE=${SERVER_NAME_BASE:-crontab_container}
- GRAYLOG_ADDR=${GRAYLOG_ADDR:-graylog.lc}
- GRAYLOG_PORT=${GRAYLOG_PORT:-12202}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./crond/config.json.dev:/opt/crontab/config.json:rw
logging:
driver: gelf
options:
gelf-address: "udp://${GRAYLOG_ADDR:-graylog.lc}:12201"
tag: "crond"
...
За основу реализованного docker-контейнера для запуска cron’овых задач был взят этот проект, там же можно найти больше примеров запуска периодических задач.