Docker-compose. Как дождаться готовности контейнера

habr.png


Существует много статей про запуск контейнеров и написание 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
conteiner_b | 
conteiner_b | 
conteiner_b | 
conteiner_b | Directory listing for /
conteiner_b | 
conteiner_b | 
conteiner_b | 

Directory listing for /

conteiner_b |
conteiner_b | conteiner_b |
conteiner_b | conteiner_b | 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.

© Habrahabr.ru