Введение
Существует много статей про запуск контейнеров и написание 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 !!!!!!!!
Получаем ответ на свой запрос, печатаем !!! 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.
Автор: azirumga