我在 Caddy 和 Discourse 之间使用 Unix 套接字而非 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:' ^  

发生此错误时,会显示一个**“Oops - Error 500”**页面。起初,我以为是 Caddy 的问题,因为它没有将客户端的 IP 地址转发给 Discourse,所以我尝试了多种不同的配置,但都没有解决问题。

值得一提的是,这种情况并不经常发生。它只是偶尔出现,而且当它发生时,只需刷新页面即可立即恢复网站。随后网站会正常运行相当长一段时间,直到错误再次随机出现。

我不是专家,而且我的自托管实例使用的是 Nginx 而不是 Caddy,但我能否请您提供 ./app/containers.yml 中实际的 Caddy 和 Discourse 模板配置?

您还记得在出错之前通常做了什么吗?我是说,更改管理员设置、发帖,还是使用了某些插件?

很抱歉无法为您的问题提供具体帮助,但我想您的回复能为您的问题增加价值,从而获得社区清晰快速的回应。

这意味着请求/用户有一个指向你的 Socket 的远程 IP,这表明你的代理链中的某些配置不正确。

@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 等其他 Web 服务器共享端口,
## 请参阅 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%。
  ## 引导程序会根据检测到的 RAM 自动设置,或者您可以覆盖
  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
  ## 支持多少并发 Web 请求?取决于内存和 CPU 核心数。
  ## 引导程序会根据检测到的 CPU 自动设置,或者您可以覆盖
  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)

  ## 如果您添加了 Let's Encrypt 模板,请取消注释下方以获取免费 SSL 证书
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## 此 Discourse 实例的 http 或 https CDN 地址(配置为拉取)
  ## 请参阅 https://meta.discourse.org/t/14857 了解详情
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## 用于 IP 地址查找的 MaxMind 地理位置 IP 账户 ID 和许可证密钥
  ## 请参阅 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
	}
}

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

# 为了在 SSL 安全评分中获得 A+
(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 中没有看到任何异常。

如果套接字连接在断开之前一直正常,我认为您可以添加显式的超时设置和传输选项。

我认为你可以添加显式的超时设置和传输选项

怎么做?

嗯,正如我之前所说,我没有使用 Caddy,但你可以查看他们的官方文档

我想我找到问题了。如果我执行重建,我从不重启 Caddy。从现在开始,我会这样做:

./launcher rebuild app && systemctl reload caddy

我怀疑 Caddy 在重建后会使用 socket 缓存或类似的东西。但这通常不会在重建后立即发生。我们拭目以待。

我已经为此忙了好几天。我确认 Caddy 确实发送了该头部,nginx 也确实接收并应用了它;没有 CDN,没有其他进程触及 socket,也没有 webhook。所有测试都通过了。但 Unix 错误仍然不断出现。我原以为是因为重建导致的,但并非如此。所有这些问题都是在极长的时间内随机发生的。

现在 TCP 是我唯一的选项了吗?

你有没有把我建议的超时设置添加到你的 Caddy 模板中?虽然我不是专家,但我认为这可能会解决你的问题。

这样做没有意义,因为有些请求在15秒后失败,而另一些则在不到1秒内失败。这基本上毫无意义。

好的,你可以查看这两个链接:

看来你缺少一个标头,或者你的 Discourse 模板文件中的信任链配置有问题。

@Falco @satonotdead

我成功修复了这个问题。我特意等了一段时间,确认问题确实已经解决,目前看起来一切正常。

环境配置

Discourse 运行在 Docker 中,并通过 Unix 套接字(/var/discourse/shared/standalone/nginx.http.sock)暴露 nginx。Caddy 作为反向代理位于其前端,并通过 X-Real-IP 传递客户端的真实 IP。

有两处需要修改:容器内的 nginx 配置,以及我运行重建的方式。

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。这是关键部分:它阻止了请求在 Unicorn 仍在启动时到达 nginx。
  4. 重新加载 Caddy(双保险,事实证明这一步并非关键)。
  5. 仅当轮询成功时才清除维护标志。

几周过去,经过多次重建,我再也没有看到过那个错误。

附注:我真的不知道我到底做了什么。