Зарегистрируйтесь, чтобы продолжить обучение

Прокси-сервер (Caddy) Продакшен и Деплой

Для запуска веб-приложений нужен веб-сервер, программа, которая принимает входящие HTTP-запросы и передает их на обработку приложению. Приложение делает свою работу и отдает результат веб-серверу, который формирует HTTP-ответ для клиента.

Приложение-сервер-клиент

Часто такой веб-сервер либо встроен в сам язык как модуль, либо написан на этом же языке, для запуска кода внутри себя. Обычно, задачу интеграции берут на себя фреймворки. В нашем приложении devops-example-app за это отвечает фреймворк fastify. Внутри себя он использует встроенный в Node.js веб-сервер:

import http from 'http';

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('okay');
}).listen(8080);

Несмотря на простоту такой схемы, ее в большинстве проектов недостаточно. Помимо встроенного веб-сервера, нужен внешний, выполняющий роль реверс-прокси (reverse-proxy), например Nginx или Caddy.

Обратный прокси

Прокси-сервер ставится между клиентами и внутренней инфраструктурой. Он принимает запросы от клиентов и переадресует их на внутренний веб-сервер или веб-сервера, если их много. Именно поэтому он называется реверс (обратный), снаружи внутрь. Зачем он нужен?

  1. Проекты редко состоят только из кода, который мы запускаем на встроенном веб-сервере. Помимо кода, почти всегда есть файлы, css, js, картинки и другие файлы. Для их "раздачи" тоже нужен веб-сервер, а еще нужно уметь их сжимать и кешировать
  2. Далеко не все внутренние веб-сервера умеют эффективно обрабатывать "медленных клиентов". Это клиенты с медленным соединением, которые очень долго отправляют и принимают данные. Они занимают ресурсы веб-сервера и приложения. Если придет достаточное количество медленных клиентов, то веб-сервер и приложение быстро перестанут отвечать. Пользователи начнут видеть ошибки
  3. Встроенные веб-сервера достаточно ограниченны в своих возможностях, например в том как они обрабатывают ошибки, как поддерживают HTTPS и так далее

Эти причины настолько серьезны, что без реверс-прокси в продакшен не ходят. Только какой выбрать? Nginx самый популярный сервер в мире, но Caddy, гораздо проще в настройке и отладке. У него встроенная поддержка Let's Encrypt, Caddy автоматически обновляет сертификаты для работы HTTPS. Поэтому в этом курсе мы воспользуемся Caddy. Изучив его, вы без труда сможете перейти на Nginx, так как концептуально все подобные веб-сервера работают одинаково.

Caddy

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

Локальное окружение

Когда наш проект состоял из одного приложения, его было достаточно разрабатывать напрямую без Docker Compose. Как только добавляется сервис, в нашем случае внешний веб-сервер Caddy, то без Docker Compose становится сложно. Появляется задача управлять набором сервисов как единым целым, стартовать их, останавливать и перезапускать.

Добавим Docker Compose с такой конфигурацией:

# file: docker-compose.yml
version: "3"

services:
  app: # Имя сервиса
    build:
      # Контекст для сборки образа,
      # в данном случае, текущая директория
      context: .
      # Имя Docker-файла из которого будет собран образ
      dockerfile: Dockerfile
    volumes:
      - .:/app
    ports:
      - 3000:3000

  caddy: # Веб-сервер
    build: # Находится внутри services/caddy
      context: .
      dockerfile: services/caddy/Dockerfile
    volumes:
      - .:/app
      - ./services/caddy/Caddyfile:/etc/caddy/Caddyfile
    ports:
      - 80:80
      - 443:443

Почему именно так? Мы придерживаемся структуры, в которой основное приложение располагается в корне проекта, а дополнительные сервисы лежат в директории services. У каждого сервиса своя директория с Dockerfile внутри.

# services/caddy/Dockerfile
# У Caddy есть официальный образ, но его недостаточно
# В продакшене конфигурация должна быть частью образа
# Поэтому мы делаем свой образ, в который "зашиваем" конфигурацию
FROM caddy

# Конфигурационный файл Caddy
# Читается из /etc/caddy/Caddyfile
COPY services/caddy/Caddyfile /etc/caddy/

# Копируем приложение для доступа к статическим файлам хранящимся в репозитории
# Обычно это элементы интерфейса
COPY . /app

Там же хранятся дополнительные файлы конкретного сервиса. Например у Caddy это файл Caddyfile, в котором содержится конфигурация:

