сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Docker

Сети Docker и compose

Сети контейнеров и compose

Поднял в одной сети два контейнера: в первом веб-сервер на порту 8000 (порт - номер, по которому на одной машине различают программы, слушающие сеть), из второго стучусь. По адресу localhost:8000 получаю «Connection refused», по имени первого контейнера - «ответ сервиса». Оба запроса ушли с одной машины, разница только в имени.

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

// Формулировки: «почему приложение не видит базу по localhost?», «что делает EXPOSE?», «depends_on не помогает - почему?»

localhost, имена и встроенный сервер имён

У контейнера свой сетевой стек: свои сетевые карты, пусть и виртуальные, свои адреса, своя таблица маршрутов. Слово localhost означает «эта машина», а машиной для процесса внутри контейнера считается сам контейнер. Поэтому обращение к localhost:8000 упирается в пустоту и мгновенно получает отказ в соединении - именно это я и увидел.

Обращаться к соседу нужно по имени. В сети, созданной руками, и в сети compose (compose - способ описать несколько контейнеров одним файлом и поднимать их вместе) работает встроенный сервер имён: контейнеру прописан адрес 127.0.0.11, и он переводит имя сервиса в адрес. У меня имя dk_api превратилось в 172.19.0.2, а шлюз сети, то есть адрес, через который эта сеть выходит наружу, - 172.19.0.1. Адрес контейнера в настройки прописывать нельзя, он выдаётся при запуске; имя же остаётся рабочим и после пересоздания.

// Важная деталь про сеть по умолчанию, в которую попадают контейнеры без указания сети. Там встроенного перевода имён НЕТ. Я проверил: обращение по имени даёт «wget: bad address», а тот же запрос по адресу 172.17.0.2 отрабатывает и возвращает ответ. Поэтому под приложение всегда создают свою сеть (compose делает это сам), и тогда имена работают.

// Публиковать порты между контейнерами одной сети не нужно: они видят друг друга напрямую по порту приложения. Публикация нужна лишь для входа снаружи.

сетевой стек
свой набор интерфейсов, адресов и маршрутов у каждого контейнера
встроенный сервер имён
служба демона на адресе 127.0.0.11, переводящая имена сервисов в адреса
сеть по умолчанию
сеть для контейнеров без явного указания; перевода имён в ней нет

EXPOSE ничего не открывает, а публикация обходит фаервол

EXPOSE в файле сборки - это подпись на образе: «приложение слушает такой-то порт». В описании образа она видна как {"8000/tcp":{}}, и на этом всё. Проверил: контейнер из образа с EXPOSE 8000, запущенный без публикации, с хоста не отвечает вовсе. Тот же образ с ключом -p 18080:8000 отвечает страницей. Наружу порт выводит только публикация при запуске.

Дальше начинается то, на чём горят прод-серверы. Публикация без указания адреса вешает порт на все интерфейсы. Замер на этой машине: с ключом -p 18080:8000 запрос на 127.0.0.1 вернул код 200, то есть сервер ответил нормально, и запрос на внешний адрес 147.45.234.86 тоже вернул 200 - сервис доступен из интернета. С ключом -p 127.0.0.1:18080:8000 локальный запрос вернул 200, а запрос на внешний адрес не прошёл вообще.

// Почему этого не видит фаервол. Фаервол - это набор правил ядра «что пускать в машину, а что нет»; ufw в Ubuntu - привычная надстройка, которая эти правила пишет за вас. А демон docker пишет их сам и кладёт свою подмену адреса в таблицу, которую ядро разбирает РАНЬШЕ той цепочки, где лежат запреты ufw. Цепочка тут - список правил, который ядро проходит по порядку. Я заглянул в правила этого сервера и вижу строки вида «порт 443 перенаправить на 172.18.0.4:443». То есть порт, «закрытый» фаерволом, остаётся доступным снаружи. Лечится тремя способами: публиковать с явным адресом 127.0.0.1, класть свои запреты в отдельную цепочку DOCKER-USER, которую демон читает первой, либо запретить демону трогать правила и написать их самому.

