Uso un socket Unix entre Caddy y Discourse en lugar de TCP, pero de vez en cuando obtengo un error de 'unix:'

Los registros muestran lo siguiente:

PG::InvalidTextRepresentation (ERROR: sintaxis de entrada no válida para el tipo inet: "unix:" LÍNEA 7: client_ip = 'unix:', ^ ) lib/mini_sql_multisite_connection.rb:109:in 'MiniSqlMult

Error del trabajo: ERROR: sintaxis de entrada no válida para el tipo inet: "unix:" LÍNEA 2: SET ip_address = 'unix:' ^  

Cuando ocurre, se muestra una página de «Oops - Error 500». Al principio, pensé que Caddy era el culpable porque no estaba reenviando la dirección IP del cliente a Discourse, así que probé varias configuraciones diferentes, pero ninguna resolvió el problema.

Cabe mencionar que esto no ocurre con mucha frecuencia. Sucede solo ocasionalmente, y cuando lo hace, simplemente actualizar la página recupera el sitio de inmediato. Luego funciona normalmente durante bastante tiempo antes de que el error vuelva a aparecer aleatoriamente.

No soy un experto y no usé Caddy, sino Nginx en mi instancia autoalojada, pero ¿puedo pedirte la configuración actual de tus plantillas de Caddy y Discourse en ./app/containers.yml?

¿Recuerdas qué solías hacer antes de que ocurriera el error? Me refiero a cambiar la configuración de administrador, publicar, usar algún complemento, etc.

Lamento no poder ofrecerte una ayuda específica para tu problema, pero creo que tus respuestas pueden aportar valor a tu pregunta para obtener una respuesta clara y rápida de la comunidad.

Esto significa que una solicitud/usuario tiene una IP remota que apunta a tu socket, lo que indica que algo en tu cadena de proxies está mal configurado.

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


## ¿qué puertos TCP/IP debe exponer este contenedor?
## Si quieres que Discourse comparta un puerto con otro servidor web como Apache o nginx,
## consulta https://meta.discourse.org/t/17247 para más detalles
expose:
  # - "66:80"   # http
  # - "66:443" # https

params:
  ## ¿Qué revisión de Git debe usar este contenedor? (predeterminado: latest)
  version: latest
  ## Tamaño máximo de subida (predeterminado: 10m)
  upload_size: 150m
  
  db_default_text_search_config: "pg_catalog.english"

  ## Establece db_shared_buffers como máximo al 25% de la memoria total.
  ## se establecerá automáticamente durante el bootstrap según la RAM detectada, o puedes anularlo
  db_shared_buffers: "2048MB"

  ## puede mejorar el rendimiento de las ordenaciones, pero añade uso de memoria por conexión
  #db_work_mem: "40MB"

  ## ¿Qué revisión de Git debe usar este contenedor? (predeterminado: 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
  ## ¿Cuántas peticiones web concurrentes se soportan? Depende de la memoria y los núcleos de CPU.
  ## se establecerá automáticamente durante el bootstrap según las CPUs detectadas, o puedes anularlo
  UNICORN_WORKERS: 8

  ## TODO: El nombre de dominio al que responderá esta instancia de Discourse
  ## Obligatorio. Discourse no funcionará con una dirección IP sin formato.
  DISCOURSE_HOSTNAME: example.com

  ## Descomenta si quieres que el contenedor se inicie con el mismo
  ## nombre de host (opción -h) que se especifica arriba (predeterminado "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: Lista de correos electrónicos separados por comas que serán admin y developer
  ## en el primer registro, ejemplo 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: 'admin+discourse@example.com'

  ## TODO: El servidor de correo SMTP utilizado para validar nuevas cuentas y enviar notificaciones
  # La DIRECCIÓN SMTP es obligatoria
  # ADVERTENCIA: La contraseña SMTP debe ir entre comillas para evitar problemas
  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           # (opcional, predeterminado: true)
  DISCOURSE_SMTP_DOMAIN: example.com # (requerido por algunos proveedores)
  DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (opcional, predeterminado: peer, valores válidos: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (predeterminado: plain, valores válidos: plain, login, cram_md5)

  ## Si has añadido la plantilla de Lets Encrypt, descomenta abajo para obtener un certificado SSL gratuito
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## La dirección CDN http o https para esta instancia de Discourse (configurada para extraer)
  ## consulta https://meta.discourse.org/t/14857 para más detalles
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## El ID de cuenta y la clave de licencia de geolocalización IP de Maxmind para búsquedas de dirección IP
  ## consulta https://meta.discourse.org/t/-/173941 para más detalles
  #DISCOURSE_MAXMIND_ACCOUNT_ID: 123456
  #DISCOURSE_MAXMIND_LICENSE_KEY: 1234567890123456

  # Forzar HTTPS
  DISCOURSE_FORCE_HTTPS: true
  
  # Límites de peticiones
  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

## El contenedor Docker es sin estado; todos los datos se almacenan en /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

## Los plugins van aquí
## consulta https://meta.discourse.org/t/19157 para más detalles
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git
          - cp -a /var/plugins/. $home/plugins/

## Cualquier comando personalizado para ejecutar después de la compilación
run:
  - exec: echo "Inicio de comandos personalizados"
  ## Si quieres establecer la dirección de correo electrónico 'De' para tu primer registro, descomenta y cambia:
  ## Después de recibir el correo de registro, vuelve a comentar la línea. Solo necesita ejecutarse una vez.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "Fin de comandos personalizados"

Caddyfile

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

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

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

# Para obtener una calificación A+ en seguridad SSL
(hsts) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

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

#---------------------------------------------------------------------------------
# Foro (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
	#-------------------------------------------------------------------------------

	# Forzar la versión sin 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

			# 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
	}
  
  #-------------------------------------------------------------------------------
  # Múltiples archivos HTML independientes
  #-------------------------------------------------------------------------------

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

  #-------------------------------------------------------------------------------
  # Sin matchers
  #-------------------------------------------------------------------------------

	handle {
		respond 404
	}

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

Para ser más específico, este error ocurre varias horas después del anterior, no sucede muy a menudo. No veo nada inusual en app.yml o en el Caddyfile.

Si la conexión al socket es correcta hasta que se interrumpe, creo que puedes agregar tiempos de espera y opciones de transporte explícitas.

Creo que puedes añadir tiempos de espera explícitos y opciones de transporte

¿Cómo?

Bueno, como dije antes, no usé Caddy, pero puedes consultar su documentación oficial.

Creo que he encontrado el problema. Si realizo una reconstrucción, nunca reinicio Caddy. A partir de ahora, voy a hacer esto:

./launcher rebuild app && systemctl reload caddy

Sospecho que Caddy utiliza la caché de sockets o algo similar después de una reconstrucción. Pero normalmente no ocurre inmediatamente después de una reconstrucción. Ya veremos.

He estado trabajando en esto durante días. He verificado que Caddy envía el encabezado, que nginx lo recibe y lo aplica, que no hay CDN, ningún otro proceso tocando el socket y no hay webhooks. Todas las pruebas individuales pasan. Y el error de Unix sigue apareciendo. Pensé que se debía a la reconstrucción, pero no es así. Todo esto ocurre aleatoriamente durante períodos de tiempo muy largos.

¿Es TCP mi única opción ahora?

¿Has añadido los tiempos de espera que sugerí a tu plantilla de Caddy? Aunque no soy un experto, creo que eso podría solucionar tu problema.

Eso no tiene sentido, porque algunas solicitudes fallan en 15 segundos y otras en menos de 1 segundo. Básicamente, es inútil.

Correcto, puedes consultar estos dos enlaces:

Parece que te falta un encabezado y/o tu cadena en el archivo de plantilla de Discourse.

@Falco @satonotdead

Logré solucionarlo. Esperé bastante tiempo para asegurarme de que estuviera realmente arreglado, y parece que sí.

La configuración

Discourse se ejecuta en Docker y expone nginx a través de un socket Unix (/var/discourse/shared/standalone/nginx.http.sock). Caddy se encuentra delante de él como proxy inverso y transmite la IP real del cliente a través de X-Real-IP.

Hubo que cambiar dos cosas: la configuración de nginx dentro del contenedor y la forma en que ejecuto la reconstrucción.

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

El código crítico es lo que se encuentra dentro del cuerpo de after_web_config.

2. El bloque Caddyfile

El siguiente script gestiona el modo de mantenimiento a través de un archivo de bandera, por lo que tu bloque de sitio debe conocerlo:

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
    }
}

La ruta de la bandera en el script y el archivo que busca el matcher @maintenance deben ser el mismo archivo. Si renombras uno, renombra el otro, de lo contrario el modo de mantenimiento nunca se activará silenciosamente.

Si la misma instancia de Caddy también sirve otras aplicaciones, usa una bandera que solo coincida con el bloque del foro, de lo contrario una reconstrucción de Discourse tumbará también esas otras aplicaciones.

3. El script de reconstrucción

Ya no uso ./launcher rebuild app directamente. 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"

Lo que hace realmente el script

  1. Pone el sitio en mantenimiento a través del archivo de bandera de Caddy.
  2. Espera a que el socket se calme y luego ejecuta ./launcher rebuild app.
  3. Realiza consultas a /srv/status a través del socket hasta que Unicorn responda 200. Esta es la parte crítica: es lo que impide que las solicitudes lleguen a nginx mientras Unicorn aún se está iniciando.
  4. Recarga Caddy (por si acaso, resultó no ser crítico).
  5. Borra la bandera de mantenimiento solo si esa consulta tuvo éxito.

Después de varias semanas y varias reconstrucciones, no he vuelto a ver ese error.

PD: De verdad no sé qué demonios hice.