Несколько контейнеров приложений для одного сайта Discourse

Вы можете разместить несколько независимых установок Discourse на одном сервере (отдельные контейнеры / отдельные порты / отдельные app.yml), не используя функцию «multisite» (мульти-сайт) в Discourse.

Это более ручной процесс по сравнению с multisite, но он обеспечивает изоляцию экземпляров и упрощает последующую миграцию отдельного сайта на собственный сервер.

Один из практических подходов:

• внешний PostgreSQL (один экземпляр)
• внешний Redis (один экземпляр)
• несколько веб-контейнеров Discourse
• один узел Sidekiq
• обратный прокси с проверками работоспособности

Это позволяет полностью избежать использования multisite, сохраняя при этом возможность экономии средств на конфигурациях с низкой нагрузкой.



РУКОВОДСТВО ПО ЗАПУСКУ DISCOURSE В НЕСКОЛЬКИХ КОНТЕЙНЕРАХ
Внешний PostgreSQL + Redis + HAProxy + app1 / app2


  1. ПАКЕТЫ ХОСТА
Шаг Команда
Обновление системы apt-get update
Установка базовых инструментов apt-get install -y ca-certificates curl gnupg lsb-release
Установка HAProxy + certbot + socat apt-get install -y haproxy certbot socat

  1. DOCKER СЕТЕВОЕ ПОДКЛЮЧЕНИЕ (ОБЯЗАТЕЛЬНО)

Требуется пользовательская сеть Docker, чтобы контейнеры могли разрешать имена друг друга.

Шаг Команда
Создание сети docker network create discourse-net
Проверка docker network ls | grep discourse-net

Это позволяет корректно работать следующим переменным:

• DISCOURSE_DB_HOST=pg
• DISCOURSE_REDIS_HOST=redis


  1. СЕКРЕТЫ
Назначение Команда
Суперпользователь PostgreSQL export PG_SUPERPASS='REPLACE_ME_super_strong'
Пароль базы данных Discourse export DISCOURSE_DBPASS='REPLACE_ME_discordb_strong'
Пароль Redis export REDIS_PASS='REPLACE_ME_redis_strong'
Базовый секретный ключ export SECRET_KEY_BASE="$(openssl rand -hex 64)"

  1. КОНТЕЙНЕР POSTGRES
Шаг Команда
Создание директории mkdir -p /var/discourse/external/postgres
Запуск контейнера docker run -d --name pg --restart=always --network=discourse-net -e POSTGRES_PASSWORD="$PG_SUPERPASS" -v /var/discourse/external/postgres:/var/lib/postgresql/data postgres:15
Проверка docker ps | grep pg

  1. СОЗДАНИЕ БАЗЫ ДАННЫХ
Шаг Команда
Создание роли docker exec -it pg psql -U postgres -c "CREATE ROLE discourse LOGIN PASSWORD '$DISCOURSE_DBPASS';"
Создание БД docker exec -it pg psql -U postgres -c "CREATE DATABASE discourse OWNER discourse ENCODING 'UTF8' TEMPLATE template0;"
Поиск текста docker exec -it pg psql -U postgres -d discourse -c "ALTER DATABASE discourse SET default_text_search_config = 'pg_catalog.english';"
Тест входа docker exec -it pg psql -U discourse -d discourse -c "select 1;"

  1. РАСШИРЕНИЕ PGVECTOR

Требуется для современных версий Discourse.

Шаг Команда
Установка docker exec -it pg bash -lc 'apt-get update && apt-get install -y postgresql-15-pgvector && rm -rf /var/lib/apt/lists/*'
Создание расширения docker exec -it pg psql -U postgres -d discourse -c "CREATE EXTENSION IF NOT EXISTS vector;"
Проверка docker exec -it pg psql -U postgres -d discourse -c "SELECT extname FROM pg_extension WHERE extname='vector';"

  1. КОНТЕЙНЕР REDIS
Шаг Команда
Создание директории mkdir -p /var/discourse/external/redis

Шаблон конфигурации Redis:

