Uso una socket Unix tra Caddy e Discourse invece di TCP, ma ogni tanto ricevo un errore 'unix:'

I log mostrano quanto segue:

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:' ^  

Quando si verifica, viene visualizzata una pagina “Oops - Errore 500”. Inizialmente, pensavo che Caddy fosse il colpevole perché non inoltrava l’indirizzo IP del client a Discourse, quindi ho provato diverse configurazioni, ma nessuna ha risolto il problema.

Vale la pena menzionare che questo non accade molto spesso. Si verifica solo occasionalmente e, quando lo fa, semplicemente aggiornando la pagina il sito si ripristina immediatamente. Funziona normalmente per un bel po’ di tempo prima che l’errore si verifichi nuovamente in modo casuale.

Non sono un esperto e non ho usato Caddy ma Nginx sulla mia istanza self-hosted, ma posso chiederti la configurazione effettiva dei tuoi template Caddy e Discourse in ./app/containers.yml?

Ricordi cosa facevi solitamente prima che si verificasse l’errore? Intendo, modificare le impostazioni di amministrazione, pubblicare, usare qualche plugin?

Mi dispiace non poter dare un aiuto specifico per il tuo problema, ma penso che le tue risposte possano aggiungere valore alla tua domanda per ottenere una risposta chiara e rapida dalla community.

Questo significa che una richiesta / un utente ha un IP remoto che punta al tuo socket, il che indica che qualcosa nella tua catena di proxy è configurato in modo errato.

@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"


## quali porte TCP/IP dovrebbe esporre questo container?
## Se vuoi che Discourse condivida una porta con un altro webserver come Apache o nginx,
## vedi https://meta.discourse.org/t/17247 per i dettagli
expose:
  # - "66:80"   # http
  # - "66:443" # https

params:
  ## Quale revisione Git dovrebbe usare questo container? (default: latest)
  version: latest
  ## Dimensione massima del caricamento (default: 10m)
  upload_size: 150m
  
  db_default_text_search_config: "pg_catalog.english"

  ## Imposta db_shared_buffers a un massimo del 25% della memoria totale.
  ## verrà impostato automaticamente da bootstrap in base alla RAM rilevata, oppure puoi sovrascriverlo
  db_shared_buffers: "2048MB"

  ## può migliorare le prestazioni di ordinamento, ma aumenta l'uso di memoria per connessione
  #db_work_mem: "40MB"

  ## Quale revisione Git dovrebbe usare questo container? (default: 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
  ## Quante richieste web simultanee sono supportate? Dipende dalla memoria e dai core CPU.
  ## verrà impostato automaticamente da bootstrap in base alle CPU rilevate, oppure puoi sovrascriverlo
  UNICORN_WORKERS: 8

  ## TODO: Il nome di dominio a cui questa istanza Discourse risponderà
  ## Obbligatorio. Discourse non funzionerà con un indirizzo IP nudo.
  DISCOURSE_HOSTNAME: example.com

  ## Decommenta se vuoi che il container venga avviato con lo stesso
  ## hostname (opzione -h) specificato sopra (default "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: Lista di email separate da virgole che saranno rese admin e developer
  ## al primo accesso, esempio 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: 'admin+discourse@example.com'

  ## TODO: Il server di posta SMTP usato per validare nuovi account e inviare notifiche
  # L'INDIRIZZO SMTP è obbligatorio
  # ATTENZIONE: la password SMTP dovrebbe essere racchiusa tra virgolette per evitare problemi
  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           # (opzionale, default: true)
  DISCOURSE_SMTP_DOMAIN: example.com # (richiesto da alcuni provider)
  DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (opzionale, default: peer, valori validi: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (default: plain, valori validi: plain, login, cram_md5)

  ## Se hai aggiunto il template Lets Encrypt, decommenta qui sotto per ottenere un certificato SSL gratuito
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## L'indirizzo CDN http o https per questa istanza Discourse (configurato per il pull)
  ## vedi https://meta.discourse.org/t/14857 per i dettagli
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## L'ID account e la chiave di licenza Maxmind geolocation IP per le ricerche di indirizzi IP
  ## vedi https://meta.discourse.org/t/-/173941 per i dettagli
  #DISCOURSE_MAXMIND_ACCOUNT_ID: 123456
  #DISCOURSE_MAXMIND_LICENSE_KEY: 1234567890123456

  # Forza HTTPS
  DISCOURSE_FORCE_HTTPS: true
  
  # Limiti delle richieste
  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

## Il container Docker è stateless; tutti i dati sono memorizzati in /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

## I plugin vanno qui
## vedi https://meta.discourse.org/t/19157 per i dettagli
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git
          - cp -a /var/plugins/. $home/plugins/

## Comandi personalizzati da eseguire dopo la build
run:
  - exec: echo "Inizio dei comandi personalizzati"
  ## Se vuoi impostare l'indirizzo email "Da" per il tuo primo registro, decommenta e modifica:
  ## Dopo aver ricevuto la prima email di registrazione, ricommenta la riga. Deve essere eseguita solo una volta.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "Fine dei comandi personalizzati"

Caddyfile

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

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

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

# Per ottenere una valutazione A+ nella sicurezza SSL
(hsts) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

# credenziali acme-dns self-hosted
(tls-challenge) {
	tls admin+letsencrypt@example.com {
		dns acmedns {
			username ***
			password ***
			subdomain ***
			server_url http://acme.example.com:5005
		}
	}
}

#---------------------------------------------------------------------------------
# Forum (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
	#-------------------------------------------------------------------------------
}

#---------------------------------------------------------------------------------
# Sottodomini
#---------------------------------------------------------------------------------

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

	# Forza la versione non-www del dominio
	@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

			# File critici
			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
	}
  
  #-------------------------------------------------------------------------------
  # File HTML standalone multipli
  #-------------------------------------------------------------------------------

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

  #-------------------------------------------------------------------------------
  # Nessun matcher
  #-------------------------------------------------------------------------------

	handle {
		respond 404
	}

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

Per essere più specifici, questo errore si verifica diverse ore dopo il precedente, non capita molto spesso. Non vedo nulla di insolito in app.yml o nel Caddyfile.

Se la connessione al socket è corretta fino a quando non viene interrotta, penso che tu possa aggiungere timeout e opzioni di trasporto espliciti.

Penso che tu possa aggiungere timeout e opzioni di trasporto espliciti

Come?

Beh, come ho detto prima, non ho usato Caddy, ma puoi consultare la loro documentazione ufficiale.

Penso di aver trovato il problema. Se eseguo una ricostruzione, non riavvio mai Caddy. D’ora in poi, farò così:

./launcher rebuild app && systemctl reload caddy

Sospetto che Caddy utilizzi la cache del socket o qualcosa di simile dopo una ricostruzione. Ma di solito non accade immediatamente dopo una ricostruzione. Vedremo.

Ci lavoro da giorni. Ho verificato che Caddy invii l’intestazione, che nginx la riceva e la applichi, che non ci sia alcun CDN, nessun altro processo che tocchi la socket e nessun webhook. Ogni singolo test supera con successo. E l’errore Unix continua a comparire. Pensavo fosse dovuto alla ricostruzione, ma non è così. Tutto ciò avviene in modo casuale durante periodi di tempo molto lunghi.

TCP è ora la mia unica opzione?

Hai aggiunto i timeout che ho suggerito al tuo template Caddy? Anche se non sono un esperto, penso che potrebbe risolvere il tuo problema.

Farlo non ha senso, poiché alcune richieste falliscono dopo 15 secondi e altre in meno di 1 secondo. È praticamente inutile.

Esatto, puoi controllare questi due link:

Sembra che ti manchi un’intestazione e/o la tua catena nel file modello di Discourse.

@Falco @satonotdead

Sono riuscito a risolvere il problema. Ho atteso un bel po’ per assicurarmi che fosse davvero sistemato, e sembra di sì.

La configurazione

Discourse gira in Docker e espone nginx su un socket Unix (/var/discourse/shared/standalone/nginx.http.sock). Caddy si trova davanti come reverse proxy e trasmette l’IP reale del client tramite X-Real-IP.

Dovevano essere apportate due modifiche: la configurazione nginx all’interno del contenitore e il modo in cui eseguo la rebuild.

1. app.yml

## Plugins go here
## see https://meta.discourse.org/t/19157 for details
hooks:
  # This after_code block is just my own plugin list — it has nothing to do with
  # the fix. Keep whatever you already have here.
  after_code:
    - exec:
        [...]

  # This is the part that matters.
  #   1. Writes an http-level map that turns the literal "unix:" into 127.0.0.1.
  #      The 00- prefix makes nginx load it before discourse.conf.
  #   2. Rewrites discourse.conf so X-Forwarded-For uses the mapped variable
  #      instead of the raw $remote_addr.
  #   3. nginx -t fails the build if the result is not valid.
  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

Il codice critico è ciò che si trova all’interno del corpo di after_web_config.

2. Il blocco Caddyfile

Lo script sottostante gestisce la modalità manutenzione tramite un file flag, quindi il tuo blocco sito deve esserne a conoscenza:

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

    root * /var/caddy/flags
    @maintenance file maintenance.flag
    handle @maintenance {
        respond "Maintenance in progress. We'll be back in a few minutes." 503
    }

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

Il percorso del flag nello script e il file cercato dal matcher @maintenance devono essere lo stesso file. Se ne rinomini uno, rinomina anche l’altro, altrimenti la modalità manutenzione non verrà mai attivata silenziosamente.

Se la stessa istanza di Caddy serve anche altre app, usa un flag che corrisponda solo al blocco del forum, altrimenti una rebuild di Discourse porterà giù anche le altre app.

3. Lo script di rebuild

Non uso più ./launcher rebuild app direttamente. Uso discourse-rebuild.sh:

#!/bin/bash
#
# discourse-rebuild.sh — Rebuilds the Discourse container without leaving orphaned
# requests behind and without the `invalid input syntax for type inet: "unix:"` error.
#
# CONTEXT
#   Discourse runs in Docker and exposes nginx on a Unix socket
#   (/var/discourse/shared/standalone/nginx.http.sock). Caddy acts as a reverse
#   proxy in front of it and passes the client's real IP through X-Real-IP.
#
#   A `rebuild` destroys the container and recreates the socket with a new inode.
#   During that transition there are two problematic windows:
#
#     1) Caddy keeps state from the previous socket until it is reloaded.
#     2) nginx starts accepting connections as soon as it boots, but Unicorn
#        takes ~15s longer before it can serve them.
#
#   A request landing in either window may arrive with no X-Real-IP. $remote_addr
#   is then left holding the literal "unix:", which PostgreSQL rejects when
#   inserting it into an inet column -> HTTP 500.
#
# WHAT IT DOES
#   1. Raises a flag file that puts the site into 503 (Caddy checks it on every
#      request, so it takes effect instantly and with no reload).
#   2. Waits until nothing but nginx itself is holding the socket open.
#   3. Rebuilds the container.
#   4. Polls /srv/status against the socket until Unicorn answers 200.
#   5. Reloads Caddy so it picks up the new socket, and clears the flag.
#   6. Starts the watcher that logs any leftover "unix:" hit.
#
#   If the rebuild fails, or Discourse never answers within the polling window,
#   the flag is NOT removed: the site stays in maintenance on purpose, so a broken
#   container is never exposed. Bring it back up by hand with:
#       rm -f /var/caddy/flags/maintenance.flag
#
# REQUIREMENTS
#   - lsof installed, and root privileges.
#   - FLAG below must point at the exact same file the Caddyfile @maintenance
#     matcher looks for.
#   - request_header X-Real-IP {remote_host} in that same Caddyfile block.
#
# USAGE
#   ./discourse-rebuild.sh
#
# TO SEE WHAT THE WATCHER CAUGHT
#   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 "✅ Rebuild finished. Site is online."
  else
    echo "⚠️  Rebuild failed (exit code $code). The site is still in maintenance."
    echo "    Check it, and once it's ready: rm -f $FLAG"
  fi
}
trap cleanup EXIT

mkdir -p "$(dirname "$FLAG")"
touch "$FLAG"
echo "🔧 Maintenance is on. Waiting for in-flight requests..."

# Rough heuristic: count the processes holding the socket open and wait until
# only the listener is left. Up to 30s.
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

# Up to 60 attempts: about 2 minutes of sleeps, more if any curl hits its own
# 5s timeout.
echo "⏳ Waiting for Discourse to answer..."
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 never answered within the polling window (last status code: $status)."
  exit 1
fi

echo "✅ Discourse ready after ~$((i*2))s."

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"

Cosa fa effettivamente lo script

  1. Mette il sito in manutenzione tramite il file flag di Caddy.
  2. Aspetta che il socket diventi silenzioso, quindi esegue ./launcher rebuild app.
  3. Esegue il polling di /srv/status tramite il socket fino a quando Unicorn risponde con 200. Questa è la parte critica: è ciò che impedisce alle richieste di raggiungere nginx mentre Unicorn è ancora in fase di avvio.
  4. Ricarica Caddy (per sicurezza, non si è rivelato critico).
  5. Rimuove il flag di manutenzione solo se il polling ha avuto successo.

Dopo diverse settimane e diverse rebuild, non ho più visto quell’errore.

PS: Non ho davvero idea di cosa diavolo abbia fatto.