Как вообще этот ваш CI CD настроить

от автора

Привет, Хабр. Это считайте моя первая статья, надеюсь на фидбек и возможно замечания от более посвященных людей в сферу статьи, да и в целом в 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' 

На этом точно конец, кланяюсь.

Картинка нагло сворована


ссылка на оригинал статьи https://habr.com/ru/articles/798551/