Наводим порядок в Terraform с помощью слоев
- EN
- RU
Table of Contents
Terraform – самый популярный фреймворк для управления инфраструктурой как код. Я не нашел нормальной статистики распространенности terraform в сети, самое близкое – отчет от 6Sense показывает рыночную долю Terraform как 37% (и это без учета OpenTofu и ближайший конкурент там Ansible, так что сравнение спорное). В любом случае – превосходство Terraform на рынке IaC подавляющее. Почти всегда если у вас есть облако любого рода – у вас будет Terraform в той или иной форме.
#
Проблема
Terraform далеко не самый удобный инструмент, хотя и самый распространенный. Terraform использует провайдеры, что позволяет использовать один язык (HCL) и примерно один код (с оговорками) для управления ресурсами в разных провайдерах. В некоторых сценариях это необходимо. К примеру, у вас есть приложение в Kubernetes cluster, которое использует OAuth2 аутентификацию. Если вы используете Azure AD – вам нужно создать учетные данные в Azure AD (client ID, tenant ID и Client secret если приложение не поддерживает PKCE). Потом эти данные нужно “скормить” приложению внутри Kubernetes. Terraform позволяет такой фокус, потому что state у Terraform единый и переменные внутри кода в единой области видимости. Это очень удобно, но это создает проблемы
##
Файл состояния
Абсолютно все, с чем работает Terraform – он хранит в файле состояния (tfstate). Это огромный JSON документ, который сам Terraform прозрачно демаршализирует в структуру прямо в памяти при старте (plan/apply). И первая проблема тут – размеры. Размер state – это количество объектов, умноженное на размер каждого объекта. Размер конкретного объекта определяется провайдером, а точнее – всеми возможными свойствами этого объекта. Эта информация зависит от API, с которым работает провайдер. Если у Google API сконструировано очень аккуратно и у каждого объекта разумный минимум свойств (только то, что необходимо для работы), то программисты MS Azure и Oracle OCI пишут очень грязно. Достаточно посмотреть на описание, к примеру, oci_core_instance, чтобы это заметить. Каждое свойство объекта должно быть вписано в state, из-за чего state стремительно пухнет, особенно когда объектов много. Большой стейт жрет память, потому что его нельзя читать “кусками” – он демаршализируется только целиком. Автор видел инфраструктуры, где для запуска Terraform требовалось 24Gb RAM. Иначе объекты состояния в память просто не влезали и terraform схлопывался с OOM. Бонус проблемы – сходимость. При старте terraform вычитывает состояние объектов из API (то самое state refresh). State хранит сами объекты но не верит в их свойства (хотя и хранит их). Большое количество объектов означает медленную сходимость. У API есть управление скоростью доступа (rate limit), чтобы кривые клиенты его не ломали. Активное использование API провайдера может быть наказано пессимизацией или даже временной блокировкой (особенно этим славится Azure). 10 минут на вычитку состояния – совсем не новость. При старте terraform прочитает все свойства всех объектов и вам придется ждать обновления всех этих свойств. Когда на вычитку нужно 30 минут – жизнь становится совсем непростой.
##
Радиус поражения
Terraform изначально придуман для отдельных людей, а не команд. HCL (язык разметки terraform, что-то среднее между разметкой и языком программирования “для бедных”) не имеет внутренних защитных механизмов, как в нормальных языках программирования. Все переменные видны всем, никаких систем контроля доступа внутри terraform кода не предусмотрено. Неудачный код может зацепить чужой в самых непредсказуемых местах. Совершенно невинная правка может выломать половину инфраструктуры. Terraform plan спасает частично, потому что не все его внимательно читают, особенно когда его много или есть ИИ-ревьюер кода. Terraform не делает запросы сам, он использует авторизацию конкретного провайдера. Логично, что провайдер должен иметь необходимые права и по умолчанию у провайдера (а значит и у terraform) прав достаточно много. Теоретически можно обезопасить изменения через создание нескольких провайдеров. У каждого провайдера будет минимум прав для решения конкретной задачи. При этом ресурсы передаются конкретным провайдерам, чтобы снизить размер проблем при ошибке. Но такой сетап очень трудно использовать, а значит – люди будут естественно избегать сложностей и все задачи решать через провайдер по умолчанию. Ошибка в коде будет означать ошибку с правами провайдера. Отключить провайдер по умолчанию вы не сможете, как и запретить что-то квалифицированным инженерам. Они все равно найдут обходной путь.
##
Бонус: безопасность
Terraform не придуман для безопасности. TFState хранит в себе абсолютно все объекты, с которыми работает. И хранит он их в открытом виде. Если вы генерируете RSA-ключи, передаете данные доступа к API, сертификаты, любую секретную информацию через terraform, например с помощью зависимости или data – вы храните эту информацию в state. в state она не зашифрована, terraform почему-то не умеет шифровать свое состояние. Человек с доступом к terraform может вытащить весь state командой terraform state pull и получит все ваши приватные данные, даже если вы запретили их вывод с помощью инструкции sensitive. Организовать ограничение доступа к части state невозможно, любому оператору нужен весь state целиком, потому что частями он не читается.
#
Решение: слои
Сразу скажу – нет готового, 100% рабочего решения, которое подойдет всем и заработает идеально у всех и для всех. Концепцию слоев автор подсмотрел у своих коллег, но так же видел ее на нескольких выставках. Суть в том, что мы сегментируем код по слоям. Каждый слой – это логическая группа ресурсов. При этом данные между слоями передаются через data или remote_state. Слои связаны “вертикально”, то есть данные передаются от “верхних” слоев основания в “нижние”. Это позволяет построить естественное дерево зависимостей между ресурсами. К примеру, у нас есть несколько ресурсов внутри google Kubernetes cluster-а. Эту инсталляцию можно разделить на три слоя:
- первый слой устанавливает свойства проекта, доступных API и политики биллинга
- второй слой отвечает за развертывание GKE, NodePools и других сервисов на уровне самого Google Cloud
- третий слой управляет ресурсами внутри GKE
У каждого слоя есть своя, строго ограниченная область ответственности. Для каждого слоя можно ограничить права доступа. Информация между слоями передается в явном виде, вам нужен output в “верхнем” слое и “remote_state” или “data” в нижнем. Если у вас есть несколько групп на одном уровне – можно создать несколько отдельных репозиториев на одном слое. Это позволит изолировать объекты еще лучше (например – отделить сервис аналитики от приложения, которое смотрят пользователи). Подход со слоями требует некоторой смены мышления, но если привыкнуть к концепции он очень хорошо работает, особенно для команд. Очень важно не допускать латеральной передачи данных между слоями – есть риск получить петлю зависимости, когда сервис А зависит от сервиса Б, а сервис Б – от сервиса А. Риск получить такую проблему растет с количеством сервисов, в больших инфраструктурах могут быть десятки тысяч сервисов и уследить за всеми невозможно. Это дает сразу кучу преимуществ:
- На уровне конкретного слоя код получается компактным. Его проще читать и его проще чинить. План тоже компактнее, а значит – его легче проверять.
- Легко контролировать доступ к данным, потому что все передается явно.
- Меньше потребление ресурсов и лучше сходимость – код компактный, данных мало.
- Требование группировать код естественно требует дисциплины в дизайне и написании кода. Нужно продумывать распределение по слоям и связи. Это отучает от неряшливости в стиле “сейчас быстро напишем, потом поправим”.
- Меньший радиус поражения. Даже при фатальных ошибках пострадает только текущий слой и (возможно) нижележащие слои. Все что выше или рядом по слою – не пострадает
- Так как у каждого слоя строго ограниченная зона ответственности – каждый конкретный слой может иметь ограниченные права. Это тоже снижает шанс ошибки и радует СБ.
- Фактически мы организуем нормальную инкапсуляцию кода с помощью слоев. Это позволит менять код в конкретном слое без риска зацепить остальные слои. Нужно только соблюдать формат данных, которые мы передаем “вниз”. Это помогает в больших командах, когда много людей работает над одним и тем же инфраструктурным кодом.
##
Практический пример
Возьмем упрощенный сетап из трех слоев:
- Верхний слой отвечает за облако на уровне проекта. Он настраивает проект и биллинг.
- Средний слой отвечает за развертывание Kubernetes Cluster. Кроме этого – на этом слое создаются OAuth credentials.
- Нижний слой настраивает ресурсы внутри кластера. Сервисы внутри кластера используют OAuth2 и им нужны ключевые пары.
Вообще это не самый удачный пример, потому что верхний слой довольно большой – тут и управление проектом, и кластер и OAuth. Просто это пример из моей домашней лабы. При дизайне слоев важно соблюдать баланс – большое количество слоев не упрощает а усложняет сетап. Кроме того, слои применяются по очереди (нижний слой нельзя применять до того, как применится верхний), а это замедляет пайплайн в целом (хотя и ускоряет конкретный слой). Так как в моей домашней лабе я пишу код один – для меня такое разделение естественно.
Проще всего использовать для таких конфигураций монорепо, структура будет примерно такой:
.
└── layer-0-project
└── layer-1-cloud
├── layer-2-front
└── layer-2-k8s
Такая организация файлов упрощает CI/CD (нам нужны зависимости) и делает код наглядным. В верхнем слое у нас создаются EntraID applications и нам нужно отправить их “вниз”:
locals {
[...]
entra_out = { for k, v in local.entra_config : k => {
"root" : v.root,
"group_id" : azuread_group.entra_group[k].object_id
"application_id" : module.entra_app[k].application_id
"client_secret" : module.entra_app[k].client_secret
"tenant_id" : module.entra_app[k].tenant_id
} }
}
output "entra_config" {
value = local.entra_out
sensitive = true
}
Теперь мы можем их получить на уровне layer-2-k8s:
data "terraform_remote_state" "layer1" {
backend = "azurerm"
config = {
resource_group_name = var.tf_group_name
storage_account_name = var.tf_sa_ro
subscription_id = var.subscription_id
container_name = var.container
key = "layer0.tfstate" # state file name, такой же как на layer0
}
}
resource "kubernetes_secret_v1" "grafana-oauth2" {
metadata {
name = "auth-generic-oauth-secret"
namespace = kubernetes_namespace_v1.gafana.metadata[0].name
}
wait_for_service_account_token = false
data = {
"client_id" = data.terraform_remote_state.layer0.outputs.entra_config["grafana"].application_id
"client_secret" = data.terraform_remote_state.layer0.outputs.entra_config["grafana"].client_secret
"tenant_id" = data.terraform_remote_state.layer0.outputs.entra_config["grafana"].tenant_id
}
}
Так мы можем передавать данные из Azure в Kubernetes не взаимодействуя с Azure напрямую. У Layer-0 и Layer-1 нет доступа в K8s, а у Layer-2 нет доступа в Azure. Кроме того, Layer-2 не может ничего поменять в state-file вышестоящих слоев, даже случайно. Это сильно упрощенный пример. Конечно, распределение слоев и организация кода специфична для вашей инфраструктуры и вашей организации.
Чуть более сложный пример организации кода:
.
└── layer_0_project
├── layer_1_gke
│ ├── layer_2_extdns
│ ├── layer_2_observability
│ └── layer_2_smesh
│ └── layer_3_vault
└── layer_1_storage
#
Выводы
Слои в terraform позволяют одновременно упростить код и улучшить безопасность и надежность. Каждый отдельный слой потребляет меньше ресурсов чем монолитный репозиторий. Кроме того, раздельные слои безопаснее, чем один большой репозиторий – код изолирован по ролям. Это помогает в разработке (каждый слой может разрабатываться независимо, главное – соблюдать формат данных) и безопасности (каждому слою можно дать минимум прав доступа). Основной минус – нужно тщательно продумать организацию кода. Кроме этого, пайплайн всех слоев может занимать больше времени, потому что слои выполняются последовательно. Каждый слой – это полноценный запуск terraform, с выгрузкой состояния, установкой модулей и провайдеров. Хорошо организованный код балансирует между сложностью и функциями конкретного слоя с одной стороны и количеством слоев с другой.