Введение


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

Комментарии (23)


  1. polyakov_andrey
    03.06.2019 00:35

    В решении подобной задачи я остановился на этом решении, проще, очевиднее https://github.com/ufoscout/docker-compose-wait


    1. gecube
      03.06.2019 00:39

      А знаете в чем проблема такого метода? Запущенный контейнер (с точки зрения докер-демона) — совершенно не эквивалентно работающему контейнеру. Соответственно, запросто может быть кейс, что сервисы поднялись, порты открыты и принимают соединения, но сервиы не готовы к подключениям. И, соответственно, зависимые сервисы при старте будут сыпать эксепшенами (нежелательный кейс).
      Еще хуже, когда сервис стартанул, а потом упал. Упс. Мы это уже отследить не можем никак.


      Вывод простой — не пользуйтесь docker-compose. Он годится только для одной единственной задачи — смоделировать запуск нескольких сервисов на локальной машине разработчика. Точка. Никакой сложной логики в него не внедряли. А то, что есть — даже извращает стандартные команды docker (run, build, network create etc.). Для более сложных задач — можно взять, например, ansible, благо в нем есть встроенный модуль работы с контейнерами: https://docs.ansible.com/ansible/latest/modules/docker_container_module.html


      1. ivlis
        03.06.2019 04:34
        +1

        ansible это какой-то оверкилл для локалсервера. А вот compose как раз то что надо.


        1. gecube
          03.06.2019 07:20

          Вопрос терминологии. И точки зрения. Что такое локалсервер — локальная машина разраба? Сервер в вагранте? Или выделенный стенд для команды разработки? И с Ваших слов, как будто, конфигурацию этих серверов не нужно описывать, не нужно ею управлять.
          К тому же, я предложил ещё вариант — wrapper в виде bash/Make, если до ansible не доросли, но docker-compose уже мало (а, поверьте, очень быстро вылезаешь за его возможности)


  1. gecube
    03.06.2019 00:35

    wait-for-it.sh — это костыли.
    Можно придумать более хитрый вариант с хелсчеками (хелсчеки + depends_on: service_healthy), но он работает только в спецификации docker-compose v.2.4. 3-й — это для docker swarm. Вы его не используете почти наверняка, поэтому использование третьей версии формата docker-compose не оправдано.
    Есть еще вариант — делать внешний запускальщик для контейнеров на базе баш скрипта или Makefile — в принципе ОК,


    1. azirumga Автор
      03.06.2019 20:24

      gecube, спасибо за отзыв. То, что wait-for-it это костыль — полностью согласен. Тему дожимал уже из интереса. На практике пользуемся entrypoint.
      Про внешний запскальщик… вы имеете в виду совсем без docker-compose?


      1. gecube
        03.06.2019 20:29
        +1

        На Ваше усмотрение. Смотрите. docker-compose — это по сути интерфейс для команд докер-клиента. Вы с тем же успехом можете вообще отказаться от docker-compose в пользу баш-скрипта с каким-то определенным количеством аргументов (типа start, stop) или несколькими скриптами. Действительно — какая разница как запускать контейнеры? Удобство compose в том, что он позволяет немного избежать повторения (через те же yaml anchor; можно ссылаться на сами контейнеры через docker-compose up имя_контейнера, например, что уменьшает кол-во простыней кода).
        Про ansible и его модуль для работы с контейнерами я уже ссылку приводил вроде.


        1. azirumga Автор
          03.06.2019 20:41

          да, я прочитал все ваши сообщения. Спасибо, очень полезные замечания.


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


    1. gecube
      03.06.2019 12:38

      Мои пять копеек — если речь про SOA и микросервисы, то Вы абсолютно правы. Но применение докера этим не ограничивается. Многие его используют просто как еще один пакетный менеджер....


    1. azirumga Автор
      03.06.2019 20:10

      fzn7, да, я с вами согласен, это правильное решение. В моём случае (тот который я рассматривал) это было невозможно.


  1. mapcuk
    03.06.2019 12:33

    Мне кажется проверка готовности должна быть сделана в docker-compose, а всякие wait-for-it.sh это костыли, которые приходится пихать в образ только для тестирования на локалхосте.
    Очень хорошо сделано в kubernetes, там есть readynessprobe и livenessProbe по сути можно проверить порт, опросить по http, или даже запустить скрипт.
    С учётом механизма dependencies можно красиво настроить сервисы.


    1. nonname
      03.06.2019 15:07

      Вот мне тоже показалось что автор пытается из жигулей 6й модели сделать комфортабельный представительский седан.


      1. gecube
        03.06.2019 15:13

        Мне кажется проверка готовности должна быть сделана в docker-compose

        т.е. фактически хелсчеки — это обязанность самого приклада, а не клиента, который к нему цепляется.


        1. mapcuk
          03.06.2019 20:37

          справедливое замечание, но тут такой сценарий, что запуская пачку микросервисов docker-compose, все сервисы стартуют одновременно, например postgres может быть ещё не готов, а другие сервисы уже успели сделать retry_connection несколько раз и отвалиться или для интеграционных тестов я не хочу тратить время на обвязку в виде retry-wrapper-ов.
          То есть всё это можно порешать, но хочется чтоб интрумент позволял хотя бы запускать в нужной последовательности.
          Ещё раз, это про локалхост.


      1. azirumga Автор
        03.06.2019 20:28

        nonname, ну почему сразу седан — так жизнь облегчить немного.
        На самом деле, согласен со всеми, кто говорит, что wait-for-it это костыль. Зацепился за него только по тому, что сразу не заработало, стало интересно раскопать.


        1. gecube
          03.06.2019 20:30

          Пробуем!
          И к сожалению, ничего не получаем.

          Очень жаль, что не разобрались, почему не работает. Вообще странная история — новый entrypoint по идее не должен переопределять старый CMD, который в Dockerfile. Вот интересно даже


          1. azirumga Автор
            03.06.2019 20:35

            gecube, да, на самом деле это основной целью было возни с wait-for-it. Но, тем не менее, это так.


    1. azirumga Автор
      03.06.2019 20:33

      mapcuk, да. У нас на самом деле так и сделано. На проде стоит kubernetes, а для приёма от программеров мы сначала релиз раскатываем локально, через docker-compose.


  1. yatheo
    03.06.2019 20:12
    +1

    container_name: conteiner_a
    ну как тут можно было допустить ошибку в слове «container». я читаю статью, и кровь из глаз.
    извините за несодержательный и эмоциональный комментарий.


    1. azirumga Автор
      03.06.2019 20:13

      yatneo, да, каюсь, видел, думал потом поправлю…


  1. gecube
    04.06.2019 10:54

    azirumga поделюсь концептом как еще можно решить задачу.
    Итак. Входные данные. Есть контейнеризованное приложение. Пускай есть еще БД. И есть еще контейнер с миграциями. Как бы все готово к миграции в кубер. Хелсчеки на базе и на приложении тоже есть. Пишем такой компоуз-файл:


    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 хранилища это реализовать. Либо вызывать миграции руками (тогда оператор сам решает где и когда их применять).
    Из плюсов реализованной схемы — у вас в памяти нет "лишних" контейнеров. Из минусов — очередные костыли.


  1. vsantonov
    05.06.2019 18:53

    Мы используем поверх compose еще и portainer. Он позволяет при локальной инсталяции бесконечно перезапускать контейнер пока ему не станет хорошо, а хорошо контейнеру определяется healthcheck.