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


TL;DR-версия: я разработал конфигурацию Commento-сервера, которая легко и просто развёртывается в полуавтоматическом режиме. Скопируйте себе этот репозиторий с GitHub и следуйте инструкциям в README.

Некоторое время назад мне неудержимо захотелось сменить Disqus — который является, пожалуй, самой распространённой системой для добавления комментариев к страницам — на свободный и открытый Commento.


Почему именно Commento?


Проблема Disqus, как и многих других «бесплатных» продуктов, в том, что продуктом в данном случае является пользователь — то есть вы. Помимо этого, Disqus «обогащает» каждую страницу, где он используется, мегабайтами скриптов и более чем сотней дополнительных HTTP-запросов.


Плюс к этому, бесплатная его версия показывает рекламу, от которой можно откупиться «всего лишь» за 9 долларов в месяц (план Plus). Уже только этого достаточно, чтобы захотелось найти что-нибудь получше.


В какой-то момент я наткнулся на этот пост и узнал о существовании свободного сервера комментариев под названием Commento. По счастливому совпадению, Commento как раз не так давно стал полностью открытым — раньше он выпускался в двух вариантах, бесплатном Community и коммерческом Enterprise. Спасибо его разработчику Adhityaa Chandrasekar.


Commento на порядки эффективнее Disqus, типичный размер дополнительной загрузки с ним около 11 КБ, плюс сами комментарии, разумеется. Примерно такая же ситуация и с требуемыми HTTP-запросами.


Ещё один плюс сервера Commento в том, что он очень быстрый, так как написан на Go.


Ну и, в качестве вишенки на торте, у него есть импорт комментариев из Disqus, о чём ещё мечтать?


Варианты использования Commento


Для непродвинутых (в техническом плане) пользователей, у Commento есть готовый к использованию облачный сервис на commento.io. Размер ежемесячной платы автор предлагает вам выбрать самостоятельно, но она не может быть меньше $3 «по техническим причинам».


Мистер Чандрасекар также великодушно предлагает бесплатный аккаунт на Commento.io в обмен на «нетривиальные патчи» к продукту.


Ну, а я выбрал третий вариант: поднять сервер Commento самостоятельно. В этом случае ты ни от кого не зависишь (помимо хостера, конечно), а я люблю независимость.


Трудности


Я большой поклонник Docker-контейнеров и также часто использую Docker Compose, инструмент для управления группами нескольких связанных контейнеров. А у Commento есть готовый к употреблению Docker-образ в GitLab container registry.


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


Трудность №1: PostgreSQL


Commento требует сервера PostgreSQL довольно свежей версии, никакие другие SQL-серверы, к сожалению, не поддерживаются.


Ну ладно, мы всё равно всё запускаем в контейнерах, так что тут довольно просто.


Трудность №2: нет поддержки HTTPS


Commento сам по себе является веб-сервером, но он поддерживает лишь незащищённый протокол HTTP.


Тут надо отметить, что такая практика в наши дни довольно распространена: сервер в данном случае прячут за обратным прокси, который также выполняет SSL offloading. Штука в том, что поддержка SSL/HTTPS в данном случае совершенно обязательна, в конце концов на дворе 2019 год и на попытки авторизовать пользователя с помощью незащищенного Интернет-протокола будут смотреть очень косо.


Я решил использовать сервер Nginx, во-первых, у меня был немалый опыт работы с ним, а во-вторых, он очень быстр, экономичен и стабилен. И публикует официальные сборки Docker-образов.


Вторым ингредиентом в рецепте HTTPS является SSL-сертификат для домена. Я бесконечно признателен EFF и Mozilla за то, что они создали центр сертификации Let's Encrypt, ежемесячно выдающий миллионы бесплатных сертификатов.


Let's Encrypt также предоставляет свободную утилиту командной строки под названием certbot, сильно упрощающую процесс получения и обновления сертификата. Ну и — разумеется — Docker-образ для него!


Трудность №3: проблема курицы-яйца Certbot


А вот эта заморочка более хитрая.


Мы хотим сослаться на SSL-сертификат в конфигурации нашего обратного прокси на Nginx, что означает, что без сертификата он просто откажется стартовать. В том же самое время, чтобы получить SSL-сертификат для домена, требуется рабочий HTTP-сервер, который докажет Let's Encrypt ваше владение этим доменом.


Мне удалось решить и эту проблему, причём, как мне кажется, довольно изящно:


  1. Сначала генерируется фиктивный, невалидный сертификат, единственное предназначение которого состоит в том, чтобы дать Nginx запуститься.
  2. Nginx и certbot совместно получают новый, теперь уже валидный сертификат.
  3. Как только сертификат получен, certbot переходит в «ждущий режим», просыпаясь раз в 12 часов для проверки необходимости его обновления — согласно рекомендациям Let's Encrypt.
  4. Когда момент наступил и сертификат обновился, certbot подаёт сигнал Nginx перезапуститься.

Трудность №4: что-то должно сохраняться


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


Также, чтобы Let's Encrypt вас не забанил из-за слишком частых запросов, неплохо бы хранить полученные сертификаты в течение всего срока их годности.


Оба момента решены в предлагаемой конфигурации с помощью томов (volumes) Docker, автоматически создаваемых systemd при первом запуске Commento. Тома помечены как «внешние» (external), поэтому Docker пропускает их при удалении контейнеров с помощью docker-compose down -v.


Сводим всё воедино


Теперь можно взглянуть, как это всё вместе работает.


Рисунок ниже показывает взаимодействие и трафик между четырьмя контейнерами:



Я применил встроенную опцию Docker Compose depends_on, чтобы обеспечить запуск контейнеров в правильном порядке.


Если вы только хотите запустить собственный сервер Commento, остаток статьи можно пропустить и сразу перейти к коду на GitHub.


Ну а я дальше расскажу о данной реализации чуть подробнее.


Как это всё работает


Файл Compose


Как видно на рисунке выше, моя «композиция» состоит из четырёх сервисов:


  1. certbot — утилита certbot от EFF
  2. nginx — обратный прокси, осуществляющий SSL offloading
  3. app — сервер Commento
  4. postgres — база данных PostgreSQL

Файл docker-compose.yml содержит декларации собственной Docker-сети, названной commento_network, и трёх томов, из которых два являются внешними (то есть, должны создаваться вне Compose):


  • commento_postgres_volume хранит данные сервера PostgreSQL для Commento: пользователей, модераторов, комментариев и т.д.
  • certbot_etc_volume содержит сертификаты, полученные certbot-ом.

Nginx


Контейнер Nginx построен на базе легковесного официального образа, основанного на Alpine, и использует следующий скрипт для запуска:


#!/bin/sh

trap exit TERM

# Wait for the certificate file to arrive
wait_for_certs() {
    echo 'Waiting for config files from certbot...'
    i=0
    while [[ ! -f /etc/letsencrypt/options-ssl-nginx.conf ]]; do
        sleep 0.5
        [[ $((i++)) -gt 20 ]] && echo 'No files after 10 seconds, aborting' && exit 2
    done
}

# Watches for a "reload flag" (planted by certbot container) file and reloads nginx config once it's there
watch_restart_flag() {
    while :; do
        [[ -f /var/www/certbot/.nginx-reload ]] &&
            rm -f /var/www/certbot/.nginx-reload &&
            echo 'Reloading nginx' &&
            nginx -s reload
        sleep 10
    done
}

# Wait for certbot
wait_for_certs

# Start "reload flag" watcher
watch_restart_flag &

# Run nginx in the foreground
echo 'Starting nginx'
exec nginx -g 'daemon off;'

  • В строке 3 (ARRGHHH, Хабр не поддерживает отображение номеров строк в коде — прим. перев.) регистрируется обработчик прерывания, чтобы Nginx и фоновый процесс мониторинга благополучно завершали работу при остановке контейнера.
  • В строке 27 вызывается функция ожидания, приостанавливающая процесс запуска Nginx до тех пор, пока не появятся конфигурационные файлы SSL, создаваемые контейнером certbot. Без этого Nginx отказался бы запускаться.
  • В строке 30 создаётся фоновый процесс, которые регулярно, каждые десять секунд, проверяет наличие файла-флага с именем .nginx-reload, и, как только обнаружит его, подаёт Nginx команду перезагрузить конфигурацию. Этот файл также создаёт certbot, в момент когда сертификат обновляется.
  • Строка 34 запускает Nginx в нормальном режиме. При этом exec означает, что текущий shell-процесс замещается процессом Nginx.

Ещё один важный файл в данном образе — это конфигурация виртуального сервера Commento, заставляющая Nginx пересылать HTTPS-запросы в контейнер commento:


server {
    listen [::]:443 ssl ipv6only=on;
    listen 443 ssl;
    server_tokens off;

    root /var/www/html;
    index index.html index.htm index.nginx-debian.html;
    server_name __DOMAIN__;

    location / {
        proxy_pass http://app:8080/;
        proxy_set_header Host            $http_host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    ssl_certificate     /etc/letsencrypt/live/__DOMAIN__/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_tokens off;

    server_name __DOMAIN__;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Redirect to HTTPS on port 80
    location / {
        return 301 https://$host$request_uri;
    }
}

Первый server-блок (строки 1-21) описывает работу с HTTPS и правило форвардинга. Именно здесь и упоминаются файлы сертификата Let's Encrypt (или заглушки, используемые вместо них).


Домен, обслуживаемый сервером, передаётся в качестве аргумента при построении образа; он заменяет строку __DOMAIN__ в конфиге сервера.


Второй блок (строки 23-38) — это конфигурация HTTP-сервера, который используется certbot-ом для подтверждения владения доменом (так называемый «ACME challenge»). Все прочие запросы вызывают редирект на соответствующий адрес через HTTPS.


certbot


Наш образ certbot основан на официальной сборке с добавлением нижеследующего скрипта:


#!/bin/sh

trap exit TERM

# Wait until nginx is up and running, up to 10 seconds
wait_for_nginx() {
    echo 'Waiting for nginx...'
    i=0
    while ! nc -z nginx 80 &>/dev/null; do
        sleep 0.5
        [[ $((i++)) -gt 20 ]] && echo "nginx isn't online after 10 seconds, aborting" && exit 4
    done
    echo 'nginx is up and running'
}

# Check vars
[[ -z "$DOMAIN" ]] && echo "Environment variable 'DOMAIN' isn't defined" && exit 2
[[ -z "$EMAIL"  ]] && echo "Environment variable 'EMAIL' isn't defined" && exit 2
TEST="${TEST:-false}"

# Check external mounts
data_dir='/etc/letsencrypt'
www_dir='/var/www/certbot'
[[ ! -d "$data_dir" ]] && echo "Directory $data_dir must be externally mounted"
[[ ! -d "$www_dir"  ]] && echo "Directory $www_dir must be externally mounted"

# If the config/certificates haven't been initialised yet
if [[ ! -e "$data_dir/options-ssl-nginx.conf" ]]; then

    # Copy config over from the initial location
    echo 'Initialising nginx config'
    cp /conf/options-ssl-nginx.conf /conf/ssl-dhparams.pem "$data_dir/"

    # Copy dummy certificates
    mkdir -p "$data_dir/live/$DOMAIN"
    cp /conf/privkey.pem /conf/fullchain.pem "$data_dir/live/$DOMAIN/"

    # Wait for nginx
    wait_for_nginx

    # Remove dummy certificates
    rm -rf "$data_dir/live/$DOMAIN/"

    # Run certbot to validate/renew certificate
    test_arg=
    $TEST && test_arg='--test-cert'
    certbot certonly --webroot -w /var/www/certbot -n -d "$DOMAIN" $test_arg -m "$EMAIL" --rsa-key-size 4096 --agree-tos --force-renewal

    # Reload nginx config
    touch /var/www/certbot/.nginx-reload

# nginx config has been already initialised - just give nginx time to come up
else
    wait_for_nginx
fi

# Run certbot in a loop for renewals
while :; do
    certbot renew
    # Reload nginx config
    touch /var/www/certbot/.nginx-reload
    sleep 12h
done

Краткая экскурсия по его строкам:


  • Строка 3, как и в предыдущем скрипте, требуется для штатного завершения работы контейнера.
  • В строках 17-19 проверяются требуемые переменные.
  • А в строках 22-25 — что необходимые для работы certbot каталоги правильно смонтированы.
  • Дальше следует развилка:
    • Строки 30-50 выполняются лишь при первом запуске контейнера:
      • Копируется фиктивный сертификат, позволяющий Nginx нормально стартовать.
      • Nginx тем временем ждёт окончания этого процесса, после чего продолжает загрузку.
      • Как только Nginx запустился, certbot инициирует процесс получения взаправдашнего сертификата у Let's Encrypt.
      • И, наконец, как только сертификат получен, создаётся файл .nginx-reload, намекающий Nginx, что пора перезагрузить конфиг.
    • Строка 54 ждёт, пока Nginx запустится — в случае, когда полноценный сертификат уже имеется в наличии.
  • После всего этого (строки 58-63) он продолжает крутить цикл, раз в 12 часов проверяя необходимость продления сертификата и сигналя Nginx перезапуститься.

Commento и PostgreSQL


Контейнеры app и postgres используют исходные образы, предоставляемые разработчиками, без каких-либо изменений.


Сервис Systemd


Последним кусочком этого пазла является юнит-файл systemd commento.service, на который нужно создать симлинк в /etc/systemd/system/commento.service, чтобы он запускался в удачный момент при старте системы:


[Unit]
Description=Commento server

[Service]
TimeoutStopSec=30
WorkingDirectory=/opt/commento
ExecStartPre=-/usr/bin/docker volume create commento_postgres_volume
ExecStartPre=-/usr/bin/docker volume create certbot_etc_volume
ExecStartPre=-/usr/local/bin/docker-compose -p commento down -v
ExecStart=/usr/local/bin/docker-compose -p commento up --abort-on-container-exit
ExecStop=/usr/local/bin/docker-compose -p commento down -v

[Install]
WantedBy=multi-user.target

Строки:


  • В строке 6 подразумевается, что код проекта склонирован в каталог /opt/commento — так намного проще.
  • В строках 7-8 создаются внешние тома, если их ещё нет.
  • В строке 9 удаляются возможные останки прежних контейнеров. Внешние тома при этом сохраняются.
  • Строка 10 знаменует собой, собственно, запуск Docker Compose. Флаг --abort-on-container-exit прибивает всю стаю контейнеров при останове любого из них. Благодаря этому systemd, как минимум, будет в курсе, что сервис остановлен.
  • Строка 11 — это вновь очистка и удаление контейнеров, сетей и томов.

Исходный код


Полностью рабочая реализация, требующая лишь настройки переменных в docker-compose.yml, имеется в наличии на GitHub. Вам нужно лишь внимательно пройти по шагам, описанным в README.


Код распространяется на условиях MIT License.


Спасибо, что дочитали до этого места, комментарии неистово приветствуются!

Комментарии (15)


  1. questor
    18.08.2019 21:49

    Можно в двух словах как-то рассказать о этой системе комментариев? я правильно понял, что комментарии будут в твоей собственной базе? авторизация будет по openid/oauth соцсетей?


    1. yktoo Автор
      18.08.2019 21:53

      Да, комментарии в своей базе, логин либо через аккаунт в самом Commento (email/пароль), либо через Twitter/GitHub/GitLab/Google. Facebook не поддерживается.


  1. dmitriylyalyuev
    18.08.2019 21:58
    -1

    Чем Remark42 не угодил?


  1. gecube
    19.08.2019 09:30

    Сама система, наверное, неплохая, но способ дистрибуции, мягко говоря, сомнителен. На тестовой машине ещё куда ни шло. А вот в продакшен такое выпускать страшновато. Я б, например, вообще не рекомендовал docker-compose в системди юнит. И коли мы используем системди юниты, то логично каждый компонент системы оформить как отдельный юнит (она, 2019-й год — самое время учить системди, во время расцвета докеров). И как бонус — системди даёт более гибкие средства для управления порядком старта сервисов, чем докер (коллега ведь хелсчеки не пишет...).


    За упоминание commento — спасибо, это хороший бонус к кругозору.


    1. yktoo Автор
      19.08.2019 10:49

      Любопытно. А можете привести аргументы, чем systemd в данном случае лучше, чем Compose?


      У нас есть опыт использования Docker Compose в продакшне, довольно положительный, нареканий именно к Compose практически нет.


      1. gecube
        19.08.2019 11:36

        В конкретном случае:


        1. все держится на том, что docker-compose запускается не в детачд режиме (-d), а как основной процесс. Если какой-то контейнер упадет — ключ --abort-on-container-exit завершает компоуз и системди видит, что все сдохло. Как минимум это неэффективно — перезапустится весь стек


        2. меня очень смущает блок с вольюмами. Как будто если вольюм уже существует — оно сломается.


        3. как минимум — необходимость тащить docker-compose и деплоить файл с описанием списка контейнеров. Предпочтительнее — использовать голый docker + можно автоматизацию на базе ansible


        4. depends_on не ждет готовности контейнера, а стартует зависимый сразу же. Я писал как сделать правильный запуск в комменте тут https://habr.com/ru/post/454552/#comment_20240656 Но это требует расстановки хелсчеков, версии 2.4 компоуз-файла. Ну, и выглядит как костыль. Системди намного гибче, тем более, если нужно построить зависимость от какой-нибудь уже существующей службы (типа NFS сервера).


        5. по умолчанию — докер создает для контейнеров отдельную сеть. Стоит ли оно того — решать в каждом конкретном случае. Но много кейсов, когда эта "типа изоляция" не нужна. И можно в хост сеть публиковать сразу.



        1. yktoo Автор
          19.08.2019 14:17

          Спасибо за ответ.


          Как минимум это неэффективно — перезапустится весь стек

          В данном случае это нестрашно. Да и вероятность падения невысокая.


          меня очень смущает блок с вольюмами. Как будто если вольюм уже существует — оно сломается.

          Тут не понял, что вы имеете в виду.


          необходимость тащить docker-compose и деплоить файл

          Ну, это уже скорее вопрос вкуса :-)


          depends_on не ждет готовности контейнера, а стартует зависимый сразу же. Я писал как сделать правильный запуск в комменте

          А вот за это спасибо, я был не в курсе, что они добавили (наконец-то) HEALTHCHECK. В моих скриптах это реализовано вручную, так что есть шанс, что я перепишу на их использование.


          1. gecube
            19.08.2019 14:39

            Тут не понял, что вы имеете в виду.

            поясню проще. Блок ExecStartPre выполняется всегда до старта сервиса.
            Нюансы


            1. если образов в локальном кэше докера нет — они будет скачаны при docker-compose up. Это может привести к излишне долгому старту. Решайте сами допустимо ли это. По идее доставка образа до целевой машины — отдельная задача от старта сервиса. Как побочный эффект — у Вас будет одна версия сервисов, у Вашего коллеги — другая
            2. Я имел в виду, что docker volume create НЕ МОЖЕТ создать тот же вольюм повторно. А, следовательно, команда (наверное, но это не точно) должна сфейлиться. Если нужно игнорировать код возврата, то пишем blablabla || true. Но, повторюсь, что это не точно, т.к. я не уверен, что docker volume create дает код возврата. И опять же хорошая идея — вольюмы нарезать в процессе установки софта (доставки), а не во время запуска.

            Извините, вышенаписанное может быть похоже на буквоедство, можете это таковым и считать.
            У Вас очень правильный подход — в целом, набор сервисов, описание того, что происходит. Вопрос только к самой реализации.


            1. yktoo Автор
              19.08.2019 14:45

              если образов в локальном кэше докера нет — они будет скачаны при docker-compose up. Это может привести к излишне долгому старту. Решайте сами допустимо ли это. По идее доставка образа до целевой машины — отдельная задача от старта сервиса. Как побочный эффект — у Вас будет одна версия сервисов, у Вашего коллеги — другая

              С этим полностью согласен, поэтому в README у меня упоминается, что нужно сделать pull и build вручную.


              Я имел в виду, что docker volume create НЕ МОЖЕТ создать тот же вольюм повторно.

              Это верно, но у меня там и стоит "-", который как раз сообщает systemd, что код возврата неважен:


              ExecStartPre=-/usr/bin/docker volume create certbot_etc_volume


              1. gecube
                19.08.2019 14:56

                Тогда единственное, что мне остается добавить — что указанный конфиг работает, только если все опубликовано на белом IP. Т.к. в противном случае Let's Encrypt не сможет подтвердить владение доменом (хотя и это можно обойти, если подтверждение по DNS и провайдер DNS умеет в API). Т.е. для эксперимента на отделенной от интернета машине — кейс не прокатит.


  1. Denizzz_ZP
    19.08.2019 12:24

    Nginx c cerbot можно спокойно заменить на Traefik и включить acme


    1. yktoo Автор
      19.08.2019 12:26

      Мы использовали Traefik, но с Docker Swarm. Разве он работает с Compose?


      Он умеет сам запрашивать сертификаты с Let's Encrypt?


      1. gecube
        19.08.2019 12:37

        да. Умеет сам запрашивать. И хранит сертификаты в своем конфиге.


        Разве он работает с Compose?

        он не с компоузом работает, а с докером. Подключаете сокет демона, прописываете аннотациями (лейблами) параметры сервисов… и полетели!


  1. akdes
    19.08.2019 13:45

    Я в своё время пользовал Прокси контейнер с поддержкой Letsencrypt от человека по имени jwilder
    Этот прокси можно сразу поднять на несколько доменов/контейнеров, лишь добавлением переменных в dcompose-Файле домена для контейнера и он автоматом будет подключён к прокси, с загрузкой и обновлением сертификата.


    1. yktoo Автор
      19.08.2019 14:12

      Да, он использует похожий подход. Я не стал заморачиваться с поддержкой нескольких доменов, потому что в данном случае это было неактуально. В чём jwilder молодец, так это что у него есть куча тестов для сгенерированной кофигурации, но у него и проект намного более универсальный.