Хаб поднялся, приложение публикует, локально всё приходит. Выкатываем за обратный прокси — поток обрывается примерно через минуту. Или не начинается вовсе, а в консоли браузера бесконечные переподключения без единой ошибки.
Ниже — четыре частые причины внутри nginx. Если они исключены, проверять нужно следующий слой: внешний прокси, авторизацию, CORS и сам хаб.
Место хаба в схеме
браузер ──▶ Traefik ──▶ nginx проекта ──▶ хаб Mercure
│
└──▶ PHP-FPMХаб — отдельный контейнер, наружу напрямую не смотрит. Браузер обращается к нему по тому же домену, что и к приложению, по пути /.well-known/mercure, а nginx решает, куда направить.
Схема с отдельным поддоменом для хаба тоже рабочая и даже проще в настройке. Я выбрал общий домен, потому что тогда кука с токеном подписчика уходит на хаб автоматически, без возни с междоменными запросами.
Сервис в compose
mercure:
image: dunglas/mercure:${MERCURE_VERSION}
container_name: ${PROJECT_NAME}_mercure
volumes:
- ./.docker/mercure/Caddyfile:/etc/caddy/Caddyfile
- mercure_data:/data
environment:
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_PUBLISHER_JWT_KEY}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_SUBSCRIBER_JWT_KEY}
networks:
internal:
aliases:
- mercure-upstreamMERCURE_VERSION у меня содержит проверенный тег, а не latest: обновление хаба тогда остаётся отдельным видимым изменением. Псевдоним в сети нужен, чтобы конфигурация nginx не знала имени проекта и переносилась между проектами без правок.
Том для данных — под журнал событий, из которого догоняются пропущенные обновления при переподключении. Без него после перезапуска хаба история теряется.
Caddyfile
Образ построен на Caddy, и его конфигурация выглядит непривычно:
{
{$GLOBAL_OPTIONS}
}
{$CADDY_EXTRA_CONFIG}
:80 {
log
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
cors_origins {$MERCURE_CORS_ORIGINS}
anonymous
subscriptions
transport bolt {
path /data/mercure.db
size 1000
cleanup_frequency 0.3
}
}
respond /healthz 200
respond "Mercure hub"
}anonymous разрешает подписку без токена. Нужен только для публичных топиков; если все топики приватные, эту директиву надо убрать — иначе любой может подписаться на публичную часть, о существовании которой вы не знали.
subscriptions включает web API подписок и служебные события об открытии и закрытии подписок. В этом проекте они нужны только при отладке, поэтому на проде директиву убираю.
transport bolt настраивает журнал событий. Параметр size=1000 хранит до тысячи обновлений; сколько это во времени, зависит от фактической частоты публикаций. Когда нужное событие уже вытеснено, догнать поток через Last-Event-ID не получится.
Слушает хаб на 80 без TLS, потому что снаружи он закрыт nginx. Шифровать трафик внутри сети Docker незачем.
Два ключа
Ключ издателя проверяет токены приложения, ключ подписчика — токены браузера. Mercure допускает один общий ключ, но на проде я разделяю их.
Сам подписной JWT не раскрывает секрет и без claim mercure.publish не даёт права публикации. Риск в другом: компрометация общего HMAC-ключа позволяет подписывать токены обеих ролей. С разными ключами утечка ключа подписчика не открывает публикацию.
Генерация:
openssl rand -base64 48Оба ключа лежат в .env и никогда не попадают в репозиторий. При компрометации ключа подписчика меняется он один, и это не требует перевыпуска токенов публикации.
Проксирование потока в nginx
Вот четыре настройки nginx, с которых я начинаю проверку обрывов.
location /.well-known/mercure {
proxy_pass http://mercure-upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}Буферизация. Главная причина. Nginx по умолчанию копит ответ и отдаёт его целиком. Для потока это означает, что события накапливаются и не доходят, пока буфер не заполнится. Симптом: события приходят пачками с задержкой в минуты или не приходят вовсе.
Тайм-аут чтения. По умолчанию 60 секунд. Поток, в котором ничего не происходит минуту, обрывается. Отсюда классическое «работает, пока кликаешь, отваливается, когда отвернулся».
Сжатие. Фильтр gzip может задерживать небольшие порции потока в своих буферах. Для SSE я отключаю его на этом location, чтобы не добавлять ещё один источник задержки поверх прокси.
Версия HTTP и заголовок соединения. Nginx по умолчанию ходит к upstream по HTTP/1.0. SSE может работать и так, но для долгого проксируемого соединения я явно включаю HTTP/1.1 и убираю hop-by-hop заголовок Connection, чтобы результат не зависел от значений по умолчанию.
Я держу этот блок целиком: отдельные директивы отвечают за разные симптомы, а явная конфигурация проще проверки догадок о настройках по умолчанию.
Проверка потока из консоли
Первое, что нужно уметь, когда что-то не работает:
curl -N -H "Authorization: Bearer $TOKEN" \
"https://app.test/.well-known/mercure?topic=https://app.test/projects/1/issues"Флаг -N отключает буферизацию вывода на стороне curl. Без него небольшое событие может появиться с задержкой и отправить диагностику не в ту сторону.
Соединение должно повиснуть и молчать. Это правильное поведение. Публикуем событие из другого терминала:
curl -X POST https://app.test/.well-known/mercure \
-H "Authorization: Bearer $PUBLISHER_TOKEN" \
-d 'topic=https://app.test/projects/1/issues' \
-d 'data={"hello":"world"}'В первом терминале должно появиться событие. Если появилось — хаб и nginx в порядке, проблема в приложении или в браузере.
CORS и куки
Если хаб доступен с того же origin, что и приложение, CORS не участвует и настраивать его для браузера не нужно.
На отдельном поддомене нужны список разрешённых origin в конфигурации хаба и передача учётных данных в клиенте. Кука должна охватывать хост хаба: host-only cookie приложения на соседний поддомен не отправится, поэтому область Domain задают осознанно.
SameSite=Strict само по себе не мешает запросу между HTTPS-поддоменами одного registrable domain: для браузера это один site. Если хаб находится уже на другом site, тогда для cookie нужны SameSite=None; Secure. Публичные топики работают и без cookie, поэтому ошибку в её области легко принять за частичную поломку хаба.
Я выбрал общий домен именно чтобы не разбираться в этом каждый раз.
Что не оставлять на проде
Директива anonymous, если все топики приватные. С ней достаточно знать имя топика, чтобы читать его без токена.
Директива subscriptions, если приложению не нужны web API и события подписок: лишнюю поверхность лучше не открывать.
Пустой список разрешённых источников или звёздочка в нём.
Ключи из примеров. Строка !ChangeThisMercureHubJWTSecretKey! встречается в проде чаще, чем хочется думать.
И режим разработки Caddy, если он был включён для самоподписанных сертификатов — на проде TLS терминируется прокси, и хабу он не нужен вовсе.
Итог
Настройка занимает час, из которого сорок минут уходит на четыре группы параметров nginx. Написать их сразу правильно можно только один раз посмотрев на список — я держу его в шаблоне конфигурации и копирую между проектами, потому что помнить, почему именно proxy_set_header Connection "", нет никакого желания.