.NET Fest: полная программа конференции на сайте. Присоединяйся к самому большому .NET ивенту
×Закрыть

Туториал по развертыванию Rails-приложений на Amazon с помощью Docker. Часть 1

Всем привет! Меня зовут Ярослав Безрукавый, я ‒ Ruby/JavaScript разработчик в RubyGarage. В прошлый раз я делился с вами туториалом по настройке Rails-приложения на Amazon EC2 с помощью Chef. Туториал вызвал живую дискуссию в комментариях, многие спрашивали меня о целесообразности Chef, ведь уже на тот момент появилось много современных инструментов вроде Docker и Kubernetes для автоматического развертывания приложений.

В этом туториале я хочу вернуться к задаче развертывания Rails-приложения, но уже с помощью Docker. В туториале мы рассмотрим весь цикл: от развертывания приложения в локальном окружении до развертывания staging и production-инфраструктуры на AWS с помощью Docker. Будем учитывать возможность масштабирования и автоматизации процесса деплоя приложения.

Какую проблему решаем

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

  • Неудачи при попытке установить все зависимое ПО, при этом не «сломав» ничего локально.
  • Непонимание, как запустить приложение.
  • Недостаток опыта в разворачивании приложения на удаленных серверах.
  • Поддержка инфраструктуры приложения на удаленных серверах.
  • Масштабирование приложения на production окружении.

Решение: Docker

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

Давайте рассмотрим, с какими преимуществами и недостатками мы можем столкнуться во время контейнеризации нашего приложения.

Преимущества

Независимость от архитектуры сервера

Для сервера контейнер является «черным ящиком». Задумка контейнера — полная стандартизация. Контейнер соединяется с сервером определенным интерфейсом, приложение в контейнере не зависит от архитектуры или ресурсов сервера. Интерфейс Docker довольно консистентный вне зависимости от работы на локальной машине, на continuous integration (CI) сервере или во время деплоя на production-сервере. Созданный один раз, один и тот же образ запускается на каждом этапе Continuous Integration/Continuous Delivery пайплайна, давая разработчику уверенность в том, что протестированное приложение будет работать одинаково стабильно в любом окружении.

Удобное управление версиями и зависимостями

Благодаря использованию контейнеров, разработчик привязывает все компоненты и зависимости к приложению, что позволяет ему работать как цельному объекту. На сервере не нужны установки дополнительных компонентов или зависимостей для запуска приложения, которое находится внутри контейнера. Нам достаточно возможности запуска Docker-контейнера.

Простота масштабирования

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

Оптимальное использование ресурсов

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

Инфраструктура как код

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

Недостатки

Производительность

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

Усложнение архитектуры

Контейнеризация — это надстройка над ОС, и, тем самым, усложнение архитектуры сервера.

Непростая настройка и поддержка

При больших масштабах и нагрузке необходима чёткая и качественная настройка систем. Для поддержки и сопровождения Docker контейнеров необходимы навыки системного администрирования и программирования.

Плохая обратная совместимость

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

Заинтересованы? Предлагаю перейти к следующему разделу и рассмотреть, как работает Docker и все его компоненты.

Как работает Docker

Образы и контейнеры

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

В чем отличие Docker от виртуальных машин

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

Виртуальная машина — это эмуляция компьютерной системы внутри вашей Host OS (платформа-хозяин, ваш сервер). Процесс виртуализации обеспечивается с помощью Hypervisor.

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

Так же каждая виртуальная машина требует свою собственную операционную систему. Таким образом, процесс полной виртуализации может потреблять большое количество ресурсов вашей машины. В том время, как для виртуальной машины нужна управляющая программа ОС (hypervisor) и установка операционной системы на каждом инстансе, Docker предлагает другое решение для задачи виртуализации вашего программного продукта.

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

Образы и слои

Docker образ состоит из ряда слоев. Каждый слой представляет собой ряд изменений от предыдущего слоя. Каждая команда (RUN, ENTRYPOINT, CMD и другие) в Dockerfile вызывает создание нового слоя, которому присваивается уникальный идентификатор при сборке образа. Структура связей между слоями в Docker — иерархическая. Имеется некий базовый слой, на который «накладываются» остальные слои.

Команда Dockerfile как слой Docker образа

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

Контейнеры и слои

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

Отдельный слой для каждого контейнера

Таким образом, Docker оптимизирует использование памяти. Например, вам нужно запустить 100 инстансов с образом Ubuntu, который весит 1GB. При использовании ПО для виртуализации, например Vagrant, это потребует 100 GB места. При использовании Docker понадобится чуть больше 1 GB.

Контейнеры и volumes

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

Docker volumes для хранения перманентных и shared данных

Docker volume — это просто папка хоста, «примонтированная» к файловой системе контейнера. Этот слой данных больше не принадлежит контейнеру, соответственно, после пересоздания последнего с данными ничего не случится. Мы можем использовать один volume в нескольких контейнерах. Например, мы можем положить assets из Rails приложения в Nginx и отдавать их клиенту, обходя Puma.

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

План действий

Процесс развертывания приложения будет происходить в несколько этапов. Сначала мы:

  1. Развернем наше Web-приложение локально с помощью Docker и Docker-compose.
  2. Развернем staging-окружение приложения на AWS.
  3. Развернем production-окружение приложения на AWS.
  4. Настроим тестовое окружение и continuous integration для staging и production окружения с помощью CircleCI.

Инструменты и ПО, которое будем использовать в цикле туториалов

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

Пошаговое описание цикла туториалов и ПО, которые мы будем применять

Данная статья посвящена первому этапу туториала — Development. Далее в статьях этого цикла мы будем рассматривать Staging и Production.

А теперь приступим к самому интересному — практической части :)

Запускаем Development окружение

Постановка задачи

В этом разделе мы запустим наше Spree-приложение и все зависимые сервисы (PostgreSQL, Redis и т.д.) на локальной машине с помощью Docker и Docker compose.

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

Схема инфраструктуры, которую мы хотим развернуть:

Инфраструктура development-окружения

Решение задачи

Для этого нам понадобится:

  • Установить Docker на локальной машине.
  • Создать Docker образ Rails-приложения.
  • Создать compose-файл для запуска Rails-приложения и зависимых сервисов (Redis, PostgreSQL, Sidekiq).

Устанавливаем Docker

Ссылка для установки Docker для Linux, Mac or Windows.

Упаковываем Rails-приложения в Docker образ

Что такое Dockerfile и как он работает

Мы с вами уже упомянули понятие образа, который является шаблоном для каждого запущенного контейнера. Dockerfile представляет из себя инструкцией для сборки образ вашего ПО.

Пошаговый принцип работы Dockerfile, Image, Container

Теперь рассмотрим Dockerfile нашего приложения и из каких слоев оно будет состоять.

Каждая команда (RUN, ENTRYPOINT, CMD и другие) в Dockerfile вызывает создание нового слоя при сборке образа. Структура связей между слоями в Docker — иерархическая. Имеется некий базовый слой, на который «накладываются» остальные слои.

Структура Dockerfile для нашего приложения

Безопасность образа

По умолчанию, все команды по сборке образа и процессы внутри контейнера выполняются от имени root-пользователя. Такой подход не безопасен. Поэтому для запуска приложения мы используем www-data пользователя. Делаем мы это с помощью команды USER, которая задает пользователя, от имени которого будут выполняться все перечисленные ниже команды, включая RUN, ENTRYPOINT и CMD.

Полезные ссылки

Полный список инструкций для Dockerfile найдете тут. Пожалуйста, ознакомьтесь с ними перед тем, как двигаться дальше по туториалу.

Описываем образ с помощью Dockerfile

Теперь приступим к описанию образа Dockerfile для нашего основного приложения. В качество демо-приложения в туториале мы будем использовать Spree-приложение.

1. Копируем готовое демо приложения с GitHub:

git clone git@github.com:bezrukavyi/spree-docker-demo.git

2. И переходим в директорию с приложением:

cd spree-docker-demo

3. Инициализируем Dockerfile и добавляем в него ранее рассмотренную структуру, где более детально описана каждая конфигурация:

touch Dockerfile

Dockerfile

# Layer 0. Качаем образ Debian OS с установленным ruby версии 2.5 и менеджером для управления gem'ами bundle из DockerHub. Используем его в качестве родительского образа.
FROM ruby:2.5.1-slim

# Layer 1. Задаем пользователя, от чьего имени будут выполняться последующие команды RUN, ENTRYPOINT, CMD и т.д.
USER root

# Layer 2. Обновляем и устанавливаем нужное для Web сервера ПО
RUN apt-get update -qq && apt-get install -y \
 build-essential libpq-dev libxml2-dev libxslt1-dev nodejs imagemagick apt-transport-https curl nano

# Layer 3. Создаем переменные окружения которые буду дальше использовать в Dockerfile
ENV APP_USER app
ENV APP_USER_HOME /home/$APP_USER
ENV APP_HOME /home/www/spreedemo

# Layer 4. Поскольку по умолчанию Docker запускаем контейнер от имени root пользователя, то настоятельно рекомендуется создать отдельного пользователя c определенными UID и GID и запустить процесс от имени этого пользователя.
RUN useradd -m -d $APP_USER_HOME $APP_USER

# Layer 5. Даем root пользователем пользователю app права owner'а на необходимые директории
RUN mkdir /var/www && \
 chown -R $APP_USER:$APP_USER /var/www && \
 chown -R $APP_USER $APP_USER_HOME

# Layer 6. Создаем и указываем директорию в которую будет помещено приложение. Так же теперь команды RUN, ENTRYPOINT, CMD будут запускаться с этой директории.
WORKDIR $APP_HOME

# Layer 7. Указываем все команды, которые будут выполняться от имени app пользователя
USER $APP_USER

# Layer 8. Добавляем файлы Gemfile и Gemfile.lock из директории, где лежит Dockerfile (root директория приложения на HostOS) в root директорию WORKDIR
COPY Gemfile Gemfile.lock ./

# Layer 9. Вызываем команду по установке gem-зависимостей. Рекомендуется запускать эту команду от имени пользователя от которого будет запускаться само приложение
RUN bundle check || bundle install

# Layer 10. Копируем все содержимое директории приложения в root-директорию WORKDIR
COPY . .

# Layer 11. Указываем все команды, которые будут выполняться от имени root пользователя
USER root

# Layer 12. Даем root пользователем пользователю app права owner'а на WORKDIR
RUN chown -R $APP_USER:$APP_USER "$APP_HOME/."

# Layer 13. Указываем все команды, которые будут выполняться от имени app пользователя
USER $APP_USER

# Layer 14. Запускаем команду для компиляции статических (JS и CSS) файлов
RUN bin/rails assets:precompile

# Layer 15. Указываем команду по умолчанию для запуска будущего контейнера. По скольку в `Layer 13` мы переопределили пользователя, то puma сервер будет запущен от имени www-data пользователя.
CMD bundle exec puma -C config/puma.rb

Команды, которые должны быть запущены перед запуском контейнера (entrypoint) мы выносим в docker-entrypoint.sh. Создадим этот файл с помощью следующей команды:

touch docker-entrypoint.sh
chmod +x docker-entrypoint.sh

И добавим в него команды для создания базы данных и прогона миграций Rails-приложения.

#!/bin/bash
# Interpreter identifier

# Exit on fail
set -e

rm -f $APP_HOME/tmp/pids/server.pid
rm -f $APP_HOME/tmp/pids/sidekiq.pid

bundle exec rake db:create
bundle exec rake db:migrate

exec "$@"

Исключить файлы, не относящиеся к сборке

Иногда нужно избежать добавления определенных файлов в образ приложения, например secrets files или файлов, которые относятся только к локальному окружению. Для этого есть .dockerignore. Принцип работы .dockerignore такой же, как с .gitignore.

4. Создаем файл .dockerignore.

touch .dockerignore

И добавляем в него следующее:

.git
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
!/tmp/pids/.keep
!/tmp/cache/.keep
/vendor/bundle
/public/assets
/config/master.key
/config/credentials.local.yml
/.bundle

Итак, мы рассмотрели схему запуска нашего Spree-приложения. В следующем разделе мы научимся запускать приложение и все зависимые сервисы (PostgreSQL, Redis, API, client) с помощью одной команды.

Запуск образа Rails-приложения и зависимых сервисов

Docker compose

Работа будущего приложения зависит от работы сторонних сервисов, таких как PostgreSQL, Redis, а также идентичное основному приложению Sidekiq-приложение. Следуя идеологии Docker, все эти сервисы должны быть изолированы от локального окружения и запущены в отдельных контейнерах, которые «общаются» друг с другом. Если структура проекта состоит из большого количества сервисов, то поднимать каждый отдельный Docker-сервис вручную неудобно.

Поэтому для автоматизации процесса запуска всех сервисов будем использовать Docker-compose.

Docker Compose — это инструмент для определения и запуска многоконтейнерных приложений Docker. С Compose вы используете файл YAML для настройки сервисов(контейнеров) вашего приложения. Затем с помощью одной команды вы создаете и запускаете все сервисы из своей конфигурации.

Docker compose как схема инфраструктуры

Полезные ссылки

Полную структуру файла для Docker compose найдете тут. Пожалуйста, ознакомьтесь с ней перед тем, как двигаться дальше по туториалу.

Структура для Docker compose

Теперь рассмотрим структуру нашего приложения уже со стороны реализации конфигурации для Docker compose.

Для этого создадим файл docker-compose.development.yml

touch docker-compose.development.yml

В него мы добавим рассмотренную ранее конфигурацию, где более детально комментариями описана каждая конфигурация:

# Version - версия синтаксиса compose-файла. Файл Compose всегда начинается с номера версии, который указывает используемый формат файла. Это помогает гарантировать, что приложения будет работать как ожидается, так как новые функции или критические изменения постоянно добавляются в Compose.
version: '3.1'

# Volume – дисковое пространство между HostOS и ContainerOS. Проще – это папка на вашей локальной машине примонтированная внутрь контейнера.
volumes: # Объявим volumes, которые будут доступны в сервисах
  redis:
  postgres:

# Service - запущенный контейнер
services: # Объявляем сервисы(контейнеры) которые будут запущены с помощью compose
  db:
    image: postgres:10 # В качестве образа сервиса используется официальный образ Postgresql из Docker Hub
    expose:
      - 5432 # Выделяем для postgres 5432-ый порт контейнера
    environment: # Указываем список глобальных ENV-переменных внутри текущего контейнера
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: spreedemo_development
    volumes:
      - postgres:/var/lib/postgresql/data # Все данные из директории data буду ложиться в volume `postgres`
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"] # Команда для проверки состояния сервиса

  in_memory_store:
    image: redis:4-alpine # В качестве образа сервиса используется официальный образ Redis из Docker Hub
    expose:
      - 6379 # Выделяем для redis 6379-ый порт контейнера
    volumes:
      - redis:/var/lib/redis/data
    healthcheck:
      test: ["CMD", "redis-cli", "-h", "localhost", "ping"]

  server_app: &server_app
    build: . # В качестве образа будет использоваться Dockerfile в текущей директории
    command: bundle exec rails server -b 0.0.0.0 # переопределяем команду запуска контейнера
    entrypoint: "./docker-entrypoint.sh" # указываем какую команду нужно запустить перед тем как контейнер запустится
    volumes:
      - .:/home/www/spreedemo # Указываем, что директория приложения в контейнере будет ссылаться на директорию приложения на Host OS (локальная нода). Таким образом, при изменение файлов из app или других директорий на вашей локальной машине, все изменения так же будут применены и на контейнер с данным сервисом.
      - /home/www/spreedemo/vendor/bundle # Исключаем монтирование установленных гемов в контейнер
      - /home/www/spreedemo/public/assets # Исключаем монтирование сгенерированых assets в контейнер
    tty: true # Открываем доступ для деббагинга контейнера
    stdin_open: true # Открываем доступ для деббагинга контейнера
    restart: on-failure # Перезапустить контейнер в случае ошибки
    environment:
      RAILS_ENV: development
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: spreedemo_development
      DB_USERNAME: postgres
      DB_PASSWORD: postgres
      REDIS_DB: "redis://in_memory_store:6379"
      SECRET_KEY_BASE: STUB
      DEVISE_SECRET_KEY: STUB
    depends_on: # Указываем список сервисов от которых зависит текущий сервис. Текущий сервис будет запущен только после того как запустятся зависимые сервисы
      - db
      - in_memory_store
    ports:
      - 3000:3000 # Указываем что порт из контейнера будет проксироваться на порт HostOS (HostPort:ContainerPort)
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000"]

  server_worker_app:
    <<: *server_app # Наследуемся от сервиса server_app
    command: bundle exec sidekiq -C config/sidekiq.yml
    entrypoint: ''
    ports: []
    depends_on:
      - db
      - server_app
      - in_memory_store
    healthcheck:
      test: ["CMD-SHELL", "ps ax | grep -v grep | grep sidekiq || exit 1"]

