Cron в docker контейнере

Feb 26, 2018 08:57 · 1246 words · 6 minute read docker cron

Как мы уже упоминали ранее, иногда есть смысл собрать отдельный 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’овых задач был взят этот проект, там же можно найти больше примеров запуска периодических задач.

tweet Share