Двенадцать проектов на портах с 8080 по 8091. Закладки называются «8083 — это который?». Половина браузерных возможностей требует защищённого контекста и не работает. Куки с флагом Secure не ставятся, а без него не воспроизвести поведение прода.
Один обратный прокси решает всё это разом и настраивается за вечер.
Прокси вместо публикации портов
Идея: ни один проект не публикует порты на хосте. Внешние порты 80 и 443 занимает только прокси. Проекты подключаются к его сети, и он раздаёт их по доменным именам.
Ниже сокращённый фрагмент конфигурации; логирование и ограничения ресурсов опущены. Версия 3.1 закреплена для воспроизводимости этого примера, а не как рекомендация новой установки: перед обновлением внутри ветки 3.x я сверяю изменившиеся параметры с документацией.
services:
traefik:
image: traefik:v3.1
container_name: dev-traefik
restart: unless-stopped
command:
- --api.dashboard=true
- --api.insecure=true
- --providers.file.directory=/etc/traefik/dynamic/projects
- --providers.file.watch=true
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.forwardedHeaders.insecure=true
ports:
- "80:80"
- "443:443"
- "127.0.0.1:8080:8080"
volumes:
- ./dynamic:/etc/traefik/dynamic:ro
- ./certs:/certs:ro
networks:
- proxy-dev
- mail-devФлаг api.insecure открывает dashboard на порту 8080 без аутентификации, поэтому порт привязан только к 127.0.0.1. Это локальное упрощение; для доступного по сети dashboard нужен отдельный router к api@internal и защита.
Конфликты портов приложений исчезают как класс. Можно поднять все двенадцать проектов одновременно, и они не подерутся.
Файловый провайдер, а не метки Docker
Traefik умеет читать настройки прямо из меток контейнеров, и почти все примеры показывают именно это. Я выбрал файлы, и вот почему.
Метки живут в docker-compose.yml проекта. Значит, каждый проект знает про прокси, про имя сети и про свой домен. При копировании набора в новый проект нужно поправить четыре метки, и рано или поздно одну из них я забуду.
С файловым провайдером проект не знает о прокси ничего. Он просто подключает nginx к общей сети. Вся маршрутизация описана снаружи, в каталоге прокси:
# dynamic/projects/teamspace.yml
http:
routers:
teamspace:
rule: "Host(`teamspace.test`)"
entryPoints: [websecure]
service: teamspace
tls: {}
services:
teamspace:
loadBalancer:
servers:
- url: "http://teamspace-app_nginx:80"Небольшой файл на проект лежит в одном месте. Видно все маршруты сразу, и добавление проекта не требует его пересборки.
Флаг наблюдения за каталогом означает, что новый файл подхватывается за секунду. Перезапускать прокси не нужно.
Сертификаты для .test
Домен .test зарезервирован стандартом для тестирования и не может быть делегирован в обычном DNS. Это отличает его от .dev, который принадлежит Google и принудительно требует HTTPS, и от .local, конфликтующего с mDNS.
Сертификат выпускается собственным центром сертификации. Порядок такой: сначала корневой сертификат, потом сертификат на подстановочное имя, потом установка корневого в систему.
brew install openssl@3
OPENSSL="$(brew --prefix openssl@3)/bin/openssl"
# корневой центр — один раз
"$OPENSSL" req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout certs/rootCA.key -out certs/rootCA.crt \
-subj "/CN=Dev Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# сертификат на *.test
"$OPENSSL" req -newkey rsa:2048 -nodes -keyout certs/test.key -out certs/test.csr \
-subj "/CN=*.test"
"$OPENSSL" x509 -req -in certs/test.csr -CA certs/rootCA.crt -CAkey certs/rootCA.key \
-CAcreateserial -out certs/test.crt -days 825 -sha256 \
-extfile <(printf "subjectAltName=DNS:*.test,DNS:test\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n")Расширение с альтернативными именами обязательно: современные браузеры не используют Common Name как замену SAN. Для TLS-сертификата также явно задано назначение serverAuth, которого требует проверка Apple.
Срок 825 дней здесь — не универсальный браузерный предел, а выбранный срок для локального сертифика от собственного доверенного центра. Ограничения для публично доверенных TLS-сертификатов на такую локальную CA не переносятся.
Корневой сертификат ставится в системное хранилище:
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain certs/rootCA.crtСовременный Firefox на macOS умеет автоматически доверять сторонним корневым сертификатам из системного хранилища, но настройка может быть отключена. Если сертификат не принят, сначала проверяю параметр автоматического доверия сторонним root CA; отдельный импорт нужен только когда эта интеграция не используется.
Разрешение имён
Домены .test должны куда-то указывать. Два варианта.
Простой — по строке в /etc/hosts на каждый проект. Работает, требует правки при добавлении проекта, не поддерживает поддомены.
Правильный — локальный резолвер, отдающий все .test на петлевой адрес. На macOS я использую dnsmasq. Сначала он должен слушать локальный адрес и отвечать за зону:
brew install dnsmasq
echo 'address=/.test/127.0.0.1' >> "$(brew --prefix)/etc/dnsmasq.conf"
sudo brew services restart dnsmasqЗатем macOS направляет запросы зоны .test этому серверу через /etc/resolver/test:
nameserver 127.0.0.1После этого любое имя в зоне .test, включая выдуманное на ходу, разрешается автоматически. Это относится только к DNS: сертификат *.test покрывает teamspace.test, но не acme.teamspace.test. Для мультиарендных поддоменов нужен дополнительный SAN вроде *.teamspace.test или отдельный сертификат проекта.
Заголовки проксирования
Здесь приложение часто начинает собирать неверные ссылки.
PHP за прокси видит запрос как HTTP на порт 80. Генератор URL честно собирает http://teamspace-app_nginx/... — то есть внутреннее имя контейнера по незащищённому протоколу. Ссылки в письмах ведут в никуда, редиректы сбрасывают на HTTP, куки с флагом Secure не ставятся.
Лечится с двух сторон. Прокси передаёт заголовки о протоколе и хосте, а приложение им доверяет:
# Symfony, config/packages/framework.yaml
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']В переменной — подсеть Docker, а не конкретный адрес: он меняется при пересоздании сети.
Для Laravel то же самое делается через промежуточный слой доверенных прокси с указанием подсети.
Флаг forwardedHeaders.insecure в командной строке Traefik — только для локальной среды. На проде он означает доверие любому источнику заголовков, что позволяет подделать адрес клиента.
Когда маршрут не поднялся
Дашборд на порту 8080 показывает все маршрутизаторы и сервисы со статусом. Порядок разбора у меня такой.
Маршрута нет в списке вовсе — Traefik не прочитал файл. Обычно ошибка синтаксиса YAML; в логах прокси она видна одной строкой.
Маршрут ест, но помечен как ошибочный — не найден сервис. Причина обычно в опечатке в имени или ссылке на несуществующий сервис.
Маршрут в порядке, но браузер отдаёт «плохой шлюз» — прокси не достучался до контейнера. Причин две: контейнер не в общей сети или nginx внутри не слушает порт 80.
docker exec dev-traefik wget -qO- http://teamspace-app_nginx:80 | head -5Одна команда, и понятно, на чьей стороне проблема.
Отдельный случай: браузер ругается на сертификат для одного проекта, а для остальных всё хорошо. Значит, у маршрута не указан раздел TLS и он отвечает по HTTP на защищённой точке входа.
Чего это стоит
Один постоянно работающий контейнер, около 40 мегабайт памяти. Небольшой файл маршрута на каждый проект. Разовая настройка сертификатов.
Взамен: адреса вида teamspace.test вместо портов, рабочий HTTPS со всеми браузерными возможностями, поведение куки как на проде и возможность держать поднятыми все проекты сразу.
Единственное, к чему пришлось привыкнуть, — проект без записи в каталоге прокси просто недоступен. Забыл добавить файл, а nginx поднялся и логов не пишет, потому что до него никто не дошёл.