Контейнеры и volumes

Несмотря на то, что все приложение упаковано в образ и запущено в изолированном контейнере, нам по-прежнему доступен rails hot reloader. Все потому, что мы воспользовались Volumes. Мы указали, что директория app и директория vendor/assets из запущенного контейнера будут ссылаться на локальную директорию HostOS.

Теперь можно запустить всю инфраструктуру приложения, выполнив команду:

docker-compose -f docker-compose.development.yml -p spreeproject up

`-p` указывает, какой префикс добавить контейнерам. Желательно использовать подобный контекст, чтобы когда проектов на Docker станет больше, вам было проще ориентироваться по контексту;
`-f` указывает, какой docker-compose файл использовать.

Здесь вы найдете другие полезные команды для взаимодействия с compose.

Проверить состояние запущенных сервисов мы можем с помощью следующей команды:

docker-compose -f docker-compose.development.yml -p spreeproject ps

Когда все сервисы буду в статусе healthy,

Приложение будет доступно по адресу `localhost:3000`

Подведем итог

В первой части туториала мы рассмотрели:

  • Принцип работы Docker и его компоненты. Преимущества и недостатки работы с Docker в сравнении с виртуальными машинами.
  • Пошаговую сборку Rails-приложения в Dockerfile.
  • Запуск образа Rails-приложения и зависимых сервисов с помощью Docker compose.

В следующих частях мы продолжим развертывание приложения с помощью сервисов AWS. Не пропустите вторую часть туториала!


Читайте следующие части туториала: часть 2.

LinkedIn

6 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Круто когда разработчик имеет такие скилы. Спасибо за статью.

Дякую за статтю! Неймовірно корисна інформація, чекаю наступних частин.

Перед `apt-get install` желательно добавить переменную окружения с датой сборки — что бы была возможность накатывать security updates поверх всех образов.

В случае с дебианом целесообразно использовать proxy mirror через github.com/cybozu-go/aptutil

Вообще хорошо делать docker export и выполнять контейнеры в голом runc, так работает под капотом тот же kubernetes и nomad.

Docker-compose довольно таки ненадёжен и во многом у него были проблемы с дизайном.
Стоит рассмотреть старт контейнеров с сетью просто на bash скриптах и OVS, в проде OVS+DPDK. Самое интересное что в таком случае приложения в контейнерах будут работать быстрее чем в окружении разработки.

Для Storage Layer’a было пару проприетарных драйверов на SPDK + blobfs, но я давно не отслеживаю развитие этих подходов.

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

Огромное спасибо. Крутые советы, не знал. Обязательно возьму на вооружение 💪
Тоже слышал про проблемы с Docker compose, но он используется только для работы в локальном окружение. Но старт контейнеров с сетью на bash тоже рассматрю. Спасибо.
В контексте AWS не используется Docker compose, там есть ECS Task Definitions. Amazon уверяет что их способ надеженее 🙃

Там у ECS тоже не всё гладко и чаще на много надёжнее просто использовать Fargate.

йошки-матрьошки, класна стаття. Грейт сенкс!

Подписаться на комментарии