Estou usando um soquete Unix entre Caddy e Discourse em vez de TCP, mas de vez em quando recebo um erro 'unix:'

Os logs mostram o seguinte:

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

Quando isso acontece, uma página de “Oops - Erro 500” é exibida. A princípio, achei que o Caddy fosse o culpado, pois ele não estava repassando o endereço IP do cliente ao Discourse, então tentei várias configurações diferentes, mas nenhuma resolveu o problema.

Vale mencionar que isso não acontece com frequência. Ocorre apenas ocasionalmente, e quando ocorre, basta atualizar a página para que o site volte a funcionar normalmente. Ele então opera sem problemas por um bom tempo, até que o erro ocorra aleatoriamente novamente.

Não sou especialista e não usei Caddy, mas sim Nginx na minha instância auto-hospedada, mas posso pedir sua configuração atual de modelos Caddy e Discourse em ./app/containers.yml?

Você consegue lembrar o que costumava fazer antes do erro acontecer? Quero dizer, alterar configurações de administrador, postar, usar algum plugin?

Sinto muito por não poder ajudar especificamente com seu problema, mas acho que suas respostas podem agregar valor à sua pergunta para obter uma resposta clara e rápida da comunidade.

Isso significa que uma solicitação/usuário tem um IP remoto apontando para seu socket, o que indica que algo na sua cadeia de proxies está configurado incorretamente.

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


## quais portas TCP/IP este contêiner deve expor?
## Se você quiser que o Discourse compartilhe uma porta com outro servidor web como Apache ou nginx,
## consulte https://meta.discourse.org/t/17247 para mais detalhes
expose:
  # - "66:80"   # http
  # - "66:443" # https

params:
  ## Qual revisão do Git este contêiner deve usar? (padrão: latest)
  version: latest
  ## Tamanho máximo de upload (padrão: 10m)
  upload_size: 150m
  
  db_default_text_search_config: "pg_catalog.english"

  ## Defina db_shared_buffers para no máximo 25% da memória total.
  ## será definido automaticamente pelo bootstrap com base na RAM detectada, ou você pode substituir
  db_shared_buffers: "2048MB"

  ## pode melhorar o desempenho de ordenação, mas aumenta o uso de memória por conexão
  #db_work_mem: "40MB"

  ## Qual revisão do Git este contêiner deve usar? (padrão: 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
  ## Quantas solicitações web simultâneas são suportadas? Depende da memória e dos núcleos de CPU.
  ## será definido automaticamente pelo bootstrap com base nas CPUs detectadas, ou você pode substituir
  UNICORN_WORKERS: 8

  ## TODO: O nome de domínio ao qual esta instância do Discourse responderá
  ## Obrigatório. O Discourse não funcionará com um número IP puro.
  DISCOURSE_HOSTNAME: example.com

  ## Descomente se quiser que o contêiner seja iniciado com o mesmo
  ## nome de host (opção -h) especificado acima (padrão "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: Lista de e-mails separados por vírgula que serão feitos admin e developer
  ## no cadastro inicial exemplo 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: 'admin+discourse@example.com'

  ## TODO: O servidor de e-mail SMTP usado para validar novas contas e enviar notificações
  # O ENDEREÇO SMTP é obrigatório
  # AVISO: a senha SMTP deve estar entre aspas 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, padrão: true)
  DISCOURSE_SMTP_DOMAIN: example.com # (obrigatório por alguns provedores)
  DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (opcional, padrão: peer, valores válidos: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (padrão: plain, valores válidos: plain, login, cram_md5)

  ## Se você adicionou o template do Lets Encrypt, descomente abaixo para obter um certificado SSL gratuito
  # LETSENCRYPT_ACCOUNT_EMAIL: admin+letsencrypt@example.com

  ## O endereço do CDN http ou https para esta instância do Discourse (configurado para pull)
  ## consulte https://meta.discourse.org/t/14857 para mais detalhes
  #DISCOURSE_CDN_URL: https://discourse-cdn.example.com

  ## O ID da conta e a chave de licença do Maxmind geolocation IP para consultas de endereço IP
  ## consulte https://meta.discourse.org/t/-/173941 para mais detalhes
  #DISCOURSE_MAXMIND_ACCOUNT_ID: 123456
  #DISCOURSE_MAXMIND_LICENSE_KEY: 1234567890123456

  # Forçar HTTPS
  DISCOURSE_FORCE_HTTPS: true
  
  # Limites de Solicitação
  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

## O contêiner Docker é stateless; todos os dados são armazenados em /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

## Os plugins vão aqui
## consulte https://meta.discourse.org/t/19157 para mais detalhes
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git
          - cp -a /var/plugins/. $home/plugins/

## Quaisquer comandos personalizados para executar após a construção
run:
  - exec: echo "Início dos comandos personalizados"
  ## Se você quiser definir o endereço de e-mail 'De' para seu primeiro cadastro, descomente e altere:
  ## Após receber o e-mail de primeiro cadastro, re-comente a linha. Ela só precisa ser executada uma vez.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "Fim dos comandos personalizados"

Caddyfile

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

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

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

# Para obter uma classificação A+ em segurança SSL
(hsts) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

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

#---------------------------------------------------------------------------------
# Fórum (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
	#-------------------------------------------------------------------------------
}