requirepass REPLACE_ME_REDIS
appendonly yes
save 900 1
save 300 10
save 60 10000
Шаг Команда
Запись конфигурации tee /var/discourse/external/redis/redis.conf >/dev/null <<EOF
Вставка пароля sed -i "s/REPLACE_ME_REDIS/$REDIS_PASS/" /var/discourse/external/redis/redis.conf
Запуск Redis docker run -d --name redis --restart=always --network=discourse-net -v /var/discourse/external/redis:/data -v /var/discourse/external/redis/redis.conf:/usr/local/etc/redis/redis.conf redis:7-alpine redis-server /usr/local/etc/redis/redis.conf
Тест аутентификации docker exec -it redis redis-cli -a "$REDIS_PASS" ping

  1. СТРУКТУРА ДИРЕКТОРИЙ DISCOURSE
Шаг Команда
Создание базовой директории mkdir -p /var/discourse
Переход в директорию cd /var/discourse
Клонирование репозитория git clone https://github.com/discourse/discourse_docker.git
Директория контейнеров mkdir -p /var/discourse/containers
Общие логи mkdir -p /var/discourse/shared/web-only/log/var-log
Ссылка на контейнеры ln -sfn /var/discourse/containers /var/discourse/discourse_docker/containers
Ссылка на launcher ln -sfn /var/discourse/discourse_docker/launcher /var/discourse/launcher

  1. КОНТЕЙНЕРЫ ПРИЛОЖЕНИЙ

app1.yml
• web + sidekiq
• порт 8001

docker_args: "--network=discourse-net"
expose:
  - "8001:80"

app2.yml
• только web
• порт 8002
• sidekiq отключен

docker_args: "--network=discourse-net"
expose:
  - "8002:80"

run:
  - exec: bash -lc 'mkdir -p /etc/service/sidekiq && touch /etc/service/sidekiq/down'

  1. ИНИЦИАЛИЗАЦИЯ (BOOTSTRAP)
Шаг Команда
Переход в директорию cd /var/discourse/discourse_docker
Инициализация app1 ./launcher bootstrap app1
Запуск app1 ./launcher start app1
Инициализация app2 ./launcher bootstrap app2
Запуск app2 ./launcher start app2

  1. ПРОВЕРКИ РАБОТОСПОСОБНОСТИ
Шаг Команда
app1 curl -sSf http://127.0.0.1:8001/srv/status
app2 curl -sSf http://127.0.0.1:8002/srv/status
sidekiq app1 docker exec -it app1 pgrep -fa sidekiq
sidekiq app2 `docker exec -it app2 pgrep -fa sidekiq

  1. TLS СЕРТИФИКАТ
Шаг Команда
Остановка прокси systemctl stop haproxy
Выпуск сертификата certbot certonly --standalone -d example.com --agree-tos -m you@example.com --non-interactive
Запуск прокси systemctl start haproxy

  1. ЛОГИКА HAPROXY
frontend fe_discourse
    bind :80
    bind :443 ssl crt /etc/letsencrypt/live/example.com/haproxy.pem

    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    http-request set-header X-Forwarded-Proto http if !{ ssl_fc }

    redirect scheme https code 301 if !{ ssl_fc }

    use_backend be_discourse if { nbsrv(be_discourse) gt 0 }
    default_backend be_maint
backend be_discourse
    balance roundrobin
    option httpchk GET /srv/status
    server app1 127.0.0.1:8001 check
    server app2 127.0.0.1:8002 check
backend be_maint
    http-request return status 503 content-type text/html string "<h1>Maintenance</h1>"

  1. ПЕРЕСОЗДАНИЕ БЕЗ ПРОСТОЯ (ZERO-DOWNTIME)
Шаг Команда
Отключение app1 echo "disable server be_discourse/app1" | socat stdio /run/haproxy/admin.sock
Пересоздание app1 ./launcher rebuild app1
Включение app1 echo "enable server be_discourse/app1" | socat stdio /run/haproxy/admin.sock
Шаг Команда
Отключение app2 echo "disable server be_discourse/app2" | socat stdio /run/haproxy/admin.sock
Пересоздание app2 ./launcher rebuild app2
Включение app2 echo "enable server be_discourse/app2" | socat stdio /run/haproxy/admin.sock

КОНЕЦ

Требуется сеть Docker
Внешние PostgreSQL и Redis
Установлено pgvector
Sidekiq изолирован в app1
Включены проверки работоспособности HAProxy
Активен резервный режим обслуживания
Поддерживается последовательное пересоздание

Миграция одного сайта на собственный сервер в будущем

Одним из преимуществ запуска полностью независимых установок Discourse (вместо multisite) является то, что миграция проста и сопряжена с низким риском.

Каждый экземпляр Discourse уже имеет:

• собственный контейнер
• собственные загрузки (uploads)
• собственную базу данных
• собственное использование Redis
• собственный app.yml

Не требуется развязывание multisite.


Общие шаги миграции

  1. Подготовка нового VPS

Установите Docker и Discourse обычным образом на новом сервере.
Не настраивайте multisite.


  1. Создание полной резервной копии

На исходном сайте:

Администрирование → Резервные копии → Создать резервную копию

Скачайте файл резервной копии.

Она включает:

• базу данных
• загрузки
• пользователей
• настройки
• темы


  1. Восстановление на новом сервере

На новом сервере:

• выполните первоначальную настройку
• войдите как администратор
• загрузите резервную копию
• восстановите данные

Discourse автоматически обрабатывает совместимость схемы.


  1. Переключение DNS

Обновите A-запись домена, чтобы она указывала на IP-адрес нового сервера.

После обновления DNS пользователи будут автоматически перенаправлены.


  1. Отключение старого контейнера

На исходном сервере:

• остановите старый контейнер
• удалите его, когда будете уверены

Другие установки Discourse на том же хосте не пострадают.


Почему это проще, чем multisite

В настройках multisite миграция часто требует:

• разделения баз данных
• извлечения данных, специфичных для сайта
• настройки multisite.yml
• перестройки Sidekiq
• перенастройки загрузок и электронной почты

При использовании независимых установок этого не требуется.

Каждый сайт уже является независимым.


Резюме

Этот подход требует немного большей сложности в операционной деятельности на начальном этапе,
но обеспечивает очень простое разделение в будущем.

Он особенно хорошо подходит для экспериментов
или создания сообществ на ранней стадии.


Когда этот подход, вероятно, не подойдет

Эта настройка обычно не является хорошей идеей, если:

• сайты ожидают умеренный или высокий трафик на раннем этапе
• вы сильно полагаетесь на официальную поддержку Discourse
• вам неудобно отлаживать Docker, сети или обратные прокси
• требования к времени безотказной работы строгие или критичны для бизнеса
• несколько сайтов тесно связаны операционно
• вы ожидаете частых экспериментов с плагинами на всех экземплярах

В этих случаях лучше выбрать:

• поддерживаемую настройку multisite
или
• одну установку Discourse на сервер

что обычно приведет к меньшему количеству операционных неожиданностей.


Важное примечание

Этот подход увеличивает гибкость инфраструктуры,
но также увеличивает ответственность администратора.

Он работает лучше всего, когда человек, его использующий, готов взять на себя полный стек технологий
и воспринимать периодические сбои как часть процесса обучения.

Если стабильность и возможность поддержки являются основными целями,
поддерживаемая конфигурация почти всегда будет лучшим выбором.

Ещё одно замечание, которое напрямую связано с частью настройки HAProxy, описанной выше.

В связке HAProxy + Discourse часто встречается следующее поведение: при пересоздании веб-контейнера (например, командой ./launcher rebuild app1) на короткое время возвращаются ответы 503 Service Unavailable, поскольку HAProxy продолжает направлять трафик на этот бэкенд, пока он перезапускается. Это не ошибка самого Discourse — такое происходит из-за того, что бэкенд временно недоступен во время пересоздания.

Рекомендуемое решение — использовать сокет администратора HAProxy для:
1. отключения сервера в HAProxy перед пересозданием и
2. повторного включения его после завершения пересоздания.

Это предотвращает возникновение этих временных ошибок 503.

Существует обсуждение на Meta, документирующее это поведение и объясняющее описанное решение:

Если кто-то из вас использует HAProxy для последовательных пересозданий, в этой теме содержится полезный контекст, объясняющий, почему команды через сокет администратора включены в инструкцию по эксплуатации (runbook).

Я делаю что-то подобное: для каждого сайта использую отдельный контейнер только для веб-сервиса и traefik (хотя у меня также есть настройка с nginx-proxy) в качестве обратного прокси. Я какое-то время пробовал HAproxy (насколько мне известно, именно его использует CDCK), но счел его неудобным.

Я почти уверен, что для каждого сервера Discourse нужен свой отдельный Redis.

Кажется, здесь возникло небольшое несоответствие в терминологии.

Когда вы говорите «один Redis на сервер Discourse», я согласен, если под «сервером» подразумевается одна логическая установка Discourse.

В моём случае:

  • HAProxy используется только для отказоустойчивости и балансировки нагрузки
  • Конфигурация мультисайта отсутствует
  • Есть только один сайт Discourse (одно имя хоста, одна база данных Postgres)
  • Просто оказалось, что есть два контейнера приложений, способных обслуживать этот же сайт

Таким образом, это ближе к схеме с несколькими веб-серверами и HA, а не к двум независимым установкам Discourse.

В такой конфигурации общий Redis является ожидаемым и необходимым — иначе вы потеряете:

  • общие сессии
  • доставку MessageBus
  • ограничение частоты запросов (rate limiting)
  • координацию фоновых задач

Это тот же паттерн, что и при запуске нескольких контейнеров web_only или горизонтальном масштабировании веб-воркеров:
несколько контейнеров приложений → одна база данных Postgres + один Redis.

Общий Redis недопустим только в случае двух отдельных сайтов Discourse (разные имена хостов / базы данных). В такой ситуации каждому сайту нужна своя база данных Redis (или отдельный экземпляр), чтобы избежать конфликтов ключей.

Таким образом, концептуально мы с вами согласны — дело лишь в следующем:

  • :white_check_mark: один Redis на один сайт Discourse
  • :cross_mark: не один Redis на каждый отдельный контейнер приложения

Готов уточнить детали, если я что-то неправильно понял — просто хотел более чётко описать топологию.

О. Это противоположное тому, о чём я думал, что мы говорим. Заголовок гласит: «Один сервер для двух сообществ Discourse». А вы говорите о «двух серверах для одного сообщества Discourse».

Вы правы — я смешал две разные топологии, и заголовок темы это явно указывает.

Эта тема касается «одного сервера для двух сообществ» (двух независимых сайтов).
Мой предыдущий комментарий об «внешнем Redis (единый экземпляр)» описывал другой паттерн: «два контейнера приложения для одного сообщества» (HA / несколько веб-инстансов для одного сайта).

Итак, чтобы сформулировать чётко:

A) Два независимых сайта Discourse на одном сервере (о чём спрашивает автор темы)

  • Рассматривайте их как две отдельные установки
  • У них должны быть разные базы данных Postgres и разные экземпляры Redis (или хотя бы достаточная изоляция для MessageBus Pub/Sub, что и является той самой проблемой, на которую вы сослались)

B) Один сайт Discourse с несколькими веб-контейнерами приложения (о чём я говорил)

  • Они обязаны использовать одну и ту же базу данных Postgres и один и тот же Redis для этого сайта
    (сессии, ограничение скорости, MessageBus и т. д.)

Итак: :white_check_mark: ваше предупреждение о Redis относится к A (два сообщества / два сайта).
Моя заметка о «совместном использовании Redis» применима только к B (одно сообщество, масштабированное на несколько контейнеров).

Спасибо за исправление — в любых последующих инструкциях или постах я буду явно разделять эти два случая.