1. services: роли и контейнеры
Когда вы впервые открываете compose.yaml, легко попасть в ловушку: кажется, что services — это просто список контейнеров, которые Compose “поднимет”. Но правильнее думать иначе: service — это описание роли в окружении, а контейнер — лишь конкретная “копия” этой роли. Это как рецепт (service) и приготовленная пицца (container): рецепт можно повторять, а пицца… ну, обычно недолго живёт.
Service vs image vs container — короткая карта понятий
У нас в курсе уже есть базовые слова Docker, но Compose добавляет ещё одно — service. Давайте сведём это в маленькую таблицу, чтобы мозг не перегревался:
| Термин | Что это | Как думать по-человечески |
|---|---|---|
| Image | Собранный шаблон/артефакт | “Коробка с приложением” (шаблон) |
| Container | Запущенный процесс по image | “Запущенная коробка” (экземпляр) |
| Service | Описание, как создавать контейнер(ы) | “Инструкция: как запускать эту роль в окружении” |
Compose оперирует именно описаниями. Он читает YAML и решает: “Окей, вот сервис app, ему нужен такой-то image или build, такие-то переменные, такие-то порты и такие-то volume-монты”.
Service в YAML: минимальный пример
Вот сервис, который берёт готовый образ и публикует порт:
services:
# Имя сервиса (role) — это то, на что вы будете опираться в Compose
app:
# Какой образ запускать (если build не указан)
image: docker-java-catalog-service
# Публикация порта на host-машину: host:container
ports:
- "8080:8080"
В этих 5 строках есть важная мысль: app — это имя сервиса, а не имя контейнера. Compose позже создаст контейнер для этого сервиса (и назовёт его по своим правилам), но ваша точка опоры — имя сервиса.
Почему container_name — плохая костыль-опора
У Compose есть настройка container_name, и новичку она кажется спасением: “О, я задам фиксированное имя контейнера и буду обращаться к нему!”. Но это как подписывать каждый шуруп в мебели маркером — один раз вроде помогает, а потом вы внезапно не можете собрать шкаф по инструкции.
Compose хорош тем, что он может управлять несколькими экземплярами сервиса, пересоздавать контейнеры, менять порядок создания и при этом сохранять предсказуемую модель. Если вы делаете container_name обязательной опорой, вы ломаете часть этой гибкости и часто создаёте себе будущие конфликты имён.
В нашем курсе мы держимся правила: опора — имя сервиса, а не ручное имя контейнера.
Пока в нашем compose.yaml сервис один, поэтому сеть легко принять за преждевременную теорию. Но ошибка localhost вылезает ровно в тот момент, когда рядом появляется хоть один сосед — база, кеш или внутренний клиент. Проще схватить этот сетевой минимум сейчас, пока нет шума от зависимостей, чем потом переучиваться уже на живом multi-container стенде.
2. networks: default network и связь сервисов
Когда у вас один контейнер, сеть кажется чем-то необязательным: пробросили порт — и всё. Но как только в окружении появляется хотя бы два участника, становится важно, как они общаются. Compose решает это так: он создаёт сеть для вашего окружения (обычно автоматически), подключает к ней сервисы и даёт им возможность находить друг друга по имени. Это не “магия”, это просто удобная автоматизация того, что вы могли бы делать руками.
Default network: сеть по умолчанию
Если в compose.yaml вы не описали networks, Compose всё равно создаст сеть проекта. Это называется default network: “сеть по умолчанию для этого Compose-окружения”. Внутри неё сервисы получают IP-адреса, а также записи во встроенном DNS.
Важный нюанс для начинающих: эта сеть — внутренняя, она про общение между контейнерами. Она не про “доступ с вашей машины”, для этого служит ports.
ports — это мост на host, а не “разрешение общаться внутри”
Команда и YAML-ключ ports очень часто вводят в заблуждение, потому что кажется: “Если я не указал ports, значит сервис недоступен”. Это правда только для доступа с вашей host-машины (например, из браузера на localhost). Внутри Compose-сети всё иначе: контейнеры общаются по внутренним портам.
Почувствуйте разницу:
ports: "8080:8080" означает “с host-машины можно зайти на 8080, и это попадёт в контейнер на порт 8080”.
Для общения внутри сети ports вообще не обязателен: сервисы могут ходить друг к другу напрямую, потому что они в одной сети.
Небольшая схема, чтобы это уложилось в голове:
flowchart LR
%% Доступ с host-машины: через опубликованный порт (ports)
Host["Ваша машина (host)"] -->|localhost:8080| App["service: app (container)"]
%% Доступ между контейнерами: по имени сервиса через встроенный DNS
Client["service: internal-client (container)"] -->|http://app:8080| App
subgraph ComposeNetwork["default network (Compose)"]
App
Client
end
Тут идея простая: “host → контейнер” идёт через ports, а “контейнер → контейнер” идёт через сеть и DNS по имени сервиса.
Явные сети: когда писать networks
На сегодняшнем дне мы не делаем сложные топологии. Но полезно увидеть, что явная сеть в YAML — это просто объявление ресурса и подключение к нему:
services:
app:
image: docker-java-catalog-service
networks:
# Явно подключаем сервис к указанной сети
- app-net
networks:
# Объявляем сеть как ресурс Compose-окружения
app-net:
Это 8 строк, которые говорят: “Создай сеть app-net и подключи к ней app”. Для одного сервиса это выглядит как лишняя писанина, и часто так и есть. Но если вы хотите дисциплинировать окружение или позже подключать несколько сервисов к одной и той же сети явно — это становится понятным инструментом. Для первого compose.yaml обычно хватает default network.
3. DNS Compose: имя сервиса как hostname и ловушка localhost
С docker run мы привыкли: “Есть контейнер — он изолирован, но я могу пробросить порт на localhost”. В Compose, где сервисов несколько, появляется новая привычка: если вы хотите обратиться к соседнему сервису, вы используете имя сервиса как host-имя. Это будет основной инженерный рефлекс в любом multi-container окружении, но мы тренируем его уже сейчас, пока всё простое и не больно.
Имя сервиса как адрес: http://app:8080
Допустим, у вас есть два сервиса: наш app (Catalog Service) и условный внутренний клиент, который ходит в него по HTTP. В Compose это выглядит так:
services:
# Сервис, который предоставляет HTTP API внутри Compose-сети
app:
image: docker-java-catalog-service
# Внутренний клиент (тоже контейнер), который будет ходить в app
internal-client:
image: curlimages/curl:8.7.1
environment:
# Важно: имя сервиса app используется как hostname в Compose DNS
APP_BASE_URL: http://app:8080
Обратите внимание на http://app:8080. Здесь app — это не DNS в интернете и не “hostname вашей машины”. Это запись во встроенном DNS Compose-сети: “сервис app доступен по имени app”.
IP-адрес контейнера может меняться (контейнер пересоздали — IP другой), но имя сервиса остаётся. Это и есть главная победа над хаосом: мы перестаём надеяться на стабильность IP и начинаем жить со стабильными именами. По той же причине база данных в Compose живёт по имени вроде db, а не по случайному IP и не по localhost.
localhost внутри контейнера — это “я сам”
Одна из самых частых ошибок новичка: “Я внутри контейнера, значит localhost — это моя машина”. Нет. localhost внутри контейнера — это тот же контейнер.
Если контейнер internal-client попробует сходить на http://localhost:8080, он будет искать HTTP-сервер внутри себя. А внутри себя у него, скорее всего, нет ничего, кроме curl и тихого отчаяния.
Мини-иллюстрация (не запускайте это как “рецепт”, просто почувствуйте разницу в адресах):
# localhost внутри контейнера = этот же контейнер (скорее всего, там нет HTTP-сервера)
curl http://localhost:8080/actuator/health
# имя сервиса app = соседний контейнер в Compose-сети (встроенный DNS)
curl http://app:8080/actuator/health
Если держать это правило в голове, вы экономите часы будущих “почему оно не коннектится?!”.
Немного про стабильность: имя вместо IP
Теоретически вы можете узнать IP контейнера и ходить по нему. Практически это ломается сразу же при пересоздании контейнера. Compose делает вам очень человеческую услугу: “Не думай про IP, думай про имена”. Это как телефонная книга: вы же не запоминаете, что у коллеги вчера был номер +1..., а сегодня +1... (ну, надеюсь), вы ищете его по имени.
Именно поэтому в Compose важны “чистые” и осмысленные имена сервисов: app, db, cache, broker и т.д. На нашем текущем этапе нам достаточно осознать принцип: имя сервиса должно быть пригодно для использования в URL.
4. volumes: данные отдельно от контейнера
Контейнер — штука “одноразовая” (в хорошем смысле). Его легко пересоздать, и это нормально. Но данные, которые вы хотите сохранить, обычно не должны исчезать вместе с контейнером. На прошлых днях мы уже говорили про writable layer, bind mounts и named volumes. Сейчас мы добавляем важный слой: в Compose тома — это часть описания окружения, и они живут рядом с сервисами в одном YAML.
Тома в Compose: два стиля
В Compose вы можете подключить хранилище к сервису через ключ volumes. Но само хранилище бывает двух типов: bind mount (путь на host-машине) и named volume (Docker-managed том). Их синтаксис похож, и это регулярно путает людей, поэтому давайте сравним их “по жизни”, а не только по YAML.
| Вопрос | Bind mount | Named volume |
|---|---|---|
| Что слева в записи X:Y | Путь на host-машине | Имя тома (ресурс Docker) |
| Видно ли данные в файловой системе проекта | Да, это обычная директория | Обычно нет, это управляемое хранилище |
| Переносимость между машинами | Хуже (пути, права) | Лучше (Docker сам управляет) |
| Когда удобно | Экспорт файлов наружу, работа с кодом/конфигами | Хранение состояния сервисов, которым “не важно быть видимыми” |
| Нужно ли объявлять на верхнем уровне volumes: | Нет | Да (как правило, чтобы явно задекларировать ресурс) |
Bind mount в Compose: наш “экспортный” сценарий
Для Container-Ready Catalog Service у нас есть экспорт каталога в файлы, и нам важно видеть результат на host-машине в ./data/exports. Значит, bind mount — логичный выбор:
services:
app:
image: docker-java-catalog-service
volumes:
# Bind mount: слева путь на host, справа путь в контейнере
- ./data/exports:/data/exports
Слева ./data/exports — путь в репозитории. Справа /data/exports — путь внутри контейнера. Это тот же принцип, что был у docker run -v ..., просто в YAML-форме.
Named volume в Compose: когда данные важны, но “снаружи не нужны”
Иногда вам важно, чтобы данные сохранялись между перезапусками, но вам не нужно открывать директорию в проводнике и смотреть файлы руками. Тогда named volume становится более удобным и менее “хрупким” (особенно кросс-платформенно):
services:
app:
image: docker-java-catalog-service
volumes:
# Named volume: слева имя Docker-ресурса, справа путь в контейнере
- exports-data:/data/exports
volumes:
# Объявляем named volume как ресурс Compose-окружения
exports-data:
Обратите внимание: появился верхнеуровневый раздел volumes. Он объявляет ресурс exports-data. Сервис затем лишь “подключает” его.
Важное правило: volume не соединяет сервисы, он хранит данные
Очень частая логическая ошибка звучит так: “Если я подключил volume, то сервисы как-то увидят друг друга через него”. Нет. Volume — это про файлы (состояние), network — про соединение (общение). Это две разные оси. Можно сказать, что сеть — это “телефон”, а том — это “шкаф с папками”. Через шкаф вы не позвоните.
Если вам нужно, чтобы сервисы видели друг друга, используйте сеть и имя сервиса. Если нужно сохранить файлы — volume/bind mount.
5. Три оси окружения: процессы, сеть, данные
На этом этапе у новичка часто возникает ощущение: “Compose — это просто YAML с кучей ключей, которые надо выучить”. На самом деле удобнее думать так: Compose описывает окружение в трёх измерениях. Есть процессы (services), есть коммуникация (networks) и есть состояние/файлы (volumes). Если вы научитесь читать любой compose.yaml по этим трём осям, файл перестанет выглядеть как шаманский свиток и станет обычной конфигурацией.
Мини-схема чтения compose-файла
Давайте разложим “как читать” на уровне мышления. Сначала вы находите services и понимаете, кто в окружении участвует. Затем смотрите, как они общаются (default network или явные networks). И только потом разбираетесь, где живут данные (volumes/bind mounts).
Вот компактный пример, где видны все три оси, но без перегруза:
services:
# Процесс/роль в окружении
app:
image: docker-java-catalog-service
# Доступ с host-машины в контейнер
ports:
- "8080:8080"
# Данные/файлы, которые должны жить вне контейнера
volumes:
- ./data/exports:/data/exports
Здесь явно описаны service и storage (bind mount), а network не описан — значит, будет default network. Это нормально. Даже если у нас пока один сервис, Compose всё равно создаёт “контейнерную среду”, и в неё позже удобно добавлять соседей без переписывания мира.
Имя сервиса важно уже сейчас, даже когда сервис один
Сегодня в первой версии compose.yaml у нас будет один сервис (наш app). Можно спросить: “А зачем мне DNS и имя сервиса, если не с кем общаться?” Ответ простой: имя сервиса — это часть дисциплины. Вы называете сервис так, как хотите на него ссылаться в окружении. Даже если сейчас соседей нет, вы не хотите, чтобы завтра (когда появится второй участник окружения) пришлось переименовывать всё и ломать конфигурацию.
Если вы выберете имя app, это коротко, нейтрально и читаемо. Если выберете имя very-important-catalog-service-that-i-love, YAML станет длиннее, а жизнь — не лучше. Compose поощряет простые имена.
6. Типичные ошибки при работе с Compose
В Compose ошибки часто выглядят “как будто сломалось приложение”, хотя на самом деле вы просто неверно мысленно смоделировали окружение. Хорошая новость: эти ошибки типовые и лечатся не героизмом, а правильными словами в голове. Сейчас закрепим самые частые, чтобы на лекции 5 compose.yaml собирался спокойно, без “почему оно не открывается и почему я снова не сплю”.
Ошибка №1: обращаться к соседнему сервису через localhost.
Это классика. Внутри контейнера localhost означает “я сам”, а не другой контейнер и не ваша host-машина. В Compose для обращения к соседу используется имя сервиса: http://app:8080, http://some-service:1234 и т.д. Если в голове закрепить “service name = hostname”, эта ошибка исчезает почти полностью.
Ошибка №2: путать внутренний порт контейнера и опубликованный host-порт.
Запись ports: "9090:8080" означает “с host-машины ходим на 9090, внутри контейнера приложение слушает 8080”. Но внутри Compose-сети другой контейнер должен обращаться к соседу на внутренний порт, то есть http://app:8080, а не http://app:9090. Внутренний порт — это порт процесса в контейнере; host-порт — это лишь внешний “вход” с вашей машины.
Ошибка №3: думать, что service — это “один конкретный контейнер навсегда”.
Service — это описание. Контейнеры пересоздаются, обновляются, могут стартовать заново после изменений. Если вы начинаете привязываться к конкретному контейнеру как к “персонажу”, появляются странные ожидания вроде “почему поменялся IP?” или “почему контейнер пересоздался?”. Compose работает именно с описаниями, а контейнеры — заменяемые исполнители.
Ошибка №4: считать, что volume — это “способ связать сервисы”.
Volume — это хранилище. Он решает проблему данных и файлов, но не проблему сетевого общения. Общение — это networks и DNS. Если вы хотите, чтобы сервисы видели друг друга, думайте про сеть и имена сервисов. Если хотите сохранить файлы — думайте про bind mounts и named volumes. Смешение этих моделей создаёт очень странные конфиги.
Ошибка №5: использовать container_name как фундамент всего окружения.
На старте кажется удобным вручную назвать контейнер, чтобы “точно знать, как к нему обратиться”. Но в Compose правильнее опираться на имя сервиса и встроенный DNS. container_name часто ломает масштабирование, создаёт конфликты имён и превращает окружение в “ручной зоопарк”, от которого Compose как раз хотел вас спасти.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