Обратный прокси должен достучаться до nginx проекта. Приложение должно куда-то отправить письмо о регистрации так, чтобы оно не улетело реальному человеку. Обе задачи решаются тем, что контейнеры из разных compose-проектов оказываются в одной сети.
Механика простая, а ошибки в ней дают самые невнятные симптомы из всех, что бывают в Docker.
Внешняя сеть и сеть проекта
При запуске compose создаёт сеть с именем по проекту и подключает к ней все сервисы. Она принадлежит проекту, удаляется вместе с ним, а контейнеры из других compose-проектов к ней по умолчанию не подключены.
Внешняя сеть создаётся отдельно и живёт сама по себе:
docker network create proxy-dev
docker network create mail-devВ файле проекта она объявляется как уже существующая:
networks:
internal:
proxy-dev:
external: true
mail-dev:
external: trueКлючевое различие: внешнюю сеть compose не создаёт и не удаляет. Если её нет, запуск падает с ошибкой — и это правильное поведение. Автоматическое создание привело бы к тому, что при опечатке в имени поднимается новая пустая сеть, в которой никого нет, а всё выглядит запущенным.
Почему сетей две, а не одна
Соблазн сделать одну общую сеть на всё большой. Я так и начал.
Проблема выяснилась быстро. В одной сети все контейнеры всех проектов видят друг друга по именам. Двенадцать проектов — это двенадцать баз PostgreSQL, доступных друг другу. Пароли у меня в локальной среде одинаковые. Достаточно опечатки в имени хоста в строке подключения, чтобы приложение подключилось к чужой базе и прогнало на ней миграции.
Со мной этого не случилось, а вот с именем контейнера кеша — почти. Два проекта с одинаковым префиксом ключей в одном Redis дают взаимное вытеснение сессий, и симптом выглядит как «случайно разлогинивает».
Сейчас разделение такое:
proxy-dev— в неё смотрит только nginx каждого проекта;mail-dev— в неё смотрит только PHP каждого проекта;internal— база, кеш, PHP, nginx, никого извне.
База не смотрит ни в одну общую сеть и не публикует порт на хост. Пока контейнер не подключили к её внутренней сети вручную, из соседнего проекта до неё не дойти.
Перехватчик почты
Один контейнер на все проекты:
services:
mailpit:
image: axllent/mailpit
container_name: dev-mailpit
restart: unless-stopped
ports:
- "127.0.0.1:8025:8025"
networks:
- mail-dev
networks:
mail-dev:
external: trueПорт 1025 принимает почту внутри сети mail-dev, публиковать его на хост не нужно. Веб-интерфейс доступен на 8025 только с локальной машины. Образ Mailpit собирается и для amd64, и для arm64, поэтому отдельное указание платформы на Apple Silicon не требуется.
Проекты настраиваются одной строкой:
MAILER_DSN=smtp://dev-mailpit:1025В этой локальной конфигурации нет ни аутентификации, ни шифрования. Перехватчик принимает всё подряд и никуда не пересылает.
Что это даёт на практике
В моём ручном сценарии регистрация с подтверждением почты отлаживается за минуту вместо пятнадцати. Форма, письмо, ссылка, переход — весь путь виден целиком, не выходя из браузера.
Второе, менее очевидное: видно, какой HTML реально сформировало приложение. Веб-интерфейс показывает HTML-часть, текстовую и исходник с заголовками. Это не заменяет проверку в Outlook или Gmail — почтовые клиенты по-разному режут CSS, — но явные поломки видны до отправки заказчику.
Третье: становится видно лишнее. Дважды я обнаруживал, что одно действие отправляет два письма — дубликат приезжал из подписчика на событие, о котором я забыл. В проде это заметили бы пользователи.
И четвёртое, ради чего всё и затевалось: при локальном MAILER_DSN письмо не уйдёт настоящему человеку. При отладке импорта пользователей это разница между неприятностью и катастрофой. Поэтому значение DSN задаётся файлом локального окружения, а не общей конфигурацией проекта.
Три команды диагностики
Симптом всегда один: «контейнер не видит соседа». Разбираю в таком порядке.
Кто вообще в сети:
docker network inspect mail-dev --format '{{range .Containers}}{{.Name}} {{end}}'Половина случаев закрывается здесь. Если external network не существует, docker compose up завершится ошибкой. Если сеть есть, а контейнера в списке нет, сервис забыли подключить к сети либо контейнер не пересоздали после изменения compose-файла.
Разрешается ли имя:
docker compose exec webserver getent hosts dev-mailpitПусто — контейнеры в разных сетях. Docker разрешает имена только внутри общей сети, и никакой ошибки при этом не возникает, просто имя не находится.
Отвечает ли порт:
docker compose exec webserver php -r '
$fp = @fsockopen("dev-mailpit", 1025, $e, $s, 3);
echo $fp ? "порт открыт\n" : "нет связи: {$s}\n";
'Имя разрешается, а порт молчит — сервис не запустился или слушает не тот интерфейс.
Три команды покрывают практически все случаи. Держу их в заметке, потому что в момент, когда что-то не работает, вспоминать синтаксис network inspect не хочется.
Порядок запуска
Внешние сети должны существовать до запуска первого проекта. На новой машине об этом легко забыть.
Я держу это в скрипте, который запускает всю среду:
#!/usr/bin/env bash
set -euo pipefail
for net in proxy-dev mail-dev; do
docker network inspect "$net" >/dev/null 2>&1 || docker network create "$net"
done
docker compose -f Proxy/docker-compose.yml up -d
docker compose -f MailService/docker-compose.yml up -dПроверка перед созданием нужна, потому что повторное создание существующей сети — ошибка, а скрипт запускается при каждом старте машины.
Что бы поменял
Добавил бы третью общую сеть для инструментов наблюдения. Сейчас, когда нужно посмотреть на базу через клиент в контейнере, я подключаю его во внутреннюю сеть проекта руками, и это ровно та дырка в изоляции, которую я старался закрыть.
И закрепил бы Mailpit на конкретном release-теге или digest. Для локального инструмента latest удобен, но однажды он обновится вместе со стартом среды — не в тот момент, когда я собирался разбираться с почтой.