EXPOSE
подпись на образе о слушаемом порте; сама по себе ничего не открывает
публикация порта
ключ -p при запуске: пробрасывает порт контейнера на порт хоста
DOCKER-USER
цепочка правил фаервола, которую демон читает раньше своих; туда кладут свои запреты

Запущен не значит готов

Ключ depends_on выстраивает порядок запуска, но «запущен» и «готов принимать запросы» - разные события. Замер на настоящей базе: контейнер перешёл в состояние работающего через 309 миллисекунд, а первое соединение база приняла через 2182. Разрыв 1873 миллисекунды, и всё это время приложение, которое стартовало сразу, получало отказ подключения. Полторы секунды на пустой машине; на загруженной это десятки секунд.

Первый рабочий путь - проверка готовности у зависимости плюс условие «ждать, пока не здоров». Проверка готовности - это команда, которую демон периодически запускает внутри контейнера, и по её коду выхода решает, здоров ли сервис. Я включил её на той же базе: первую и вторую секунду состояние здоровья было starting, на третьей стало healthy, при том что состояние самого контейнера было running с первой секунды. Вот эти два разных поля и есть вся суть вопроса.

// Второй путь надёжнее: повторные попытки с нарастающей паузой в самом приложении. Причина простая: старт - далеко не единственный момент, когда базы нет. Она может уйти на перезапуск посреди рабочего дня, и сервис, который умеет ждать лишь однажды при запуске, снова свалится. Ожидание порта в стартовом скрипте прикрывает первый случай и совсем не помогает во втором.

depends_on
порядок запуска контейнеров; о готовности сервиса ничего не говорит
проверка готовности
команда, которую демон повторяет внутри контейнера, чтобы понять, здоров ли сервис
повтор с нарастающей паузой
приём в приложении: пробовать снова, увеличивая интервал между попытками

Как отвечать: «В compose приложение не подключается к базе по localhost. Почему?»

Потому что у каждого контейнера свой сетевой стек, и localhost внутри контейнера приложения означает сам этот контейнер, а не соседний. В сети compose работает встроенный сервер имён, поэтому в строке подключения указывают имя сервиса, например db и порт 5432. Адрес контейнера туда писать нельзя, он выдаётся заново при каждом запуске, а публиковать порт базы наружу для этого не требуется - внутри общей сети контейнеры видят друг друга напрямую. И сразу добавлю про сеть по умолчанию: если контейнеры запущены без своей сети, перевод имён там не работает вовсе, я это проверял - по имени отказ, по адресу ответ. Compose создаёт сеть сам, поэтому в нём имена работают из коробки.

Ответ объясняет причину, даёт правильную запись подключения и отсекает два обходных пути. Упоминание сети по умолчанию показывает, что человек это трогал руками.

