أستخدم مقبس 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:' ^  

عند حدوث ذلك، تظهر صفحة “عذراً - خطأ 500”. في البداية، اعتقدت أن Caddy هو السبب لأنه لم يكن ينقل عنوان IP الخاص بالعميل إلى Discourse، لذلك جربت عدة إعدادات مختلفة، لكن لم تحل أي منها المشكلة.

جدير بالذكر أن هذا لا يحدث بشكل متكرر. يحدث فقط بشكل عرضي، وعندما يحدث، فإن تحديث الصفحة فوراً يعيد استعادة الموقع. ثم يعمل بشكل طبيعي لفترة طويلة قبل أن يحدث الخطأ عشوائياً مرة أخرى.

لستُ خبيراً، ولم أستخدم Caddy بل Nginx على نسختي المستضافة ذاتياً، لكن هل يمكنني طلب إعدادات قوالب Caddy وDiscourse الفعلية لديك في ./app/containers.yml؟

هل يمكنك تذكر ما كنت تفعله عادةً قبل حدوث الخطأ؟ أعني، هل كنت تغير إعدادات المسؤول، أو تنشر منشوراً، أو تستخدم بعض الإضافات؟

أعتذر لعدم تقديم مساعدة محددة لمشكلتك، لكنني أعتقد أن ردودك يمكن أن تضيف قيمة إلى سؤالك للحصول على رد واضح وسريع من المجتمع.

هذا يعني أن طلبًا / مستخدمًا لديه عنوان IP بعيد يشير إلى منفذك (socket)، مما يعني أن هناك خطأ في تكوين سلسلة الوكلاء (proxy chain) الخاصة بك.

@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% من إجمالي الذاكرة.
  ## سيتم تعيينه تلقائيًا بواسطة bootstrap بناءً على ذاكرة الوصول العشوائي المكتشفة، أو يمكنك تجاوز ذلك
  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
  ## كم عدد طلبات الويب المتزامنة المدعومة؟ يعتمد على الذاكرة ونوى وحدة المعالجة المركزية.
  ## سيتم تعيينه تلقائيًا بواسطة bootstrap بناءً على وحدات المعالجة المركزية المكتشفة، أو يمكنك تجاوز ذلك
  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 مطلوب
  # تحذير: يجب وضع كلمة مرور 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

  ## معرف حساب وعنوان ترخيص Maxmind geolocation IP للبحث عن عناوين 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"
	}
}

# بيانات اعتماد 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.

إذا كان الاتصال بالـ socket صحيحًا حتى يتم قطعه، أعتقد أنه يمكنك إضافة مهلات زمنية صريحة وخيارات نقل.

أعتقد أنه يمكنك إضافة مهلات زمنية (timeouts) وخيارات نقل (transport) صريحة

كيف؟

حسنًا، كما قلتُ من قبل، لم أستخدم Caddy، لكن يمكنك الاطلاع على وثائقهم الرسمية.

أعتقد أنني وجدت المشكلة. إذا قمت بإعادة البناء، فلا أعيد تشغيل Caddy أبدًا. من الآن فصاعدًا، سأقوم بالآتي:

./launcher rebuild app && systemctl reload caddy

أشتبه في أن Caddy يستخدم ذاكرة التخزين المؤقت للمقبس أو شيئًا مشابهًا بعد إعادة البناء. لكن هذا لا يحدث عادةً مباشرة بعد إعادة البناء. سنرى.

لقد كنت أعمل على هذا الأمر لعدة أيام. لقد تأكدت من أن Caddy يرسل الرأس، وأن nginx يستقبله ويطبقه، وأن هناك لا CDN ولا أي عملية أخرى تلمس المقبض، ولا أي webhooks. كل اختبار فردي نجح. ومع ذلك، يظل خطأ Unix يظهر. ظننت أن السبب هو إعادة البناء، لكن الأمر ليس كذلك. كل هذا يحدث عشوائياً على فترات زمنية طويلة جداً.

هل TCP هو خيارى الوحيد الآن؟

هل أضفت فترات المهلة التي اقترحتها إلى قالب Caddy الخاص بك؟ على الرغم من أنني لست خبيراً، إلا أنني أعتقد أن ذلك قد يحل مشكلتك.

لا معنى لذلك لأن بعض الطلبات تفشل بعد 15 ثانية، بينما تفشل أخرى في أقل من ثانية واحدة. الأمر عديم الفائدة عملياً.

حسنًا، يمكنك التحقق من هذين الرابطين:

يبدو أنك تفتقد إلى رأس (header) و/أو أن سلسلة الثقة في ملف قالب Discourse لديك غير مكتملة.

@Falco @satonotdead

تمكنت من إصلاح المشكلة. انتظرت فترة كافية للتأكد من أن المشكلة تم حلها بالفعل، ويبدو أنها كذلك.

الإعداد

يعمل Discourse داخل Docker ويعرض nginx عبر مقبس Unix (/var/discourse/shared/standalone/nginx.http.sock). ويقف Caddy في المقدمة كوكيل عكسي (reverse proxy) ويمرر عنوان IP الفعلي للعميل عبر X-Real-IP.

كان لا بد من تغيير شيئين: إعدادات nginx داخل الحاوية، والطريقة التي أشغّل بها عملية إعادة البناء (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

الكود الحرج هو ما يوجد داخل جسم after_web_config.

2. كتلة Caddyfile

يسمح السكربت أدناه بتشغيل وضع الصيانة عبر ملف علامة (flag file)، لذا يجب أن تعرف كتلة الموقع الخاصة بك عن ذلك:

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

يجب أن يكون مسار العلامة في السكربت والملف الذي يبحث عنه مُطابق @maintenance هو نفس الملف. إذا أعدت تسمية أحدهما، فأعد تسمية الآخر، وإلا فلن يتم تفعيل وضع الصيانة بشكل صامت.

إذا كانت نفس نسخة Caddy تخدم تطبيقات أخرى أيضًا، فاستخدم علامة تتطابق فقط مع كتلة المنتدى، وإلا فإن إعادة بناء Discourse ستُسقط تلك التطبيقات الأخرى معها.

3. سكربت إعادة البناء

لم أعد أستخدم ./launcher rebuild app مباشرة. أستخدم 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"

ما يفعله السكربت فعليًا

  1. يضع الموقع في وضع الصيانة عبر ملف علامة Caddy.
  2. ينتظر حتى يصبح المقسب هادئًا، ثم يشغّل ./launcher rebuild app.
  3. يستعلم عن /srv/status عبر المقسب حتى يجيب Unicorn بـ 200. هذا هو الجزء الحرج: فهو ما يمنع الطلبات من الوصول إلى nginx بينما لا يزال Unicorn قيد الإقلاع.
  4. يعيد تحميل Caddy (كإجراء احترازي إضافي، حيث تبين أنه لم يكن حرجًا في النهاية).
  5. يزيل علامة الصيانة فقط إذا نجح الاستعلام.

بعد عدة أسابيع وعدة عمليات إعادة بناء، لم أرَ تلك الخطأ مرة أخرى.

ملاحظة: لا أعرف حقًا ما الذي فعلته بالضبط.