1. Проблема: depends_on не спасает
У нас уже есть рабочий app + postgres стенд: приложение ходит в БД по имени сервиса postgres, профиль postgres включается через env vars, а compose.yaml выглядит вполне правдоподобно. Следующая неприятность появляется не в конфиге, а во времени старта: стек поднимается, но иногда app всё равно падает в первые секунды с ошибкой подключения к БД.
На этом этапе у начинающего Docker/Compose-пользователя обычно появляется искренняя (и честная) обида на реальность: вы добавили depends_on, Compose действительно запускает PostgreSQL раньше приложения, но иногда app всё равно падает на старте с ошибкой подключения к БД. Кажется, что это «случайная магия», но на самом деле это аккуратная инженерная разница между порядком запуска и готовностью сервиса.
Начнём с того, как чаще всего выглядит depends_on в первом приближении — в короткой записи. Она очень приятна: читается легко, выглядит как «правильная зависимость», а в голове сразу рождается ожидание «значит, Compose дождётся базы».
services:
app:
# Короткая запись: управляет порядком запуска контейнеров
depends_on:
- postgres
postgres:
# Остальные поля сервиса сейчас не важны; нужен сам факт, что postgres стартует раньше app
image: postgres:17
На уровне порядка запуска это правда работает: Compose сначала создаст и запустит контейнер postgres, потом — контейнер app. Но ключевой момент в том, что короткая запись depends_on не говорит ничего про момент, когда PostgreSQL станет готова принимать соединения. Она не обещает вам «подождать, пока база прогреется», «подождать, пока закончится инициализация», «подождать, пока поднимутся все внутренние процессы». Она говорит только: «Запусти контейнеры в таком порядке».
И здесь полезно немного «переобуться» в мышлении: Compose — это не «менеджер готовности приложения», а, скорее, «организатор запуска контейнеров». Он не читает ваши мысли и не знает, что именно означает «PostgreSQL готова» в терминах вашего приложения. Для Spring Boot в postgres-профиле это означает конкретную вещь: приложение должно суметь открыть JDBC-соединение, а часто ещё и выполнить первые шаги старта persistence-слоя. Но Compose в короткой записи depends_on про это не в курсе.
Чтобы совсем не было ощущения, что это “философия ради философии”, добавим контекст из нашего проекта. В compose.yaml у app обычно уже есть env vars, и всё выглядит абсолютно корректно. Хост правильный (postgres), порт правильный (5432), профиль правильный.
services:
app:
environment:
# Профиль Spring Boot, который включает подключение к PostgreSQL
SPRING_PROFILES_ACTIVE: postgres
# В Compose-сети "postgres" — это имя сервиса (DNS-имя), а не localhost
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
# Но это всё ещё только startup order, не readiness базы
depends_on:
- postgres
И вот после такой конфигурации особенно обидно видеть падение: «Да как же так? Всё же правильно написано!» Но «правильно написано» — это про конфигурацию, а сегодня мы говорим про тайминг. И тайминг — это отдельный источник боли, который не лечится переписыванием SPRING_DATASOURCE_URL.
2. Startup order и readiness
Перед тем как смотреть на логи и «симптомы», нам нужно поставить в голове две таблички-ярлыка. В Docker/Compose мире очень легко назвать оба состояния словом «готово» и потом удивляться, почему «готово» не работает. Сегодня мы разорвём эту привычку и будем различать startup order и readiness, потому что без этого следующая часть курса будет выглядеть как набор ритуалов.
В разговорном языке startup order — это «кто стартует раньше». То есть в каком порядке Compose запускает контейнеры. Это про то, кому первому сделали docker run в автоматическом режиме. Readiness (или ready-state) — это «кто уже реально способен выполнять свою работу». Для PostgreSQL это означает готовность принимать соединения и отвечать на них, а не просто факт, что процесс postgres где-то там уже начал подниматься.
Чтобы закрепить разницу, полезно сравнить эти понятия не «в вакууме», а через наблюдаемые сигналы.
| Что мы обсуждаем | Что это означает на практике | Как обычно проявляется |
|---|---|---|
| Startup order | Compose запустил контейнер | В docker compose ps контейнер быстро становится Up |
| Readiness | Сервис внутри контейнера готов обслуживать клиентов | По логам видно, что сервис дошёл до состояния “ready”, но это может занять секунды/десятки секунд |
Если хочется аналогии из жизни, то startup order — это «вы пришли на работу и включили кофемашину». Она включилась, лампочки загорелись — это Up. Но кофе ещё не готов: надо прогреться, набрать давление, промыть систему. Вот это и есть readiness. И если вы в этот момент попытаетесь “взять кофе” (то есть Spring Boot попытается открыть JDBC-соединение), получите примерно тот же результат, что и наша app-контейнерная жизнь: разочарование и ошибки.
Важно также понять психологический момент: статус Up часто воспринимается как «работает». Но Up — это всего лишь «контейнерный процесс жив». Он ничего не обещает про протоколы, порты, готовность, успешные соединения и внутреннюю инициализацию сервиса.
3. Гонка старта app + postgres в реальности
Теперь давайте посмотрим на самый типичный сценарий. Он неприятен именно тем, что иногда “сам проходит”, а иногда — ломается. И из-за этого создаётся ощущение, что Compose «рандомный». На самом деле рандомный не Compose — рандомный тайминг старта двух процессов в разных контейнерах. Это и называется startup race condition: гонка на старте, где победитель определяется миллисекундами и настроением ноутбука.
Представьте последовательность событий при docker compose up. Compose запускает PostgreSQL, почти сразу считает, что контейнер «поднят», и переходит к запуску app. Но PostgreSQL внутри контейнера в этот момент может ещё быть в стадии «поднимаю систему, инициализирую директории данных, прогреваю внутренности, стартую слушатели». Если это самый первый запуск с новым volume, база вообще может делать первичную инициализацию (initdb) — это заметно дольше.
Снаружи всё выглядит примерно так:
# Условный пример: контейнер postgres уже запущен, но сервис ещё не готов
postgres-1 | database system is starting up
app-1 | Failed to connect to database
Да, этот пример логов упрощённый, но он показывает главное: postgres уже «живёт», но ещё «не готов», а app уже честно попытался подключиться и получил отказ.
И вот классическая картина в статусах контейнеров (обратите внимание на смысловую ловушку: база Up, приложение Exited):
# Важно: "Up" у postgres не означает "готов принимать подключения"
NAME STATUS
postgres-1 Up
app-1 Exited (1)
Что здесь важно понять (и очень полезно проговорить вслух): контейнер app не «сломался навсегда». Он просто завершил свой основной процесс (Java-процесс Spring Boot), потому что на старте не смог выполнить обязательный шаг — подключиться к базе. Контейнеры не «живут сами по себе»: контейнер живёт ровно пока живёт главный процесс. Spring Boot упал → главный процесс завершился → контейнер завершился. Это нормальная, предсказуемая модель, и мы её уже встречали в теме про жизненный цикл контейнера и exit codes.
Чтобы картинка была ещё прозрачнее, полезно мысленно нарисовать простой таймлайн. Compose сделал всё, что обещал depends_on: запустил базу раньше. Но вот readiness он не обещал.
sequenceDiagram
participant C as docker compose
participant P as postgres container
participant A as app container
C->>P: start postgres
P-->>P: "init / warmup / start listeners"
C->>A: "start app because postgres container is started"
A-->>A: Spring Boot startup
A->>P: try JDBC connect
P-->>A: "not ready yet: connection refused / starting up"
A-->>C: "process exits 1"
В этом сценарии «плохая новость» ровно одна: стек стартует нестабильно. Но «хороших новостей» тут даже две. Первая — это не загадка, это объяснимо. Вторая — это лечится на уровне Compose-конфигурации, не переписыванием бизнес-кода и не “танцами” вокруг JDBC URL.
4. Раннее падение Spring Boot в postgres-режиме
Когда мы видим, что app падает почти сразу, есть соблазн думать: «Наверное, приложение слишком нервное, нельзя ли сделать, чтобы оно было терпеливее?» Но тут лучше включить инженерный режим: Spring Boot не «нервный», он просто стартует так, как задумано. В postgres-профиле он обязан поднять persistence-инфраструктуру, а для этого ему нужен живой и готовый PostgreSQL.
Очень упрощённо старт Spring Boot приложения в нашем режиме выглядит так: приложение поднимает контекст, собирает конфигурацию, создаёт DataSource, а затем пытается открыть соединение к базе. Если соединения нет, у приложения часто нет смысла продолжать: без базы оно не может обслуживать API, потому что его persistence-слой не готов.
На практике в логах это часто видно по характерным кускам: упоминание пула соединений (например, Hikari), затем ошибка подключения, затем “Application run failed”. Примерный образ (не слово в слово, а по смыслу):
# Типичный порядок: старт пула -> попытка подключения -> ошибка -> падение
app-1 | HikariPool-1 - Starting...
app-1 | org.postgresql.util.PSQLException: Connection refused
app-1 | ... Application run failed
И тут важно не перепутать два класса ошибок. Если вы видите “Connection refused” ровно в тот момент, когда PostgreSQL ещё пишет “starting up”, это отличный кандидат на race condition. Но если PostgreSQL уже давно пишет “ready to accept connections”, а приложение всё равно не подключается, то это уже похоже на настоящую конфигурационную проблему (не тот пароль, не тот хост, не тот порт, не тот профиль).
Ещё один нюанс, который новички часто упускают: первый старт PostgreSQL с новым volume почти всегда медленнее. База создаёт структуру данных, настраивает системные таблицы, поднимает внутреннюю инфраструктуру. На следующих стартах, когда volume уже заполнен, база обычно стартует быстрее. Отсюда и «плавающий» эффект: у вас вчера всё завелось, потому что всё было уже прогрето, а сегодня после down -v или на машине коллеги база стартует дольше — и гонка снова проигрывается.
5. Диагностика: гонка или конфиг
Когда сервис падает на старте, руки сами тянутся «починить конфиг»: переписать SPRING_DATASOURCE_URL, поменять порт, добавить localhost (пожалуйста, не надо), поменять profile, “на всякий случай” пересобрать образ. Проблема в том, что при race condition конфиг может быть правильным, а падение будет происходить всё равно — просто потому что база не успела.
Здесь нам нужна спокойная диагностика, без магии. И она, кстати, очень короткая: мы просто смотрим на два потока логов и проверяем, в каком состоянии база в момент, когда приложение упало. Самое полезное, что можно сделать — читать логи одновременно, синхронизируя события по времени. Именно поэтому в прошлом дне мы учились не смотреть только на app.
Минимальный набор команд (без флагового перегруза) обычно выглядит так:
docker compose ps # быстро понять, какие контейнеры Up/Exited
docker compose logs -f app postgres # сопоставить по времени логи app и postgres
Первой командой мы быстро видим “кто жив, кто умер”. Второй — видим хронологию: что писал postgres, когда стартовал app, и что именно случилось в момент падения.
И вот что даёт вам эту самую «инженерную развилку»:
Если в логах PostgreSQL вы видите, что она в момент падения приложения ещё говорит «starting up», то высока вероятность, что проблема — тайминговая. Если в логах PostgreSQL уже есть фраза, что она «готова принимать соединения», а приложение всё равно не подключается, то это уже больше похоже на сломанную конфигурацию (неправильный пользователь/пароль/БД, неправильный хост, неправильный профиль). Важно не “угадывать”, а смотреть на сигнал.
Ещё один трюк, который помогает мозгу не паниковать: при race condition очень часто повторный запуск стека (без смены конфигурации) внезапно «исправляет проблему». Это плохая новость с точки зрения воспроизводимости, но отличная — с точки зрения диагностики. Если у вас «то падает, то не падает» при одинаковой конфигурации, это почти всегда запах гонки на старте.
И наоборот: если у вас падает стабильно и каждый раз одинаково, то это уже чаще конфиг. В этом случае depends_on вообще ни при чём: он не мог “сломать” правильный URL, он мог только “не дождаться”.
Цель: сигнал готовности зависимости
Здесь важно зафиксировать главную мысль максимально практично. Нам нужен стек, который стартует предсказуемо. В предсказуемом стеке приложение стартует тогда, когда база не просто запущена, а реально готова. Это и есть readiness-модель: мы хотим связать запуск app с готовностью postgres, а не с таймингом.
Мы уже умеем произносить это без стыда и без “вроде работает”: depends_on даёт startup order, но не даёт readiness. Отсюда и startup race condition. Как только вы научились так формулировать, вы перестаёте лечить болезнь обезболивающим. Вы больше не “подбираете секунды”, не переписываете JDBC URL на удачу и не делаете из приложения «терпеливого котика» в бизнес-коде.
Теперь нужен наблюдаемый сигнал на стороне самой PostgreSQL: не просто факт, что контейнер запущен, а честная проверка “можно принимать соединения”. Без такого сигнала всё снова сведётся к угадыванию тайминга.
6. Типичные ошибки в этой точке
Ошибка №1: считать, что depends_on “ждёт базу”.
Это самая распространённая ловушка, потому что формулировка “depends_on” звучит как “зависит от”, а в голове автоматически дорисовывается “значит, дождётся”. На самом деле короткая запись depends_on управляет только порядком запуска контейнеров, а не готовностью PostgreSQL принимать соединения.
Ошибка №2: смотреть только на docker compose ps и делать вывод “Postgres же Up”.
Статус Up — это не “готова к работе”, а “процесс контейнера жив”. Для базы это особенно коварно: она может быть Up, но при этом ещё подниматься внутри. Если приложение упало, всегда смотрите на логи postgres в тот же момент времени, иначе вы расследуете дело, глядя только на одну половину улик.
Ошибка №3: лечить гонку переписыванием SPRING_DATASOURCE_URL.
При race condition конфигурация может быть полностью правильной, а проблема — во времени. Из-за этого попытки “поменять хост” часто приводят к ухудшению: вы добавляете реальную конфигурационную ошибку поверх тайминговой и получаете уже два разных источника падений, которые ощущаются одинаково.
Ошибка №4: воспринимать “один удачный запуск” как доказательство корректности модели.
Иногда стек завёлся, и хочется радостно закоммитить Compose как «рабочий». Но если запуск нестабилен и зависит от скорости машины, состояния volume и других случайностей, вы просто загнали проблему в будущее (и чаще всего — на ноутбук коллеги). Здесь важно думать не “завелось”, а “заводится предсказуемо”.
Ошибка №5: пытаться чинить readiness задержками вида sleep 10.
Такое “лечение” кажется простым, но оно не даёт гарантии: на одной машине 10 секунд хватит, на другой — нет, а потом всё снова превращается в лотерею. Если уж и решать проблему, то через наблюдаемый сигнал готовности сервиса, а не через угадывание времени.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