За последние несколько лет я очень полюбил GitLab CI. В основном за его простоту и функциональность. Достаточно просто создать в корне репозитория файл .gitlab-ci.yml
, добавить туда несколько строчек кода и при следующем коммите запустится пайплайн с набором джобов, которые будут выполнять указанные команды.
А если добавить к этому возможности include и extends, можно делать достаточно интересные вещи: создавать шаблонные джобы и пайплайны, выносить их в отдельные репозитории и повторно использовать в разных проектах без копирования кода.
Но к сожалению, не всё так радужно, как хотелось бы. Инструкция script
в GitLab CI очень низкоуровневая. Она просто выполняет те команды, которые ей переданы в виде строк. Писать большие скрипты внутри YAML не очень удобно. По мере усложнения логики количество скриптов увеличивается, они перемешиваются с YAML делая конфиги нечитаемыми и усложняя их поддержку.
Мне очень не хватало какого-то механизма, который бы упростил разработку больших скриптов. В результате у меня родился микрофреймворк для разработки GitLab CI, про который я и хочу рассказать в этой статье (на примере простого пайплайна для сборки docker-образов).
Пример: Сборка docker-образов в GitLab
Я хочу рассмотреть процесс создания пайплайна на примере простой задачи, которую часто встречал у разных команд: создание базовых docker-образов в GitLab для их повторного использования.
Например, команда пишет микросервисы и хочет для них для всех использовать свой собственный базовый образ с набором предустановленных утилит для отладки.
Или другой пример, команда пишет тесты и хочет используя services прямо в GitLab создавать для них временную базу данных (или очередь, или что-то ещё) используя свой собственный образ.
В целом, создать docker-образ из отдельного докерфайла в GitLab достаточно просто. Например можно сделать это с помощью такого .gitlab-ci.yml
:
services:
- docker:dind
Build:
image: docker
script:
- |
docker login "$CI_REGISTRY" \
--username "$CI_REGISTRY_USER" \
--password "$CI_REGISTRY_PASSWORD"
docker build \
--file "Dockerfile" \
--tag "$CI_REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_REF_SLUG" .
docker push "$CI_REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_REF_SLUG"
Здесь мы создаём пайплайн с одним джобом Build
, который на каждый коммит логинится в GitLab Container Registry, собирает образ из файла докерфайла в корне репозитория и пушит полученный образ. В качестве тега образа используется название ветки или тега, где происходит сборка (возможно не самая лучшая схема, но это сделано для примера).
Но предположим, что нам необходимо создать чуть более сложный пайплайн, который будет работать следующим образом:
В репозитории может находиться несколько докерфайлов, которые будут располагаться в подпапке
dockerfiles
.На каждый коммит должен запускаться пайплайн с двумя джобами:
Build All
- будет находить и пересобирать все докерфайлы в подпапкеdockerfiles
. Этот джоб будет ручным (запускается по кнопке из интерфейса).Build Changed
- будет находить и пересобирать все докерфайлы в подпапкеdockerfiles
, которые были изменены в последнем коммите. Этот джоб будет автоматическим (запускается сразу при коммите) и будет появляться только при изменении файлов.
Подобный пайплайн должен достаточно хорошо работать. Он будет быстро пересобирать все изменённые докерфайлы при каждом коммите. При этом у разработчиков останется возможность вручную пересобрать сразу всё, если это потребуется.
Структура репозитория для такого пайплайна может выглядеть следующим образом:
Содержимое и название докерфайлов особой роли не играет, но в данном случае это докерфайлы для разных версий .NET.
В интерфейсе гитлаба подобный пайплайн может выглядеть следующим образом:
Результатом работы такого пайплайна станет набор docker-образов, которые можно будет найти в Container Registry проекта:
А теперь попробуем создать пайплайн, который бы решал нашу задачу.
Шаг 1: Решение "в лоб"
Начнём самого простого и очевидного решения. Мы можем поместить весь код в файл .gitlab-ci.yml
. В таком случае он будет выглядеть следующим образом:
services:
- docker:dind
stages:
- Build
Build All:
stage: Build
image: docker
when: manual
script:
- |
dockerfiles=$(find "dockerfiles" -name "*.Dockerfile" -type f)
docker login "$CI_REGISTRY" \
--username "$CI_REGISTRY_USER" \
--password "$CI_REGISTRY_PASSWORD"
for dockerfile in $dockerfiles; do
path=$(echo "$dockerfile" | sed 's/^dockerfiles\///' | sed 's/\.Dockerfile$//')
tag="$CI_REGISTRY/$CI_PROJECT_PATH/$path:$CI_COMMIT_REF_SLUG"
echo "Building $dockerfile..."
docker build --file "$dockerfile" --tag "$tag" .
echo "Pushing $tag..."
docker push "$tag"
done
Build Changed:
stage: Build
image: docker
only:
changes:
- 'dockerfiles/*.Dockerfile'
- 'dockerfiles/**/*.Dockerfile'
script:
- |
apk update
apk add git # Вообще говоря, так не очень хорошо делать, но для примера можно...
dockerfiles=$(git diff --name-only HEAD HEAD~1 -- 'dockerfiles/***.Dockerfile')
docker login "$CI_REGISTRY" \
--username "$CI_REGISTRY_USER" \
--password "$CI_REGISTRY_PASSWORD"
for dockerfile in $dockerfiles; do
path=$(echo "$dockerfile" | sed 's/^dockerfiles\///' | sed 's/\.Dockerfile$//')
tag="$CI_REGISTRY/$CI_PROJECT_PATH/$path:$CI_COMMIT_REF_SLUG"
echo "Building $dockerfile..."
docker build --file "$dockerfile" --tag "$tag" .
echo "Pushing $tag..."
docker push "$tag"
done
Ссылки на проект:
Здесь мы делаем следующее:
Создаём джоб
Build All
, который:Запускается вручную, т.к. содержит настройку
when: manual
.Выполняет поиск всех докерфайлов при помощи команды:
find "dockerfiles" -name "*.Dockerfile" -type f
Создаём джоб
Build Changed
, который:Создаётся только при изменении докерфайлов, т.к. содержит настройку
only:changes
.Выполняет поиск всех докерфайлов, изменённых в последнем коммите при помощи команды:
git diff --name-only HEAD HEAD~1 -- 'dockerfiles/***.Dockerfile'
В остальном оба джоба работают одинаково, после поиска докерфайлов, они проходят по ним циклом, вырезают из названия каждого докерфайла префикс
dockerfiles/
и суффикс.Dockerfile
, после чего собирают его и пушат в GitLab Container Registry.
Данный пайплайн работает но есть очевидные проблемы:
Скрипты обоих джобов практически полностью дублируются (за исключением поиска докерфайлов)
Скрипты сильно загромождают конфиг, усложняя его понимание.
В этом месте появляется тот микрофреймворк для разработки GitLab CI про который я упоминал в самом начале.
GitLab CI Bootstrap
GitLab CI Bootstrap - это микрофреймворк для разработки GitLab CI. Его основные цели:
Разделить описание пайплайна (файл
.gitlab-ci.yml
) и его скрипты (bash или shell), чтобы оставить YAML более декларативным.Дать возможность разбивать большие скрипты на более мелкие и подключать одни скрипты к другим, чтобы улучшить структурирование кода и упростить его поддержку.
Дать возможность выносить скрипты и джобы (со скриптами) в отдельные репозитории, чтобы повторно использовать их в разных проектах.
GitLab CI Bootstrap состоит из одного единственного файла bootstrap.gitlab-ci.yml, который необходимо подключить в файле .gitlab-ci.yml
при помощи include. Сделать это можно несколькими способами. Проще всего скопировать файл в свой проект и подключить его через include:local:
include:
- local: 'bootstrap.gitlab-ci.yml'
В этом файле находится один единственный скрытый джоб .bootstrap
, который имеет следующий вид:
.bootstrap:
before_script:
- |
...
Другие джобы могут расширять джоб .bootstrap
, используя extends:
example:
extends: '.bootstrap'
script:
- '...'
В таких джобах будет включаться механизм загрузки скриптов (который описан ниже), который позволяет загружать скрипты из текущего репозитория или из любого другого репозитория внутри того же экземпляра GitLab.
При этом для загрузки скриптов не нужны специальные токены доступа. Загружать скрипты можно даже из внутреннего или приватного репозиториев. Главное, чтобы у разработчика, запустившего пайплайн был доступ на чтение к этим репозиториям.
Единственным требованием для загрузчика является наличие bash или shell. Также желательно наличие git, однако при его отсутствии он будет установлен автоматически. Благодаря этому джоб .bootstrap
можно использовать практически с любыми официальными docker-образами и он будет там корректно работать.
Итак, давайте посмотрим, как это поможет нам упростить наш пайплайн для сборки docker-образов.
Шаг 2: Выносим все скрипты в отдельный файл
Все джобы, использующие джоб .bootstrap
автоматически проверяют наличие в репозитории специального файла .gitlab-ci.sh
. При его наличии этот файл автоматически загружается и все переменные и функции, которые в нём определены становятся доступными на этапе выполнения скриптов джоба.
Поэтому мы можем вынести все скрипты в этот файл и разбить их на функции, чтобы избежать дублирования, а затем вызвать эти функции из файла .gitlab-ci.yml
:
Файл .gitlab-ci.yml
:
include:
- project: '$CI_PROJECT_NAMESPACE/bootstrap'
ref: 'master'
file: 'bootstrap.gitlab-ci.yml'
services:
- docker:dind
stages:
- Build
Build All:
stage: Build
image: docker
extends: .bootstrap
when: manual
script:
- search_all_dockerfiles_task
- build_and_push_dockerfiles_task
Build Changed:
stage: Build
image: docker
extends: .bootstrap
only:
changes:
- 'dockerfiles/*.Dockerfile'
- 'dockerfiles/**/*.Dockerfile'
script:
- install_git_task
- search_changed_dockerfiles_task
- build_and_push_dockerfiles_task
Файл .gitlab-ci.sh
:
DOCKERFILES=""
function search_all_dockerfiles_task() {
DOCKERFILES=$(find "dockerfiles" -name "*.Dockerfile" -type f)
}
function search_changed_dockerfiles_task() {
DOCKERFILES=$(git diff --name-only HEAD HEAD~1 -- 'dockerfiles/***.Dockerfile')
}
function install_git_task() {
# Вообще говоря, так не очень хорошо делать, но для примера можно...
apk update
apk add git
}
function build_and_push_dockerfiles_task() {
docker login "$CI_REGISTRY" \
--username "$CI_REGISTRY_USER" \
--password "$CI_REGISTRY_PASSWORD"
for dockerfile in $DOCKERFILES; do
path=$(echo "$dockerfile" | sed 's/^dockerfiles\///' | sed 's/\.Dockerfile$//')
tag="$CI_REGISTRY/$CI_PROJECT_PATH/$path:$CI_COMMIT_REF_SLUG"
echo "Building $dockerfile..."
docker build --file "$dockerfile" --tag "$tag" .
echo "Pushing $tag..."
docker push "$tag"
done
}
Ссылки на проект:
Репозиторий (ветка step2): https://gitlab.com/chakrygin/dockerfiles-example/-/tree/step2
Пайплайн: https://gitlab.com/chakrygin/dockerfiles-example/-/pipelines/303676828
Теперь все скрипты у нас вынесены в отдельный файл gitlab-ci.sh
и разбиты на функции, которые вызываются из файла .gitlab-ci.yml
. Функции search_all_dockerfiles_task
и search_changed_dockerfiles_task
заполняют переменную DOCKERFILES
, которая позже используется функцией build_and_push_dockerfiles_task
для сборки докерфайлов.
Шаг 3: Переносим пайплайн в отдельный репозиторий
Мы сделали работающий пайплайн и вынесли его скрипты в отдельный файл, но пока этот пайплайн находится в том же репозитории, что и докерфайлы. Это означает, что если другая команда захочет использовать его для сборки своих докерфайлов, им придётся скопировать все скрипты. А это в свою очередь означает, что если нам необходимо будет внести в пайплайн какие-нибудь изменения, то потом их нужно будет внести и всем командам, которые скопировали себе пайплайн.
Вместо этого мы можем вынести пайплайн в отельный репозиторий и предложить нескольким командам подключить его через include.
Однако для этого придётся внести в пайплайн небольшое изменение. Дело в том, что поскольку скрипты теперь будут фактически расположены не в том же репозитории, где запускается пайплайн, джоб должен как-то это понять и определить, откуда загрузить скрипты. Это делается при помощи специальных переменных, которые добавляются в пайплайн.
Если в джобе, использующей джоб .bootstrap
определена переменная CI_IMPORT
, то этот джоб проверит наличие трёх переменных: CI_{module}_PROJECT
, CI_{module}_REF
и CI_{module}_FILE
, где {module}
- это нормализованное значение переменной CI_IMPORT
(приведённое в верхнему регистру с заменой дефисов на подчёркивания).
Если все три переменные существуют, джоб перед загрузкой скриптов из файла .gitlab-ci.sh
сначала загрузит файл, указанный в этих переменных.
Наш репозиторий с докерфайлами теперь будет выглядеть так:
Файл .gitlab-ci.yml
:
include:
- project: '$CI_PROJECT_NAMESPACE/dockerfiles-example-ci'
ref: 'step3'
file: 'dockerfiles.gitlab-ci.yml'
Сам пайплайн в отдельном репозитории будет выглядеть следующим образом:
Файл dockerfiles.gitlab-ci.yml
(сюда перенесён код из .gitlab-ci.yml
):
include:
- project: '$CI_PROJECT_NAMESPACE/bootstrap'
ref: 'master'
file: 'bootstrap.gitlab-ci.yml'
services:
- docker:dind
stages:
- Build
variables:
CI_DOCKERFILES_PROJECT: '$CI_PROJECT_NAMESPACE/dockerfiles-example-ci'
CI_DOCKERFILES_REF: 'step3'
CI_DOCKERFILES_FILE: 'dockerfiles.gitlab-ci.sh'
Build All:
stage: Build
image: docker
extends: .bootstrap
variables:
CI_IMPORT: dockerfiles
when: manual
script:
- search_all_dockerfiles_task
- build_and_push_dockerfiles_task
Build Changed:
stage: Build
image: docker
extends: .bootstrap
variables:
CI_IMPORT: dockerfiles
only:
changes:
- 'dockerfiles/*.Dockerfile'
- 'dockerfiles/**/*.Dockerfile'
script:
- search_changed_dockerfiles_task
- build_and_push_dockerfiles_task
Файл dockerfiles.gitlab-ci.sh
(сюда перенесён код из .gitlab-ci.sh
):
DOCKERFILES=""
function search_all_dockerfiles_task() {
DOCKERFILES=$(find "dockerfiles" -name "*.Dockerfile" -type f)
}
function search_changed_dockerfiles_task() {
DOCKERFILES=$(git diff --name-only HEAD HEAD~1 -- 'dockerfiles/***.Dockerfile')
}
function build_and_push_dockerfiles_task() {
docker login "$CI_REGISTRY" \
--username "$CI_REGISTRY_USER" \
--password "$CI_REGISTRY_PASSWORD"
for dockerfile in $DOCKERFILES; do
path=$(echo "$dockerfile" | sed 's/^dockerfiles\///' | sed 's/\.Dockerfile$//')
tag="$CI_REGISTRY/$CI_PROJECT_PATH/$path:$CI_COMMIT_REF_SLUG"
echo "Building $dockerfile..."
docker build --file "$dockerfile" --tag "$tag" .
echo "Pushing $tag..."
docker push "$tag"
done
}
Ссылки на проект:
Репозиторий с докерфайлами (ветка step3): https://gitlab.com/chakrygin/dockerfiles-example/-/tree/step3
Репозиторий с пайплайном (ветка step3): https://gitlab.com/chakrygin/dockerfiles-example-ci/-/tree/step3
Пайплайн: https://gitlab.com/chakrygin/dockerfiles-example/-/pipelines/303702906
Оба наших джоба Build All
и Build Changed
содержат переменную CI_IMPORT
со значением dockerfiles
. Поэтому загрузчик при старте джоба также проверит наличие трёх переменых: CI_DOCKERFILES_PROJECT
, CI_DOCKERFILES_REF
и CI_DOCKERFILES_FILE
, которые также определены в пайплайне.
Благодаря этим переменным загрузчик понимает, что должен загрузить скрипты из файла dockerfiles.gitlab-ci.sh
, который находится в репозитории $CI_PROJECT_NAMESPACE/dockerfiles-example-ci
, в ветке step3
.
Кроме того, мы убрали из скриптов функцию install_git_task
. Явная установка git больше не нужна, т.к. загрузчик сам по себе требует git для загрузки скриптов из другого репозитория и поэтому устанавливает git самостоятельно.
Шаг 4: Разбиваем большие скрипты на отдельные файлы
Мы вынесли пайплайн в отдельный репозиторий и теперь другие команды могут подключать его в свои проекты простым инклюдом. Изменения, которые мы будем вносить в пайплайн будут автоматически отражаться на всех командах и нам не придётся ходить по командам с просьбами обновиться на последнюю версию.
Однако сейчас все скрипты у нас находятся в одном файле dockerfiles.gitlab-ci.sh
. По мере добавления функциональности, количество кода в нём будет расти и ориентироваться в нём будет всё сложнее и сложнее.
К счастью, если в процессе загрузки скрипта в нём встречается вызов функции include
, файл, указанный в аргументе этой функции также загружается. При этом не важно, в каком репозитории находится загружаемый скрипт. Функция include
всегда загружет файл из того же репозитория, из которого она вызвана.
Также хочу отметить, что эта функция всегда загружает каждый файл только один раз, даже если вызов функции include
с этим файлом встретится несколько раз.
Поэтому мы можем разбить наш файл dockerfiles.gitlab-ci.sh
на несколько более мелких:
Файл dockerfiles.gitlab-ci.sh
:
include "tasks/search.sh"
include "tasks/docker.sh"
Файл tasks/search.sh
:
DOCKERFILES=""
function search_all_dockerfiles_task() {
DOCKERFILES=$(find "dockerfiles" -name "*.Dockerfile" -type f)
}
function search_changed_dockerfiles_task() {
DOCKERFILES=$(git diff --name-only HEAD HEAD~1 -- 'dockerfiles/***.Dockerfile')
}
Файл tasks/docker.sh
:
function build_and_push_dockerfiles_task() {
docker login "$CI_REGISTRY" \
--username "$CI_REGISTRY_USER" \
--password "$CI_REGISTRY_PASSWORD"
for dockerfile in $DOCKERFILES; do
path=$(echo "$dockerfile" | sed 's/^dockerfiles\///' | sed 's/\.Dockerfile$//')
tag="$CI_REGISTRY/$CI_PROJECT_PATH/$path:$CI_COMMIT_REF_SLUG"
echo "Building $dockerfile..."
docker build --file "$dockerfile" --tag "$tag" .
echo "Pushing $tag..."
docker push "$tag"
done
}
Ссылки на проект:
Репозиторий с докерфайлами (ветка step4): https://gitlab.com/chakrygin/dockerfiles-example/-/tree/step4
Репозиторий с пайплайном (ветка step4): https://gitlab.com/chakrygin/dockerfiles-example-ci/-/tree/step4
Пайплайн: https://gitlab.com/chakrygin/dockerfiles-example/-/pipelines/303704359
Заключение
В данной статье мы прошли по шагам создания переиспользуемого пайплайна для GitLab CI с использованием bash и GitLab CI Bootstrap.
Чтобы не увеличивать статью я описал только самые основные возможности своего микрофреймворка. Кроме этого он также позволяет:
Подключать скрипты из нескольких репозиториев по цепочке. Т.е. общий пайплайн, вынесенный в отдельный репозиторий сам по себе может также подключать скрипты из ещё нескольких репозиториев. Это создаёт большой простор для создания целых библиотек для GitLab CI.
Получать доступ к произвольным файлам в подключаемом репозитории. Это можно использовать для написания скриптов на других языках, таких как Python или C#.
Делать хуки на определённые функции. Это позволяет разработчикам, использующим общий пайплайн расширять его логику при необходимости.
Если нужно, я могу рассказать про эти возможности в комментариях.
Я пока не могу сказать, что мой микрофреймворк близок к версии 1.0. Ещё есть вещи, над которыми стоит поработать. Тем не менее, ранние его версии я успешно использовал в нескольких проектах и это действительно сильно упростило мне разработку.
Исходники: можно найти тут: https://gitlab.com/chakrygin/bootstrap
Репозитории с примерами тут: