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

Логи показывают следующее:

PG::InvalidTextRepresentation (ERROR: invalid input syntax for type inet: "unix:" LINE 7: client_ip = 'unix:', ^ ) lib/mini_sql_multisite_connection.rb:109:in 'MiniSqlMult

Job exception: ERROR: invalid input syntax for type inet: "unix:" LINE 2: SET ip_address = 'unix:' ^  

При возникновении этой ошибки отображается страница «Oops - Error 500». Сначала я подумал, что виноват Caddy, поскольку он не передавал IP-адрес клиента в Discourse, поэтому я попробовал несколько различных конфигураций, но ни одна из них не решила проблему.

Стоит отметить, что это происходит нечасто. Ошибка возникает лишь изредка, и в таких случаях достаточно просто обновить страницу, чтобы сайт снова заработал. Затем он функционирует нормально довольно долго, прежде чем ошибка случайно появится снова.

Я не эксперт и использовал не Caddy, а Nginx на своём самохостинге, но могу ли я попросить у вас вашу фактическую конфигурацию шаблонов Caddy и Discourse в ./app/containers.yml?

Помните ли вы, что вы обычно делали перед возникновением ошибки? Я имею в виду, изменение настроек администратора, публикация, использование какого-либо плагина?

Извините, что не могу дать конкретную помощь по вашей проблеме, но я думаю, что ваши ответы могут добавить ценности к вашему вопросу для получения чёткого и быстрого ответа от сообщества.

Это означает, что у запроса/пользователя есть удалённый IP-адрес, указывающий на ваш сокет, что свидетельствует о неправильной конфигурации чего-то в вашей цепочке прокси.

@satonotdead @Falco

app.yml

templates:
  - "templates/postgres.18.template.yml"
  - "templates/redis.template.yml"
  - "templates/web.template.yml"
  
  - "templates/web.socketed.template.yml"
  
  - "templates/enable-ruby-yjit.yml"


## какие TCP/IP-порты должен открывать этот контейнер?
## Если вы хотите, чтобы Discourse разделял порт с другим веб-сервером, например Apache или nginx,
## ознакомьтесь с подробностями по ссылке https://meta.discourse.org/t/17247
expose:
  # - "66:80"   # http
  # - "66:443" # https

params:
  ## Какую ревизию Git должен использовать этот контейнер? (по умолчанию: latest)
  version: latest
  ## Максимальный размер загрузки (по умолчанию: 10m)
  upload_size: 150m
  
  db_default_text_search_config: "pg_catalog.english"

  ## Установите db_shared_buffers не более 25% от общей памяти.
  ## будет установлено автоматически при инициализации на основе обнаруженной оперативной памяти, или вы можете переопределить
  db_shared_buffers: "2048MB"

  ## может улучшить производительность сортировки, но увеличивает использование памяти на соединение
  #db_work_mem: "40MB"

  ## Какую ревизию Git должен использовать этот контейнер? (по умолчанию: tests-passed)
  #version: tests-passed

