Как реализовать CI\CD, используя gitlab и docker-compose?

Я периодически создают pet-проекты, но неожиданно встала проблема, как их устанавливать на удаленном сервере. Т.е. я что-то напрограммировал нажал commit и через несколько минут уже всё работает на веб-сервере в интернете. Я накачал уйму курсов видео по CI\CD, на видео у тренеров всё получалось как-то легко и просто, а вот у меня что-то не получалось, ни с первой попытки, ни со второй. Получилось с 68 (я просто вместо коммита ставил число, вот так и подсчитал) 🙂 Изначально CI\CD казалось загадкой, завернутая в тайну и помещенная внутрь головоломки. Но я разобрался. Свой собственный путанный опыт я хочу здесь изложить.
Проект я взял простой: фронтенд на ReactJS, бекенд на ASP.Net Core и БД Postgres. Решил всё упаковать в докер-контейнеры и запускать через docker-compose.
Алгоритм CI\CD у меня получился следующий:

  1. Подготовка приложения
  2. Подготовка сервера
  3. Написание сценария развёртывания
    Те кто не любит много и долго читать исходники лежат здесь, а результат можно увидеть здесь. Иногда читать не нужно, можно просто посмотреть код.

Подготовка приложения
Приложение ASP.Net Core + ReactJS я нашёл на просторах ютуба. Это простейший CRUD можно добавить\редактировать\удалить\отобразить запись из БД. Почему именно такой стек? Потому что я его знаю 🙂
Всё хорошо, но это приложение даже не подозревает, что существуют какие-то переменные окружения и нужно их считывать.
Для бекенда для приложения на ASP.Net Core v6 необходимо добавить следующие строки

var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ??
                          Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
builder.Configuration.SetBasePath(AppContext.BaseDirectory)
        .AddJsonFile("appsettings.json", true)
        .AddJsonFile($"appsettings.{environmentName}.json", true)
        .AddEnvironmentVariables();

Они сообщают, что нужно считать переменную окружения ASPNETCORE_ENVIRONMENT и в зависимости от её значения, подгрузить нужный файл конфигурации.
Я её устанавливаю в значение ASPNETCORE_ENVIRONMENT=Prod и соответственно файл называется appsettings.Prod.json, а файл для локальной разработки appsettings.Development.json
По факту в настройках всего одна строка. Строка подключения к базе данных
"DefaultConnection": "Host=db;Port=5432;Database=postdb;Username=postgres;Password=*****"
Хитрое название хоста Host=db это имя докер-контейнера, который задаётся в docker-compose.
И ещё я проверил, чтобы при развёртывании миграции на БД накатывались автоматически.

Фронтенд на ReactJS
В коде я прописал строку
const API_URL = process?.env?.REACT_APP_API_URL ?? 'http://localhost:7155';
Т.е. API_URL брать из переменной окружения REACT_APP_API_URL, если она есть. Если её нет, из локалхоста.
В файле package.json создал хитрую команду
"build": "cross-env GENERATE_SOURCEMAP=false NODE_ENV=production react-scripts build",
Cross-env – занимательный пакет. в Windows и Linux переменные окружения задаются несколько по-своему, разница во всяких кавычках и пробелах. Через Cross-env можно передать параметры и неважно, где запускает скрипт на компьютере разработчика с Windows или на сервере с Linux переменные окружения будут установлены корректно.
GENERATE_SOURCEMAP=false это не генерировать вспомогательные файлы, они нужны для разработки на сервере они бесполезны.
NODE_ENV=production - это установка переменной NODE_ENV в значение production.
В результате ReactJS будет искать файл .env.production где я прописал url до сервера
REACT_APP_API_URL=http://185-225-35-229.nip.io
Но вот фронтенд ничего не подозревает ни о каких докер-контейнера на сервере и выполняется на компьютере клиента, поэтому url должен быть на внешний сайт.
А не кажется урл http://185-225-35-229.nip.io немного странным? А так оно и есть. Можно было купить доменное имя и привязать его к ip сервера. А можно воспользоваться сайтом https://nip.io/ который «превращает» DNS имя в ip-адрес и при том абсолютно бесплатно.
Так что имя 185-225-35-229.nip.io превратиться в 185.225.35.229
Теперь приложение, может работать с переменными окружения.

Подготовка сервера
Я искал дешевые VPS сервер, в результате купил VPS у beget. Самая простейшая виртуалка продаётся за 7руб.\день. Но можно в принципе выбрать любую. Но у меня у beget ещё хостятся сайты, существенных претензий к ним нет.
Итак, создали виртуалку, установили на неё свой публичный ssh-ключ. Именно ключ, а не пароль. Живому хакеру такая горе-виртуалка нафиг не нужна, а вот хакер-бот будет перебирать пароли круглосуточно, пока не подберёт.
Далее стандартно обновляем и ставим пару приложений для удобства и мониторинга.

apt-get update && apt-get upgrade -y && apt-get dist-upgrade -y
apt-get install mc htop

Устанавливаем таймзону, я живу в Сибири, поэтому таймзона Ёбурга. Но каждый может выбрать свою. Иначе сложнее будет логи читать из-за смещённого времени.

timedatectl set-timezone Asia/Yekaterinburg

Ставим докер

curl -o - https://get.docker.com | bash -

И docker-compose

/bin/bash -c "$(curl -fsSL https://git.io/JDGfm)"

Для установки я взял скрипт какой-то китайца. Лучше написать свой, но мне было лень.

И вот ответственный момент ставим gitlab-runner.
Как работает gitlab. Думаете gitlab сам собирает контейнеры, запускает скрипты? Нет. Gitlab оркестратор, он передаёт управление специальным программам gitlab-runner. Они принимают команды с сервера gitlab и их выполняют.
Я решил создать 2 gitlab-runner. Первый большой и тяжелый, который из докера собирает докер-контейнеры. Второй лёгкий он просто выполняет посланные на него команды.
Gitlab-runner ставим по инструкции с официального сайта. Но самые важные команды я приведу

gitlab-runner register --url https://gitlab.com/ --registration-token $token --executor docker --tag-list "docker" --docker-image "docker:stable" --description docker-executor-01 --executor docker

В этой команде кроме url и токена очень важно --tag-list "docker" - это тег, и мы через этот тег в файле .gitlab-ci.yml будем указывать, что для сборки докер-контейнеров надо воспользоваться этим раннером и образ будет docker:stable.
Второй раннер. Это shell раннер. Простой лёгкий, он просто выполняет все команды, которые получает с сервера.

gitlab-runner register --url https://gitlab.com/ --registration-token $token --executor shell --tag-list "shell" --description shell-executor-01

У него тег - shell.
Думаете установили раннеры и они будут все команды выполнять. Как бы не так, это линукс, у них никаких прав нет.
Поэтому добавил в файл /etc/sudoers строку gitlab-runner ALL=(ALL) NOPASSWD: ALL Теперь пользователь gitlab-runner может делать на сервере всё. По факту надо было прописать конкретно какие команды он может реализовывать, но решил оставить эту дыру в безопасности.
Далее надо включить gitlab-runner в группу docker. А то он не сможет управлять докером.

usermod -aG docker gitlab-runner

Думаете это всё? А нет, в домашнем каталоге /home/gitlab-runner/ надо удалить файлы .bashrc, .profile, .bash_logout Иначе возникает непонятная ошибка при выполнении команд.
Сам gitlab даёт shared runner, но кто их держит и что он добавит в Ваш код во время сборки вопрос открытый. Поэтому лучше всё своё родное. 🙂
И ещё надо включить firewall

ufw allow ssh
ufw allow http
ufw enable

Оставим только порты ssh и http.
Вот теперь действительно всё.

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

Написание сценария развёртывания
А вот теперь самое интересное, контейнеризация приложения и его деплой на сервер.

Сначала создаём Dockerfile для сервера.
У всякой проблемы всегда есть решение – простое, удобное и, конечно, ошибочное. Простой и неправильный путь – это собирать и публиковать всё из одного контейнера. Почему неправильный. Обычно приложение зависит от туевой хучи библиотек, который зависят от других библиотек и так по цепочке. После того, как приложение уже собрано эти библиотеки для библиотек уже нафиг не нужны. Поэтому придумали multistage сборку, т.е. в первом контейнере собираем приложение в виде exe\dll файлов. Эти файлы копируем в другой контейнер откуда всё и запускаем, а первый контейнер уничтожаем.
Как создать грамотную docker multistage сборку для ASP.NET Core приложения вопрос философский. Но вот MS рекомендует для сборки использовать 4 контейнера. Но вручную реализовать рекомендации MS достаточно трудоёмко. Но есть выход, для VSC существет расширение docker оно анализирует проект на ASP.NET Core и создаёт по нему docker-файл. И он чудесным образом соответствует всем рекомендациям MS и ничего не нужно писать вручную. Вот что получилось в результате

Теперь Dockerfile для frontend.
Он намного проще. Собрать проект, а полученную папку скопировать в другой контейнер. Где её будет отдавать в интернет nginx в качестве веб-сервера.
Вот результат
Как не выкачивать node_modules при следующей сборке я расскажу ниже. И ещё лучше использовать nginx в качестве веб-сервера, он статику отдаёт как из пулемёта.
Докер-образ для базы данных мы создадим в самом файле docker-compose.yml.
А на самое сладкое я оставил создание конфиг файла для nginx в качестве реверс-прокси. Что делает реверс-прокси? Он распределяет трафик между докер-контейнерами.
Если http-запрос заканчивает на «/» он перенаправляет на «http://frontend». Заметьте именно по внутреннему имени докер-контейнера frontend и без «/» на конце. Я на этом сильно споткнулся. Если на реверс-прокси приходит url с «/api» он перенаправляет на “ http://server:7155». Там тоже есть веб-сервер kestrel. Как путешествует http-запрос внутри приложения ASP.Net Core и как добирается до БД – это тема отдельной лекции. Но поверьте, через все middleware он всё-таки добирается. 🙂
Есть ещё одна подстава, о которую я хорошо споткнулся. Для внутренней маршрутизации внутри контейнеров можно использовать имена контейнеров, поэтому реверс-прокси их так бодро маршрутизирует и бекенд приложение обращается к базе данных по имени контейнера. А вот фронтенд приложение, ни о каких внутренних именах докер-контейнеров ничего не подозревает, ему нужно внешнее имя, т. е. http://185-225-35-229.nip.io/
Сам конфиг получился вот такой
И устроен он достаточно просто. Есть директива server в который определяются имя сервера и параметры порта. А внутри неё есть директива location которая уже разбирает url и в зависимости от него перенаправляет на нужный контейнер.
Как реализовать CI\CD, используя gitlab и docker-compose?
Но в моём конфиге есть много разного и сжатие, и защита, но я это не сам придумал а сгенерировал при помощи сайта NGINXConfig
Сам docker-файл для Nginx самый простой он просто копирует конфиги nginx в контейнер. Вот он
Теперь надо собрать все контейнеры воедино в одну кучку при помощи файла docker-compose.production.yml
В этом файле нет никаких открытий, описываются контейнеры, как они зависят друг от друга.
Файлы БД postgres монтируются в каталог pgdata. Чтобы база данных не исчезала после пересоздания контейнера.

volumes:
      - ./pgdata:/var/lib/postgresql/data

Так же есть диагностика, проверка жива ли БД или нет. Если мертва, то контейнер надо перезапустить.

healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d postdb"]
      interval: 10s
      timeout: 5s
      retries: 5

Логи nginx тоже интересно почитать. Пусть тоже никуда не исчезают 🙂

volumes:
      - ./logs/nginx:/var/log/nginx

Но и это ещё не всё, есть 2 похожие директивы: еxpose и ports. Они обе открывают порты. Но ports открывает порт во вне во внешний мир, а еxpose только во внутреннею сеть докера. Я сначала пооткрывал порты через ports во весь мир, в результате у меня-то на локальном компьютере всё хорошо, всё великолепно, всё работает, а вот если разместить на хостинге и закрыть порты файерволом, то всё умирает. Через ports надо открыть только 80 порт реверс-прокси, а вот все остальные порты для связи микросервисов друг с другом необходимо открывать через еxpose.
А вот теперь разберём как происходит сборка и деплой контейнеров через файл .gitlab-ci.yml

Алгоритм будет следующий:

  1. Сначала собираем докер-контейнеры и помещаем их репозиторий gitlab.
    Это служит 2 целям: 1. Можно будет откатиться на пару версий назад, если что-то пошло куда-то не туда 2. Эти контейнеры будут использоваться в качестве кеша для последующих сборок. Помните я говорил про node_modules? Docker увидит, что слой с node_modules не поменялся и поэтому заново выкачивать из интернета их не будет. Nginx вообще будет собираться влёт, т.к. меняет сильно редко.
  2. Каждом докер-контейнеру присвоим теги: sha-код текущего коммита, чтобы была возможность вернуться назад и latest.
  3. При деплое docker-compose будет выкачивает последнею версию наших контейнеров с тегом latest и выполнять именно их.

Алгоритм фазы build следующий.

Сначала авторизуемся в репозитории

docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

Страшные символы $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD, $CI_REGISTRY – это переменные окружения gitlab. Раннер их подставит автоматически и не нужно нигде хранить системные логин и пароль.
Затем получаем последнею версию докер-образа из репозитория.

docker pull $CI_REGISTRY_IMAGE:latest.server || true

Но её может и не быть и этот код вернёт ошибку. И когда gitlab увидит эту ошибку, он решит, что наша программа сделала что-то настолько ужасное, от чего не может оправиться и он должна героически убить весь pipeline и чтобы полностью избавиться от этого. Поэтому допишем || true Чтобы наша команда всегда возвращала true.
Далее собираем наш сервер

docker build
      --pull
      --cache-from $CI_REGISTRY_IMAGE:latest.server
      --build-arg ASPNETCORE_ENVIRONMENT="Prod"
      --tag $CI_REGISTRY_IMAGE:${CI_COMMIT_SHA}.server
      --tag $CI_REGISTRY_IMAGE:latest.server
      ./aspnetserver 

С использованием нужной переменной окружения ASPNETCORE_ENVIRONMENT="Prod" и нужными тегами $CI_REGISTRY_IMAGE:${CI_COMMIT_SHA}.server и $CI_REGISTRY_IMAGE:latest.server и образом кеша.
Слова $CI_REGISTRY_IMAGE и $CI_COMMIT_SHA – это тоже переменные окружения gitlab они задают путь до репозитория и хеш-коммита
А затем отправляем собранные образы в репозиторий

docker push --all-tags $CI_REGISTRY_IMAGE

Но при каждом коммите этого делать не нужно. Веток может быть много, поэтому сборку образов и деплой необходимо делать только если идёт пуш в ветку main или запрос на слияние merge_request.
Это задаётся правилами

rules:
    - if: $CI_COMMIT_BRANCH == 'main'
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" 

Ну и не забываем использвать на докер-раннер через

tags:
    - docker

Сам деплой намного проще. Помните про раннер на сервере, который из под админа просто выполняет переданные команды. Пришла его очередь. Он задаётся через тег shell. И основные команды, которые он выполняет следующие.

- docker-compose down --remove-orphans
- docker-compose pull
- docker-compose -f docker-compose.yml up -d 

Сначала копируем с переименованием файл docker-compose.production.yml в папку deploy на нашем сервере. Затем отключаем docker-compose и удаляем ненужные образы, вытягиваем свежую latest-версию из репозитария докер-образы и снова запускаем docker-compose.
Рекомендую запомнить следующие команды docker-compose

docker-compose build – собрать проект
docker-compose up -d – запустить проект и отсоединиться от него
docker-compose down – остановить проект
docker-compose logs – просмотреть логи
docker system prune -a – убить всё

Теперь при любом коммите в ветку main запускается процесс сначала сборки, а затем деплоя на удаленный сервер.
Так что вот мои упражнения в DevOps с использованием gitlab и docker-compose. Если кому-то изложение показалось странным, путанным, непонятным, значит так оно и есть, поэтому пишите комментарии. Я всё объясню 🙂