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

    Введение


    Существует много статей про запуск контейнеров и написание 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.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 26

      +1

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

        +1

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


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

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

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

        +2

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

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

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

              0
              да, я прочитал все ваши сообщения. Спасибо, очень полезные замечания.
          +2
          Вы перекладываете задачу с больной головы на здоровую. «Приложение в контейнере B не умеет само проверять готовы данные или нет, оно сразу начинает с ними работать» — может допишите код? docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly
            0

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

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

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

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

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

                    0

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

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

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

                        0
                        gecube, да, на самом деле это основной целью было возни с wait-for-it. Но, тем не менее, это так.
                    0
                    mapcuk, да. У нас на самом деле так и сделано. На проде стоит kubernetes, а для приёма от программеров мы сначала релиз раскатываем локально, через docker-compose.
                    +2
                    container_name: conteiner_a
                    ну как тут можно было допустить ошибку в слове «container». я читаю статью, и кровь из глаз.
                    извините за несодержательный и эмоциональный комментарий.
                      0
                      yatneo, да, каюсь, видел, думал потом поправлю…
                      +1

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

                        0
                        gecube, спасибо. Кстати, про миграции. Может направите в нужном направлении.
                        У нас сейчас в кубере, развёрнута система, которая состоит из большого количества одинаковых приложений. Каждое приложение работает со своей базой. Они небольшие, но их много, около сотни пока.
                        Миграции, действительно, делаются в контейнере, более того, это делает непосредственно приложение. Механизма отката миграций при сбое нет.
                        Пытаюсь придумать схему безопасного наката обновлений на эту систему. Т.е. чтобы при проблемах, я мог откатиться на предыдущую версию.
                          0

                          Вопрос на самом деле очень глубокий. Потому что если рассматривать теоретически, то там много чего вылезает — и RTO/RPO, и переделка процессов, и организация бекапов, и необходимость 0 time downtime, и вопрос стоимости.
                          Если же рассмотреть чисто практически — для начала нужно научиться делать миграции не деструктивными. Т.е. если вы хотите удалить колонку, то очевидно, что роллбек на такую миграцию сделать будет нельзя. Типичный вариант как сделать красиво: переименовать колонку (вывести из эксплуатации приложением), подождать, пока все приложение перекатится на новую версию и потом уже когда-нибудь удалить колонку безопасно. Аналогично с переименованиями и пр.
                          Как черновой вариант — можно вообще на все забить и тупо снимать снапшот перед осуществлением миграции. Завалились? Ну, ок — восстанавливаемся. Но это работает только на маленьких базах.

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

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

                        Самое читаемое