Создание CI/CD-цепочки и автоматизация работы с Docker
Я написала мои первые сайты в конце 90-х. Тогда приводить их в рабочее состояние было очень просто. Был Apache-сервер на каком-нибудь общем хостинге, на этот сервер можно было войти по FTP, написав в браузерной строке нечто вроде ftp://ftp.example.com. Потом надо было ввести имя и пароль и выгрузить файлы на сервер. Другие были времена, всё тогда было проще, чем сейчас.
За прошедшие с тех пор два десятилетия всё изрядно изменилось. Сайты стали сложнее, их, перед выпуском в продакшн, надо собирать. Один единственный сервер стал множеством серверов, работающих за балансировщиками нагрузки, обычным делом стало использование систем контроля версий.
Для моего персонального проекта у меня была особая конфигурация. И я знала, что мне нужна возможность разворачивать сайт в продакшне, выполняя всего одно действие: запись кода в ветку master на GitHub. Я, кроме того, знала, что мне, для обеспечения работы моего маленького веб-приложения, не хочется заниматься управлением огромным кластером Kubernetes, или пользоваться технологией Docker Swarm, или поддерживать парк серверов с подами, агентами и всякими другими сложностями. Для того чтобы достичь цели по максимальному упрощению работы, мне понадобилось познакомиться с CI/CD.
Если у вас имеется маленький проект (в нашем случае речь идёт о Node.js-проекте) и вам хотелось бы узнать о том, как автоматизировать развёртывание этого проекта, сделав при этом так, чтобы то, что хранится в репозитории, в точности соответствовало бы тому, что работает в продакшне, то, полагаю, вас может заинтересовать эта статья.
Предварительные требования
Ожидается, что читатель этой статьи имеет базовые знания в области работы с командной строкой и написания Bash-скриптов. Кроме того, ему понадобятся учётные записи Travis CI и Docker Hub.
Цели
Не скажу, что эта статья может безоговорочно называться «учебным руководством». Это — скорее документ, в котором я рассказываю о том, что узнала, и описываю устраивающий меня процесс тестирования и развёртывания кода в продакшне, выполняемый за один автоматизированный проход.
Вот каким в итоге получился мой рабочий процесс.
Для кода, отправленного в любую ветку репозитория, кроме master, производятся такие действия:
Запускается сборка проекта на Travis CI.
Выполняются все модульные, интеграционные и сквозные тесты.
Только для кода, который попадает в master, выполняется следующее:
Всё то, о чём сказано выше, плюс…
Сборка образа Docker на основании текущего кода, настроек и окружения.
Размещение образа на Docker Hub.
Подключение к продакшн-серверу.
Загрузка образа с Docker Hub на сервер.
Остановка текущего контейнера и запуск нового, основанного на новом образе.
Если вы совершенно ничего не знаете о Docker, об образах и контейнерах — не беспокойтесь. Я об этом всём вам расскажу.
Непрерывная интеграция — это процесс, в ходе которого разработчики делают коммиты в главное хранилище исходного кода проекта (обычно в ветку master). При этом качество кода обеспечивается путём проведения автоматизированного тестирования.
▍Непрерывное развёртывание
Непрерывное развёртывание — это частое автоматизированное развёртывание кода в продакшне. Вторая часть аббревиатуры CI/CD иногда раскрывается как «continuous delivery» («непрерывная доставка»). Это, в целом, то же самое, что и «непрерывное развёртывание», но «непрерывная доставка» подразумевает необходимость ручного подтверждения изменений перед запуском процесса развёртывания проекта.
Начало работы
Приложение, на котором я это всё осваивала, называется TakeNote. Это — веб-проект, над которым я работаю, предназначенный для того, чтобы делать заметки. Сначала я попыталась сделать JAMStack-проект, или только фронтенд-приложение без сервера, для того чтобы воспользоваться стандартными возможностями по хостингу и развёртыванию проектов, которые предлагает Netlify. По мере того, как росла сложность приложения, мне понадобилось создать и его серверную часть, а это означало, что мне надо было бы сформировать собственную стратегию по автоматизированной интеграции и автоматизированному развёртыванию проекта.
В моём случае приложение представляет собой Express-сервер, работающий в среде Node.js, обслуживающий одностраничное React-приложение и поддерживающий защищённый серверный API. Эта архитектура следует стратегии, которую можно найти в данном руководстве по фуллстек-аутентификации.
Я посоветовалась с другом, который является экспертом по автоматизации, и спросила его о том, что мне надо сделать для того, чтобы всё это работало так, как мне нужно. Он подкинул мне идею о том, как должен выглядеть автоматизированный рабочий процесс, изложенный в разделе «Цели» этой статьи. То, что я поставила перед собой подобные цели, означало, что мне нужно разобраться в том, как пользоваться Docker.
Docker
Docker — это инструмент, который, благодаря технологии контейнеризации, позволяет легко распространять приложения, а также выполнять их развёртывание и запуск в одном и том же окружении даже в том случае, если сама платформа Docker работает в различных средах. Для начала мне нужно было получить в своё распоряжение инструменты командной строки (CLI) Docker. Инструкцию по установке Docker нельзя назвать очень чёткой и понятной, но из неё можно узнать о том, что для того, чтобы сделать первый шаг установки, надо скачать Docker Desktop (для Mac или Windows).
Docker Hub — это примерно то же самое, что GitHub для git-репозиториев, или реестр npm для JavaScript-пакетов. Это — онлайн-репозиторий для образов Docker. Именно к нему подключается Docker Desktop.
Итак, для того чтобы приступить к работе с Docker, нужно сделать две вещи:
После этого можете проверить работоспособность Docker CLI, выполнив следующую команду для проверки версии Docker:
docker -v
Далее, войдите в Docker Hub, введя, когда вас об этом спросят, свое имя пользователя и пароль:
docker login
Для того чтобы пользоваться Docker, вы должны понимать концепции образов и контейнеров.
▍Образы
Образ — это нечто вроде плана, содержащего инструкции по сборке контейнера. Это неизменяемый снимок файловой системы и настроек приложения. Разработчики могут с лёгкостью обмениваться образами.
# Вывод сведений обо всех образах
docker images
Эта команда выведет таблицу со следующим заголовком:
REPOSITORY TAG IMAGE ID CREATED SIZE
---
Далее мы будем рассматривать некоторые примеры команд в таком же формате — сначала идёт команда с комментарием, а потом — пример того, что она может вывести.
▍Контейнеры
Контейнер — это исполняемый пакет, который содержит всё, что нужно для выполнения приложения. Приложение при таком подходе всегда будет работать одинаково, независимо от инфраструктуры: в изолированном окружении и в одной и той же среде. Речь идёт о том, что в разных окружениях запускаются экземпляры одного и того же образа.
# Перечисление всех контейнеров
docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
---
▍Теги
Тег — это указание на конкретную версию образа.
▍Краткая справка по командам Docker
Вот обзор некоторых часто используемых команд Docker.
Я знаю, как локально запустить приложение для продакшна. У меня есть Webpack-конфигурация, предназначенная для сборки готового React-приложения. Далее, у меня имеется команда, запускающая сервер, основанный на Node.js, на порте 5000. Выглядит это так:
npm i # установка зависимостей
npm run build # сборка React-приложения
npm run start # запуск Node-сервера
Надо отметить, что у меня нет приложения-примера для этого материала. Но тут, для экспериментов, подойдёт любое простое Node-приложение.
Для того чтобы воспользоваться контейнером, вам понадобиться дать инструкции Docker. Делается это посредством файла, называемого Dockerfile, находящегося в корневой директории проекта. Этот файл, поначалу, кажется довольно-таки непонятным.
Но то, что в нём содержится, всего лишь описывает, особыми командами, нечто подобное настройке рабочего окружения. Вот некоторые из этих команд:
FROM — Эта команда начинает файл. В ней указывается базовый образ, на основе которого строится контейнер.
COPY — Копирование файлов из локального источника в контейнер.
WORKDIR — Установка рабочей директории для следующих команд.
# Загрузить базовый образ
FROM node:12-alpine
# Скопировать файлы из текущей директории в директорию app/
COPY . app/
# Использовать app/ в роли рабочей директории
WORKDIR app/
# Установить зависимости (команда npm ci похожа npm i, но используется для автоматизированных сборок)
RUN npm ci --only-production
# Собрать клиентское React-приложение для продакшна
RUN npm run build
# Прослушивать указанный порт
EXPOSE 5000
# Запустить Node-сервер
ENTRYPOINT npm run start
В зависимости от выбранного базового образа вам может понадобиться установить дополнительные зависимости. Дело в том, что некоторые базовые образы (вроде Node Alpine Linux) созданы с целью сделать их как можно более компактными. В результате в них могут отсутствовать некоторые программы, на которые вы рассчитываете.
▍Сборка, тегирование и запуск контейнера
Локальные сборка и запуск контейнера — это, после того, как у нас есть Dockerfile, задачи довольно простые. Прежде чем отправлять образ на Docker Hub, его нужно протестировать локально.
▍Сборка
Сначала надо собрать образ, указав имя, и, что необзательно, тег (если тег задан не будет, система автоматически назначит образу тег latest).
# Сборка образа
docker build -t <image>:<tag> .
После выполнения этой команды можно наблюдать за тем, как Docker выполняет сборку образа.
Sending build context to Docker daemon 2.88MB
Step 1/9 : FROM node:12-alpine
---> ...выполнение этапов сборки...
Successfully built 123456789123
Successfully tagged <image>:<tag>
Сборка может занять пару минут — тут всё зависит от того, сколько у вас имеется зависимостей. После завершения сборки можно выполнить команду docker images и взглянуть на описание своего нового образа.
REPOSITORY TAG IMAGE ID CREATED SIZE
<image> latest 123456789123 About a minute ago x.xxGB
▍Запуск
Образ создан. А это значит, что на его основе можно запустить контейнер. Так как я хочу, чтобы у меня была бы возможность обращаться к приложению, работающему в контейнере, по адресу localhost:5000, я, в левой части пары 5000:5000 в следующей команде установила 5000. В правой части находится порт контейнера.
# Запуск с использованием локального порта 5000 и порта контейнера 5000
docker run -p 5000:5000 <image>:<tag>
Теперь, когда контейнер создан и запущен, можно воспользоваться командой docker ps для того чтобы взглянуть на сведения об этом контейнере (или можно воспользоваться командой docker ps -a, которая выводит сведения обо всех контейнерах, а не только о работающих).
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
987654321234 <image> "/bin/sh -c 'npm run…" 6 seconds ago Up 6 seconds 0.0.0.0:5000->5000/tcp stoic_darwin
Если перейти теперь по адресу localhost:5000 — можно увидеть страницу работающего приложения, которая выглядит точно так же, как страница приложения, работающего в продакшн-окружении.
▍Назначение тега и публикация
Для того чтобы воспользоваться одним из созданных образов на продакшн-сервере, нужно, чтобы у нас была бы возможность загрузить этот образ с Docker Hub. Это значит, что сначала надо создать на Docker Hub репозиторий для проекта. После этого в нашем распоряжении окажется место, куда можно оправить образ. Образ надо переименовать так, чтобы его имя начиналось с нашего имени пользователя на Docker Hub. После этого должно идти название репозитория. В конце имени может располагаться любой тег. Ниже показан пример именования образов по этой схеме.
Теперь можно собрать образ с назначением ему нового имени и выполнить команду docker push для отправки его в репозиторий Docker Hub.
docker build -t <username>/<repository>:<tag> .
docker tag <username>/<repository>:<tag> <username>/<repository>:latest
docker push <username>/<repository>:<tag>
# На практике это может выглядеть, например, так:
docker build -t user/app:v1.0.0 .
docker tag user/app:v1.0.0 user/app:latest
docker push user/app:v1.0.0
Если всё пройдёт как надо, образ будет доступен на Docker Hub и его легко можно будет загрузить на сервер или передать другим разработчикам.
Следующие шаги
К настоящему моменту мы убедились в том, что приложение, в виде контейнера Docker, работает локально. Мы загрузили контейнер на Docker Hub. Всё это значит, что мы уже очень неплохо продвинулись к цели. Теперь надо решить ещё два вопроса:
Настройка CI-инструмента для тестирования и развёртывания кода.
Настройка продакшн-сервера так, чтобы он мог бы загружать и запускать наш код.
В нашем случае в качестве CI/CD-решения используется Travis CI. В качестве сервера — DitigalOcean.
Надо отметить, что здесь можно воспользоваться и другой комбинацией сервисов. Например, вместо Travis CI можно воспользоваться CircleCI или Github Actions. А вместо DigitalOcean — AWS или Linode.
Мы решили работать с Travis CI, а в этом сервисе у меня уже кое-что настроено. Поэтому сейчас я кратко расскажу о том, как подготовить его к работе.
Travis CI
Travis CI — это инструмент для тестирования и развёртывания кода. Мне не хотелось бы входить в тонкости настройки Travis CI, так как каждый проект уникален, и это не принесёт особой пользы. Но я расскажу об основах, которые позволят вам начать работу в том случае, если вы решите пользоваться Travis CI. Что бы вы ни выбрали — Travis CI, CircleCI, Jenkins, или что-то другое, везде будут применяться похожие методы настройки.
Для того чтобы приступить к работе с Travis CI, перейдите на сайт проекта и создайте учётную запись. Затем интегрируйте Travis CI с вашим GitHub-аккаунтом. Вам, в ходе настройки системы, понадобится указать репозиторий, работу с которым вы хотите автоматизировать, и включить доступ к нему. (Я пользуюсь GitHub, но уверена, что Travis CI может интегрироваться и с BitBucket, и с GitLab, и с другими подобными сервисами).
Каждый раз, когда Travis CI принимается за работу, запускается сервер, выполняющий указанные в конфигурационном файле команды, включая развёртывание соответствующих веток репозитория.
▍Жизненный цикл задания
Конфигурационный файл Travis CI, называемый .travis.yml и хранящийся в корневой директории проекта, поддерживает концепцию событий жизненного цикла задания. Вот эти события, приведённые в том порядке, в котором они происходят:
apt addons
cache components
before_install
install
before_script
script
before_cache
after_success или after_failure
before_deploy
deploy
after_deploy
after_script
▍Тестирование
В конфигурационном файле я собираюсь настроить локальный сервер Travis CI. В качестве языка я выбрала Node 12 версии и указала системе на то, что нужно установить зависимости, необходимые для использования Docker.
Всё, что перечислено в .travis.yml, будет выполняться при выполнении всех pull-запросов во все ветки репозитория, если только не указано иное. Это полезная особенность, так как она означает, что мы можем тестировать весь код, поступающий в репозиторий. Это позволяет знать о том, готов ли код к записи в ветку master, и не нарушит ли он процесс сборки проекта. В этой глобальной конфигурации я устанавливаю всё локально, запускаю сервер разработчика Webpack в фоне (это — особенность моего рабочего процесса) и выполняю тесты.
Если вы хотите, чтобы в вашем репозитории выводились бы значки со сведениями о покрытии кода тестами, тут вы можете найти краткую инструкцию об использовании Jest, Travis CI и Coveralls для сбора и вывода этих сведений.
Итак, вот содержимое файла .travis.yml:
# Установить язык
language: node_js
# Установить версию Node.js
node_js:
- '12'
services:
# Использовать командную строку Docker
- docker
install:
# Установить зависимости для тестов
- npm ci
before_script:
# Запустить сервер и клиент для тестов
- npm run dev &
script:
# Запустить тесты
- npm run test
Здесь заканчиваются те действия, которые выполняются для всех ветвей репозитория и для pull-запросов.
▍Развёртывание
Исходя из предположения о том, что все автоматизированные тесты завершились успешно, мы, что делать необязательно, можем развернуть код на продакшн-сервере. Так как мы хотим делать это лишь для кода из ветки master, мы даём системе соответствующие указания в настройках развёртывания. Прежде чем вы попробуете воспользоваться в своём проекте кодом, который мы рассмотрим дальше, я хотела бы предупредить вас о том, что у вас должен быть реальный скрипт, вызываемый для развёртывания.
deploy:
# Собрать Docker-контейнер и отправить его на Docker Hub
provider: script
script: bash deploy.sh
on:
branch: master
Скрипт развёртывания решает две задачи:
Сборка, тегирование и отправка образа на Docker Hub средствами CI-инструмента (в нашем случае это Travis CI).
Загрузка образа на сервере, остановка старого контейнера и запуск нового (в нашем случае сервер работает на платформе DigitalOcean).
Сначала нужно настроить автоматический процесс сборки, тегирования и отправки образа на Docker Hub. Всё это очень похоже на то, что мы уже делали вручную, за исключением того, что тут нам нужна стратегия назначения образам уникальных тегов и автоматизация входа в систему. У меня были сложности с некоторыми деталями скрипта развёртывания, с такими, как стратегия тегирования, вход в систему, кодировка SSH-ключей, установление SSH-соединения. Но, к счастью, мой бойфренд очень хорошо управляется с bash, как и со многими другими вещами. Он помог мне написать этот скрипт.
Итак, первая часть скрипта — это отправка образа на Docker Hub. Сделать это довольно просто. Использованная мной схема составления тегов подразумевает комбинирование git-хэша и git-тега, если он существует. Это позволяет обеспечить создание уникального тега и упрощает идентификацию сборки, на которой он основан. DOCKER_USERNAME и DOCKER_PASSWORD — это пользовательские переменные окружения, которые можно задать с помощью интерфейса Travis CI. Travis CI автоматически обработает секретные данные так, чтобы они не попали в чужие руки.
Вот первая часть скрипт deploy.sh.
#!/bin/sh
set -e # Остановить скрипт при наличии ошибок
IMAGE="<username>/<repository>" # Образ Docker
GIT_VERSION=$(git describe --always --abbrev --tags --long) # Git-хэш и теги
# Сборка и тегирование образа
docker build -t ${IMAGE}:${GIT_VERSION} .
docker tag ${IMAGE}:${GIT_VERSION} ${IMAGE}:latest
# Вход в Docker Hub и выгрузка образа
echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin
docker push ${IMAGE}:${GIT_VERSION}
То, какой будет вторая часть скрипта, полностью зависит от того, какой хост вы используете, и от того, как организовано подключение к нему. В моём случае, так как пользуюсь я Digital Ocean, для подключения к серверу используются команды doctl. При работе с Aws будет использоваться утилита aws, и так далее.
Настроить работу сервера было не особенно сложно. Так, я настроила дроплет, основанный на базовом образе. Надо отметить, что выбранная мной система требует выполнения однократной ручной установки Docker и однократного ручного запуска Docker. Я, для установки Docker, использовала Ubuntu 18.04, поэтому вы, если тоже используете Ubuntu, чтобы сделать то же самое, можете просто следовать этому простому руководству.
Я не говорю тут о конкретных командах для сервиса, так как этот аспект в разных случаях может сильно варьироваться. Я лишь приведу общий план действий, выполняемый после подключения по SSH к серверу, на котором будет развёрнут проект:
Нужно найти контейнер, который сейчас выполняется, и остановить его.
Затем нужно, в фоне, запустить новый контейнер.
Вам нужно будет установить локальный порт сервера в значение 80 — это позволит входить на сайт по адресу вида example.com, без указания порта, а не пользоваться адресом наподобие example.com:5000.
И, наконец, нужно удалить все старые контейнеры и образы.
Вот продолжение скрипта.
# Найти ID работающего контейнера
CONTAINER_ID=$(docker ps | grep takenote | cut -d" " -f1)
# Остановить старый контейнер, запустить новый, очистить систему
docker stop ${CONTAINER_ID}
docker run --restart unless-stopped -d -p 80:5000 ${IMAGE}:${GIT_VERSION}
docker system prune -a -f
Некоторые вещи, на которые стоит обратить внимание
Возможно, когда вы подключитесь к серверу по SSH из Travis CI, вы увидите предупреждение, которое не позволит продолжить установку, так как система будет ждать реакции пользователя.
The authenticity of host '<hostname> (<IP address>)' can't be established.
RSA key fingerprint is <key fingerprint>.
Are you sure you want to continue connecting (yes/no)?
Я узнала о том, что строковой ключ можно закодировать в base64 для того, чтобы сохранить её в таком виде, в котором с ней можно будет удобно и надёжно работать. На стадии установки можно декодировать публичный ключ и записать его в файл known_hosts для того, чтобы избавиться от вышеописанной ошибки.
Тот же подход можно использовать с приватным ключом при установлении соединения, так как вам, для доступа к серверу, вполне может понадобится приватный ключ. При работе с ключом вам лишь нужно обеспечить его безопасное хранение в переменной окружения Travis CI, и то, чтобы он нигде не выводился бы.
Ещё одна вещь, на которую стоит обратить внимание, это то, что вам может понадобиться запустить весь скрипт развёртывания, представленный в виде одной строки, например — с помощью doctl. Это может потребовать некоторых дополнительных усилий.
doctl compute ssh <droplet> --ssh-command "все команды будут здесь && здесь"
TLS/SSL и балансировка нагрузки
После того, как я сделала всё то, о чём шла речь выше, последней вставшей передо мной проблемой стало то, что у сервера не было SSL. Так как я пользуюсь Node.js-сервером, для того, чтобы заставить работать обратный прокси Nginx и Let’s Encrypt, нужно изрядно повозиться.
Мне совсем не хотелось выполнять все эти SSL-настройки вручную, поэтому я просто создала балансировщик нагрузки и записала сведения о нём в DNS. В случае с DigitalOcean, например, создание автообновляемого самоподписываемого сертификата на балансировщике нагрузки — простая, бесплатная и быстрая процедура. У такого подхода есть и дополнительное преимущество, которое заключается в том, что это, если нужно, позволяет очень просто настроить SSL на множестве серверов, работающих за балансировщиком нагрузки. Это позволяет самим серверам совершенно не «задумываться» о SSL, но при этом использовать, как обычно, порт 80. Так что настройка SSL на балансировщике нагрузки — это гораздо проще и удобнее, чем альтернативные методы настройки SSL.
Теперь можно закрыть на сервере все порты, принимающие входящие соединения — кроме порта 80, используемого для связи с балансировщиком нагрузки, и порта 22 для SSH. В результате попытка прямого обращения к серверу по любым портам, за исключение этих двух, потерпит неудачу.
Итоги
После того, как я сделала всё то, о чём рассказала в этом материале, меня уже не пугала ни платформа Docker, ни концепции автоматизированных CI/CD-цепочек. Я смогла настроить цепочку непрерывной интеграции, в ходе выполнения которой производится тестирование кода до попадания его в продакшн и автоматическое развёртывание кода на сервере. Всё это для меня пока ещё относительно ново, и я уверена, что есть способы улучшить мой автоматизированный рабочий процесс и сделать его эффективнее. Поэтому если у вас есть идеи на этот счёт — дайте мне знать. Надеюсь, эта статья помогла вам в ваших делах. Мне хочется верить, что прочтя её, вы узнали столько же, сколько узнала я, пока разбиралась со всем тем, о чём в ней рассказала.
P.S. В нашем маркетплейсе имеется образ Docker, который устанавливается в один клик. Вы можете проверить работу контейнеров на VPS. Всем новым клиентам бесплатно предоставляются 3 дня для тестирования.
Уважаемые читатели! Пользуетесь ли вы технологиями CI/CD в своих проектах?