#---------------------------------------------------------------------------------
# Subdomínios
#---------------------------------------------------------------------------------

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

	# Forçar a versão não-www do domínio
	@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

			# Arquivos 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/*
		}

		# proteger_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últiplos arquivos HTML independentes
  #-------------------------------------------------------------------------------

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

  #-------------------------------------------------------------------------------
  # Sem matchers
  #-------------------------------------------------------------------------------

	handle {
		respond 404
	}

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

Para ser mais específico, esse erro ocorre várias horas após o anterior, não acontece com muita frequência. Não vejo nada de incomum no app.yml ou no Caddyfile.

Se a conexão com o socket estiver correta até ser interrompida, acho que você pode adicionar tempos limite e opções de transporte explícitos.

Acho que você pode adicionar tempos limite e opções de transporte explícitos

Como?

Bem, como eu disse antes, não usei o Caddy, mas você pode consultar a documentação oficial deles.

Acho que encontrei o problema. Quando faço uma reconstrução, nunca reinicio o Caddy. A partir de agora, vou fazer isso:

./launcher rebuild app && systemctl reload caddy

Suspeito que o Caddy use o cache de soquetes ou algo semelhante após uma reconstrução. Mas isso geralmente não acontece imediatamente após a reconstrução. Vamos ver.

Estou trabalhando nisso há dias. Verifiquei que o Caddy envia o cabeçalho, que o nginx recebe e aplica, que não há CDN, nenhum outro processo tocando no soquete e nenhum webhook. Todos os testes passam. E o erro do Unix continua aparecendo. Pensei que fosse por causa da reconstrução, mas não é. Tudo isso acontece aleatoriamente ao longo de períodos muito longos.

TCP é minha única opção agora?

Você adicionou os tempos limite que sugeri ao seu modelo do Caddy? Embora eu não seja um especialista, acho que isso pode resolver o seu problema.

Isso não faz sentido, pois algumas solicitações falham em 15 segundos e outras em menos de 1 segundo. É basicamente inútil.

Certo, você pode verificar estes dois links:

Parece que você está perdendo um cabeçalho e/ou sua cadeia de confiança no arquivo de modelo do Discourse.

@Falco @satonotdead

Consegui consertar. Esperei um bom tempo para ter certeza de que estava realmente corrigido, e parece que está.

A configuração

O Discourse roda em Docker e expõe o nginx em um socket Unix (/var/discourse/shared/standalone/nginx.http.sock). O Caddy fica na frente dele como proxy reverso e passa o IP real do cliente através de X-Real-IP.

Duas coisas precisaram ser alteradas: a configuração do nginx dentro do contêiner e a forma como executo o 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

O código crítico é o que está dentro do corpo de after_web_config.

2. O bloco do Caddyfile

O script abaixo controla o modo de manutenção através de um arquivo de sinalização (flag), então seu bloco de site precisa saber disso:

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

O caminho da flag no script e o arquivo que o matcher @maintenance procura precisam ser o mesmo arquivo. Se você renomear um, renomeie o outro, caso contrário o modo de manutenção nunca será ativado silenciosamente.

Se a mesma instância do Caddy também servir outros aplicativos, use uma flag que apenas o bloco do fórum corresponda, caso contrário um rebuild do Discourse derrubará esses outros aplicativos junto.

3. O script de rebuild

Não uso mais ./launcher rebuild app diretamente. 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"

O que o script realmente faz

  1. Coloca o site em manutenção através do arquivo de flag do Caddy.
  2. Aguarda o socket ficar quieto e então executa ./launcher rebuild app.
  3. Faz polling em /srv/status através do socket até que o Unicorn responda 200. Esta é a parte crítica: é o que impede que as requisições cheguem ao nginx enquanto o Unicorn ainda está inicializando.
  4. Recarrega o Caddy (por garantia, não se mostrou crítico).
  5. Limpa a flag de manutenção apenas se aquele polling tiver sucesso.

Depois de várias semanas e vários rebuilds, não vi esse erro novamente.

P.S.: Eu realmente não sei o que diabos eu fiz.