Как мы пришли к CI/CD? Я пришел в компанию, уже 2 года назад, можно сказать для построение IT направление. По началу - это GitHub + Vercel(да-да, и фронт и бек в рамках 1 проекта), позже новый бек, новый фронт - уже два проекта, нужно разворачивать все на сервере - SSH, ручками git pull, nginx - где-то в конфигах. Не сказать, чтобы я был рад - ибо уже на тот момент хорошо понимал, что в Docker сила, но проект разрабатывали аутсорсеры. Позже рост, повышение требований, переход на GitLab, пополнение штата - нужно что-то делать. Как и любая другая компания - приходим к выводу, что нужен автодеплой. На этот момент появляется кубер. Наши первые попытки в CI/CD Что собственно делать? Ну естественно - изучение документации.
БилдС данного периода неизменным остался билд(ну почти) - его и рассмотрим. Как и говорил выше - 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 - это позволит нам иметь уникальный тег, который привязан к комиту - по которому легко найти в двустороннем порядке одно или другое, мелочь, а приятно. Также важно указать - что билд всего
ДеплойСоответсвенно кубер, мы же хотим деплой приложения сделать - верно?
# 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.
А что дальше?Вот у нас появился автоматический деплой. Собственно с ростом команды и проектов - появляется желание, а давайте еще тестировать перед тем, как будем бездумно все в прод пихать. Естественно прикольно, когда бизнес в том числе может потыкать все ручками самостоятельно, по этому решение пало на 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 будет как-то вдохновлен нашим, то придется внедрить(либо адаптировать)
#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'