Быстрое создание тестовых сред — решение на Terraform в Azure

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

О чем и для кого статья

В какой-то момент в нашей компании возникла необходимость уметь быстро разворачивать множество тестовых сред в Azure. Данная статья расскажет об архитектуре данного решения и о его ключевых деталях.

Я подразумеваю, что читатель статьи уже владеет основами Terraform.

Предполсылки и требования

Для начала хотелось бы рассказать о нашей компании. Мы финтех стартап, занимающийся факторингом. Мы с самого начала развивались как cloud native. Все наши вычислительные мощности и сервера находятся в Azure.  

На момент событий, описываемых в статье, большинство наших ресурсов составляли Azure App Service, Azure Sql Servers и Azure Blob storages. У нас было два крупных монолита и около 10 микросервисов вокруг. Честно говоря, это было больше похоже на распределённый монолит, потому что тестировать приходилось всю экосистему, а не отдельные сервисы.

В определенный момент мы начали очень быстро расти с, примерно, 20 человек в Tech отделе до 120 за год. В это время основной нашей болью было количество тестовых сред. У нас были три среды: test, staging и prod. Команды толкались в этих средах, test был постоянно разломан, протестировать что-либо было невозможно. От этого staging тоже забивался неработающим функционалом и выпуски затягивались на недели. 

Быстрым решением на тот момент было увеличение числа контуров. Мы хотели дать по тестовому контуру на команду. То есть попросту продублировать все сервисы столько раз сколько есть команд. Понятно, что это не идеальное решение, и в идеале для работы над одним микросервисом команде не нужны все остальные сервисы и инфраструктура вокруг них. Но наличие крупных сервисов, над которыми работали несколько команд, и сильная связность между микросервисами не позволяли нам так сделать. Поэтому мы стали искать решение по автоматизации создания нашей облачной инфраструктуры. Нужна была возможность быстро создавать и уничтожать тестовые контура.

Выбор технологии

Так как мы полностью облачные, нам нужно было какое-то средство как развертывать именно облачную инфраструктуру.

На тот момент мы не имели Kubernetes (вся рабочая нагрузка была в Azure App Services). Поэтому в целом виделись следующие альтернативы:

  1. Terraform

  2. Kubernetes + Azure operator

  3. Azure ARM templates

Мы отказались от второго варианта, так как Kubernetes у нас еще не было и не было уверенности, что он понадобится. Во вторых, на начало 2022 года Azure Operator был беттой. 

ARM templates мы не стали брать, так как предполагали, что нам нужно будет создавать ресурсы не только в Azure но и в других системах. И это оказалось верной ставкой. 

Что изменилось на рынке технологий

На данный момент Azure Kubernetes Operator уже не бетта, и начинают взлетать технологии такие как CrossPlane (см. сравнение с terraform и Kubernetes Operator).

Тераформ тоже не стоит на месте. Он развивается как язык, не так давно появилась возможность использовать все плюсы терраформа как средства применения изменений к инфраструктуре, но при этом писать на своих любимых языках программирования. 

Еще один важный вопрос был в том, использовать terragrunt или нет. Вот этот кейс довольно похож на наш. Но, тем не менее, нам показалось, что нам terragrunt не сможет поддержать желаемую гибкость, когда новый контур создается просто добавлением одного файла/параметра. Кажется, что он больше подходит для ситуаций с небольшим фиксированным числом контуров, имеющих более значительные отличия. 

Контур и суперконтур

При написании кода обычно у нас есть тестовая среда и боевая среда. Вначале мы думали, что в случае инфраструктуры все должно быть так же. Но при более детальном анализе мы стали понимать, что тестовая среда, в которой работает 70 человек, не может неожиданно ломаться посредине рабочего дня. Так к нам пришло понимание, что тестовая разработческая инфраструктура для DevOps инжинеров является боевой. Таким образом, у нас появились три типа контуров или три "суперконтура":

  • Боевой

  • Тестовый

  • Эксперементальный

Суперконтур включает в себя общие ресурсы, такаие как Kubernetis cluster, SQL Database, SQL Elastic pool и несколько контуров. Таким образом мы можем обкатывать все инфраструктурные измнения, такие как обновление версии Kubernetes вначале на эксперементальном суперконутре, потом на тестовом и только потом выкатывать в боевую среду.

