Ich verwende einen Unix-Socket zwischen Caddy und Discourse statt TCP, aber ab und zu erhalte ich einen „unix:“-Fehler

Die Protokolle zeigen Folgendes:

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

Wenn dies passiert, wird eine „Oops - Error 500“-Seite angezeigt. Zuerst dachte ich, Caddy wäre der Schuldige, weil er die IP-Adresse des Clients nicht an Discourse weiterleitete. Ich versuchte daher verschiedene Konfigurationen, aber keine davon löste das Problem.

Es ist erwähnenswert, dass dies nicht sehr häufig vorkommt. Es tritt nur gelegentlich auf, und wenn es passiert, wird die Seite durch einfaches Aktualisieren sofort wiederhergestellt. Anschließend funktioniert sie für eine ziemlich lange Zeit normal, bevor der Fehler erneut zufällig auftritt.

Ich bin kein Experte und habe Caddy nicht verwendet, sondern Nginx für meine selbst gehostete Instanz, aber kann ich dich nach deiner aktuellen Caddy- und Discourse-Vorlagenkonfiguration in ./app/containers.yml fragen?

Kannst du dich daran erinnern, was du normalerweise getan hast, bevor der Fehler aufgetreten ist? Ich meine, Admin-Einstellungen geändert, Beiträge erstellt oder ein Plugin verwendet?

Es tut mir leid, keine spezifische Hilfe für dein Problem zu bieten, aber ich denke, deine Antworten können deinen Frage mehr Klarheit verleihen und eine schnelle Antwort von der Community ermöglichen.

Das bedeutet, dass eine Anfrage / ein Benutzer eine Remote-IP hat, die auf deinen Socket zeigt, was darauf hindeutet, dass etwas in deiner Proxy-Kette falsch konfiguriert ist.

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


## Welche TCP/IP-Ports soll dieser Container exponieren?
## Wenn du möchtest, dass Discourse einen Port mit einem anderen Webserver wie Apache oder nginx teilt,
## siehe https://meta.discourse.org/t/17247 für Details
expose:
  # - "66:80"   # http
  # - "66:443" # https

params:
  ## Welche Git-Revision soll dieser Container verwenden? (Standard: latest)
  version: latest
  ## Maximale Upload-Größe (Standard: 10m)
  upload_size: 150m
  
  db_default_text_search_config: "pg_catalog.english"

  ## Setze db_shared_buffers auf maximal 25 % des gesamten Speichers.
  ## Wird automatisch vom Bootstrap-Skript basierend auf dem erkannten RAM festgelegt, oder du kannst es überschreiben
  db_shared_buffers: "2048MB"

  ## Kann die Sortierleistung verbessern, erhöht aber den Speicherverbrauch pro Verbindung
  #db_work_mem: "40MB"

  ## Welche Git-Revision soll dieser Container verwenden? (Standard: 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
  ## Wie viele gleichzeitige Webanfragen werden unterstützt? Hängt vom Speicher und den CPU-Kernen ab.
  ## Wird automatisch vom Bootstrap-Skript basierend auf den erkannten CPUs festgelegt, oder du kannst es überschreiben
  UNICORN_WORKERS: 8

  ## TODO: Der Domainname, auf den diese Discourse-Instanz reagieren soll
  ## Erforderlich. Discourse funktioniert nicht mit einer nackten IP-Adresse.
  DISCOURSE_HOSTNAME: example.com

  ## Kommentiere auf, wenn du möchtest, dass der Container mit demselben
  ## Hostnamen (-h-Option) gestartet wird, wie oben angegeben (Standard "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: Liste durch Kommas getrennter E-Mails, die bei der ersten Anmeldung als Admin und Entwickler festgelegt werden
  ## Beispiel 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: 'admin+discourse@example.com'

  ## TODO: Der SMTP-Mailserver, der zur Validierung neuer Konten und zum Senden von Benachrichtigungen verwendet wird
  # SMTP-ADRESSE ist erforderlich
  # WARNUNG: SMTP-Passwort sollte in Anführungszeichen gesetzt werden, um Probleme zu vermeiden
  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, Standard: true)
  DISCOURSE_SMTP_DOMAIN: example.com # (von einigen Anbietern erforderlich)
  DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (optional, Standard: peer, gültige Werte: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (Standard: plain, gültige Werte: plain, login, cram_md5)

  ## Wenn du das Lets Encrypt-Template hinzugefügt hast, kommentiere unten auf, um ein kostenloses SSL-Zertifikat zu erhalten
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## Die http- oder https-CDN-Adresse für diese Discourse-Instanz (konfiguriert zum Pullen)
  ## siehe https://meta.discourse.org/t/14857 für Details
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## Die MaxMind-Geolokalisierung IP-Konto-ID und Lizenzschlüssel für IP-Adressnachschläge
  ## siehe https://meta.discourse.org/t/-/173941 für Details
  #DISCOURSE_MAXMIND_ACCOUNT_ID: 123456
  #DISCOURSE_MAXMIND_LICENSE_KEY: 1234567890123456

  # HTTPS erzwingen
  DISCOURSE_FORCE_HTTPS: true
  
  # Anfragereschränkungen
  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

## Der Docker-Container ist zustandslos; alle Daten werden in /shared gespeichert
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 gehören hierher
## siehe https://meta.discourse.org/t/19157 für Details
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git
          - cp -a /var/plugins/. $home/plugins/

## Alle benutzerdefinierten Befehle, die nach dem Build ausgeführt werden sollen
run:
  - exec: echo "Beginn der benutzerdefinierten Befehle"
  ## Wenn du die 'Von'-E-Mail-Adresse für deine erste Registrierung festlegen möchtest, kommentiere auf und ändere:
  ## Nach Erhalt der ersten Anmelde-E-Mail die Zeile wieder auskommentieren. Sie muss nur einmal ausgeführt werden.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "Ende der benutzerdefinierten Befehle"

Caddyfile

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

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

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

# Um eine A+-Bewertung in der SSL-Sicherheit zu erreichen
(hsts) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

# selbst gehostete acme-dns-Anmeldeinformationen
(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
	#-------------------------------------------------------------------------------
}

#---------------------------------------------------------------------------------
# Subdomains
#---------------------------------------------------------------------------------

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

	# Erzwingen der Nicht-www-Version der 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

			# Kritische Dateien
			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
	}
  
  #-------------------------------------------------------------------------------
  # Mehrere eigenständige HTML-Dateien
  #-------------------------------------------------------------------------------

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

  #-------------------------------------------------------------------------------
  # Keine Matcher
  #-------------------------------------------------------------------------------

	handle {
		respond 404
	}

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

Um genauer zu sein, tritt dieser Fehler mehrere Stunden nach dem vorherigen auf, er passiert nicht sehr oft. Ich sehe in app.yml oder der Caddyfile nichts Ungewöhnliches.

Wenn die Socket-Verbindung bis zum Abbruch korrekt ist, können Sie meiner Meinung nach explizite Timeouts und Transportoptionen hinzufügen.

Ich denke, du kannst explizite Timeouts und Transportoptionen hinzufügen

Wie?

Nun, wie ich bereits gesagt habe, habe ich Caddy nicht verwendet, aber du kannst sich ihre offizielle Dokumentation ansehen.

Ich glaube, ich habe das Problem gefunden. Wenn ich einen Neuaufbau durchführe, starte ich Caddy nie neu. Ab jetzt werde ich folgendes tun:

./launcher rebuild app && systemctl reload caddy

Ich vermute, dass Caddy nach einem Neuaufbau den Socket-Cache oder etwas Ähnliches verwendet. Aber das passiert normalerweise nicht sofort nach einem Neuaufbau. Wir werden sehen.

Ich arbeite seit Tagen daran. Ich habe überprüft, dass Caddy den Header sendet, dass nginx ihn empfängt und anwendet, dass kein CDN, kein anderer Prozess, der den Socket berührt, und keine Webhooks vorhanden sind. Jeder einzelne Test bestanden. Und der Unix-Fehler taucht weiterhin auf. Ich dachte, es lag am Neuaufbau, aber das ist nicht der Fall. All dies geschieht zufällig über sehr lange Zeiträume.

Ist TCP jetzt meine einzige Option?

Hast du die von mir vorgeschlagenen Timeouts in deine Caddy-Vorlage aufgenommen? Ich bin zwar kein Experte, aber ich denke, das könnte dein Problem lösen.

Das ergibt keinen Sinn, da einige Anfragen nach 15 Sekunden fehlschlagen und andere in weniger als einer Sekunde. Im Grunde ist es sinnlos.

Richtig, du kannst diese beiden Links überprüfen:

Es scheint, als würde dir ein Header fehlen und/oder deine Kette in der Discourse-Vorlagendatei.

@Falco @satonotdead

Ich habe es geschafft, es zu beheben. Ich habe eine gute Weile gewartet, um sicherzugehen, dass es wirklich behoben ist, und es scheint, dass es der Fall ist.

Die Konfiguration

Discourse läuft in Docker und stellt nginx über einen Unix-Socket (/var/discourse/shared/standalone/nginx.http.sock) bereit. Caddy sitzt davor als Reverse Proxy und leitet die echte IP des Clients über X-Real-IP weiter.

Zwei Dinge mussten geändert werden: die nginx-Konfiguration innerhalb des Containers und die Art und Weise, wie ich den Rebuild durchführe.

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

Der entscheidende Code ist der, der im Körper von after_web_config steht.

2. Der Caddyfile-Block

Das folgende Skript steuert den Wartungsmodus über eine Flag-Datei, daher muss dein Site-Block darüber Bescheid wissen:

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

Der Pfad zur Flag-Datei im Skript und die Datei, nach der der @maintenance-Matcher sucht, müssen dieselbe Datei sein. Wenn du eine umbenennst, benenne auch die andere um, sonst greift der Wartungsmodus stillschweigend nie.

Wenn dieselbe Caddy-Instanz auch andere Apps bedient, verwende eine Flag, die nur vom Block des Forums abgefangen wird, da ein Discourse-Rebuild sonst diese anderen Apps mit herunterreißt.

3. Das Rebuild-Skript

Ich benutze ./launcher rebuild app nicht mehr direkt. Ich verwende 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"

Was das Skript tatsächlich tut

  1. Setzt die Site über die Flag-Datei von Caddy in den Wartungsmodus.
  2. Wartet, bis der Socket ruhig ist, und führt dann ./launcher rebuild app aus.
  3. Pollt /srv/status über den Socket, bis Unicorn mit 200 antwortet. Das ist der entscheidende Teil: Es verhindert, dass Anfragen nginx erreichen, während Unicorn noch startet.
  4. Lädt Caddy neu (Vorsichtshalber, es stellte sich heraus, dass dies nicht kritisch war).
  5. Entfernt die Wartungs-Flag nur, wenn dieser Poll erfolgreich war.

Nach mehreren Wochen und mehreren Rebuilds habe ich diesen Fehler nicht wieder gesehen.

PS: Ich weiß wirklich nicht, was ich da eigentlich gemacht habe.