J'utilise une socket Unix entre Caddy et Discourse au lieu de TCP, mais de temps en temps j'obtiens une erreur « unix: »

Les journaux affichent ce qui suit :

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

Lorsque cela se produit, une page « Oops - Error 500 » s’affiche. Au début, je pensais que Caddy était en cause car il ne transmettait pas l’adresse IP du client à Discourse, j’ai donc essayé plusieurs configurations différentes, mais aucune n’a résolu le problème.

Il est à noter que cela n’arrive pas très souvent. Cela se produit seulement de temps en temps, et lorsque c’est le cas, il suffit de rafraîchir la page pour que le site reprenne son fonctionnement normal. Il fonctionne alors correctement pendant un certain temps avant que l’erreur ne se reproduise de manière aléatoire.

Je ne suis pas expert et j’utilise Nginx plutôt que Caddy sur mon instance auto-hébergée, mais puis-je vous demander votre configuration actuelle des modèles Caddy et Discourse dans ./app/containers.yml ?

Vous souvenez-vous de ce que vous faisiez habituellement avant que l’erreur ne se produise ? Je veux dire, modifier les paramètres administrateur, publier, utiliser une extension ?

Je suis désolé de ne pas pouvoir vous apporter une aide spécifique concernant votre problème, mais je pense que vos réponses pourront enrichir votre question et vous permettre d’obtenir une réponse claire et rapide de la communauté.

Cela signifie qu’une requête / un utilisateur a une adresse IP distante pointant vers votre socket, ce qui indique qu’un élément de votre chaîne de proxies est mal configuré.

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


## which TCP/IP ports should this container expose?
## If you want Discourse to share a port with another webserver like Apache or nginx,
## see https://meta.discourse.org/t/17247 for details
expose:
  # - "66:80"   # http
  # - "66:443" # https

params:
  ## Which Git revision should this container use? (default: latest)
  version: latest
  ## Maximum upload size (default: 10m)
  upload_size: 150m
  
  db_default_text_search_config: "pg_catalog.english"

  ## Set db_shared_buffers to a max of 25% of the total memory.
  ## will be set automatically by bootstrap based on detected RAM, or you can override
  db_shared_buffers: "2048MB"

  ## can improve sorting performance, but adds memory usage per-connection
  #db_work_mem: "40MB"

  ## Which Git revision should this container use? (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
  ## How many concurrent web requests are supported? Depends on memory and CPU cores.
  ## will be set automatically by bootstrap based on detected CPUs, or you can override
  UNICORN_WORKERS: 8

  ## TODO: The domain name this Discourse instance will respond to
  ## Required. Discourse will not work with a bare IP number.
  DISCOURSE_HOSTNAME: example.com

  ## Uncomment if you want the container to be started with the same
  ## hostname (-h option) as specified above (default "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: List of comma delimited emails that will be made admin and developer
  ## on initial signup example 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: 'admin+discourse@example.com'

  ## TODO: The SMTP mail server used to validate new accounts and send notifications
  # SMTP ADDRESS is required
  # WARNING: SMTP password should be wrapped in quotes to avoid problems
  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           # (optional, default: true)
  DISCOURSE_SMTP_DOMAIN: example.com # (required by some providers)
  DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (optional, default: peer, valid values: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (default: plain, valid values: plain, login, cram_md5)

  ## If you added the Lets Encrypt template, uncomment below to get a free SSL certificate
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## The http or https CDN address for this Discourse instance (configured to pull)
  ## see https://meta.discourse.org/t/14857 for details
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## The maxmind geolocation IP account ID and license key for IP address lookups
  ## see https://meta.discourse.org/t/-/173941 for details
  #DISCOURSE_MAXMIND_ACCOUNT_ID: 123456
  #DISCOURSE_MAXMIND_LICENSE_KEY: 1234567890123456

  # Force HTTPS
  DISCOURSE_FORCE_HTTPS: true
  
  # Request Limits
  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

## The Docker container is stateless; all data is stored 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

## Plugins go here
## see https://meta.discourse.org/t/19157 for details
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git
          - cp -a /var/plugins/. $home/plugins/

## Any custom commands to run after building
run:
  - exec: echo "Beginning of custom commands"
  ## If you want to set the 'From' email address for your first registration, uncomment and change:
  ## After getting the first signup email, re-comment the line. It only needs to run once.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "End of custom commands"

Caddyfile

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

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

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

# To achieve an A+ rating in SSL security
(hsts) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

# self-hosted acme-dns credentials
(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
	#-------------------------------------------------------------------------------
}

#---------------------------------------------------------------------------------
# Subdominios
#---------------------------------------------------------------------------------

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

	# Force the non-www version of the domain
	@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

			# Archivos críticos
			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
	}
  
  #-------------------------------------------------------------------------------
  # Multiple standalone HTML files
  #-------------------------------------------------------------------------------

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

  #-------------------------------------------------------------------------------
  # No matchers
  #-------------------------------------------------------------------------------

	handle {
		respond 404
	}

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

Pour être plus précis, cette erreur se produit plusieurs heures après la précédente, elle n’arrive pas très souvent. Je ne vois rien d’anormal dans app.yml ou le Caddyfile.

Si la connexion au socket est correcte jusqu’à ce qu’elle soit interrompue, je pense que vous pouvez ajouter des délais d’attente explicites et des options de transport.

Je pense que vous pouvez ajouter des délais d’expiration et des options de transport explicites

Comment ?

Eh bien, comme je l’ai dit précédemment, je n’ai pas utilisé Caddy, mais vous pouvez consulter leur documentation officielle.

Je pense avoir trouvé le problème. Si je lance une reconstruction, je ne redémarre jamais Caddy. À partir de maintenant, je vais procéder comme suit :

./launcher rebuild app && systemctl reload caddy

Je soupçonne que Caddy utilise le cache de sockets ou quelque chose de similaire après une reconstruction. Mais cela n’arrive pas généralement immédiatement après une reconstruction. On verra bien.

Je travaille sur ce problème depuis des jours. J’ai vérifié que Caddy envoie bien l’en-tête, que nginx le reçoit et l’applique, qu’il n’y a ni CDN, ni autre processus touchant le socket, ni webhooks. Tous les tests passent sans exception. Et l’erreur Unix continue d’apparaître. Je pensais que cela venait de la reconstruction, mais ce n’est pas le cas. Tout cela se produit de manière aléatoire sur de très longues périodes.

Est-ce que TCP est maintenant ma seule option ?

As-tu ajouté les délais d’attente que j’ai suggérés à ton modèle Caddy ? Bien que je ne sois pas expert, je pense que cela pourrait résoudre ton problème.

Cela n’a pas de sens, car certaines requêtes échouent après 15 secondes et d’autres en moins d’une seconde. C’est essentiellement inutile.

D’accord, vous pouvez consulter ces deux liens :

Il semble que vous manquiez d’un en-tête et/ou que votre chaîne de confiance dans le fichier de modèle Discourse soit incorrecte.

@Falco @satonotdead

J’ai réussi à le corriger. J’ai attendu un bon moment pour m’assurer que c’était vraiment réparé, et cela semble le cas.

La configuration

Discourse s’exécute dans Docker et expose nginx sur un socket Unix (/var/discourse/shared/standalone/nginx.http.sock). Caddy est placé devant en tant que proxy inverse et transmet l’IP réelle du client via l’en-tête X-Real-IP.

Deux éléments ont dû être modifiés : la configuration nginx à l’intérieur du conteneur et la manière dont je lance la reconstruction.

1. app.yml

## Les plugins vont ici
## voir https://meta.discourse.org/t/19157 pour les détails
hooks:
  # Ce bloc after_code n'est que ma propre liste de plugins — il n'a rien à voir avec
  # le correctif. Conservez ce que vous avez déjà ici.
  after_code:
    - exec:
        [...]

  # C'est la partie qui compte.
  #   1. Écrit une map au niveau http qui transforme la chaîne littérale "unix:" en 127.0.0.1.
  #      Le préfixe 00- fait en sorte que nginx la charge avant discourse.conf.
  #   2. Réécrit discourse.conf pour que X-Forwarded-For utilise la variable mappée
  #      au lieu du $remote_addr brut.
  #   3. nginx -t échoue la construction si le résultat n'est pas valide.
  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

Le code critique est ce qui se trouve dans le corps de after_web_config.

2. Le bloc Caddyfile

Le script ci-dessous gère le mode maintenance via un fichier drapeau, votre bloc de site doit donc en tenir compte :

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

    root * /var/caddy/flags
    @maintenance file maintenance.flag
    handle @maintenance {
        respond "Maintenance en cours. Nous serons de retour dans quelques minutes." 503
    }

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

Le chemin du drapeau dans le script et le fichier que le sélecteur @maintenance recherche doivent être le même fichier. Si vous renommez l’un, renommez l’autre, sinon le mode maintenance ne s’activera jamais silencieusement.

Si la même instance Caddy sert également d’autres applications, utilisez un drapeau que seul le bloc du forum correspond, sinon une reconstruction de Discourse entraînera la mise hors ligne de ces autres applications.

3. Le script de reconstruction

Je n’utilise plus ./launcher rebuild app directement. J’utilise discourse-rebuild.sh :

#!/bin/bash
#
# discourse-rebuild.sh — Reconstruit le conteneur Discourse sans laisser de
# requêtes orphelines et sans l'erreur `invalid input syntax for type inet: "unix:"`.
#
# CONTEXTE
#   Discourse s'exécute dans Docker et expose nginx sur un socket Unix
#   (/var/discourse/shared/standalone/nginx.http.sock). Caddy agit comme proxy
#   inverse devant et transmet l'IP réelle du client via X-Real-IP.
#
#   Une `rebuild` détruit le conteneur et recrée le socket avec un nouvel inode.
#   Pendant cette transition, il y a deux fenêtres problématiques :
#
#     1) Caddy conserve l'état du socket précédent jusqu'à ce qu'il soit rechargé.
#     2) nginx commence à accepter les connexions dès son démarrage, mais Unicorn
#        met environ 15s de plus avant de pouvoir les servir.
#
#   Une requête atterrissant dans l'une de ces fenêtres peut arriver sans X-Real-IP. $remote_addr
#   reste alors avec la chaîne littérale "unix:", que PostgreSQL rejette lors
#   de l'insertion dans une colonne inet -> HTTP 500.
#
# CE QU'IL FAIT
#   1. Lève un fichier drapeau qui met le site en 503 (Caddy le vérifie à chaque
#      requête, donc il prend effet instantanément et sans rechargement).
#   2. Attend que rien d'autre que nginx lui-même ne tienne le socket ouvert.
#   3. Reconstruit le conteneur.
#   4. Interroge /srv/status via le socket jusqu'à ce qu'Unicorn réponde 200.
#   5. Recharge Caddy pour qu'il prenne en compte le nouveau socket, et efface le drapeau.
#   6. Démarre le surveillant qui journalise tout hit "unix:" restant.
#
#   Si la reconstruction échoue, ou si Discourse ne répond pas dans la fenêtre d'interrogation,
#   le drapeau n'est PAS retiré : le site reste en maintenance volontairement, afin qu'un conteneur
#   cassé ne soit jamais exposé. Remettez-le en ligne manuellement avec :
#       rm -f /var/caddy/flags/maintenance.flag
#
# EXIGENCES
#   - lsof installé, et privilèges root.
#   - FLAG ci-dessous doit pointer vers exactement le même fichier que le sélecteur @maintenance
#     du Caddyfile recherche.
#   - request_header X-Real-IP {remote_host} dans ce même bloc Caddyfile.
#
# UTILISATION
#   ./discourse-rebuild.sh
#
# POUR VOIR CE QUE LE SURVEILLANT A CAPTÉ
#   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 "✅ Reconstruction terminée. Le site est en ligne."
  else
    echo "⚠️  Échec de la reconstruction (code de sortie $code). Le site est toujours en maintenance."
    echo "    Vérifiez, et une fois prêt : rm -f $FLAG"
  fi
}
trap cleanup EXIT

mkdir -p "$(dirname "$FLAG")"
touch "$FLAG"
echo "🔧 Maintenance activée. En attente des requêtes en cours..."

# Heuristique approximative : compte les processus tenant le socket ouvert et attend jusqu'à
# ce que seul l'écouteur reste. Jusqu'à 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

# Jusqu'à 60 tentatives : environ 2 minutes de pauses, plus si curl atteint son
# propre timeout de 5s.
echo "⏳ En attente de la réponse de 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 n'a jamais répondu dans la fenêtre d'interrogation (dernier code de statut : $status)."
  exit 1
fi

echo "✅ Discourse prêt après ~$((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"

Ce que fait réellement le script

  1. Met le site en maintenance via le fichier drapeau de Caddy.
  2. Attend que le socket soit calme, puis exécute ./launcher rebuild app.
  3. Interroge /srv/status via le socket jusqu’à ce qu’Unicorn réponde 200. C’est la partie critique : c’est ce qui empêche les requêtes d’atteindre nginx pendant qu’Unicorn est encore en cours de démarrage.
  4. Recharge Caddy (par sécurité, il s’est avéré que ce n’était pas critique).
  5. Efface le drapeau de maintenance uniquement si cette interrogation a réussi.

Plusieurs semaines et plusieurs reconstructions plus tard, je n’ai plus vu cette erreur.

P.-S. : Je ne sais vraiment pas ce que j’ai fait, honnêtement.