Environment and superenvironment
Environment and superenvironment

Суперконтур live состоит только из одного контура live_live. Население остальных суперконтуров может  отличаться. Например, в нашем случае в суперконтуре experemental обычно бывает 1-2 контура, а в суперконтуре test порядка 20 контуров.  

Terraform workspace

Давайте теперь посмотрим как это все реализовано на уровне Terraform.

Для начала, поговорим о том, что такое workspace в Terraform. Workspace позволяет создавать из одного и того же кода несколько разных копий инфраструктуры, которая может отличаться параметрами.

К примеру, мы хотим создать подсеть в каждом из суперконтуров. Для этого опишем подсеть следующим образом:

locals{
  superenvironment_name = terraform.workspace
  ip_second_octets_dict =  {
        test-envs = "51"
        exp-envs  = "52"
        live      = "53"
    }
  ip_second_octets = local.ip_second_octets_dict[local.superenvironment_name]
}

resource "azurerm_subnet" "resources" {
  name                 = "${local.superenvironment_name}-subnet-resources"
  resource_group_name  = var.resource_group_name
  address_prefixes     = ["10.${local.ip_second_octets}.0.0/18"]
  virtual_network_name = azurerm_virtual_network.main.name
}

С точки зрения Terraform ресурсы, созданные в разных workspace, полностью независимы. Terraform state каждого workspace лежит в отдельном файле. А с точки зрения подлежащей технологии это не всегда так. К примеру, имена некторых ресурсов в Azure должны быть уникальны среди вообще всех клиентов Azure. В любом случае нужно задуматься, как ваш Terraform код поведет себя, в случае применения в нескольких workspace.

Workspace + Modules

У нас есть несколько модулей верхнего уровня (иначе говоря папок, из которых вызывается terraform apply) для суперконтура и контура. Есть несколько причин, по которым мы разбили код на несколько модулей верхнего уровня.  

Во-первых, чем меньше будет модуль, тем быстрее произойдет apply. Скорость apply в свою очередь сильно влияет на скорость разработки тераформа.

Во-вторых, есть одно жесткое правило, которое я не советую нарушать. Никогда не создавайте ресурсы внутри Kubernetes кластера через kuber провайдер в том же модуле верхнего уровня (в том же apply), что и создание самого кубера. Вначале это сработает, а потом вы получите супер странные ошибки.

Каждый модуль имеет несколько workspace. Модули суперконтура имеют по 3 workspace, по числу суперконтуров. Модули контура имеют столько workspace, сколько есть контуров во всех суперконтурах.

Workspace + Modules + git

Что бы полностью закончить описание, стоит поговорить о git. Мы имеем по ветке на суперконтур. Может возникнуть вопрос, почему нет веток на контур. Краткий ответ — потому что все контура суперконтура имеют одну и ту же версию кода Terraform, а все отличия находятся в метаданных и хранятся отдельно. Я расскажу об этом более подробно чуть позже.

Структура репозитория с кодом Terraform
Структура репозитория с кодом Terraform

Итак, давайте представим что мы хотим применить новую версию кода на Terraform к тестовому суперконтуру. В этом случае произойдет примерно следующее (на псевдокоде):

git checkout test

cd ./supernv
terraform workspace select test
terraform apply 

cd ../superenv_kuber
terraform workspace select test
terraform apply 

cd ../envioronment
for env in (terraform workspace list):
  if env.startswith("test"):
    terraform workspace select env
    terraform apply

Метаданные — описание сервисов

В нашей экосистеме довольно много микросервисов, конечно же все они потребляют разное количество памяти, используют различные хранилища итд. Для того, что бы их можно было гибко настраивать, мы используем json файлы с описанием сервиса.

Давайте определимся, что мы называем сервисом. Под сервисом мы понимаем один или несколько endpoint, имеющих общую кодовую базу, имеющих одно бизнес назначение и использующих один определенный набор хранилищ. В 90% случаев сервис имеет только один endpoint и ему сответствует один deployment в Kubernetes. Но в некоторых, случаях мы хотим отделить обработку запросов пользователей/внешних систем от нагрузки по обработке сообщений и выполнению периодических заданий. В таком случае мы имеем два endpoint'а. В наших терминах это будет означать, что один service включает в себя два application. 

Я приведу пару примеров наших описаний сервисов. Не думаю что нужно разбирать каждый параметр, просто хочется показать, как примерно выглядят такие описания и что они могут содержать.

service_name: client-notification-system
applications:
  memory_limit: 1000            
sql_databases:
- {}                            # one sql darabase with default params
domain: clients                 # additional metadata for alerting
service_name: operations
applications:
- resource_name: web
  dns_prefix: operations
  is_public: true
  is_front: true
  hosting: kuber
  cpu_request: 100
  memory_limit: 2000
- resource_name: api
  hosting: app_service
sql_databases:
- max_size_gb: 10
  performance: high
storage: {}
domain: ops
notification_groups:
- team-one
- team-two

Основная идея, что описание включает всё для развертывания ресурсов в Azure, Kubernetes и некоторых дополнительных метаданных для алертинга и автодокументации.

Метаданные — описание периодических заданий

Описание сервиса так же включает в себя описание периодических заданий.

jobs_group:
  name: "Operations"
  jobs_application: api
  jobs:
  - job:
    name: "DeclineOldDeals"
    url: "/api/declineOldDeals"
    timeout: 3000
    schedule: "0 15 05 * * ? *"
    check_response_body: false
    check_response_code: true

Сейчас эти описания применяются к Rundeck, но в будущем планируем заменить планировщик. Наличие описания заданий, не привязанных к средству их исполнения, сильно облегчит задачу по смене планировщика.

Метаданные — состав контура

Остается обсудить последний вид метаданнх — это описания составов контуров. У нас много контуров. К сожалению, мы пока не умеем разворачивать в контуре только один сервис и разрабатывать его в изоляции. С другой стороны, конечно, мы не можем разворачивать в каждом контуре все наши сервисы. Поэтому у нас есть описание состава контуров.

Чтобы упростить описание контуров, мы ввели промежуточную абстракцию service set, которая описывает стандартные наборы сервисов. Таким образом, в описаниях контуров используются уже не списки сервисов, а списки service set.

 Так может выглядеть файл описывающий сервис сет с именем  invoice_financing_full.yaml

[
  "invoice-service",
  "risk-assessment-service",
  "some-other-service"
]

 А так файл, описывающий контур

[
  "invoice_financing_full",
  "portal_landings"
]

Это перечисление уже не сервисов, а service set.

Метаданные — контура и ветки

Как я уже говорил выше, сейчас код на Terraform и метаданные хранятся в одном репозитории. У нас есть планы по перекладыванию описаний сервисов в репозитории сервисов. Это было бы очень удобно с точки зрения ветвления. Если команда хочет избавиться от sql хранилища и перейти на no-sql, она в одной и той же ветке репозитория может добавить no-sql базу в yaml и тут же написать код его использующий. Мы пока не дошли до этого, так как это требует некоторого усложнения системы тригеров, по которым запускается Terraform.

Перспективная архитектура
Перспективная архитектура

При этом остается проблема с различными конфигурациями сервисов в разных ветках. Для решения этой проблемы у нас используется особая структура папок.

Если необходимо переопределить описание сервиса на определенном контуре, то нужно внутри папки сервиса создать папку с именем контура (что равно имени ветки репозитория сервиса) и положить в нее новое описание сервиса.

Описание сервиса emailsender переопределено в контурах clientsrc и cs
Описание сервиса emailsender переопределено в контурах clientsrc и cs

Конечно это не оптимальное решение, но, на мой взгляд, это обязательный промежуточный шаг, так как он дает вам право на ошибку. Если сразу же разложить yaml файлы по десяткам репозиториев (или даже по десяткам папок в монорепе, но со своими правилами ветвления), у вас сразу возникнут сложности с поддержанием обратной совместимости описания сервиса. Если же у вас все описания лежат в одном месте, у вас есть свобода делать любые изменения в схеме данных, что очень полезно на первых этапах.

Метаданные — реализация

Давайте теперь обсудим как именно Terraform читает параметры.

Terraform с помощью функций для работы с файловой системой, таких как fileexists, file, вычитывает все метаданные, создает массив параметров, описывающих каждый сервис обновляемого/создаваемого контура и передает его в модель создания сервиса.

module service {
   source = "./service"
   for_each = {for i, val in module.services_reader.service_file_data : val.service_name => val}
   #...    
   #arguments refereing to common env and superenv resources
   #...
}

Различия между суперконтурами

Мы стараемся держать суперконтура максимально похожими друг на друга. Понятно что держать полностью одинаковые контура неоправданно дорого. Поэтому между контурами есть отличия. Обычно это число ядер базы/сервера или наличие зеркал и бэкапов.

В части мест это решается простым условием вида

min_count             =  var.is_prod ? 2 : 0

Если же какой-то параметр отличается для всех трех суперконтуров, то мы выносим его во внешнее хранилище настроек. В нашем случае это Azure App Configuration. 

Pipelines

Мы применяем философию GitOps, то есть состояние инфраструктуры полностью описывается кодом, лежащим в гит. И применение новой версии инфраструктуры происходит по комиту. В качестве CI/CD решения мы используем Azure Pipelines. 

Давайте рассмотрим чуть подробнее, как мы применяем изменения к инфраструктуре. Чуть выше я уже приводил псевдокод, показывающий как принципиально работает применение изменений. Давайте теперь заглянем в реальный код. 

Весь код на Terraform и метаданные храняться в одном репозитории. В этом же репозитории находится следующий код описания пайплайнов: 

#...
#super env apply
#...
  jobs:
  - job: ListWorkspaces
    workspace:
      clean: all
    steps:
    - template: templates/terraform_install_and_init.yaml
      parameters:
        workingDirectory: $(WorkingDirectory)
    - template: templates/detect_envs.yaml
  - job: ApplyWorkspace
    timeoutInMinutes: 600
    dependsOn:
    - ListWorkspaces
    strategy:
      maxParallel: ${{parameters.poolParallelism}}
      matrix: $[ dependencies.ListWorkspaces.outputs['ListTerraformWorkspaces.workspaces'] ]
    uses:
      pools:
        - Azure Default Scaleset
    workspace:
      clean: all
    steps:
    - template: templates/plan_apply.yaml
      parameters:
        workspace: $(workspace)
        terraformParallelism: ${{parameters.terraformParallelism}}
        workingDirectory: $(WorkingDirectory)

Джоб ListWorkspaces получает список всех контуров/терраформ воркспейсов данного суперконтура. А джоб ApplyWorkspace применяет изменения к контурам. Благодаря параметру strategy: matrix, этот job читает список контуров из предыдущего шага и создаёт отдельный job на каждый контур. В Azure Devops job, не связанные отношением depends_on могут выполняться паралельно, но не более чем maxParallel в один момент времени. Таким образом вы можем применять изменения к нескольким десяткам контуров за разумное время. 

Нужно отметить, что при серьезном рефакторинге структуры сущностей Kubernetes, особенно ingress'ов, могут возникать проблемы производительности. Предположим, что у нас параллелизм ограничен 10 job. В каждом job мы запускам Terraform, который тоже параллелит свою работу. Тогда оказывается, что мы пытаемся менять кластер в сотни потоков. В таких случая мы иногда получали ошибки timeout от Kubernetes API. Для их решения мы ввели параметры jobParallelism и terraformParallelism, которые могут применяться вручную в случае проблем. 

Как не удалить боевую базу данных?

GitOps очень удобный подход, но как защититься от человеческой ошибки? Вероятность того, что человек явно удалит ценный ресурс в коде даже ниже, чем вероятность того, что он это сделает вручную мышкой или явной командой. Но, благодаря параметризации, абстаркциям итд нельзя исключать ситуацию, что каких то сервисов просто не окажется в списке, например, из-за ошибки в логике загрузки метаданных. Если терраформ увидит, что какие-то ресурсы не описаны, но они есть в стейте, он тут же удалит их. Поэтому для боевого контура нужна дополнительная защита. 

Terraform позволяет добавить аргумент prevent_destroy на любой ресурс, после чего его будет невозможно явно удалить. А не явно? 

Since this argument must be present in configuration for the protection to apply, note that this setting does not prevent the remote object from being destroyed if the resource block were removed from configuration entirely: in that case, the prevent_destroy setting is removed along with it, and so Terraform will allow the destroy operation to succeed.

Таким образом, prevent_destroy защищает только от части ошибок и, на мой взгляд, не является достаточной защитой для боевой среды. 

Поэтому мы решили защитить наши ресурсы по-другому. Мы определили для себя несколько типов ресурсов, хранящих данные, и некоторые другие ресурсы, которые не хранят в себе данные, но на их пересоздание уходит очень много времени. Для всех этих ресурсов мы навешиваем Azure Resource Lock. К сожалению, мы не можем просто создать этот лок из Terraform, потому что в случае случайного удаления ресурса из кода, Terraform вначале аккуратно удалит все зависимые ресурсы, включая lock, а потом и сам ресурс. 

Поэтому мы используем косвенный подход. Все нуждающиеся в защите ресурсы создаются с пользовательским тегом requires_lock. После любого применения конфигурации к боевому контуру мы отдельным пайплайном ищем ресурсы с тегом requires_lock и создаем Azure Resource Lock.

Хранениe Terraform state

Наверное последний вопрос который хотелось бы здесь рассмотреть - это место хранения Terraform State. В нашем случае он хранится в Azure Storage Account: 

  backend "azurerm" {
    resource_group_name  = "terraform"
    storage_account_name = "terraformstate"
    container_name       = "state"
    key                  = "{module name}.terraform.tfstate"
  }

Стоит помнить, что в Terraform State хранятся все пароли и ключи. Имея state, можно получить доступ ко всей инфраструктуре, поэтому важно обеспечить безопасное хранение state. В нашем случае права на доступ к Azure Storage Account раздаются через Azure ABAC, то есть выдаются конкретным людям и service principal (для использования в Azure Pipelines). Таким образом, у нас нет необходимости хранить где либо строку подключения к сторадж экаунту.

А где Helm?

— У вас же Kubernetes, где Helm?

— А у нас его нет. Конечно, мы используем Helm когда нам нужно установить что-то стороннее в Kubernetes. Но, для развертывания наших приложений, мы используем в качестве шаблонизатора Terraform, а не Helm. 

Выводы

У нас получилось достичь поставленных целей, мы двольно бысто смогли научилсь создавать и удалять контура по требования и разработческим командам зажилось свободнее. Одновременно с этой системой у нас возник и DevOps отдел, ответственный за подобную инфраструктуру. 

Кроме того, пропал бардак при создании ресурсов. До этого команды сами отвечали за создание всех необходимых ресурсов, и так как это не было их основной работой, то допускалось много ошибок. К примеру, оказалось, что большинство баз не имеют зеркалирования. Теперь команде больше не нужно об этом думать, потому что если команда описала в yaml что ей нужна база, то она будет создана со всеми необходимыми зеркалированиями и бэкапами.

Источник: https://habr.com/ru/articles/736480/


Интересные статьи

Интересные статьи

Совсем недавно мне пришлось разбираться с проблемами перформанса одного веб приложения. В процессе определения источника проблем возникали вопросы "сколько в среднем занимает вызов метода класса X", "...
«Скажи мне, Рождённый Женщиной, — вопросил Кришна, Куда движутся эти миры, Зачем злой Парвана по ночам охотится за своей второй сущностью, И почему у ласточки Бшакти две ноги, а у Меня дв...
В настоящее время разработка львиной доли веб-приложений, основанных на фреймворке React, ведется с использованием библиотеки Redux. Данная библиотека является самой популярной реализацией FLUX...
Привет, читатели Хабра. Этой статьей мы открываем цикл, который будет рассказывать о разработанной нами гиперконвергентной системе AERODISK vAIR. Изначально мы хотели первой же статьей рассказа...
В контексте событий про Open Distro, открытие исходников X-Pack, а также статьи «The Cloud and Open Source Powder Keg» — перевод поста Шейя Бэнона (основатель и CEO Elastic). ...