# Домены, которые будет обслуживать Caddy
# Первый домен для локальной разработки, второй для продакшена (воображаемого)
localhost, devops-example.test {
    # Формируем самоподписной сертификат для работы https
    tls internal

    # Отдаем картинки напрямую из файловой системы минуя приложение
    # Картинки в нашем проекте хранятся в /app/public внутри образа с Caddy
    # Выше в Dockerfile мы туда их копируем
    # /images/app.png => /app/public/images/app.png
    handle /images/* {
        file_server
        root * /app/public
    }

    # Все остальные запросы передаются в приложение
    handle {
        # app - имя домена во внутренней сети Docker
        # В девелопменте совпадает с именем сервиса в docker-compose.yml
        # Для продакшена надо будет создать общую сеть
        reverse_proxy app:3000
    }

    # Включаем логгирование для удобной отладки
    log {
        format json
    }
}

После того как все файлы добавлены, можно запустить проект локально и убедиться что он работает:

docker compose up

docker compose up --abort-on-container-exit
[+] Running 2/0
 ⠿ Container devops-example-app-caddy-1  Created
 ⠿ Container devops-example-app-app-1    Created
Attaching to devops-example-app-app-1, devops-example-app-caddy-1

# Урезанный вывод
devops-example-app-caddy-1  | {"level":"info","ts":1646952470.170849,"msg":"serving initial configuration"}
devops-example-app-app-1    | > fastify start server/plugin.js -a 0.0.0.0 -l info -P
devops-example-app-app-1    | 22:48:50 ✨ Server listening at http://0.0.0.0:3000

После этого запрос к localhost пойдет в Caddy, который выполнит автоматическое перенаправление на https. Браузер укажет на недоверенный сертификат, что нормально, так как он самоподписной. Все что остается сделать, чтобы увидеть сайт - подвердить что вы согласны с риском.

Продакшен

Первым делом добавим Caddy в сборку на коммит. Для этого создадим репозиторий для Caddy на Docker Hub и расширим воркфлоу main.yml:

# .github/workflows/main.yml

# Добавляем сразу после сборки app
- name: Build and push caddy
  uses: docker/build-push-action@v2
  with:
    context: .
    file: services/caddy/Dockerfile
    push: true
    # Не забудьте создать репозиторий в Docker Hub
    tags: hexletcomponents/devops-example-caddy:latest

Следующим шагом добавим в релиз:

# .github/workflows/release.yml

# Добавляем сразу после сборки app
- run: docker pull hexletcomponents/devops-example-caddy:latest
- run: docker tag hexletcomponents/devops-example-caddy:latest hexletcomponents/devops-example-caddy:${{ github.ref_name }}
- run: docker push hexletcomponents/devops-example-caddy:${{ github.ref_name }}

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

Если просто добавить старт контейнера в плейбук ansible/release.yml, то сайт не откроется. Caddy попытается обратиться к указанному в конфигурации app:3000 и выдаст в лог ошибку о недоступности домена. Почему? Каждый контейнер в Docker запускается в изолированной среде и не знает про другие контейнеры. Для их связи используется network.

tasks:
  # Создаем общую сеть
  # Контейнеры добавленные в эту сеть, могут обращаться друг к другу по имени контейнера,
  # который становится dns именем
  - community.docker.docker_network:
      name: devops-example

  - community.docker.docker_container:
      name: app # имя используется как домен
      image: "hexletcomponents/devops-example-app:{{ version }}"
      restart_policy: always
      state: started
      networks: # Добавляем контейнер в сеть
        - name: devops-example
      ports: # Больше не нужно выставлять наружу, потому что доступ идет через Caddy
        # - 3000:3000
      env:
        NODE_ENV: production

  - community.docker.docker_container:
      name: caddy # имя используется как домен
      image: "hexletcomponents/devops-example-caddy:{{ version }}"
      restart_policy: always
      state: started
      networks: # Добавляем контейнер в сеть
        - name: devops-example
      ports:
        - 80:80
        - 443:443

Когда все добавлено, выполните деплой последнего релиза:

ansible-playbook release.yml -i inventory.yml --extra-vars="version=v6" -vv

Наш проект выложен на сервер и работает. Технически мы можем обратиться к нему по ip-адресу, но так делать не стоит. ip-адрес всегда меняется при пересоздании сервера, из-за чего постоянно придется запоминать новый. Запоминать ip-адрес, в принципе, не очень удобно. Лучше иметь доменное имя, которое никогда не меняется.

Самый простой способ добавить доменное имя для разработки, внести запись в файл /etc/hosts. Этот файл часть системы DNS, которая выполняет поиск ip-адреса по имени. Поиск всегда начинается с него:

# Укажите тут адрес вашей машины, которую вы создали
65.108.149.193 devops-example.test

Попробуйте открыть в браузере devops-example.test. Если все было сделано правильно, то вы увидите сайт.

Обработка ошибок

Во время деплоя приложение перезапускается. Между остановкой и стартом новой версии проходит какое-то время, когда приложение не работает, а пользователи видят ошибку 502 Bad Gateway. Иногда это время достаточно большое, есть приложения которые стартуют минуты.

Обработка ошибки 502 задача реверс-прокси. Для этого создается html-файл, в котором красиво описывается ситуация "мы обновляемся". Этот файл добавляется в образ к веб-серверу, а в конфигурации прописываются такие строки:

# Caddy
reverse_proxy {
  to app:3000

  @error status 502 # Только 502 ошибки
  handle_response @error {
    # Путь по которому хранятся html-файлы для разных ошибок
    root * /app/services/caddy/error_pages
    # Для всех 502 запросов отдаем файл 502.html
    # Имя произвольное
    rewrite * /502.html
    file_server
  }
}

# Nginx
# Имя файла произвольное
error_page 502 /502.html;
location = /502.html {
  root /path/to/files/with/html;
  internal;
}

Учтите, что эта ошибка возникает не только во время деплоя. Если приложение упало, по какой-либо причине, то мы получим тоже самое.


Самостоятельная работа

  1. Разверните свое приложение с помощью Docker Compose по примеру из теории. Сделайте так, чтобы оно работало с Caddy.


Дополнительные материалы

  1. FastCGI
  2. Как работает DNS
  3. Что такое обратный прокси-сервер

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»