Введение
Существует много статей про запуск контейнеров и написание docker-compose.yml. Но для меня долгое время оставался не ясным вопрос, как правильно поступить, если какой-то контейнер не должен запускаться до тех пор, пока другой контейнер не будет готов обрабатывать его запросы или не выполнит какой-то объём работ.
Вопрос этот стал актуальным, после того, как мы стали активно использовать docker-compose, вместо запуска отдельных докеров.
Для чего это надо
Действительно, пусть приложение в контейнере B зависит от готовности сервиса в контейнере A. И вот при запуске, приложение в контейнере B этот сервис не получает. Что оно должно делать?
Варианта два:
- первый — умереть (желательно с кодом ошибки)
- второй — подождать, а потом всё равно умереть, если за отведённый тайм-аут приложение в контейнере B так и не ответило
После того как контейнер B умер, docker-compose (в зависимости от настройки конечно) перезапустит его и приложение в контейнере B снова попытается достучаться до сервиса в контейнере A.
Так будет продолжаться, пока сервис в контейнере A не будет готов отвечать на запросы, либо пока мы не заметим, что у нас постоянно перегружается контейнер.
И по сути, это нормальный путь для многоконтейнерной архитектуры.
Но, мы, в частности, столкнулись с ситуацией, когда контейнер А запускается и готовит данные для контейнера B. Приложение в контейнере B не умеет само проверять готовы данные или нет, оно сразу начинает с ними работать. Поэтому, сигнал о готовности данных нам приходится получать и обрабатывать самостоятельно.
Думаю, что можно ещё привести несколько вариантов использования. Но главное, надо точно понимать зачем вы этим занимаетесь. В противном случае, лучше пользоваться стандартными средствами docker-compose
Немного идеологии
Если внимательно читать документацию, то там всё написано. А именно — каждый
контейнер единица самостоятельная и должен сам позаботиться о том, что все сервисы, с
которыми он собирается работать, ему доступны.
Поэтому, вопрос состоит не в том запускать или не запускать контейнер, а в том, чтобы
внутри контейнера выполнить проверку на готовность всех требуемых сервисов и только
после этого передать управление приложению контейнера.
Как это реализуется
Для решения этой задачи мне сильно помогло описание docker-compose, вот эта её часть
и статья, рассказывающая про правильное использование entrypoint и cmd.
Итак, что нам нужно получить:
- есть приложение А, которое мы завернули в контейнер А
- оно запускается и начинает отвечать OK по порту 8000
- а также, есть приложение B, которое мы стартуем из контейнера B, но оно должно начать работать не ранее, чем приложение А начнёт отвечать на запросы по 8000 порту
Официальная документация предлагает два пути для решения этой задачи.
Первый это написание собственной entrypoint в контейнере, которая выполнит все проверки, а потом запустит рабочее приложение.
Второй это использование уже написанного командного файла wait-for-it.sh.
Мы попробовали оба пути.
Написание собственной entrypoint
Что такое entrypoint?
Это просто исполняемый файл, который вы указываете при создании контейнера в Dockerfile в поле ENTRYPOINT. Этот файл, как уже было сказано, выполняет проверки, а потом запускает основное приложение контейнера.
Итак, что у нас получается:
Создадим папку Entrypoint.
В ней две подпапки — container_A и container_B. В них будем создавать наши контейнеры.
Для контейнера A возьмём простой http сервер на питоне. Он, после старта, начинает отвечать на get запросы по порту 8000.
Для того, чтобы наш эксперимент был более явным, поставим перед запуском сервера задержку в 15 секунд.
Получается следующий докер файл для контейнера А:
FROM python:3
EXPOSE 8000
CMD sleep 15 && python3 -m http.server --cgi
Для контейнера B создадим следующий докер файл для контейнера B:
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl
COPY ./entrypoint.sh /usr/bin/entrypoint.sh
ENTRYPOINT [ "entrypoint.sh" ]
CMD ["echo", "!!!!!!!! Container_A is available now !!!!!!!!"]
И положим наш исполняемый файл entrypoint.sh в эту же папку. Он у нас будет вот такой
#!/bin/bash
set -e
host="conteiner_a"
port="8000"
cmd="$@"
>&2 echo "!!!!!!!! Check conteiner_a for available !!!!!!!!"
until curl http://"$host":"$port"; do
>&2 echo "Conteiner_A is unavailable - sleeping"
sleep 1
done
>&2 echo "Conteiner_A is up - executing command"
exec $cmd
Что у нас происходит в контейнере B:
- При своём старте он запускает ENTRYPOINT, т.е. запускает entrypoint.sh
- entrypoint.sh, с помощью curl, начинает опрашивать порт 8000 у контейнера A. Делает он это до тех пор, пока не получит ответ 200 (т.е. curl в этом случае завершится с нулевым результатом и цикл закончится)
- Когда 200 получено, цикл завершается и управление передаётся команде, указанной в переменной $cmd. А в ней указано то, что мы указали в докер файле в поле CMD, т.е.
echo "!!! Container_A is available now !!!!!!!!» . Почему это так, рассказывается в указанной выше статье - Печатаем —
!!! Container_A is available now!!! и завершаемся.
Запускать всё будем с помощью docker-compose.
docker-compose.yml у нас вот такой:
version: '3'
networks:
waiting_for_conteiner:
services:
conteiner_a:
build: ./conteiner_A
container_name: conteiner_a
image: conteiner_a
restart: unless-stopped
networks:
- waiting_for_conteiner
ports:
- 8000:8000
conteiner_b:
build: ./conteiner_B
container_name: conteiner_b
image: waiting_for_conteiner.entrypoint.conteiner_b
restart: "no"
networks:
- waiting_for_conteiner
Здесь, в conteiner_a не обязательно указывать ports: 8000:8000. Сделано это с целью иметь возможность снаружи проверить работу запущенного в нём http сервера.
Также, контейнер B не перезапускаем после завершения работы.
Запускаем:
docker-compose up —-build
Видим, что 15 секунд идёт сообщение о недоступности контейнера A, а затем
conteiner_b | Conteiner_A is unavailable - sleeping
conteiner_b | % Total % Received % Xferd Average Speed Time Time Time Current
conteiner_b | Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0<!DOCTYPE HTML PUBLIC
"-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
conteiner_b | <html>
conteiner_b | <head>
conteiner_b | <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
conteiner_b | <title>Directory listing for /</title>
conteiner_b | </head>
conteiner_b | <body>
conteiner_b | <h1>Directory listing for /</h1>
conteiner_b | <hr>
conteiner_b | <ul>
conteiner_b | <li><a href=".dockerenv">.dockerenv</a></li>
conteiner_b | <li><a href="bin/">bin/</a></li>
conteiner_b | <li><a href="boot/">boot/</a></li>
conteiner_b | <li><a href="dev/">dev/</a></li>
conteiner_b | <li><a href="etc/">etc/</a></li>
conteiner_b | <li><a href="home/">home/</a></li>
conteiner_b | <li><a href="lib/">lib/</a></li>
conteiner_b | <li><a href="lib64/">lib64/</a></li>
conteiner_b | <li><a href="media/">media/</a></li>
conteiner_b | <li><a href="mnt/">mnt/</a></li>
conteiner_b | <li><a href="opt/">opt/</a></li>
conteiner_b | <li><a href="proc/">proc/</a></li>
conteiner_b | <li><a href="root/">root/</a></li>
conteiner_b | <li><a href="run/">run/</a></li>
conteiner_b | <li><a href="sbin/">sbin/</a></li>
conteiner_b | <li><a href="srv/">srv/</a></li>
conteiner_b | <li><a href="sys/">sys/</a></li>
conteiner_b | <li><a href="tmp/">tmp/</a></li>
conteiner_b | <li><a href="usr/">usr/</a></li>
conteiner_b | <li><a href="var/">var/</a></li>
conteiner_b | </ul>
conteiner_b | <hr>
conteiner_b | </body>
conteiner_b | </html>
100 987 100 987 0 0 98700 0 --:--:-- --:--:-- --:--:-- 107k
conteiner_b | Conteiner_A is up - executing command
conteiner_b | !!!!!!!! Container_A is available now !!!!!!!!
Получаем ответ на свой запрос, печатаем
Использование wait-for-it.sh
Сразу стоит сказать, что этот путь у нас не заработал так, как это описано в документации.
А именно, известно, что если в Dockerfile прописать ENTRYPOINT и CMD, то при запуске контейнера будет выполняться команда из ENTRYPOINT, а в качестве параметров ей будет передано содержимое CMD.
Также известно, что ENTRYPOINT и CMD, указанные в Dockerfile, можно переопределить в docker-compose.yml
Формат запуска wait-for-it.sh следующий:
wait-for-it.sh адрес_и_порт -- команда_запускаемая_после_проверки
Тогда, как указано в статье, мы можем определить новую ENTRYPOINT в docker-compose.yml, а CMD подставится из Dockerfile.
Итак, получаем:
Докер файл для контейнера А остаётся без изменений:
FROM python:3
EXPOSE 8000
CMD sleep 15 && python3 -m http.server --cgi
Докер файл для контейнера B
FROM ubuntu:18.04
COPY ./wait-for-it.sh /usr/bin/wait-for-it.sh
CMD ["echo", "!!!!!!!! Container_A is available now !!!!!!!!"]
Docker-compose.yml выглядит вот так:
version: '3'
networks:
waiting_for_conteiner:
services:
conteiner_a:
build: ./conteiner_A
container_name: conteiner_a
image: conteiner_a
restart: unless-stopped
networks:
- waiting_for_conteiner
ports:
- 8000:8000
conteiner_b:
build: ./conteiner_B
container_name: conteiner_b
image: waiting_for_conteiner.wait_for_it.conteiner_b
restart: "no"
networks:
- waiting_for_conteiner
entrypoint: ["wait-for-it.sh", "-s" , "-t", "20", "conteiner_a:8000", "--"]
Запускаем команду wait-for-it, указываем ей ждать 20 секунд пока оживёт контейнер A и указываем ещё один параметр «--», который должен отделять параметры wait-for-it от программы, которую он запустит после своего завершения.
Пробуем!
И к сожалению, ничего не получаем.
Если мы проверим с какими аргументами у нас запускается wait-for-it, то мы увидим, что передаётся ей только то, что мы указали в entrypoint, CMD из контейнера не присоединяется.
Работающий вариант
Тогда, остаётся только один вариант. То, что у нас указано в CMD в Dockerfile, мы должны перенести в command в docker-compose.yml.
Тогда, Dockerfile контейнера B оставим без изменений, а docker-compose.yml будет выглядеть так:
version: '3'
networks:
waiting_for_conteiner:
services:
conteiner_a:
build: ./conteiner_A
container_name: conteiner_a
image: conteiner_a
restart: unless-stopped
networks:
- waiting_for_conteiner
ports:
- 8000:8000
conteiner_b:
build: ./conteiner_B
container_name: conteiner_b
image: waiting_for_conteiner.wait_for_it.conteiner_b
restart: "no"
networks:
- waiting_for_conteiner
entrypoint: ["wait-for-it.sh", "-s" ,"-t", "20", "conteiner_a:8000", "--"]
command: ["echo", "!!!!!!!! Container_A is available now !!!!!!!!"]
И вот в таком варианте это работает.
В заключение надо сказать, что по нашему мнению, правильный путь это первый. Он наиболее универсальный и позволяет делать проверку готовности любым доступным способом. Wait-for-it просто полезная утилита, которую можно использовать как отдельно, так и встраивая в свой entrypoint.sh.
Комментарии (23)
gecube
03.06.2019 00:35wait-for-it.sh — это костыли.
Можно придумать более хитрый вариант с хелсчеками (хелсчеки + depends_on: service_healthy), но он работает только в спецификации docker-compose v.2.4. 3-й — это для docker swarm. Вы его не используете почти наверняка, поэтому использование третьей версии формата docker-compose не оправдано.
Есть еще вариант — делать внешний запускальщик для контейнеров на базе баш скрипта или Makefile — в принципе ОК,azirumga Автор
03.06.2019 20:24gecube, спасибо за отзыв. То, что wait-for-it это костыль — полностью согласен. Тему дожимал уже из интереса. На практике пользуемся entrypoint.
Про внешний запскальщик… вы имеете в виду совсем без docker-compose?gecube
03.06.2019 20:29+1На Ваше усмотрение. Смотрите. docker-compose — это по сути интерфейс для команд докер-клиента. Вы с тем же успехом можете вообще отказаться от docker-compose в пользу баш-скрипта с каким-то определенным количеством аргументов (типа start, stop) или несколькими скриптами. Действительно — какая разница как запускать контейнеры? Удобство compose в том, что он позволяет немного избежать повторения (через те же yaml anchor; можно ссылаться на сами контейнеры через docker-compose up имя_контейнера, например, что уменьшает кол-во простыней кода).
Про ansible и его модуль для работы с контейнерами я уже ссылку приводил вроде.azirumga Автор
03.06.2019 20:41да, я прочитал все ваши сообщения. Спасибо, очень полезные замечания.
fzn7
03.06.2019 12:06Вы перекладываете задачу с больной головы на здоровую. «Приложение в контейнере B не умеет само проверять готовы данные или нет, оно сразу начинает с ними работать» — может допишите код? docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly
gecube
03.06.2019 12:38Мои пять копеек — если речь про SOA и микросервисы, то Вы абсолютно правы. Но применение докера этим не ограничивается. Многие его используют просто как еще один пакетный менеджер....
azirumga Автор
03.06.2019 20:10fzn7, да, я с вами согласен, это правильное решение. В моём случае (тот который я рассматривал) это было невозможно.
mapcuk
03.06.2019 12:33Мне кажется проверка готовности должна быть сделана в docker-compose, а всякие wait-for-it.sh это костыли, которые приходится пихать в образ только для тестирования на локалхосте.
Очень хорошо сделано в kubernetes, там есть readynessprobe и livenessProbe по сути можно проверить порт, опросить по http, или даже запустить скрипт.
С учётом механизма dependencies можно красиво настроить сервисы.nonname
03.06.2019 15:07Вот мне тоже показалось что автор пытается из жигулей 6й модели сделать комфортабельный представительский седан.
gecube
03.06.2019 15:13Мне кажется проверка готовности должна быть сделана в docker-compose
т.е. фактически хелсчеки — это обязанность самого приклада, а не клиента, который к нему цепляется.
mapcuk
03.06.2019 20:37справедливое замечание, но тут такой сценарий, что запуская пачку микросервисов docker-compose, все сервисы стартуют одновременно, например postgres может быть ещё не готов, а другие сервисы уже успели сделать retry_connection несколько раз и отвалиться или для интеграционных тестов я не хочу тратить время на обвязку в виде retry-wrapper-ов.
То есть всё это можно порешать, но хочется чтоб интрумент позволял хотя бы запускать в нужной последовательности.
Ещё раз, это про локалхост.
azirumga Автор
03.06.2019 20:28nonname, ну почему сразу седан — так жизнь облегчить немного.
На самом деле, согласен со всеми, кто говорит, что wait-for-it это костыль. Зацепился за него только по тому, что сразу не заработало, стало интересно раскопать.gecube
03.06.2019 20:30Пробуем!
И к сожалению, ничего не получаем.Очень жаль, что не разобрались, почему не работает. Вообще странная история — новый entrypoint по идее не должен переопределять старый CMD, который в Dockerfile. Вот интересно даже
azirumga Автор
03.06.2019 20:35gecube, да, на самом деле это основной целью было возни с wait-for-it. Но, тем не менее, это так.
azirumga Автор
03.06.2019 20:33mapcuk, да. У нас на самом деле так и сделано. На проде стоит kubernetes, а для приёма от программеров мы сначала релиз раскатываем локально, через docker-compose.
gecube
04.06.2019 10:54azirumga поделюсь концептом как еще можно решить задачу.
Итак. Входные данные. Есть контейнеризованное приложение. Пускай есть еще БД. И есть еще контейнер с миграциями. Как бы все готово к миграции в кубер. Хелсчеки на базе и на приложении тоже есть. Пишем такой компоуз-файл:
version: 2.4 services: db: container_name: postgres image: postgres:10.1-alpine healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 migrate: image: my_app cmd: migrate && touch /tmp/flag && sleep 100 healthcheck: test: ["CMD-SHELL", "test -f /tmp/flag"] interval: 20s timeout: 5s retries: 5 depends_on: db: condition: service_healthy app: image: my_app depends_on: migrate: condition: service_healthy cmd: app ...
Смысл в чем. Поднимается база. Дальше мы не стартуем все остальное ПОКА она не даст готовность. Иначе дальнейшие действия бессмысленны. Потом стартуют миграции. Т.к. процесс завершается, то я создают файл флага и потом сплю какое-то время. Во время сна должен отработать хелсчек — тут нужно точно определить первоначальный интервал (~время миграции) + таймауты. Иначе не сработает. Но с другой стороны, если миграции идут дольше, чем нужно, то это явно проблема. К сожалению, в ванильном докере нет флага ready у контейнера, поэтому пришлось так извращаться. Ну, и сам приклад стартует только после того, как миграции успешны. На самом деле в кубере у вас скорее всего, во-первых, миграции будут в том же контейнере, что и приложение. Но, во-вторых, это создает проблему связанную с тем, что возможно, что если запустите несколько инстансов приложения, то они параллельно начнуть модифицировать базу и будет ой-ой-ой. Это можно решить внедрением какого-нибудь алгоритма выбора кто же из копий приложения является ведущей копией и имеет право накатывать миграции — туда про алгоритмы raft, paxos etc., либо можно поверх распределенного k-v хранилища это реализовать. Либо вызывать миграции руками (тогда оператор сам решает где и когда их применять).
Из плюсов реализованной схемы — у вас в памяти нет "лишних" контейнеров. Из минусов — очередные костыли.
vsantonov
05.06.2019 18:53Мы используем поверх compose еще и portainer. Он позволяет при локальной инсталяции бесконечно перезапускать контейнер пока ему не станет хорошо, а хорошо контейнеру определяется healthcheck.
polyakov_andrey
В решении подобной задачи я остановился на этом решении, проще, очевиднее https://github.com/ufoscout/docker-compose-wait
gecube
А знаете в чем проблема такого метода? Запущенный контейнер (с точки зрения докер-демона) — совершенно не эквивалентно работающему контейнеру. Соответственно, запросто может быть кейс, что сервисы поднялись, порты открыты и принимают соединения, но сервиы не готовы к подключениям. И, соответственно, зависимые сервисы при старте будут сыпать эксепшенами (нежелательный кейс).
Еще хуже, когда сервис стартанул, а потом упал. Упс. Мы это уже отследить не можем никак.
Вывод простой — не пользуйтесь docker-compose. Он годится только для одной единственной задачи — смоделировать запуск нескольких сервисов на локальной машине разработчика. Точка. Никакой сложной логики в него не внедряли. А то, что есть — даже извращает стандартные команды docker (run, build, network create etc.). Для более сложных задач — можно взять, например, ansible, благо в нем есть встроенный модуль работы с контейнерами: https://docs.ansible.com/ansible/latest/modules/docker_container_module.html
ivlis
ansible это какой-то оверкилл для локалсервера. А вот compose как раз то что надо.
gecube
Вопрос терминологии. И точки зрения. Что такое локалсервер — локальная машина разраба? Сервер в вагранте? Или выделенный стенд для команды разработки? И с Ваших слов, как будто, конфигурацию этих серверов не нужно описывать, не нужно ею управлять.
К тому же, я предложил ещё вариант — wrapper в виде bash/Make, если до ansible не доросли, но docker-compose уже мало (а, поверьте, очень быстро вылезаешь за его возможности)