Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров27K

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

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

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

Теги:
Хабы:
Всего голосов 18: ↑12 и ↓6+11
Комментарии13

Публикации

Истории

Работа

DevOps инженер
52 вакансии

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань