Подход «Инфраструктура как код» означает, что инфраструктура создаётся, развёртывается и управляется при помощи кода. Это позволяет автоматизировать процессы, делать их более гибкими и масштабируемыми. Код для инфраструктуры фиксирует конфигурацию, обеспечивает воспроизводимость и упрощает управление настройками. Также благодаря этому подходу возрастает эффективность работы команды, поскольку он позволяет вести совместное развитие инфраструктуры и обеспечивает удобство отслеживания изменений.
Именно этот подход мы используем при нашей работе. Однако в процессе его использования мы столкнулись с проблемой написания пайплайнов для инфраструктуры.
Мы были вынуждены сделать процесс выкатки инфраструктуры максимально точечным из-за использования terragrunt. Каждый его модуль должен выкатываться отдельно, иначе будут получены десятки планов, и понять, что делает каждый из них, будет невозможно. Это означает, что каждому модулю terragrunt нужна отдельная джоба в пайплайне на plan и apply, но для каждого модуля они во многом повторяют друг друга. Подобное постоянное написание одинаковых частей CI/CD пайплайна при добавлении новых баз и бакетов навевало тоску.
Меня зовут Татьяна Мигулаева, я DevOps-инженер в «Магните». Поделюсь тем, как мы создали генератор джоб в GitLab CI/CD и навсегда забыли о ручном написании пайплайнов для развёртывания элементов инфраструктуры.
Что мы имеем
Инфраструктура на базе Yandex Cloud, описанная в terragrunt-файлах, код которых хранится в GitLab.
Периодически приходится создавать новые файлы или изменять существующие – например, чтобы поправить название пользователя в managed базе или создать новый топик в Кафке. После внесения этих изменений их необходимо выкатить в облако, предварительно проверив, что при выкатке ничего не сломается. Для этого используются CI/CD пайплайны со следующим флоу:
При создании merge request должен запускаться terragrunt plan, который проверяет допустимость вносимых в коде изменений и показывает, что произойдёт, если их применить. При мерже этих изменений в main должен повторно запускаться terragrunt plan, а после него – terragrunt apply, который применит эти изменения на инфраструктуре.
Есть два примерных варианта, как это реализовать:
1. Большой статичный пайплайн, который включает в себя все сервисы сразу.
Плюсы: не надо ничего менять, для выкатки достаточно изменений в terragrunt-файле. Удобно, когда инфраструктура небольшая и не меняется слишком часто.
Минусы: Такой пайплайн может выполняться довольно долго. При этом результатом его работы будут сотни планов и отчетов в гитлабе. По ним будет очень тяжело понять, какие изменения были внесены в код и как они отразятся на инфраструктуре. Также такой пайплайн будет не полностью управляем: не получится нажать кнопку деплоя отдельно для того элемента инфраструктуры, в который были внесены изменения.
2. Для каждого элемента — отдельный шаг в пайплайне. Автоматически запускаются только те шаги, в которые были внесены изменения. terragrunt apply применяется отдельно для каждого элемента инфраструктуры и запускается только вручную.
Плюсы: Это удобно, пайплайн выполняется относительно быстро. При этом нет риска случайно выкатить не то.
Минусы: Придётся постоянно дописывать новые шаги в CI/CD, высок риск ошибки. Учитывая, что наша инфраструктура динамична и много и часто меняется, делать такие изменения пришлось бы постоянно. И это просто скучно :)
Наше решение
В нашем решении объединены плюсы обоих вариантов. По сути мы изменяем только код инфраструктуры, а наш пайплайн автоматически подстраивается под это.
При создании merge request автоматически генерируется и запускается шаг пайплайна, который запускает terragrunt plan только для тех terragrunt-файлов, в которые были внесены изменения.
Например, мы решили создать postgres-базу в облаке для сервиса my-lovely-service. Для этого создали в репозитории в нужной папке terragrunt-файл с конфигурацией бакета. Далее создали merge request с этими изменениями. При этом был автоматически создан и запущен следующий шаг пайплайна.

Есть возможности зайти в пайп и посмотреть вывод команды terragrunt plan. Стоит обратить внимание, что для остальных файлов в репозитории пайплайн не запускался, что значительно ускоряет работу.

После слияния изменений в main запускается пайплайн целиком, но при этом триггерятся только те шаги terragrunt plan, которые запускались на merge request. У нас этот пайплайн выглядит немного страшно.

Но это зависит от количества сервисов и размера инфраструктуры.
Далее в нём можно вручную запустить terragrunt apply для тех элементов инфраструктуры, которые необходимо развернуть.
Реализация
Далее рассмотрим подробнее, как мы это реализовали. Для примера будет взято использование dev-окружения, при необходимости можно добавлять аналогичные шаги соответственно количеству окружений.
Здесь важно обратить внимание на то, как работает include в GitLab CI/CD. Если кратко — он объединяет имеющийся CI/CD файл и те, которые указаны в include. При этом шаги, которые называются одинаково в нескольких файлах, сливаются между собой. Подробнее можно изучить в документации Gitlab: https://docs.gitlab.com/ee/ci/yaml/#include
Предположим, нам надо создать s3 бакет для сервиса my-service в dev-окружении. Для этого в папке dev создаём папку my-service и создаём в ней terragrunt.hcl файл, из которого будет создаваться бакет.
Делаем коммит с этими изменениями в отдельную ветку. В этот момент pre-commit hook запускает следующий скрипт.
generate-mr-jobs.sh
#!/bin/sh echo '' > .gitlab-ci-mr-jobs.yml for env in "dev" "uat" "mm-dev" "mm-uat" do isMmProject=false if [[ "$env" =~ ^mm-.*$ ]] then isMmProject=true fi for root in $(git ls-files -s |grep -oE "\s${env}/.*terragrunt.hcl$" | xargs dirname) do prefix=$(echo "$root" | tr / -) cat >>.gitlab-ci-mr-jobs.yml <<EOF ${prefix}:plan: variables: IS_MM_PROJECT: "${isMmProject}" TF_ROOT: "${root}" extends: - .terragrunt:plan:${env} - .rules:mr:${env} - .variables:unprotected EOF done done
В данном скрипте сначала очищается имеющийся файл с джобами, после чего прописывается ci/cd шаг для каждого terragrunt файла в репозитории.
Таким образом, мы получаем файл .gitlab-ci-mr-jobs.yml со списком шагов следующего вида:
dev-s3-my-service:plan: variables: TF_ROOT: "dev/s3/my-service" extends: - .terragrunt:plan:dev - .rules:mr:dev
После создания merge request с нашими изменениями запускается ci/cd пайплайн. Сам gitlab-ci.yml файл включает в себя три других:
include: - '.gitlab-ci-terragrunt.yml' - '.gitlab-ci-templates.yml' - '.gitlab-ci-mr-jobs.yml'
А также следующие шаги:
stages: - build - deploy - setup - triggers
Файл gitlab-ci-mr-jobs.yml был рассмотрен ранее.
В gitlab-ci-templates.yml задаются такие элементы, как теги раннеров, правила создания шагов и так далее. Выглядит он следующим образом.
gitlab-ci-templates.yml
.runners:dev: tags: - devops - dev .rules:mr:dev: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: - terragrunt.hcl - provider.tmpl - dev/env.hcl - ${TF_ROOT}/* - ${TF_ROOT}/**/* .rules:default:dev: rules: - if: $CI_COMMIT_REF_PROTECTED == "true" changes: - terragrunt.hcl - provider.tmpl - dev/env.hcl - ${TF_ROOT}/* - ${TF_ROOT}/**/* - when: manual .terragrunt:plan:dev: extends: - .terragrunt:plan - .runners:dev variables: TF_TARGET_ENV: "dev" .terragrunt:apply:dev: extends: - .terragrunt:apply - .runners:dev variables: TF_TARGET_ENV: "dev"
В gitlab-ci-terragrunt.yml описаны шаги подключения к облаку и работы с ним. В нашем случае это Yandex Cloud, но может быть и любое другое. В качестве хранилища секретов используется Hashicorp Vault.
gitlab-ci-terragrunt.yml
variables: TF_PLAN_CACHE: plan.cache .terragrunt:common: image: name: my_name resource_group: ${TF_ROOT} id_tokens: VAULT_ID_TOKEN: aud: ${VAULT_ADDR} variables: VAULT_AUTH_PATH: "id_jwt" YC_KEY: /tmp/yc_key.json before_script: - export VAULT_TOKEN="$(vault write -field=token auth/${VAULT_AUTH_PATH}/login role=${VAULT_AUTH_ROLE} jwt=${VAULT_ID_TOKEN})" - vault kv get -field=${TF_TARGET_ENV} infra/${TF_SECRET_PATH}/terraform > ${YC_KEY} - yc config profile create my-robot-profile - yc config set service-account-key ${YC_KEY} - export YC_TOKEN=$(yc iam create-token) - git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/".insteadOf ssh://git@gitlab.com/ - export TF_HTTP_PASSWORD=${CI_JOB_TOKEN} - cd ${TF_ROOT} .terragrunt:validate: extends: - .terragrunt:common stage: validate script: - terragrunt validate .terragrunt:plan: variables: TF_PLAN_JSON: plan.json JQ_PLAN: | ( [.resource_changes[]?.change.actions?] | flatten ) | { "create":(map(select(.=="create")) | length), "update":(map(select(.=="update")) | length), "delete":(map(select(.=="delete")) | length) } extends: - .terragrunt:common stage: build script: - terragrunt plan -input=false -out="$(pwd)/${TF_PLAN_CACHE}" - terragrunt show -json "$(pwd)/${TF_PLAN_CACHE}" | jq -r "${JQ_PLAN}" > "${TF_PLAN_JSON}" artifacts: expire_in: 1 week paths: - ${TF_ROOT}/${TF_PLAN_CACHE} reports: terraform: ${TF_ROOT}/${TF_PLAN_JSON} .terragrunt:apply: extends: - .terragrunt:common stage: deploy script: - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - terragrunt apply -input=false "$(pwd)/${TF_PLAN_CACHE}" when: manual
Вернёмся к нашему бакету для my-service. После создания merge request из всех описанных ранее файлов будут объединены следующие шаги.
.rules:mr:dev: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: - terragrunt.hcl - provider.tmpl - dev/env.hcl - ${TF_ROOT}/* - ${TF_ROOT}/**/* .terragrunt:plan:dev: extends: - .terragrunt:plan - .runners:dev variables: TF_TARGET_ENV: "dev" .runners:dev: tags: - devops - dev - .terragrunt:plan - настройка terragrunt с Yandex Cloud
Таким образом, этот шаг будет запускаться только на те ресурсы, в которых произошло изменение, то есть на созданный terragrunt файл s3 бакета сервиса my-service. Далее на раннеры ставятся нужные теги, а terragrunt настраивается на использование нужного нам dev окружения в облаке.

Далее, когда всё это прошло успешно, merge request вливается в main ветку. В этот момент запускается скрипт generate-default-jobs.sh
generate-default-jobs.sh
#!/bin/sh # directory is in the first argument directory=$1 # Environment is in the second argument env=$2 # Prefix is the directory name with slashes replaced by dashes parent_prefix=$(echo "$directory" | tr / -) cat >.gitlab-ci-${parent_prefix}.yml <<EOF include: - '.gitlab-ci-terragrunt.yml' - '.gitlab-ci-templates.yml' stages: - build - deploy - setup - triggers EOF # filter subdirectories with terragrunt.hcl has_terragrunt="" no_terragrunt="" # Loop through all subdirectories of the given directory for dir in "$directory"/*/; do # Check if the directory contains a terragrunt.hcl file if [ -f "${dir}terragrunt.hcl" ]; then has_terragrunt="${has_terragrunt} ${dir%/}" else no_terragrunt="${no_terragrunt} ${dir%/}" fi done echo ${has_terragrunt} echo ${no_terragrunt} for root in ${has_terragrunt} do prefix=$(echo "$root" | tr / -) basename=$(basename "$root") cat >>.gitlab-ci-${parent_prefix}.yml <<EOF ${basename}:plan: stage: build variables: TF_ROOT: "${root}" extends: - .terragrunt:plan:${env} - .rules:default:${env} - .variables:protected ${basename}:apply: stage: deploy variables: TF_ROOT: "${root}" extends: - .terragrunt:apply:${env} - .variables:protected needs: - ${basename}:plan EOF done for root in ${no_terragrunt} do prefix=$(echo "$root" | tr / -) basename=$(basename "$root") cat >>.gitlab-ci-${parent_prefix}.yml <<EOF ${basename}:generate: stage: setup extends: - .runners:${env} script: - ./generate-default-jobs.sh ${root} ${env} artifacts: paths: - .gitlab-ci-${prefix}.yml expire_in: 1 week ${basename}:trigger: stage: triggers trigger: include: - artifact: .gitlab-ci-${prefix}.yml job: ${basename}:generate EOF done
Суть этого скрипта, что он генерирует gitlab-ci файл аналогично предыдущему, с той разницей, что появится шаг развёртывания файлов terraform apply. Результат выглядит следующим образом:


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