Использую Unix-сокет между Caddy и Discourse вместо TCP, но время от времени получаю ошибку «unix:»

@Falco @satonotdead

Мне удалось это исправить. Я подождал достаточно долго, чтобы убедиться, что проблема действительно решена, и, похоже, так оно и есть.

Настройка

Discourse работает в Docker и предоставляет nginx через Unix-сокет (/var/discourse/shared/standalone/nginx.http.sock). Caddy стоит перед ним в качестве обратного прокси и передает реальный IP клиента через заголовок X-Real-IP.

Пришлось изменить две вещи: конфигурацию nginx внутри контейнера и способ запуска пересборки.

1. app.yml

## Плагины добавляются сюда
## см. https://meta.discourse.org/t/19157 для подробностей
hooks:
  # Этот блок after_code — просто мой собственный список плагинов, он не имеет
  # отношения к исправлению. Оставьте здесь то, что у вас уже есть.
  after_code:
    - exec:
        [...]

  # Вот важная часть.
  #   1. Записывает http-level map, который превращает литерал "unix:" в 127.0.0.1.
  #      Префикс 00- заставляет nginx загружать этот файл перед discourse.conf.
  #   2. Перезаписывает discourse.conf так, чтобы X-Forwarded-For использовал
  #      смэппедную переменную вместо сырого $remote_addr.
  #   3. nginx -t прерывает сборку, если результат недействителен.
  after_web_config:
    - exec: >-
        printf 'map $remote_addr $safe_remote_addr {\n  "unix:" 127.0.0.1;\n  default $remote_addr;\n}\nreal_ip_header X-Real-IP;\n'
        > /etc/nginx/conf.d/00-safe-remote-addr.conf
    - exec: >-
        sed -i 's/X-Forwarded-For \$remote_addr;/X-Forwarded-For $safe_remote_addr;/g'
        /etc/nginx/conf.d/discourse.conf
    - exec: nginx -t

Критически важным кодом является то, что находится внутри тела after_web_config.

2. Блок Caddyfile

Скрипт ниже управляет режимом обслуживания с помощью флага-файла, поэтому вашему сайту-блоку нужно знать о нем:

forum.example.com {
    request_header X-Real-IP {remote_host}

    root * /var/caddy/flags
    @maintenance file maintenance.flag
    handle @maintenance {
        respond "Идут технические работы. Мы вернемся через несколько минут." 503
    }

    handle {
        reverse_proxy unix//var/discourse/shared/standalone/nginx.http.sock
    }
}

Путь к флагу в скрипте и файл, который ищет матчер @maintenance, должны быть одним и тем же файлом. Если вы переименуете одно, переименуйте и другое, иначе режим обслуживания молча никогда не включится.

Если тот же экземпляр Caddy обслуживает и другие приложения, используйте флаг, который соответствует только блоку форума, иначе пересборка Discourse выведет из строя и эти другие приложения.

3. Скрипт пересборки

Я больше не использую ./launcher rebuild app напрямую. Я использую discourse-rebuild.sh:

#!/bin/bash
#
# discourse-rebuild.sh — Пересобирает контейнер Discourse, не оставляя за собой
# осиротевших запросов и без ошибки `invalid input syntax for type inet: "unix:"`.
#
# КОНТЕКСТ
#   Discourse работает в Docker и предоставляет nginx через Unix-сокет
#   (/var/discourse/shared/standalone/nginx.http.sock). Caddy действует как
#   обратный прокси перед ним и передает реальный IP клиента через X-Real-IP.
#
#   Команда `rebuild` уничтожает контейнер и пересоздает сокет с новым inode.
#   Во время этого перехода есть два проблемных временных окна:
#
#     1) Caddy сохраняет состояние от предыдущего сокета, пока его не перезагрузят.
#     2) nginx начинает принимать соединения сразу после запуска, но Unicorn
#        требуется еще ~15 секунд, чтобы начать их обслуживать.
#
#   Запрос, попавший в любое из этих окон, может прийти без X-Real-IP. Тогда
#   $remote_addr остается с литералом "unix:", который PostgreSQL отклоняет при
#   вставке в inet-колонку -> HTTP 500.
#
# ЧТО ДЕЛАЕТ СКРИПТ
#   1. Создает флаг-файл, который переводит сайт в режим 503 (Caddy проверяет его
#      при каждом запросе, поэтому это вступает в силу мгновенно и без перезагрузки).
#   2. Ждет, пока ничего, кроме самого nginx, не будет удерживать сокет открытым.
#   3. Пересобирает контейнер.
#   4. Опрашивает /srv/status через сокет, пока Unicorn не ответит 200.
#   5. Перезагружает Caddy, чтобы он подхватил новый сокет, и удаляет флаг.
#   6. Запускает наблюдателя, который логирует любые оставшиеся хиты "unix:".
#
#   Если пересборка не удалась, или Discourse так и не ответил в течение окна
#   опроса, флаг НЕ удаляется: сайт намеренно остается в режиме обслуживания, чтобы
#   сломанный контейнер никогда не был доступен. Верните его в работу вручную:
#       rm -f /var/caddy/flags/maintenance.flag
#
# ТРЕБОВАНИЯ
#   - Установлен lsof и права root.
#   - FLAG ниже должен указывать на тот же самый файл, который ищет матчер
#     @maintenance в Caddyfile.
#   - request_header X-Real-IP {remote_host} в том же блоке Caddyfile.
#
# ИСПОЛЬЗОВАНИЕ
#   ./discourse-rebuild.sh
#
# ЧТОБЫ УВИДЕТЬ, ЧТО ЗАХВАТИЛ НАБЮДАТЕЛЬ
#   cat /var/log/unixip-hits.log
#

set -e

FLAG=/var/caddy/flags/maintenance.flag
SOCK=/var/discourse/shared/standalone/nginx.http.sock

cleanup() {
  local code=$?
  systemctl reload caddy
  if [ $code -eq 0 ]; then
    rm -f "$FLAG"
    echo "✅ Пересборка завершена. Сайт в сети."
  else
    echo "⚠️  Пересборка не удалась (код выхода $code). Сайт все еще в режиме обслуживания."
    echo "    Проверьте его, и когда будет готово: rm -f $FLAG"
  fi
}
trap cleanup EXIT

mkdir -p "$(dirname "$FLAG")"
touch "$FLAG"
echo "🔧 Режим обслуживания включен. Ожидание активных запросов..."

# Грубый эвристический метод: подсчитать процессы, удерживающие сокет открытым,
# и ждать, пока останется только слушатель. До 30 секунд.
for i in $(seq 30); do
  n=$(lsof -t "$SOCK" 2>/dev/null | wc -l || echo 0)
  [ "$n" -le 1 ] && break
  sleep 1
done

/var/discourse/launcher rebuild app

# До 60 попыток: около 2 минут сна, больше, если какой-либо curl достигнет своего
# таймаута в 5 секунд.
echo "⏳ Ожидание ответа от Discourse..."
status=000
for i in $(seq 60); do
  status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
    --unix-socket "$SOCK" http://localhost/srv/status 2>/dev/null || echo 000)
  [ "$status" = "200" ] && break
  sleep 2
done

if [ "$status" != "200" ]; then
  echo "⚠️  Discourse так и не ответил в течение окна опроса (последний код статуса: $status)."
  exit 1
fi

echo "✅ Discourse готов примерно через $((i*2))с."

systemd-run --unit=unixip-watch --collect \
  /bin/bash -c "docker exec app tail -F /var/log/nginx/access.log | grep --line-buffered 'unix:' >> /var/log/unixip-hits.log"

Что на самом деле делает скрипт

  1. Переводит сайт в режим обслуживания через флаг-файл Caddy.
  2. Ждет, пока сокет не станет «тихим», затем запускает ./launcher rebuild app.
  3. Опрашивает /srv/status через сокет, пока Unicorn не ответит 200. Это критически важная часть: именно она не дает запросам доходить до nginx, пока Unicorn еще запускается.
  4. Перезагружает Caddy (на всякий случай, хотя оказалось, что это не критично).
  5. Удаляет флаг обслуживания только если этот опрос был успешен.

Спустя несколько недель и несколько пересборок я больше не видел этой ошибки.

P.S.: Я и правда не знаю, что я там наделал.