На чём валятся

  • Пишут localhost в строке подключения к соседнему контейнеру.
  • Ждут, что EXPOSE откроет порт наружу. Это подпись на образе, а не публикация.
  • Закрывают порт в ufw и считают контейнер защищённым: публикация разбирается раньше и проходит мимо.
  • Прописывают в настройках адрес контейнера вместо имени сервиса.
  • Полагаются на depends_on как на ожидание готовности базы и падают на старте.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 7, остальные разбираются в тренажёре.

  1. #dvo_docker_network1 / 5
    В compose стоит depends_on: [db], но приложение падает на старте с ошибкой подключения. Почему?
    A)Порядок в depends_on обратный: сначала поднимается зависимый сервис
    B)Сеть compose поднимается позже контейнеров, DNS ещё не отвечает
    C)depends_on ждёт запуска контейнера, а не готовности сервиса внутри
    D)depends_on работает только в swarm, в обычном режиме он игнорируется
    показать ответ и разбор
    +C)depends_on ждёт запуска контейнера, а не готовности сервиса внутри

    // разбор: depends_on выстраивает порядок старта контейнеров, но контейнер «запущен» ещё до того, как СУБД внутри примет соединения. Правильных путей два: healthcheck у зависимости плюс condition: service_healthy, либо ретраи с бэкоффом в самом приложении. Второй вариант надёжнее: сервис должен переживать и падение базы посреди работы, а не только медленный старт.

  2. #dvo_docker_network2 / 5
    Запустили контейнер с -p 8080:80. На каком порту сервис доступен снаружи и куда идёт трафик?
    A)Снаружи 8080 хоста → на 80 контейнера (host:container)
    B)Снаружи 80, а 8080 — это внутренний порт приложения
    C)Оба порта на хосте, и трафик балансируется между ними
    D)8080 и 80 — это диапазон портов, который слушает контейнер
    показать ответ и разбор
    +A)Снаружи 8080 хоста → на 80 контейнера (host:container)

    // разбор: Формат -p HOST:CONTAINER. -p 8080:80 значит: обращения на порт 8080 хоста docker пробрасывает (DNAT) на порт 80 внутри контейнера, где слушает приложение. Снаружи, стало быть, 8080. Без публикации (-p/-P) порт контейнера доступен только внутри docker-сети. Полезно биндить на конкретный интерфейс: -p 127.0.0.1:8080:80, чтобы не открыть наружу.

  3. #dvo_docker_network3 / 5
    Два контейнера, запущенные через docker run на дефолтном bridge, не находят друг друга по имени. В compose то же самое работает. Почему?
    A)docker run требует --privileged, чтобы контейнеры нашли друг друга
    B)Compose открывает лишние порты, поэтому имена и резолвятся
    C)Имена резолвятся, только если контейнеры на разных хостах
    D)Резолв по имени есть лишь в user-defined сети; compose её создаёт
    показать ответ и разбор
    +D)Резолв по имени есть лишь в user-defined сети; compose её создаёт

    // разбор: Встроенный DNS docker резолвит контейнеры по имени только в user-defined сетях. На legacy default bridge разрешения имён нет (исторически там был --link). docker compose под каждый проект создаёт свою пользовательскую сеть, поэтому сервисы находят друг друга по имени из коробки. Для docker run: создать сеть (docker network create) и запускать контейнеры в ней (--network).

  4. #dvo_docker_network4 / 5
    Отладочный контейнер должен видеть сеть приложения как своё окружение: тот же localhost, те же порты. Как это делается одним флагом?
    A)Запустить оба контейнера с --privileged в одной подсети
    B)Общий сетевой namespace: --network=container:<app>
    C)Пробросить все порты приложения через -p в отладочный контейнер
    D)Использовать --network=host сразу для обоих контейнеров
    показать ответ и разбор
    +B)Общий сетевой namespace: --network=container:<app>

    // разбор: --network=container:<name> помещает новый контейнер в сетевой namespace существующего: у них общий сетевой стек, один localhost и одни порты — отладочный видит сервисы приложения как локальные (127.0.0.1). Это ровно та модель, по которой в Kubernetes контейнеры одного пода делят сеть. Удобно для сетевой диагностики без установки инструментов в основной образ.

  5. #dvo_docker_network5 / 5
    Для чего нужен docker compose?
    A)Собрать несколько образов в один
    B)Описать и поднять несколько связанных контейнеров одним файлом
    C)Объединить контейнеры на разных серверах в кластер
    D)Сжать образ перед отправкой в реестр
    показать ответ и разбор
    +B)Описать и поднять несколько связанных контейнеров одним файлом

    // разбор: В одном YAML описывают сервисы, их образы, порты, тома и сети — дальше всё поднимается командой up. Работает это в пределах одной машины: для нескольких серверов берут оркестраторы вроде Kubernetes.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.