Привет, Хабр. Это считайте моя первая статья, надеюсь на фидбек и возможно замечания от более посвященных людей в сферу статьи, да и в целом в IT. Я простой разработчик, что пытается разобраться с процессами и не претендую на истину и уж тем более не учу кого-то как правильно. Данную статью хочу написать для себя в прошлом, человека - что совсем не разбирается и пытается найти хоть какую-то информацию в рамках требований, хочется рассказать о своем опыте и к чему мы пришли. Ну и все шутки про лучшие скрипты и подходы - это все шутки, в комментариях уже 1000 Best Practices из 10 написали, так что жду и вас там.
Как мы пришли к CI/CD?
Я пришел в компанию, уже 2 года назад, можно сказать для построение IT направление. По началу - это GitHub + Vercel(да-да, и фронт и бек в рамках 1 проекта), позже новый бек, новый фронт - уже два проекта, нужно разворачивать все на сервере - SSH, ручками git pull, nginx - где-то в конфигах. Не сказать, чтобы я был рад - ибо уже на тот момент хорошо понимал, что в Docker сила, но проект разрабатывали аутсорсеры. Позже рост, повышение требований, переход на GitLab, пополнение штата - нужно что-то делать. Как и любая другая компания - приходим к выводу, что нужен автодеплой. На этот момент появляется кубер.
Наши первые попытки в CI/CD
Что собственно делать? Ну естественно - изучение документации.
P.S. Со временем нашел себя в чтение документации яндекса - советую всем ознакомиться.
P.P.S. Ну и естественно все-все-все из документации gitlab нужно отчитать и выучить - не зря писали.
Билд
С данного периода неизменным остался билд(ну почти) - его и рассмотрим. Как и говорил выше - Docker это мощь, естественно мы в первую очередь внедрили его в наш продукт. Написали Dockerfile'ы - пишем наш CI:
# templates/build/kaniko.yaml
variables:
TAG: $CI_COMMIT_SHA
DOCKERFILE: $CI_PROJECT_DIR/Dockerfile
ARGUMENTS: ""
build-kaniko:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.21.0-debug
entrypoint: [ "" ]
script:
- echo "Build with tag $TAG"
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"},\"$(echo -n $CI_DEPENDENCY_PROXY_SERVER | awk -F[:] '{print $1}')\":{\"auth\":\"$(printf "%s:%s" ${CI_DEPENDENCY_PROXY_USER} "${CI_DEPENDENCY_PROXY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- echo "Run this build ${CI_REGISTRY_IMAGE}-${CI_ENVIRONMENT_NAME}"
- >-
/kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile $DOCKERFILE
--destination $CI_REGISTRY_IMAGE:$TAG
--cache=true
$ARGUMENTS
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: on_success
- when: never
Что тут может нам понадобиться? Ну во первых - нам нужно тегировать наши образы и сохранять их в Registry - по умолчанию наш билд пусть будет сохранять образы с тегом $CI_COMMIT_SHA - это позволит нам иметь уникальный тег, который привязан к комиту - по которому легко найти в двустороннем порядке одно или другое, мелочь, а приятно. Также важно указать - что билд всего 1.
По настройке Registry:
Деплой
Соответсвенно кубер, мы же хотим деплой приложения сделать - верно?
# templates/deploy/kubernetes.yaml
variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE
DOCKER_TAG: $CI_COMMIT_SHA
DEPLOYMENT_DATE: `date +%Y%m%d-%H-%M-%S`
MANIFEST_FOLDER: "k8s"
KUBECONFIG: ""
deploy-k8s:
stage: deploy
image:
name: alpine:3.19
script:
- apk update && apk add gettext
- wget https://storage.googleapis.com/kubernetes-release/release/v1.26.0/bin/linux/amd64/kubectl
- chmod +x ./kubectl
- ./kubectl create secret docker-registry regcred --docker-server=$CI_REGISTRY --docker-username=$CI_DEPLOY_USER --docker-password=$CI_DEPLOY_PASSWORD -n $CI_ENVIRONMENT_NAME || true
- >-
for MANIFEST in $MANIFEST_FOLDER/*; do
envsubst < $MANIFEST | ./kubectl apply -n $CI_ENVIRONMENT_NAME -f -;
done
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: on_success
- when: never
Также пробежимся по важному. DOCKER_IMAGE и DOCKER_TAG у нас в манифесте деплоя должны быть помечены, с помощью envsubst мы как раз формируем файл манифеста и собственно говоря его отправляем в кубер. KUBECONFIG обязательно нужно сохранить в CI/CD > Variables, в формате файла. Также хочется сказать - что данная джоба позволяет нам сразу несколько файлов отправлять в кубер, в рамках одной папки - что также в енвах можно указать(либо в variables в gitlab-ci проекта).
Также важно отметить, что я создаю секрет regcred - где храняться креды для registry.
По regcred информация:
А что дальше?
Вот у нас появился автоматический деплой. Собственно с ростом команды и проектов - появляется желание, а давайте еще тестировать перед тем, как будем бездумно все в прод пихать. Естественно прикольно, когда бизнес в том числе может потыкать все ручками самостоятельно, по этому решение пало на dev и prod версии продукта - где вначале пушится все в dev, позже мы проверяем, все ок - летит в prod.
Первая попытка была максимально простая. Мы просто делаем 2 ветки, stable и main(у нас заменяет dev), CI для main - это создай билд, отправь на dev, CI для stable - создай билд, отправь на prod. Быстро стало ясно, что не совсем это хороший кейс. Долго заострять внимание не буду, коротко скажу - что для нас проблема в том, что образ, что мы протестировали на dev != образ на prod, а что если какой-то не умный человек~~(я)~~ запушит на stable напрямую? В общем от идеи отказались, пошли думать.
Выход естественно нашли не быстро, в голове то и крутилась фраза "Непрерывная интеграция. Непрерывная доставка". А давайте тот же докер образ по всему CI прогонять? Собственно, пошли делать.
Деплой в разных окружениях
По итогу было сделано так, чтобы избавились от 2х билдов для разных енвов. Теперь 1 билд вначале попадает в dev, а позже он же летит в prod - эффективность. Для начала нам нужно сделать 2 разных деплоя в кубер - один для dev, другой для prod. Как?
# templates/deploy/kubernetes.yaml
...
.deploy-k8s-template:
stage: deploy
image:
name: alpine:3.19
...
Да в общем просто, для начала мы точку добавляем в начало нашей джобы, ну и переименуем нормально. Теперь это считается шаблоном и из него мы можем сделать новые 2 джобы!
# templates/deploy/kubernetes.yaml
...
deploy-dev:
environment: dev
extends: .deploy-k8s-template
deploy-prod:
environment: prod
extends: .deploy-k8s-template
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
- when: never
Что тут важно? Да в целом особо ничего. Мы используем шаблон и исключительно меняем environment - то есть то, в каком окружение будет запущен наш.
Полезный материал:
Релиз?
У нас есть собственно все - деньги, тачки билд, деплой в разные окружения. Компания росла, появлялось больше сервисов, наш монолит потихоньку распиливался(надеюсь через лет 10 распилится). Стало не хватать того, что не понятно - что за версия. А как откатить? Искать последний рабочий комит? Много вопросов и воды утекло в этот период и идея была внедрить систему релизов и тегов. Я, как очень не любящий что-то делать руками человек, решил это все дело автоматизировать. Что нам делать? Полезли в интернет, нашли какие-то там соглашения, semver, но вот не задача. Во первых, нужно наладить формат комитов - сделали, во вторых semver оказывается работает только в рамках 1 языка, не порядок
Ознакомиться настоятельно рекомендую с "Соглашением о комитах" и если ваш CI будет как-то вдохновлен нашим, то придется внедрить(либо адаптировать)
IT CRINGE P.S Лучше конечно на английском - там больше информации, но кому как с языком
Генерация тега
#templates/release/analyze_commit.yaml
variables:
MAJOR_CHANGE_PATTERN: 'BREAKING\sCHANGE:'
MINOR_CHANGE_PATTERN: '^feat(\(.*\))?!?:\s'
PATCH_CHANGE_PATTERN: '^(fix|perf|refactor)(\(.*\))?!?:\s'
RELEASE_NOTE_FILE: "RELEASE_NOTE.md"
CHANGES_PATTERN: '^(feat|fix|docs|style|refactor|perf|test|chore|wip)(\(.*\))?(!)?:\s?.+$'
stages:
- prepare_release
.generate_release_tag: &generate_release_tag
- >-
if [ -z "$LAST_TAG" ]; then
echo "No previous tag found, using the full commit history."
COMMITS=$(git log --oneline)
else
echo "Analyzing commits since $LAST_TAG..."
COMMITS=$(git log --oneline $LAST_TAG..$CI_COMMIT_SHA)
fi
- echo "Commits to analyze ${COMMITS}"
- VERSION_CHANGE=""
- >-
if echo "$COMMITS" | grep -qE "$MAJOR_CHANGE_PATTERN"; then
VERSION_CHANGE="major"
elif echo "$COMMITS" | grep -qE "$MINOR_CHANGE_PATTERN"; then
VERSION_CHANGE="minor"
elif echo "$COMMITS" | grep -qE "$PATCH_CHANGE_PATTERN"; then
VERSION_CHANGE="patch"
fi
- echo "Change type defined $VERSION_CHANGE"
- >-
if echo "$LAST_TAG" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" >/dev/null; then
echo "Valid previous tag found: $LAST_TAG"
MAJOR=$(echo "$LAST_TAG" | cut -d 'v' -f 2 | cut -d '.' -f 1)
MINOR=$(echo "$LAST_TAG" | cut -d '.' -f 2)
PATCH=$(echo "$LAST_TAG" | cut -d '.' -f 3)
else
echo "No valid previous tag found or format is incorrect. Starting from 1.0.0"
MAJOR=1; MINOR=0; PATCH=0
fi
- >-
case "$VERSION_CHANGE" in
major)
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor)
MINOR=$((MINOR + 1)); PATCH=0 ;;
patch)
PATCH=$((PATCH + 1)) ;;
*)
echo "No version change detected stopping the release." exit 0 ;;
esac
- NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
- echo "New tag $NEW_TAG"
- echo "NEW_TAG=$NEW_TAG" >> variables.env
.generate_release_note: &generate_release_note
- >-
if [ -z "$LAST_TAG" ]; then
RANGE=""
else
RANGE="$LAST_TAG..$CI_COMMIT_SHA"
fi
- echo "# Release Notes" > "$RELEASE_NOTE_FILE"
- echo "" >> "$RELEASE_NOTE_FILE"
- |-
add_commit_to_release_note() {
commit_data="$1"
commit_hash=$(echo "$commit_data" | sed -n '1p')
commit_author=$(echo "$commit_data" | sed -n '2p')
commit_date=$(echo "$commit_data" | sed -n '3p')
echo "## Commit: $commit_hash" >> "$RELEASE_NOTE_FILE"
echo "**Author:** $commit_author" >> "$RELEASE_NOTE_FILE"
echo "" >> "$RELEASE_NOTE_FILE"
echo "**Date:** $commit_date" >> "$RELEASE_NOTE_FILE"
echo "" >> "$RELEASE_NOTE_FILE"
echo "**Updates:**" >> "$RELEASE_NOTE_FILE"
echo "" >> "$RELEASE_NOTE_FILE"
messages=$(echo "$commit_data" | sed '1,3d')
IFS=$'\n'
for message in $messages; do
if echo "$message" | grep -Eq "$CHANGES_PATTERN"; then
echo "- $message" >> "$RELEASE_NOTE_FILE"
else
echo "> $message" >> "$RELEASE_NOTE_FILE"
fi
done
echo "" >> "$RELEASE_NOTE_FILE"
}
- |-
process_git_log() {
range="$1"
commit_data=""
git log "$range" --pretty=format:"%H%n%an <%ae>%n%ad%n%B%n<ENDCOMMIT>" | while IFS= read -r line || [ -n "$line" ]; do
if [ "$line" = "<ENDCOMMIT>" ]; then
add_commit_to_release_note "$(echo -e "$commit_data")"
commit_data=""
else
commit_data="${commit_data}${line}\n"
fi
done
}
- process_git_log "$RANGE"
analyze_commit:
image:
name: alpine/git
entrypoint: [ '' ]
stage: prepare_release
script:
- apk add --no-cache curl jq
- echo "Analyze commit..."
- LAST_TAG=$(git describe --tags --abbrev=0 $(git rev-list --tags --max-count=1) 2>/dev/null || echo "")
- *generate_release_tag
- *generate_release_note
variables:
SHELL: '/bin/bash'
artifacts:
paths:
- $RELEASE_NOTE_FILE
reports:
dotenv: variables.env
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: on_success
- when: never
Ну тут по порядку. Для начала цель этой джобы - проанализировать комит на изменения. Суть работы в том, если есть есть fix:, perf:, refactor: в тех коммитах, что прилетели в main - то мы делаем PATCH версию. Если есть feat: - то делаем MINOR версию, а если BREAKING CHANGE - то мажорную. Логика на самом деле максимально простая.
Также оставлю вам вполне годный форматер для текстового Release Note, которого нам пока за глаза и что очень упрощает написание список изменений как и для сотрудников, так и для пользователей.
Пока данная джоба у нас на тесте, возможно со временем она претерпит изменений или вовсе заменится, но пока так. Возможно позже отрефакторю, но желания времени нет.
После выполнение джобы мы получаем артифакт, для выполнение уже следующей, не менее важной джобы.
Релиз
#templates/release/release.yaml
include:
- local: templates/release/analyze_commit.yaml
stages:
- prepare_release
- release
release:
stage: release
needs:
- job: analyze_commit
artifacts: true
image: registry.gitlab.com/gitlab-org/release-cli:latest
script:
- echo "Release with tag $NEW_TAG"
release:
name: 'Release $NEW_TAG'
tag_name: '$NEW_TAG'
description: RELEASE_NOTE.md
ref: '$CI_COMMIT_SHA'
assets:
links:
- name: "GitLab Registry Image"
url: "https://$CI_REGISTRY_IMAGE:$NEW_TAG"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
- when: never
Тут мы используем артифакт из предыдущей джобы для получение тега и собственно Release Note. Джоба максимально простая - создает релиз. Что еще описывать?
По материалам:
Что касается общей концепции - супер мега важно с последовательностью разобраться. Все предыдущее выполнялось в рамках CI на ветки, сам релиз тоже и на этом этапе он создает тег - тот самый заветный, который мы хотели получить. Что нам нужно понять в такой логике - с начала билда до релиза - это теперь только подготовка к релизу(не как джобе) - как соответственно и к prod среде. Теперь мы получили тег, по котором можно получить версию, откатиться, оперировать теми же версиями в HELM и многое что еще. Мы пока в рамках самого деплоя обсуждаем, так что продолжим.
Публикация докер образов
Так как тег создался - теперь джобы, что триггерятся на тег - могут входить в цепочку. Так мы определяем 2 стадии - до релиза и после.
# templates/publish/docker.yaml
stages:
- prepare_publish
- publish
publish_latest:
stage: prepare_publish
script:
- echo "Tagging image $CI_COMMIT_SHA as latest..."
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_TAG
when: on_success
- when: never
publish_release_tag:
stage: prepare_publish
script:
- echo "Tagging image $CI_COMMIT_SHA as $CI_COMMIT_TAG..."
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
when: on_success
- when: never
publish_stable:
stage: publish
script:
- echo "Tagging image $CI_COMMIT_SHA as stable..."
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:stable
- docker push $CI_REGISTRY_IMAGE:stable
rules:
- if: $CI_COMMIT_TAG
when: on_success
- when: never
Для начала мы бы возьмем наш образ и просто распилим по разным тегам - в первую очередь latest - он все таки dev прошел и мы его выпустили. Позже сам тег - который сгенерировался в прошлый раз. Момент с stable пока пропустим, но помните - он будет финалом.
Конечный деплой
# templates/deploy/kubernetes.yaml
...
deploy-dev:
extends: .deploy-k8s-template
environment:
name: dev
action: start
kubernetes:
namespace: dev
deploy-prod:
extends: .deploy-k8s-template
variables:
DOCKER_TAG: $CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
when: manual
- when: never
environment:
name: prod
action: start
kubernetes:
namespace: prod
Вот тут мы должны поправить вопрос с деплоем в прод - теперь он исключительно запускается после релиза и имеет тег симантический. Также чуть доработал окружение.
Заключение
Вот мы и построили идеальный деплой. Всем peace и успехов.
Еще не все?
Уж больно я беспокойный человек. Мне не хватает из всего вышенаписанного гарантий, что я завтра поменяю что-то в скриптах и все не посыпется. По этому обьективному решению я решил поработать на выходных чуть больше уделить этому аспекту и внедрить версионирование в наш CI проект.
# .gitlab-ci.yml
include:
- local: "templates/release/release.yaml"
variables:
GITLAB_CI_LINT_API_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ci/lint"
PRIVATE_TOKEN: $GITLAB_ACCESS_TOKEN
TEMPLATES_FILE: templates
stages:
- lint
- prepare_release
- release
validate_templates:
stage: lint
image: alpine:3.19
rules:
- if: $CI_COMMIT_BRANCH
when: always
before_script:
- apk add --no-cache curl jq
script:
- |
find $TEMPLATES_FILE -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | while IFS= read -r -d $'\0' file; do
printf "Validating %s\n" "${file}"
yaml_content=$(cat "${file}")
data=$(jq --null-input --arg yaml "$yaml_content" '. | {content: $yaml}')
response=$(curl "${GITLAB_CI_LINT_API_URL}" \
--header "PRIVATE-TOKEN: ${PRIVATE_TOKEN}" \
--header "Content-Type: application/json" \
--data "$data" -s)
valid=$(printf "%s" "$response" | jq --raw-output '.valid')
if [ "$valid" != true ]; then
printf "Validation failed for %s:\n" "${file}"
printf "%s" "$response" | jq -r '.errors[]'
exit 1
else
printf "Validation successful for %s\n" "${file}"
fi
warnings=$(printf "%s" "$response" | jq '.warnings | length')
if [ "$warnings" -ne 0 ]; then
printf "Warnings for %s:\n" "${file}"
printf "%s" "$response" | jq -r '.warnings[]'
fi
printf "\n"
done
analyze_commit:
needs: [ ]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: on_success
release:
needs:
- job: validate_templates
- job: analyze_commit
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: on_success
Поехали по порядку. Такая фича, можно сказать, позволяет нам импортировать в проект конкретную версию CI и не менять ее без надобности. Теперь гарантированно - если CI однажды отработал полный цикл в проекте - значит он будет работать вечно.
А теперь по содержанию
Во первых да, видим странную джобу на первом этапе - lint. Коротко он проверяет все скрипты в папке с темплейтами. То есть то, что мы писали выше на ошибки - что не позволяет сам Gitlab, либо я просто не нашел(если файлик не .gitlab-ci.yml - то считай и не скрипт вовсе - его проверять нельзя). Формула та же - что и в Pipeline Editor(как я понял).
Дальше собственно готовые джобы анализа и релиза - в целом все.
По итогу импорт в ваши проекты выглядеть может вот так:
include:
- project: 'devops/ci-cd-includes'
file: '/templates/build/kaniko.yaml'
ref: 'v1.7.0'
- project: 'devops/ci-cd-includes'
file: 'templates/deploy/helm.yaml'
ref: 'v1.7.0'
На этом точно конец, кланяюсь.