env:
  LC_ALL: en_US.UTF-8
  LANG: en_US.UTF-8
  LANGUAGE: en_US.UTF-8
  # DISCOURSE_DEFAULT_LOCALE: en

  ## https://meta.discourse.org/t/rescaling-the-server-which-configs-need-to-be-changed-unicorn-workers-memory-etc/252788
  ## Сколько одновременных веб-запросов поддерживается? Зависит от оперативной памяти и ядер процессора.
  ## будет установлено автоматически при инициализации на основе обнаруженных процессоров, или вы можете переопределить
  UNICORN_WORKERS: 8

  ## TODO: Имя домена, на которое будет отвечать этот экземпляр Discourse
  ## Обязательно. Discourse не будет работать с «голым» IP-адресом.
  DISCOURSE_HOSTNAME: example.com

  ## Раскомментируйте, если хотите, чтобы контейнер запускался с тем же
  ## именем хоста (опция -h), что указано выше (по умолчанию "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: Список адресов электронной почты, разделенных запятыми, которые будут назначены администраторами и разработчиками
  ## при первой регистрации, например 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: 'admin+discourse@example.com'

  ## TODO: SMTP-сервер, используемый для подтверждения новых аккаунтов и отправки уведомлений
  # SMTP ADDRESS обязателен
  # ВНИМАНИЕ: пароль SMTP должен быть заключен в кавычки, чтобы избежать проблем
  DISCOURSE_SMTP_ADDRESS: smtp.provider.com
  DISCOURSE_SMTP_PORT: 587
  DISCOURSE_SMTP_USER_NAME: noreply@example.com
  DISCOURSE_SMTP_PASSWORD: "***"
  #DISCOURSE_SMTP_ENABLE_START_TLS: true           # (необязательно, по умолчанию: true)
  DISCOURSE_SMTP_DOMAIN: example.com # (требуется некоторыми провайдерами)
  DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (необязательно, по умолчанию: peer, допустимые значения: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (по умолчанию: plain, допустимые значения: plain, login, cram_md5)

  ## Если вы добавили шаблон Lets Encrypt, раскомментируйте ниже, чтобы получить бесплатный SSL-сертификат
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## Адрес CDN (http или https) для этого экземпляра Discourse (настроен на получение данных)
  ## ознакомьтесь с подробностями по ссылке https://meta.discourse.org/t/14857
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## ID аккаунта и лицензионный ключ Maxmind для геолокации по IP-адресам
  ## ознакомьтесь с подробностями по ссылке https://meta.discourse.org/t/-/173941
  #DISCOURSE_MAXMIND_ACCOUNT_ID: 123456
  #DISCOURSE_MAXMIND_LICENSE_KEY: 1234567890123456

  # Принудительное использование HTTPS
  DISCOURSE_FORCE_HTTPS: true
  
  # Ограничения запросов
  DISCOURSE_MAX_REQS_PER_IP_MODE: none
  DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE: 12000
  
  DISCOURSE_MAX_DATA_EXPLORER_API_REQ_MODE: none
  DISCOURSE_MAX_DATA_EXPLORER_API_REQS_PER_10_SECONDS: 1000

  DISCOURSE_YJIT_ENABLED: true

## Контейнер Docker не сохраняет состояние; все данные хранятся в /shared
volumes:
  - volume:
      host: /var/discourse/shared/standalone
      guest: /shared
  - volume:
      host: /var/discourse/shared/standalone/log/var-log
      guest: /var/log
  - volume:
      host: /var/discourse/plugins
      guest: /var/plugins

## Плагины размещаются здесь
## ознакомьтесь с подробностями по ссылке https://meta.discourse.org/t/19157
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git
          - cp -a /var/plugins/. $home/plugins/

## Любые пользовательские команды для выполнения после сборки
run:
  - exec: echo "Beginning of custom commands"
  ## Если вы хотите установить адрес электронной почты «От» для первой регистрации, раскомментируйте и измените:
  ## После получения первого регистрационного письма снова закомментируйте строку. Она должна выполняться только один раз.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "End of custom commands"

Caddyfile

#---------------------------------------------------------------------------------

{
	storage file_system {
		root /var/caddy/data
	}
}

#---------------------------------------------------------------------------------

# Для получения оценки A+ по безопасности SSL
(hsts) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

# учетные данные self-hosted acme-dns
(tls-challenge) {
	tls admin+letsencrypt@example.com {
		dns acmedns {
			username ***
			password ***
			subdomain ***
			server_url http://acme.example.com:5005
		}
	}
}

#---------------------------------------------------------------------------------
# Форум (Discourse)
#---------------------------------------------------------------------------------
example.com {
	#-------------------------------------------------------------------------------
	import hsts
	import tls-challenge
	#-------------------------------------------------------------------------------
	request_header X-Real-IP {remote_host}
	reverse_proxy unix//var/discourse/shared/standalone/nginx.http.sock
	#-------------------------------------------------------------------------------
}

#---------------------------------------------------------------------------------
# Поддомены
#---------------------------------------------------------------------------------

#---------------------------------------------------------------------------------
*.example.com {
	#-------------------------------------------------------------------------------
	import hsts
	import tls-challenge
	#-------------------------------------------------------------------------------

	# Принудительное использование версии домена без www
	@www host www.example.com
	redir @www https://example.com{uri} permanent
  
	#-------------------------------------------------------------------------------
  
	@store host store.example.com
	handle @store {
		@wc_private {
			path /license.txt
			path /readme.html

			# Критические файлы
			path /wp-config.php
			path /xmlrpc.php
			path /wp-settings.php
			path /wp-load.php
			path /wp-blog-header.php

			path /wp-admin.php

			path /wp-admin/install.php

			path /wp-content/uploads/wc-logs/*
			path /wp-content/uploads/nuvei-logs/*

			path /wp-content/uploads/woocommerce_uploads/*
		}

		# protect_php
		@wc_private_php {
			not path /wp-includes/ms-files.php

			path_regexp protect_php ^/(wp-includes|wp-admin/includes|wp-content/uploads)/.*\.php$
		}
		respond @wc_private 403
		respond @wc_private_php 403

		root * /usr/share/wordpress
		php_fastcgi unix//run/php/php8.3-fpm.sock
		file_server
	}
  
  #-------------------------------------------------------------------------------
  # Несколько отдельных HTML-файлов
  #-------------------------------------------------------------------------------

  @join host join.example.com
  handle @join {
    root * /var/example/join
    file_server
  }

  #-------------------------------------------------------------------------------
  # Без соответствий
  #-------------------------------------------------------------------------------

	handle {
		respond 404
	}

	#-------------------------------------------------------------------------------
}

Если быть более точным, эта ошибка возникает через несколько часов после предыдущей, она случается не очень часто. В app.yml или Caddyfile я не вижу ничего необычного.

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

Я думаю, что вы можете добавить явные таймауты и параметры транспорта

Как?

Ну, как я уже говорил, я не использовал Caddy, но вы можете ознакомиться с официальной документацией.

Думаю, я нашёл проблему. Если я выполняю пересборку, я никогда не перезапускаю Caddy. С этого момента я буду делать так:

./launcher rebuild app && systemctl reload caddy

Я подозреваю, что Caddy использует кэш сокета или что-то подобное после пересборки. Но обычно это не происходит сразу после пересборки. Посмотрим.

Я работаю над этим уже несколько дней. Я проверил, что Caddy отправляет заголовок, что nginx получает и применяет его, что нет CDN, нет других процессов, работающих с сокетом, и нет вебхуков. Все тесты проходят успешно. И ошибка Unix продолжает появляться. Я думал, что это связано с пересборкой, но это не так. Всё это происходит случайными образом в течение очень длительных периодов времени.

Теперь TCP — мой единственный вариант?

Добавил ли ты таймауты, которые я предложил, в свой шаблон Caddy? Хотя я и не эксперт, думаю, это может решить твою проблему.

Это бессмысленно, поскольку некоторые запросы завершаются с ошибкой через 15 секунд, а другие — менее чем за секунду. В целом это бесполезно.

Верно, вы можете проверить эти две ссылки:

Похоже, что у вас отсутствует заголовок и/или ваша цепочка в шаблоне Discourse.

@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.: Я и правда не знаю, что я там наделал.