Туториал по развертыванию 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.
План действий
Процесс развертывания приложения будет происходить в несколько этапов. Сначала мы:
- Развернем наше Web-приложение локально с помощью Docker и Docker-compose.
- Развернем staging-окружение приложения на AWS.
- Развернем production-окружение приложения на AWS.
- Настроим тестовое окружение и 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 Данный адрес e-mail защищен от спам-ботов, Вам необходимо включить Javascript для его просмотра. :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. Не пропустите вторую часть туториала!