Настройка связки Nginx / LetsEncrypt в Docker Swarm

Про то, как поднять контейнер Nginx и настроить для него автообновление сертификатов LetsEncrypt, есть довольно много статей. В этой будет описана довольно нестандартная схема. Основные моменты:

  1. Nginx разворачивается как сервис в Docker Swarm, а не как standalone-контейнер;
  2. Для проверки используется схема DNS-01, а не гораздо более популярная HTTP-01;
  3. Для DNS-провайдера GoDaddy в настоящий момент нет DNS-plugin’а для certbot’а, но есть API по управлению доменными записями.

Что такое DNS-01 и зачем он нужен

Когда у LetsEncrypt’а запрашивают сертификат, ему нужно убедиться, что у того, кто его запрашивает, есть права на соответствующий домен. Для этого LetsEncrypt использует проверки. Самая популярная проверка называется HTTP-01. Она заключается в том, что клиенту сначала выдается специальный токен, а потом сервер LetsEncrypt делает запрос на адрес http://<DOMAIN>/.well-known/acme-challenge/<TOKEN> и проверяет, что ответ содержит тот же токен + хэшированный ключ того же клиента, которому токен выписывали. Но здесь есть 2 момента:

  • Запрос всегда выполняется именно такой, как написано выше. Поэтому у вас обязан быть открыт порт 80, а указанный путь всегда доступен извне без всякой аутентификации;
  • Wildcard-сертификаты не поддерживаются. Хотя вы можете выписать один сертификат сразу на несколько поддоменов (в этом случае LetsEncrypt будет делать запросы на каждый из поддоменов).

Проверка DNS-01 позволяет, во-первых, обойтись без открытого 80-порта, а во-вторых, использовать Wildcard-сертификаты. Но для этого требуется опубликовать токен, полученный от certbot’а, в виде DNS-записи _acme-challenge.<YOUR_DOMAIN> с типом TXT. Вручную это сделать не проблема, а вот для автоматизации требуется поддержка со стороны провайдера. Почти все крупные провайдеры предостставляют API для управления DNS-записями, а для некоторых существует dns-plugin’ы для certbot’а, которые реализуют клиентское подключение к этим API. Но для провайдера GoDaddy плагина нет, хотя API и существует.

А зачем Docker Swarm?

Docker Swarm — это встроенный в Docker механизм кластеризации. По сравнению с Kubernetes он менее функциональный, зато настраивается гораздо проще и доступен из коробки. Даже если вам не нужен кластер из нескольких машин, однонодный Docker Swarm может быть удобен, поскольку он предоставляет:

  • больше абстракций для организации сервисов (stack/service vs container);
  • горизонтальное масштабирование сервисов и поинстансное обновление;
  • лучшую поддержку рестарта;
  • возможность использования secret’ов (хотя и не в том виде, в котором предлагает ее Kubernetes).

В статье будет использоваться именно однонодный Swarm.

Что конкретно будет делаться в статье

Допустим, у вас есть домен example.com, купленный через GoDaddy. Вы хотите сделать сервер-gateway в этом домене. У вас планируется несколько сервисов, разделенных по поддоменам, пока вы точно не знаете сколько. Будут это отдельные машины или сервисы в рамках Swarm’а вы пока тоже не знаете, но допускаете, что может быть и так, и так. Вы хотите, чтобы весь трафик шел через этот gateway, а дальше уже маршрутизировался внутри вашей сети. Входящий трафик должен быть защищен SSL. Все поддомены вы регистрируете на один и тот же адрес, а за маршрутизацию у вас будет отвечать nginx. Он же будет выступать SSL-терминатором.

Далее в статье мы получим wildcard-сертификат для вашего домена, поднимем nginx в виде сервиса в docker swarm, а также настроим автоматическое обновление сертификата. Маршрутизация запросов после nginx’а рассматриваться не будет. Будем считать, что есть только один поддомен gateway.example.com, и все действия выполняются с этой машины.

Настройка Docker Swarm

Предполагается, что Docker Engine на машине уже установлен.

  1. Как уже упоминалось ранее, создание Swarm’а выполняется крайне просто:
    $ docker swarm init
  2. Чтобы посмотреть список и статус узлов кластера, выполните:
    $ docker node ls

Получение SSL-сертификатов

Предполагается, что у вас уже есть аккаунты в LetsEncrypt и GoDaddy.

  1. Получите необходимые данные для использования API своего DNS-провайдера. Для случая GoDaddy, зайдите на https://developer.godaddy.com/ и сгенерируйте себе API Key. В качестве типа ключа обязательно укажите Production.
  2. Сгенерируйте ACME-токен при помощи certbot’а. Certbot мы будем использовать также контейнеризированным:

    $ docker run --rm -it --mount type=bind,source=/opt/letsencrypt,target=/etc/letsencrypt certbot/certbot:v1.3.0 --email account@example.com --agree-tos -d *.example.com --manual --preferred-challenges dns certonly

    где /opt/letsencrypt — директория, в которую будут сохранены сертификаты и которая впоследствии будет подмонтирована nginx’ом для их использования;
    account@example.com — ваш аккаунт в LetsEncrypt;
    *.example.com — домен, для которого будет получен сертификат (в данном случае, wildcard).

    В ходе выполнения у вас спросят про то, можно ли вам присылать почту на email (можно ответить Нет), а также разрешение на публикацию факта, что с вашего IP происходил запрос сертификата (здесь надо ответить Да). Затем certbot будет ждать, когда вы опубликуете значение TOKEN_STRING в TXT-записи _acme-challenge.example.com.

  3. Далее откройте еще одну консоль (в первой certbot все еще ждет, пока вы не скажете ему проверить DNS). В этой консоли при помощи curl’а воспользуйтесь API GoDaddy для регистрации записи. Можно это сделать и через интерфейс сайта, но впоследствии похожие действия надо будет делать для настройки автообновления сертификатов.

    1. Сначала подготовьте payload:

      $ cat <<EOF > payload.json
      [{
        "data": "TOKEN_STRING",
        "name": "_acme-challenge",
        "type": "TXT"
      }]
      EOF

      где TOKEN_STRING — значение, выданное certbot’ом.

    2. Затем установите в переменные ключ и секретный токен вашего ключа от GoDaddy, полученного на шаге 1:

      $ export GODADDY_KEY=<KEY>
      $ export GODADDY_SECRET=<SECRET>

    3. Создайте запись _acme-challenge в вашем домене (обратите внимание на то, что в качестве одной из частей URL выступает ваш домен):

      $ curl -XPUT -d @payload.json -H "Content-Type: application/json" -H "Authorization: sso-key $GODADDY_KEY:$GODADDY_SECRET" https://api.godaddy.com/v1/domains/example.com/records/TXT/_acme-challenge

    4. Проверьте, что эта запись уже доступна через DNS и ее содержимое соответствует ожидаемому (может потребоваться время, особенно если вы генерируете запись повторно. Обычно, не более минуты):

      $ dig -t txt _acme-challenge.example.com
      ...
      ;; ANSWER SECTION:
      _acme-challenge.example.com. 600 IN TXT "TOKEN_STRING"
      ...

  4. Вернитесь в консоль с certbot’ом и нажмите Enter. Он завершит формирование сертификатов, которые можно будет найти в директории /opt/letsencrypt/live/example.com после того, как контейнер закончит свою работу.

Настройка сервиса Nginx

В этом примере будет использоваться единственный конфигурационный файл, который будет монтироваться как nginx.conf. На любой запрос, пришедший по SSL, сервер будет отвечать статусом HTTP 200 и текстом "It works!". В более сложных случаях вам может понадобиться монтировать для конфигов специальную директорию, так же как и директории для статического контента.

  1. Создайте файл nginx.conf в директории /opt/nginx/conf:

    nginx.conf

    user nginx;
    worker_processes 1;
    
    error_log /var/log/nginx/error.log warn;
    pid /var/run/nginx.pid;
    
    events {
      worker_connections 1024;
    }
    
    http {
      include /etc/nginx/mime.types;
      default_type application/octet-stream;
    
      log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    
      access_log /var/log/nginx/access.log main;
    
      sendfile on;
    
      keepalive_timeout 65;
    
      server {
        listen 443 ssl default_server;
        server_name _;
    
        ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
        location / {
          return 200 'It works!';
          add_header Content-Type text/plain;
        }
      }
    }

  1. Создайте сервис nginx:
    $ docker service create --name nginx -p 443:443 --mount type=bind,source=/opt/nginx/conf/nginx.conf,target=/etc/nginx/nginx.conf,ro --mount type=bind,source=/opt/letsencrypt,target=/etc/letsencrypt,ro nginx:1.17.9
  2. Зайдите на https://gateway.example.com в своем браузере, вы должны увидеть страницу с текстом "It works!".
  3. Для рестарта сервиса после изменения конфигурации, используйте команду:
    $ docker service update --force nginx

Автоматическое обновление сертификатов

Это будет непросто 🙂 Сертификаты LetsEncrypt действуют 90 дней. Сам LetsEncrypt рекомендует обновлять их через 60. Чтобы не делать это вручную каждые 2 месяца, задачу надо автоматизировать. Если попытаться саму логику периодического обновления тоже перенести в докер, то надо иметь ввиду следующее:

  • Лучше не запускать несколько процессов внутри контейнера. То есть, сделать образ, в котором будет и выполняться периодическое обновление сертификатов, и выполняться основная работа nginx — не самый хороший вариант;
  • Docker Swarm не поддерживает ни одноразовые задачи, ни задачи по расписанию (хотя есть довольно многообещающая инициатива: https://github.com/docker/swarmkit/issues/2852);
  • В любом случае, для того, чтобы Nginx подхватил новые сертификаты ему нужно сделать либо reload, либо restart. reload делается через отправку сигнала HUP, что нельзя сделать из другого сервиса или контейнера;
  • Есть рабочий вариант: сделать образ, который запускает nginx в фоне, а в качестве foreground-процесса использует while true; do nginx -s reload; sleep 1d; done и это ужасно.

На мой взгляд, лучшее решение — управлять обновлением сертификата с самой машины (в нашем примере, gateway) через cron. Но с этим подходом есть другая проблема — скрипт, запускаемый cron’ом, должен как-то получить ключ и токен для аккаунта GoDaddy, а их не очень хочется хранить в открытом виде. Здесь на помощь приходят docker secrets, которые доступны в Swarm-режиме. Но ими может воспользоваться только сервис, который по идеологии докера должен работать всегда (см. п. 2 про одноразовые задачи в Swarm). Для решения проблемы я предлагаю воспользоваться инструментом JaaS oт Alex Ellis (https://github.com/alexellis/jaas).

Более простая альтернатива

Вам может показаться, что лучше уж положить ключи в открытом виде в условно-закрытый файл, чем усложнять себе жизнь использованием непонятного инструмента. В конце концов, если кто-то сможет читать на вашем сервере файлы, доступные только для root’а, то вряд ли утекшие ключи для API GoDaddy окажутся вашей главной проблемой. В этом случае просто используйте cron, docker run и немного модифицируйте приложенные скрипты, явно прописав туда значения ключей.

  1. Скачайте код проекта jaas:

    $ git clone https://github.com/alexellis/jaas

  2. Соберите проект:

    $ cd jaas
    $ docker run --rm -v "$PWD":/usr/src/jaas -w /usr/src/jaas golang:1.13 bash -c "go get -d -v github.com/alexellis/jaas && go build -v"

  3. Скопируйте полученный бинарный файл в /usr/local/bin (или создайте символьную ссылку):

    $ sudo cp jaas /usr/local/bin

  4. Проверьте работу jaas:

    $ jaas run --image alpine:3.8 --env FOO=bar --command "env"

    В этом примере создается сервис на базе image’а alpine, которому передается переменная окружения FOO=bar. Сам сервис выполняет команду env, чтобы удостовириться, что переменная окружения успешно передана. После завершения работы, этот сервис удаляется jaas’ом.

  5. Создайте secret’ы в докере для хранения ключей к API GoDaddy:

    $ printf $GODADDY_KEY | docker secret create godaddy-key -
    $ printf $GODADDY_SECRET | docker secret create godaddy-secret -

  6. Для обновления записи в DNS воспользуемся возможностью certbot’а запускать authenticate- и cleanup-скрипты (https://certbot.eff.org/docs/using.html#hooks). Эти скрипты будут запускаться из сервиса, созданном из docker-образа certbot. В этом образе нет установленного curl’а, а поставляемая версия wget’а не умеет слать PUT-запросы. Зато там есть установленный python 3.8 и библиотека requests. Положите скрипты authenticate.sh и cleanup.sh в /opt/godaddy-hooks (сами скрипты есть в конце статьи).

    Обратите внимание на то, что в образе certbot так же нет установленной утилиты dig, поэтому затруднительно проверить, распространились настройки DNS или нет (скрипт должен завершаться, когда они уже распространились). По моим экспериментам за 60 секунд это успевает произойти (а вот за 45, к примеру, не всегда успевает). Для использования в продакшне лучше использовать значения с некоторым запасом, либо воспользоваться другим механизмом контроля распространения DNS-записей, но это потребует сборки отдельного образа. Также обратите внимание на то, что GoDaddy по какой-то причине не дает возможность удалить отдельную DNS-запись. Cleanup-скрипт использует метод API для обновления всех записей, относящихся к домену, что потенциально рискованно.

  7. Сделайте скрипты исполняемыми:

    $ chmod +x /opt/godaddy-hooks/authenticate.sh /opt/godaddy-hooks/cleanup.sh

  8. Проверьте работу скриптов:

    $ jaas run --timeout 90s --mount /opt/letsencrypt=/etc/letsencrypt --mount /opt/godaddy-hooks=/opt/hooks -s godaddy-key -s godaddy-secret --image certbot/certbot:v1.3.0 --command "certbot --manual --manual-auth-hook /opt/hooks/authenticate.sh --manual-cleanup-hook /opt/hooks/cleanup.sh renew --dry-run --no-random-sleep-on-renew"

  9. Создайте периодическую задачу, которая будет выполнять обновление скриптов. Используйте cron, либо, если это ваша рабочая машина или домашний сервер, то может лучше подойти anacron. Настройте обновление сертификатов раз в неделю (первые 2 месяца оно не будет выполняться, а на 3-й месяц у вас будет 4 попытки успеть это сделать). Также обратите внимание, что certbot при renew по умолчанию добавляет случайную задержку, длительностью до 8 минут. Это, судя по всему, связано с тем, что слишком многие настравивают автообновление одинаково и CA перестает справляться с таким количеством запросов. Либо настройте запуск на "некруглое" время и используйте опцию --no-random-sleep-on-renew, либо увеличьте таймаут jaas’а. Пример скрипта для обновления:

    #!/bin/sh
    jaas run --timeout 90s --mount /opt/letsencrypt=/etc/letsencrypt --mount /opt/godaddy-hooks=/opt/hooks -s godaddy-key -s godaddy-secret --image certbot/certbot:v1.3.0 --command "certbot --manual --manual-auth-hook /opt/hooks/authenticate.sh --manual-cleanup-hook /opt/hooks/cleanup.sh renew --no-random-sleep-on-renew"
    docker service update --force nginx

Пример скриптов для GoDaddy

authenticate.sh

#!/bin/sh
read key < /run/secrets/godaddy-key
read secret < /run/secrets/godaddy-secret

python - <<EOF
import requests
requests.put(
    url = 'https://api.godaddy.com/v1/domains/$CERTBOT_DOMAIN/records/TXT/_acme-challenge',
    json = [{'type': 'TXT', 'name': '_acme-challenge', 'data': '$CERTBOT_VALIDATION'}],
    headers = {'Authorization': 'sso-key $key:$secret'}
)
EOF
sleep 60

cleanup.sh

#!/bin/sh
read key < /run/secrets/godaddy-key
read secret < /run/secrets/godaddy-secret

python - <<EOF
import requests
response = requests.get(
    url = 'https://api.godaddy.com/v1/domains/$CERTBOT_DOMAIN/records',
    headers = {'Authorization': 'sso-key $key:$secret'}
)
requests.put(
    url = 'https://api.godaddy.com/v1/domains/$CERTBOT_DOMAIN/records',
    json = [record for record in response.json() if record['name'] != '_acme-challenge'],
    headers = {'Authorization': 'sso-key $key:$secret'}
)
EOF

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр