
С подключением, хабровчане! Меня зовут Роман Волков, я Senior DevOps в МТС Web Services. Последние несколько лет мне приходилось создавать и адаптировать конвейеры на базе GItLab-CI, изменяя процесс автоматизации под каждую новую команду, стек, продукт и окружения эксплуатации. Чтобы облегчить жизнь себе и коллегам, я сделал небольшой внутренний фреймворк — FundaPipe, значительно упрощающий создание, развитие, переиспользование и применение самих конвейеров разработчиками.
А что, собственно, фреймворкаем?
За годы работы у меня сформировался шаблон, который я применял в каждом проекте:
договориться о стратегии ветвления;
узнать используемые/предпочитаемые разработкой инструменты;
разобраться с необходимыми интеграциями;
подготовить инфраструктуру;
предложить автоматизацию;
создать конвейер;
протестировать его.
Код проходит через 3 стадии:
разработка;
тестирование;
готовность к применению.
Схематически это можно представить следующим образом:

Для проекта на языке python, разрабатываемого по git-flow, заполненная таблица может выглядеть примерно так:

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

Разработчик создавал файл .env с необходимыми параметрами в репозитории исходного кода, а я подгружал переменные из него в оболочку при сборке. При этом файл для gitlab-ci был общим и подключался через «General pipelines → CI/CD configuration file».
И мне, и коллегам такой способ показался довольно комфортным. Его я и взял за основу.
Архитектура фреймворка

Проанализировав свой опыт, я разделил все процессы по возможности их унификации. Так, к общему стандарту были приведены задачи автоматизации из блоков интеграций и сред эксплуатации (всё что предполагает создание артефакта).
CI (Continuous Integration):
получить предыдущие версии артефакта;
определить его последнюю доступную и будущую версию;
создать артефакт;
проверить его качества;
отправить в какое-либо хранилище;
изменить тег полученного ранее артефакта.
CD (Continuous Delivery):
развернуть артефакт (атомарная установка);
указать где-нибудь созданную версию в качестве зависимости (пакетная установка).
К нестандартным отнёс все задачи, не предполагающие создания артефакта, например запуск линтеров и юнит-тестов.
Все процессы оформляются в качестве черновиков задач GitLab-CI. Их можно назвать общим словом «обработчики». Разработчик может вызвать их через файл .env. Для удобства, файлы процессов разложены по директориям, синонимичным своим переменным окружения. Эти директории хранятся в общей директории handlers.
Для переменных CONTAINERIZATION и HELMING структура файлов будет выглядеть так:
handlers
├── helming
│ └── main.yaml
└── containerization
└── main.yaml
Так как часть стандартного процесса CI/CD может быть триггерами, будет удобно сложить законченные реализации в общую директорию subpipes, чтобы полный процесс можно было подключить из одного места.
Стратегии ветвления сводятся к набору правил для GitLab-CI. Их для удобства складываем в общую директорию rules.
Пример:
# rules/git-flow.yaml
.rules:
develop:
- if: ($CI_PIPELINE_SOURCE == "push") && ($CI_COMMIT_BRANCH == "develop")
variables:
STAGE: "develop"
SUFFIX: "-dev"
SORT_SUFFIX: "$SUFFIX"
exists:
- .env
- if: ($CI_PIPELINE_SOURCE == "push") && ($CI_COMMIT_BRANCH =~ /^feature\/.+--dod$/)
variables:
STAGE: "develop"
SUFFIX: "-dev"
SORT_SUFFIX: "$SUFFIX"
exists:
- .env
- if: ($CI_PIPELINE_SOURCE == "push") && ($CI_COMMIT_BRANCH =~ /^bugfix\/.*/)
variables:
STAGE: "develop"
SUFFIX: "-dev"
SORT_SUFFIX: "$SUFFIX"
exists:
- .env
test:
- if: ($CI_PIPELINE_SOURCE == "push") && ($CI_COMMIT_BRANCH =~ /^release/)
variables:
STAGE: "test"
SUFFIX: "-rc"
SORT_SUFFIX: "$SUFFIX"
exists:
- .env
readiness:
- if: ($CI_COMMIT_TAG =~ /^[0-9]+[.][0-9]+([.][0-9]+)?$/)
variables:
STAGE: "readiness"
exists:
- .env
В GitLab можно возможность создавать окружения и гибко использовать их в конвейерах «из коробки», однако их конфигурация сводится к множественным кликам и заполнению полей в UI, что не всегда удобно. Мы можем исправить это через оформление задач развертки, но тогда нужно где-то задать свойственные среде эксплуатации переменные окружения. Помимо этого, у обработчиков должен быть приоритет — важно понимать в какой последовательности их запускать. А значит, нужен файл конфигурации.
Кроме того нужно собрать из черновиков чистовые задачи, дополнить переменные окружения, выставить зависимость одних задач от других, чтобы сохранялась строгая последовательность, решить когда необходимо использовать атомарную развёртку, когда пакетную, а когда и то и другое… Другими словами, нужен код. Его я написал на python. Он и будет генерировать файл итогового конвейера, исходя из имеющихся при запуске программы переменных окружения.
Все это должно подключаться и запускаться через файл, который и будет подключать заинтересованное лицо в качестве конвейера к репозитория.
Пример:
# git-flow.yaml
include:
- local: "rules/git-flow.yaml"
stages:
- "--> Checking ?♂️"
- "--> Navigation ??➡️"
default:
tags: [$CI_RUNNERS]
image: $UNIBUILDER_IMAGE
variables:
PIPELINE_NAME: '? $CI_PROJECT_NAME: $CI_COMMIT_REF_SLUG ? <-- ✨ $CI_PIPELINE_SOURCE ✨ <-- $GITLAB_USER_LOGIN ?'
workflow:
name: '$PIPELINE_NAME'
DevSecOps scan:
stage: "--> Checking ?♂️"
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web" && $ASOC == "true"
- when: never
trigger:
include:
- project: 'devsecops3000ProMAX/security-pipeline'
file: 'security_pipeline.yaml'
ref: 'master'
forward:
pipeline_variables: true
yaml_variables: true
Branch check ?:
stage: "--> Checking ?♂️"
rules:
- if: $CI_COMMIT_TAG =~ /^[0-9]+[.][0-9]+([.][0-9]+)?$/
when: always
- when: never
before_script:
- git fetch && git switch master
script:
- export TAG_COMMIT_HASH=$(git rev-parse $CI_COMMIT_REF_NAME^{commit})
- if $(git rev-list --first-parent master | grep $TAG_COMMIT_HASH -vqz); then
echo "Base TAG NOT ON MASTER branch" && exit 1;
fi
Entrypoint ?:
stage: "--> Navigation ??➡️"
cache: []
artifacts:
paths:
- pipeline.yml
rules:
- !reference [".rules", develop]
- !reference [".rules", test]
- !reference [".rules", readiness]
- when: never
before_script:
- set -a && . ${CI_PROJECT_DIR}/.env && set +a
- git clone --single-branch --branch ${PIPE_REPO_BRANCH:-master} https://gitlab-ci:${PROJECT_TOKEN}@${CI_SERVER_HOST}/${PIPE_REPO_PATH}.git ./pipeline
- cd ./pipeline && sed --in-place «s/https:\/\/{ЗДЕСЬ БЫЛ АДРЕС НАШЕГО ИНСТАНСА}/https:\/\/gitlab-ci:${PROJECT_TOKEN}@{ЗДЕСЬ БЫЛ АДРЕС НАШЕГО ИНСТАНСА}/g» .gitmodules && git submodule sync --recursive && git submodule update --init --recursive --remote && cd ..
- chmod +x ./pipeline/pipegen/main.py
script: #PRINT_PIPE_CONFIG - содержит способ вывести в stdout конфигурацию
- >
if [ "$STAGE" = "develop" ] && [ "$SKIP_DEV_PIPELINE" = "true" ]; then
echo "Used SKIP_DEV_PIPELINE environ. Skiped" && exit 0;
elif [ "$STAGE" = "test" ] && [ "$SKIP_TEST_PIPELINE" = "true" ]; then
echo "Used SKIP_TEST_PIPELINE environ. Skiped" && exit 0;
elif [ "$STAGE" = "readiness" ] && [ "$SKIP_READY_PIPELINE" = "true" ]; then
echo "Used SKIP_READY_PIPELINE environ. Skiped" && exit 0; fi
- >
if [ "$TYPE" = "image" ]; then
export CONTAINERIZATION="true";
fi
- >
if [ "$TYPE" = "helm" ]; then
export HELMING="true";
fi
- eval "$PRINT_PIPE_CONFIG" > pipe_config.json && mv pipeline/handlers ./handlers && ./pipeline/pipegen/main.py pipe_config.json handlers
Starter ?:
stage: "--> Navigation ??➡️"
rules:
- !reference [".rules", develop]
- !reference [".rules", test]
- !reference [".rules", readiness]
- when: never
needs:
- "Entrypoint ?"
variables:
PARENT_PIPELINE_ID: "$CI_PIPELINE_ID"
trigger:
strategy: depend
include:
- artifact: pipeline.yml
job: "Entrypoint ?"
Исходный код PipelineGenerator хранится отдельно от основного репозитория, для удобства подключается через механизм git submodules:

Все это должно легко подключаться к репозиторию разработчика. Так что основной репозиторий пополнился скриптом repobootstrap.py. А требования к предварительной подготовке со стороны проекта свелись к добавлению переменной PROJECT_TOKEN c API-токеном. С ним и с ID группы/проекта запускается скрипт в качестве аргумента.
FundaPipe/
├── git-flow.yaml
├── handlers/
│ ├── containerization/
│ └── helming/
├── pipegen/
│ ├── lib/
│ ├── main.py*
│ ├── pyproject.toml
│ └── requirements.txt
├── pipeline.yml
├── README.md
├── repobootstrap.py*
├── rules/
│ └── git-flow.yaml
└── subpipes/
└── helm-product-flow.yaml
Описание деталей займет много времени, так что просто вставлю скриншот из документации к FundaPipe из корпоративной wiki:


Красивый велосипед, а зачем?
Для ответа на этот вопрос стоит взглянуть на репозиторий конвейеров глазами пользователя:

Что подключать, если мне надо сделать контейнер? Для ответа нужно либо разобраться в том, как это работает либо дернуть профильного специалиста, чтобы получить ответ: «В зависимости от того, куда ты его подключаешь, и что у тебя за стек». Добавлять новую логику тоже очень сложно даже тем, кто в теме.
В FundaPipe разработчик просто подключает свойственную его команде/продукту стратегию ветвления. Этот подход куда более интуитивно понятен:

Плюсы для DevOps-инженеров тоже есть:
Не нужно придумывать и добавлять стадии, они уже готовы: «Проверка» и «Навигация» в файле стратегии и «Подготовка», «CI», «CD» и «Перенаправление» в создаваемом конвейере.
Не нужно тратить время на продумывание необходимых задач: если процесс подразумевает создание артефакта, то он укладывается в определение стандартных процессов CI/CD, в остальных случаях название не имеет значения (почти):

Из всех возможных вариантов наполнения заданий, обязательными остаются только script и image. По желанию можно добавить cache и artifacts. Артефакты станут доступны всем создаваем задачам, кроме того, для обмена переменными предусмотрен pipeline.env и, конечно же, всегда добавляется expire_in.
Без минусов тоже не обошлось:
Задачи стали сложнее.
Необходимо писать об используемых в обработчике переменных окружения в документацию.
Такой конвейер не может работать очень быстро.

Разработчику просто нужно найти в FundaPipe желаемую стратегию ветвления, которую может создать или обновить он сам или DevOps с минимальным количеством копирования и замен текста. DevOps же остается составить черновики задач и положить их в соответствующую обработку директорию (можно в разных файлах):
Пример черновика:
.cont_create_artifact: # создание артефакта
image: "$KANIKO_IMAGE"
artifacts:
paths:
- ${CI_PROJECT_NAME}.tar
script:
- >
config="";
if [ -n "$CONT_HARBOR_URL" ] && [ -n "$CONT_HARBOR_FOLDER" ] && [ -n "$CONT_HARBOR_LOGIN" ] && [ -n "$CONT_HARBOR_PASSWORD" ]; then
config="{\"auths\": {\"$CONT_HARBOR_URL/$CONT_HARBOR_FOLDER\": {\"auth\": \"$(echo -n "${CONT_HARBOR_LOGIN}:${CONT_HARBOR_PASSWORD}" | base64 -w 0)\"}}}"; fi;
if [ -n "$CONT_HARBOR_CACHE_FOLDER" ] && [ -n "$CONT_HARBOR_CACHE_LOGIN" ] && [ -n "$CONT_HARBOR_CACHE_PASSWORD" ]; then
config=$(echo "$config" | awk -v harbor="$CONT_HARBOR_URL/$CONT_HARBOR_CACHE_FOLDER" -v auth="$(echo -n "${CONT_HARBOR_CACHE_LOGIN}:${CONT_HARBOR_CACHE_PASSWORD}" | base64 -w 0)" 'BEGIN{auth_str="\"auth\": \""auth"\""} {sub(/}}$/, ", \""harbor"\": {"auth_str"}}}")} 1'); fi;
if [ -n "$config" ]; then echo "$config" > /kaniko/.docker/config.json; fi
- |
cat <<EOF >/kaniko/proxy.conf
http_proxy="http://{ЗДЕСЬ БЫЛ АДРЕС НАШЕГО ИНСТАНСА}"
https_proxy="http://{ЗДЕСЬ БЫЛ АДРЕС НАШЕГО ИНСТАНСА}"
no_proxy="{ЗДЕСЬ БЫЛ АДРЕС НАШЕГО ИНСТАНСА}"
EOF
- |
cat <<EOF >/kaniko/pip.conf
[global]
index-url = https://${PYPI_PROXY}
trusted-host = ${PYPI_MIRROR_CACHE}
${PYPI_MIRROR_HOST}
EOF
- >
args="";
args="$args --no-push";
args="$args --context \"$CI_PROJECT_DIR\"";
args="$args --build-arg \"BUILD_ID=${CONT_NEXT_VERSION}${SUFFIX}\"";
args="$args --dockerfile \"${CI_PROJECT_DIR}/${CONTAINER_DIR}/Dockerfile\"";
args="$args --registry-mirror ${CENTRAL_MIRROR}";
args="$args --tar-path ${CI_PROJECT_NAME}.tar";
if [ "$CONTAINER_CACHED" = "true" ]; then
args="$args --cache=${CONTAINER_CACHED}";
args="$args --cache-repo oci://${CONT_HARBOR_URL}/${CONT_HARBOR_CACHE_FOLDER}/${CI_PROJECT_NAME}"; fi;
if [ -n "$CONTAINER_ARGS" ];
then IFS=','; for arg in $CONTAINER_ARGS; do
arg_value=$(eval echo "\"$arg\"");
args="$args --build-arg $arg_value"; done; fi;
echo $args | xargs /kaniko/executor
Здорово, но как с этим жить?
Довольно комфортно! Для демонстраций я использовал репозиторий с такой структурой (в больших проектах это очень удобно):

Разработчику нужно:
Создать gitlab-runner (CI/CD → Runners). У нас с этим просто, есть IDP и понятные инструкции с готовыми скриптами.
Создать токен (Settings → Access Tokens) для доступа в группу с полными правами на API. (Токен следует положить в переменную окружения группы).
Используя group id и созданный токен, запустить скрипт подготовки, который создаст переменные окружения для всех существующих обработчиков или заменит старые. В том числе проставит нужные флаги.
Заполнить необходимые проекту или группе переменные:

Указать файл конвейера в нужном репозитории:

Awesome-app имеет такую структуру:

.env

Доступные обработчики в FundaPipe:

Конвейер для атомарной развертки:

Ковейер для пакетной развертки (указываем новую версию в качестве зависимости, после dependency_binding отрабатывает конвейер из subpipes FundaPipe):

Конфигурация хранится в PIPE_CONFIG по умолчанию, и доступна благодаря переменной PRINT_PIPE_CONFIG=‘cat $PIPE_CONFIG’.
PIPE_CONFIG:
{
"process_vars_precedence": [
"CONTAINERIZATION",
"HELMING"
],
"other_vars_precedence": [],
"production_environments": {
"develop": {
"01_develop": {}
},
"test": {
"01_test": {
"SKIP_CD": "true",
"DEPLOY_STRATEGY": "product"
}
},
"readiness": {
"01_preprod": {
"SKIP_CD": "true",
"DEPLOY_STRATEGY": "product"
}
}
}
}
Разработчики говорят, что это очень удобно. А как вам кажется?
Yurchicnice
Интересная статья. Спасибо!