add pulse terragrunt mvp

This commit is contained in:
kochetkov.s 2026-01-23 13:53:24 +03:00
parent d86dc1b732
commit 465325a679
24 changed files with 582 additions and 1293 deletions

View File

@ -1,6 +1,3 @@
# Основной GitLab CI пайплайн для Terraform инфраструктуры
# Использует downstream pipelines для динамической генерации джоб
stages:
- generate
- trigger

144
README.md
View File

@ -1,144 +0,0 @@
# Terraform Infrastructure for Pulse Project
Этот репозиторий содержит инфраструктуру как код (IaC) для проекта Pulse, развернутого в Yandex Cloud.
## Архитектура
Проект использует **единый конфигурационный файл** `infrastructure.yaml` (аналог `values.yaml` в Helm), который описывает всю инфраструктуру декларативно. Модули Terraform универсальны и не знают про конкретные сущности - они используются для создания ресурсов, описанных в конфиге.
## Структура репозитория
```
.
├── infrastructure.yaml # Единый конфигурационный файл (аналог values.yaml)
├── modules/ # Универсальные Terraform модули
│ ├── yc-s3/ # Модуль для создания S3 бакета
│ ├── yc-database/ # Модуль для работы с существующим PostgreSQL
│ ├── k8s-namespace/ # Модуль для создания Kubernetes namespace
│ └── k8s-secret/ # Модуль для создания Kubernetes секретов
└── live/ # Живая инфраструктура
├── terragrunt.hcl # Глобальная конфигурация Terragrunt
└── stage/ # Окружение stage
├── env.hcl # Конфигурация окружения
├── namespace/ # Namespace из конфига
├── s3/ # S3 бакет из конфига
├── database/ # База данных из конфига
└── secrets/ # Секреты из конфига
```
## Конфигурационный файл
Все ресурсы описываются в `infrastructure.yaml`:
```yaml
environments:
stage:
namespaces:
- name: pulse
labels: {}
annotations: {}
secrets:
- name: dockerhub
namespace: pulse
type: dockerconfigjson
# ...
buckets:
- name: pulse
acl: public-read
# ...
databases:
- cluster_id: pg-stage # ID существующего кластера
database:
name: pulse_db
user:
name: pulse
password_length: 32
```
Каждый `terragrunt.hcl` читает этот конфиг через `yamldecode(file(...))` и извлекает нужные параметры.
## Модули
Все модули **универсальны** и не содержат хардкода про конкретные сущности:
- **yc-s3** - создает S3 бакет с любыми параметрами из конфига
- **yc-database** - работает с существующим PostgreSQL кластером, создает БД и пользователя
- **k8s-namespace** - создает namespace с любыми labels/annotations
- **k8s-secret** - создает секреты с поддержкой `lifecycle.ignore_changes`
## Особенности
### Секреты с защитой от перезаписи
Секреты могут быть настроены с `lifecycle.ignore_changes: true`, чтобы не перезаписываться после создания:
```yaml
secrets:
- name: pulse-s3-secret
lifecycle:
ignore_changes: true
```
### Использование существующего PostgreSQL
Модуль `yc-database` работает с **существующим** кластером PostgreSQL в Yandex Cloud. Он только создает базу данных и пользователя, не создавая новый кластер.
### Зависимости между ресурсами
Terragrunt автоматически управляет зависимостями через блоки `dependency`. Например, секреты зависят от namespace, s3 и database, и получают их outputs.
## Использование
### Локальная разработка
1. Отредактируйте `infrastructure.yaml` для добавления/изменения ресурсов
2. Перейдите в директорию компонента:
```bash
cd live/stage/namespace
```
3. Инициализируйте Terragrunt:
```bash
terragrunt init
```
4. Просмотрите план:
```bash
terragrunt plan
```
5. Примените изменения:
```bash
terragrunt apply
```
### Развертывание всех компонентов
```bash
cd live/stage
terragrunt run-all apply
```
## Переменные окружения
- `YC_TOKEN` - токен Yandex Cloud
- `YC_CLOUD_ID` - ID облака
- `YC_FOLDER_ID` / `YC_STAGE_FOLDER_ID` - ID каталога
- `DOCKER_REGISTRY_USERNAME` / `DOCKER_REGISTRY_PASSWORD` - для секрета dockerhub
- `TF_STATE_BUCKET` - бакет для хранения state
- `TF_STATE_DYNAMODB_ENDPOINT` / `TF_STATE_DYNAMODB_TABLE` - для блокировок state
## GitLab CI
Проект использует динамическую генерацию GitLab CI пайплайнов через downstream pipelines. Для каждого компонента создаются джобы: validate, plan, apply.
## Принципы
1. **DRY** - вся конфигурация в одном файле `infrastructure.yaml`
2. **Универсальность** - модули не знают про конкретные сущности
3. **Декларативность** - описание желаемого состояния, а не шагов
4. **Идемпотентность** - повторный запуск безопасен
5. **Изоляция** - каждый компонент имеет свой state

View File

@ -1,6 +1,3 @@
# Единый конфигурационный файл инфраструктуры
# Аналог values.yaml в Helm
environments:
stage:
namespaces:
@ -16,33 +13,50 @@ environments:
namespace: pulse
type: dockerconfigjson
registry_url: cr.yandex
# username и password из переменных окружения
- name: pulse-s3-secret
namespace: pulse
type: opaque
keys:
access_key: "" # Заполнится из outputs модуля s3
secret_key: "" # Заполнится из outputs модуля s3
bucket: "" # Заполнится из outputs модуля s3
endpoint: https://storage.yandexcloud.net
type: s3
lifecycle:
ignore_changes: true
ignore_changes: false
- name: pulse-postgresql-secret
namespace: pulse
type: database
dependencies:
cluster: "c9qa2coo5ukgcg93fldm"
db: pulse_db
user: pulse
lifecycle:
ignore_changes: false
- name: session-secret
namespace: pulse
type: opaque
keys:
host: "" # Заполнится из outputs модуля database
port: "6432"
database: "" # Заполнится из outputs модуля database
user: "" # Заполнится из outputs модуля database
password: "" # Заполнится из outputs модуля database
random_keys:
key:
length: 32
special: false
lifecycle:
ignore_changes: true
# Пример секрета для другой базы данных (test_db/test)
- name: test-db-secret
namespace: pulse
type: database
dependencies:
cluster: "c9qa2coo5ukgcg93fldm"
db: test_db
user: test
random_keys:
session_key:
length: 32
special: false
lifecycle:
ignore_changes: false
buckets:
- name: pulse
- name: pulse-stage
acl: public-read
versioning:
enabled: false
@ -55,10 +69,11 @@ environments:
max_age_seconds: 3600
databases:
- cluster_id: "c9q..." # ID существующего кластера PostgreSQL в YC (заменить на реальный)
- cluster_id: "c9qa2coo5ukgcg93fldm"
database:
name: pulse_db
user:
name: pulse
password_length: 32
password_special: false
conn_limit: 10

15
live/backend.tf Normal file
View File

@ -0,0 +1,15 @@
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "s3" {
access_key = ""
bucket = "tfstate-terragrunt-stage"
endpoint = "storage.yandexcloud.net"
force_path_style = true
key = "./terraform.tfstate"
region = "ru-central1"
secret_key = ""
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
}
}

63
live/provider.tf Normal file
View File

@ -0,0 +1,63 @@
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
required_version = ">= 1.0"
required_providers {
yandex = {
source = "yandex-cloud/yandex"
version = "~> 0.100"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.23"
}
random = {
source = "hashicorp/random"
version = "~> 3.1"
}
}
}
provider "yandex" {
token = var.yc_token != "" ? var.yc_token : null
cloud_id = var.yc_cloud_id != "" ? var.yc_cloud_id : null
folder_id = var.yc_folder_id != "" ? var.yc_folder_id : null
zone = "ru-central1-a"
}
provider "kubernetes" {
# Если kubeconfig_path не указан, провайдер использует дефолтный путь ~/.kube/config
# Если указан, берем только первый путь (KUBECONFIG может содержать несколько путей через :)
config_path = var.kubeconfig_path != "" ? split(":", var.kubeconfig_path)[0] : null
config_context = var.kube_context != "" ? var.kube_context : null
}
variable "yc_token" {
type = string
default = ""
description = "Yandex Cloud token"
}
variable "yc_cloud_id" {
type = string
default = ""
description = "Yandex Cloud ID"
}
variable "yc_folder_id" {
type = string
default = ""
description = "Yandex Cloud folder ID"
}
variable "kubeconfig_path" {
type = string
default = ""
description = "Path to kubeconfig file"
}
variable "kube_context" {
type = string
default = ""
description = "Kubernetes context"
}

View File

@ -1,37 +1,37 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Чтение конфигурации инфраструктуры
locals {
infra_config = yamldecode(file("${get_parent_terragrunt_dir()}/../../infrastructure.yaml"))
env_config = local.infra_config.environments[local.environment]
repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../../..")
infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml"))
env_name = basename(dirname(get_terragrunt_dir()))
env_config = local.infra_config.environments[local.env_name]
db_config = local.env_config.databases[0] # Первая БД из списка
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//yc-database"
source = "${get_terragrunt_dir()}/../../../modules//yc-database"
}
# Входные переменные из конфига
inputs = {
cluster_id = local.db_config.cluster_id
database_name = local.db_config.database.name
user_name = local.db_config.user.name
password_length = try(local.db_config.user.password_length, 32)
password_special = try(local.db_config.user.password_special, false)
permissions = [
{
database_name = local.db_config.database.name
}
]
conn_limit = try(local.db_config.user.conn_limit, 10)
yc_token = get_env("YC_TOKEN", "")
yc_cloud_id = get_env("YC_CLOUD_ID", "")
yc_folder_id = get_env("YC_STAGE_FOLDER_ID", get_env("YC_FOLDER_ID", ""))
kubeconfig_path = get_env("KUBECONFIG", "")
kube_context = get_env("KUBE_CONTEXT", "")
}

View File

@ -1,30 +1,34 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Чтение конфигурации инфраструктуры
locals {
infra_config = yamldecode(file("${get_parent_terragrunt_dir()}/../../infrastructure.yaml"))
env_config = local.infra_config.environments[local.environment]
repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../../..")
infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml"))
env_name = basename(dirname(get_terragrunt_dir()))
env_config = local.infra_config.environments[local.env_name]
namespace = local.env_config.namespaces[0] # Первый namespace из списка
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//k8s-namespace"
source = "${get_terragrunt_dir()}/../../../modules//k8s-namespace"
}
# Входные переменные из конфига
inputs = {
namespace_name = local.namespace.name
labels = local.namespace.labels
annotations = local.namespace.annotations
yc_token = get_env("YC_TOKEN", "")
yc_cloud_id = get_env("YC_CLOUD_ID", "")
yc_folder_id = get_env("YC_STAGE_FOLDER_ID", get_env("YC_FOLDER_ID", ""))
kubeconfig_path = get_env("KUBECONFIG", "")
kube_context = get_env("KUBE_CONTEXT", "")
}

View File

@ -1,32 +1,38 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Чтение конфигурации инфраструктуры
locals {
infra_config = yamldecode(file("${get_parent_terragrunt_dir()}/../../infrastructure.yaml"))
env_config = local.infra_config.environments[local.environment]
repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../../..")
infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml"))
env_name = basename(dirname(get_terragrunt_dir()))
env_config = local.infra_config.environments[local.env_name]
bucket = local.env_config.buckets[0] # Первый bucket из списка
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//yc-s3"
source = "${get_terragrunt_dir()}/../../../modules//yc-s3"
}
# Входные переменные из конфига
inputs = {
bucket_name = local.bucket.name
folder_id = local.folder_id
folder_id = get_env("YC_STAGE_FOLDER_ID", get_env("YC_FOLDER_ID", ""))
acl = try(local.bucket.acl, "private")
versioning_enabled = try(local.bucket.versioning.enabled, false)
cors_enabled = try(local.bucket.cors.enabled, false)
cors_config = try(local.bucket.cors, {})
yc_token = get_env("YC_TOKEN", "")
yc_cloud_id = get_env("YC_CLOUD_ID", "")
yc_folder_id = get_env("YC_STAGE_FOLDER_ID", get_env("YC_FOLDER_ID", ""))
kubeconfig_path = get_env("KUBECONFIG", "")
kube_context = get_env("KUBE_CONTEXT", "")
}

View File

@ -17,16 +17,18 @@ dependency "namespace" {
name = "pulse"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
# При apply используем реальные outputs, не mock
}
dependency "s3" {
config_path = "../s3"
mock_outputs = {
bucket_name = "pulse"
bucket_name = "pulse-stage"
access_key = "mock-access-key"
secret_key = "mock-secret-key"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
# При apply используем реальные outputs, не mock
}
dependency "database" {
@ -38,67 +40,139 @@ dependency "database" {
password = "mock-password"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
# При apply используем реальные outputs, не mock
}
# Чтение конфигурации инфраструктуры
# Сертификат Yandex Cloud PostgreSQL (константа, не копируется в infrastructure.yaml)
locals {
infra_config = yamldecode(file("${get_parent_terragrunt_dir()}/../../infrastructure.yaml"))
env_config = local.infra_config.environments[local.environment]
yc_postgresql_ca_cert = <<-EOT
-----BEGIN CERTIFICATE-----
MIIE3TCCAsWgAwIBAgIKPxb5sAAAAAAAFzANBgkqhkiG9w0BAQ0FADAfMR0wGwYD
VQQDExRZYW5kZXhJbnRlcm5hbFJvb3RDQTAeFw0xNzA2MjAxNjQ0MzdaFw0yNzA2
MjAxNjU0MzdaMFUxEjAQBgoJkiaJk/IsZAEZFgJydTEWMBQGCgmSJomT8ixkARkW
BnlhbmRleDESMBAGCgmSJomT8ixkARkWAmxkMRMwEQYDVQQDEwpZYW5kZXhDTENB
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqgNnjk0JKPcbsk1+KG2t
eM1AfMnEe5RkAJuBBuwVV49snhcvO1jhKBx/pCnjr6biICc1/oAFDVgU8yVYYPwp
WZ2vH3ZtscjJ/RAT/NS9OKKG7kKknhFhVYxua5xhoIQmm6usBNYYiTcWoFm1eHC8
I9oddOLSscZYbh3unVRvt+3V+drVmUx9oSUKpqMgfysiv1MN6zB3vq9TFkbhz53E
k0tEcV+W2NnDaeFhLKy284FDKLvOdTDj1EDsSAihxl7sNEKpupNuhgyy2siOqUb+
d5mO/CRfaAKGg3E6hDM3pEi48E506dJdjPXWfHKSvuguMLRlb2RWdVocRZuyWxOh
0QIDAQABo4HkMIHhMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRMU5uItjx+
TOicX1+ovC1Xq2PSnzAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8E
BAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSrucX/oe/mUx0zOSKE
0XbUN04tajBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3Jscy55YW5kZXgucnUv
WWFuZGV4SW50ZXJuYWxSb290Q0EvWWFuZGV4SW50ZXJuYWxSb290Q0EuY3JsMA0G
CSqGSIb3DQEBDQUAA4ICAQAsR5Lb4Pv2FD0Kk+4oc1GEOnehxKLsQtdV81nrU+IV
l9pr2oNMdi8lwIolvHZRllLM4Ba5AcRH6YJ5fe7AjKm+5EdSkhqVWo2UOllRCbtS
wmL50+erOAkxstSlRkO6b8x1L0MOBKv54E5YcQ/Wwt27ldSb6RkEmJBGvmxObAaf
5zc51pqSqao9tnldYaCblEQ/Zmy43FliIpa2eUJoh8DqK8bVo2gcI3wbQ32tWs9u
wvKk8fo4lAdhCwhv+QHuqau1VAY9hPU106bsFIDUmijTMxjAobKBi6CkIX6EbNHU
Jv4DzYVLlDd2y0CADdn2F6I70xpCBn5cquSGuvFbqZjQDmIHwb7WQSxadkiGRWfc
zVTnmiHjJONJJIpE2t+FOV3hc+8o98OzOtNaH2QQ9j6dnKvtIGKGFeNSDp0vXPOi
QhHiIyuB7eWx+g2whktQ74UCpGDSXYnEW3s8w5wezVWIEmouq7q4rCEkTNvJ7Ico
43AgUdPzAFS2zYktw1C+cbUALM8smvXbXrXOBzMmscjIhtXvLMrpPeh23VfdJfQB
0rN2BmRCLUE8JOV+o0k98XMm83oN+lGkL1l+hyoj3ok1uI3JrsWOcDyjOds3ptcN
KimJLm27ndjcxDNo/iA6gefMJuCxFRaqI+eF4P0jSkMgnnQqZkvLGFuHCw8eRDhm
bw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFGTCCAwGgAwIBAgIQJMM7ZIy2SYxCBgK7WcFwnjANBgkqhkiG9w0BAQ0FADAf
MR0wGwYDVQQDExRZYW5kZXhJbnRlcm5hbFJvb3RDQTAeFw0xMzAyMTExMzQxNDNa
Fw0zMzAyMTExMzUxNDJaMB8xHTAbBgNVBAMTFFlhbmRleEludGVybmFsUm9vdENB
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAgb4xoQjBQ7oEFk8EHVGy
1pDEmPWw0Wgw5nX9RM7LL2xQWyUuEq+Lf9Dgh+O725aZ9+SO2oEs47DHHt81/fne
5N6xOftRrCpy8hGtUR/A3bvjnQgjs+zdXvcO9cTuuzzPTFSts/iZATZsAruiepMx
SGj9S1fGwvYws/yiXWNoNBz4Tu1Tlp0g+5fp/ADjnxc6DqNk6w01mJRDbx+6rlBO
aIH2tQmJXDVoFdrhmBK9qOfjxWlIYGy83TnrvdXwi5mKTMtpEREMgyNLX75UjpvO
NkZgBvEXPQq+g91wBGsWIE2sYlguXiBniQgAJOyRuSdTxcJoG8tZkLDPRi5RouWY
gxXr13edn1TRDGco2hkdtSUBlajBMSvAq+H0hkslzWD/R+BXkn9dh0/DFnxVt4XU
5JbFyd/sKV/rF4Vygfw9ssh1ZIWdqkfZ2QXOZ2gH4AEeoN/9vEfUPwqPVzL0XEZK
r4s2WjU9mE5tHrVsQOZ80wnvYHYi2JHbl0hr5ghs4RIyJwx6LEEnj2tzMFec4f7o
dQeSsZpgRJmpvpAfRTxhIRjZBrKxnMytedAkUPguBQwjVCn7+EaKiJfpu42JG8Mm
+/dHi+Q9Tc+0tX5pKOIpQMlMxMHw8MfPmUjC3AAd9lsmCtuybYoeN2IRdbzzchJ8
l1ZuoI3gH7pcIeElfVSqSBkCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB
/wQFMAMBAf8wHQYDVR0OBBYEFKu5xf+h7+ZTHTM5IoTRdtQ3Ti1qMBAGCSsGAQQB
gjcVAQQDAgEAMA0GCSqGSIb3DQEBDQUAA4ICAQAVpyJ1qLjqRLC34F1UXkC3vxpO
nV6WgzpzA+DUNog4Y6RhTnh0Bsir+I+FTl0zFCm7JpT/3NP9VjfEitMkHehmHhQK
c7cIBZSF62K477OTvLz+9ku2O/bGTtYv9fAvR4BmzFfyPDoAKOjJSghD1p/7El+1
eSjvcUBzLnBUtxO/iYXRNo7B3+1qo4F5Hz7rPRLI0UWW/0UAfVCO2fFtyF6C1iEY
/q0Ldbf3YIaMkf2WgGhnX9yH/8OiIij2r0LVNHS811apyycjep8y/NkG4q1Z9jEi
VEX3P6NEL8dWtXQlvlNGMcfDT3lmB+tS32CPEUwce/Ble646rukbERRwFfxXojpf
C6ium+LtJc7qnK6ygnYF4D6mz4H+3WaxJd1S1hGQxOb/3WVw63tZFnN62F6/nc5g
6T44Yb7ND6y3nVcygLpbQsws6HsjX65CoSjrrPn0YhKxNBscF7M7tLTW/5LK9uhk
yjRCkJ0YagpeLxfV1l1ZJZaTPZvY9+ylHnWHhzlq0FzcrooSSsp4i44DB2K7O2ID
87leymZkKUY6PMDa4GkDJx0dG4UXDhRETMf+NkYgtLJ+UIzMNskwVDcxO4kVL+Hi
Pj78bnC5yCw8P5YylR45LdxLzLO68unoXOyFz1etGXzszw8lJI9LNubYxk77mK8H
LpuQKbSbIERsmR+QqQ==
-----END CERTIFICATE-----
EOT
repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../../..")
infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml"))
env_name = basename(dirname(get_terragrunt_dir()))
env_config = local.infra_config.environments[local.env_name]
secrets = local.env_config.secrets
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//k8s-secret"
source = "${get_terragrunt_dir()}/../../../modules//k8s-secret"
}
# Входные переменные из конфига с подстановкой значений из dependencies
# Входные переменные из конфига - модуль сам формирует data из зависимостей
inputs = {
# Передаем секреты как есть из infrastructure.yaml, модуль сам обработает type и dependencies
secrets = [
# dockerhub секрет
{
name = local.secrets[0].name
for secret in local.secrets : {
name = secret.name
namespace = dependency.namespace.outputs.name
secret_type = local.secrets[0].type
data = {
".dockerconfigjson" = jsonencode({
auths = {
"${local.secrets[0].registry_url}" = {
username = get_env("DOCKER_REGISTRY_USERNAME", "")
password = get_env("DOCKER_REGISTRY_PASSWORD", "")
auth = base64encode("${get_env("DOCKER_REGISTRY_USERNAME", "")}:${get_env("DOCKER_REGISTRY_PASSWORD", "")}")
}
}
})
}
ignore_changes = false
},
# pulse-s3-secret
{
name = local.secrets[1].name
namespace = dependency.namespace.outputs.name
secret_type = local.secrets[1].type
data = {
access_key = base64encode(dependency.s3.outputs.access_key)
secret_key = base64encode(dependency.s3.outputs.secret_key)
bucket = base64encode(dependency.s3.outputs.bucket_name)
endpoint = base64encode(local.secrets[1].keys.endpoint)
}
ignore_changes = try(local.secrets[1].lifecycle.ignore_changes, false)
},
# pulse-postgresql-secret
{
name = local.secrets[2].name
namespace = dependency.namespace.outputs.name
secret_type = local.secrets[2].type
data = {
host = base64encode(dependency.database.outputs.host)
port = base64encode(local.secrets[2].keys.port)
database = base64encode(dependency.database.outputs.database_name)
user = base64encode(dependency.database.outputs.user_name)
password = base64encode(dependency.database.outputs.password)
}
ignore_changes = try(local.secrets[2].lifecycle.ignore_changes, false)
secret_type = secret.type
registry_url = try(secret.registry_url, "")
dependencies = try(secret.dependencies, {})
custom_keys = try(secret.custom_keys, {})
random_keys = try(secret.random_keys, {})
labels = try(secret.labels, {})
annotations = try(secret.annotations, {})
ignore_changes = try(secret.lifecycle.ignore_changes, false)
}
]
# Зависимости от других модулей
s3_outputs = {
bucket_name = dependency.s3.outputs.bucket_name
access_key = dependency.s3.outputs.access_key
secret_key = dependency.s3.outputs.secret_key
}
# Database outputs как map, ключ = "cluster_id:database_name:user_name"
# Собираем все базы данных из infrastructure.yaml
database_outputs_map = {
# Текущая база из dependency
"${local.env_config.databases[0].cluster_id}:${local.env_config.databases[0].database.name}:${local.env_config.databases[0].user.name}" = {
host = dependency.database.outputs.host
database_name = dependency.database.outputs.database_name
user_name = dependency.database.outputs.user_name
password = dependency.database.outputs.password
}
}
# Константы
constants = {
s3_endpoint = "https://storage.yandexcloud.net"
postgres_port = "6432"
postgres_ca = local.yc_postgresql_ca_cert
}
# Environment variables
env_vars = {
DOCKER_REGISTRY_USERNAME = get_env("DOCKER_REGISTRY_USERNAME", "")
DOCKER_REGISTRY_PASSWORD = get_env("DOCKER_REGISTRY_PASSWORD", "")
}
# Провайдеры (передаются в сгенерированный provider.tf)
yc_token = get_env("YC_TOKEN", "")
yc_cloud_id = get_env("YC_CLOUD_ID", "")
yc_folder_id = get_env("YC_STAGE_FOLDER_ID", get_env("YC_FOLDER_ID", ""))
kubeconfig_path = get_env("KUBECONFIG", "")
kube_context = get_env("KUBE_CONTEXT", "")
}

View File

@ -9,22 +9,19 @@ remote_state {
}
config = {
endpoint = "storage.yandexcloud.net"
bucket = get_env("TF_STATE_BUCKET", "terraform-state-pulse")
bucket = "tfstate-terragrunt-stage"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ru-central1"
access_key = get_env("S3_ACCESS_KEY", get_env("AWS_ACCESS_KEY_ID", ""))
secret_key = get_env("S3_SECRET_KEY", get_env("AWS_SECRET_ACCESS_KEY", ""))
skip_region_validation = true
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
force_path_style = true
# Настройка блокировок состояний через Yandex Database (YDB)
dynamodb_endpoint = get_env("TF_STATE_DYNAMODB_ENDPOINT", "")
dynamodb_table = get_env("TF_STATE_DYNAMODB_TABLE", "terraform-locks")
}
}
# Генерация общего провайдера для Yandex Cloud
# Генерация общего провайдера для всех модулей
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
@ -49,15 +46,47 @@ terraform {
}
provider "yandex" {
token = get_env("YC_TOKEN", "")
cloud_id = get_env("YC_CLOUD_ID", "")
folder_id = get_env("YC_FOLDER_ID", "")
token = var.yc_token != "" ? var.yc_token : null
cloud_id = var.yc_cloud_id != "" ? var.yc_cloud_id : null
folder_id = var.yc_folder_id != "" ? var.yc_folder_id : null
zone = "ru-central1-a"
}
provider "kubernetes" {
config_path = get_env("KUBECONFIG", "~/.kube/config")
config_context = get_env("KUBE_CONTEXT", "")
# Если kubeconfig_path не указан, провайдер использует дефолтный путь ~/.kube/config
# Если указан, берем только первый путь (KUBECONFIG может содержать несколько путей через :)
config_path = var.kubeconfig_path != "" ? split(":", var.kubeconfig_path)[0] : null
config_context = var.kube_context != "" ? var.kube_context : null
}
variable "yc_token" {
type = string
default = ""
description = "Yandex Cloud token"
}
variable "yc_cloud_id" {
type = string
default = ""
description = "Yandex Cloud ID"
}
variable "yc_folder_id" {
type = string
default = ""
description = "Yandex Cloud folder ID"
}
variable "kubeconfig_path" {
type = string
default = ""
description = "Path to kubeconfig file"
}
variable "kube_context" {
type = string
default = ""
description = "Kubernetes context"
}
EOF
}

View File

@ -1,10 +1,4 @@
terraform {
required_version = ">= 1.0"
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.23"
}
}
}

View File

@ -1,20 +1,128 @@
# Универсальный модуль для создания Kubernetes секрета
# Не знает про конкретные сущности, работает с любыми данными
resource "kubernetes_secret" "this" {
locals {
secrets_map = {
for idx, secret in var.secrets : secret.name => secret
}
random_keys_flat = merge([
for secret_name, secret in local.secrets_map : {
for key, config in try(secret.random_keys, {}) : "${secret_name}:${key}" => {
secret_name = secret_name
key = key
length = try(config.length, 32)
special = try(config.special, false)
}
}
]...)
database_outputs_by_secret = {
for name, secret in local.secrets_map : name => try(
var.database_outputs_map["${try(secret.dependencies.cluster, "")}:${try(secret.dependencies.db, "")}:${try(secret.dependencies.user, "")}"],
null
)
}
secrets_data = {
for name, secret in local.secrets_map : name =>
secret.secret_type == "dockerconfigjson" ? {
".dockerconfigjson" = jsonencode({
auths = {
"${secret.registry_url}" = {
username = try(var.env_vars.DOCKER_REGISTRY_USERNAME, "")
password = try(var.env_vars.DOCKER_REGISTRY_PASSWORD, "")
auth = base64encode("${try(var.env_vars.DOCKER_REGISTRY_USERNAME, "")}:${try(var.env_vars.DOCKER_REGISTRY_PASSWORD, "")}")
}
}
})
} : secret.secret_type == "s3" ? {
access_key = var.s3_outputs.access_key
secret_key = var.s3_outputs.secret_key
bucket = var.s3_outputs.bucket_name
endpoint = try(var.constants.s3_endpoint, "https://storage.yandexcloud.net")
} : secret.secret_type == "database" && local.database_outputs_by_secret[name] != null ? merge({
host = local.database_outputs_by_secret[name].host
port = try(var.constants.postgres_port, "6432")
database = local.database_outputs_by_secret[name].database_name
user = local.database_outputs_by_secret[name].user_name
password = local.database_outputs_by_secret[name].password
"ca.crt" = try(var.constants.postgres_ca, "")
}, {
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
}) : merge(
try(secret.custom_keys, {}),
{
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
}
)
}
secrets_with_data = {
for name, secret in local.secrets_map : name => {
name = secret.name
namespace = secret.namespace
secret_type = secret.secret_type
labels = try(secret.labels, {})
annotations = try(secret.annotations, {})
ignore_changes = try(secret.ignore_changes, false)
data = try(local.secrets_data[name], {})
}
}
secrets_with_ignore = {
for name, secret in local.secrets_with_data : name => secret
if secret.ignore_changes
}
secrets_without_ignore = {
for name, secret in local.secrets_with_data : name => secret
if !secret.ignore_changes
}
}
resource "random_password" "secrets" {
for_each = local.random_keys_flat
length = each.value.length
special = each.value.special
upper = true
lower = true
numeric = true
}
resource "kubernetes_secret" "with_ignore" {
for_each = local.secrets_with_ignore
metadata {
name = var.secret_name
namespace = var.namespace
labels = var.labels
annotations = var.annotations
name = each.value.name
namespace = each.value.namespace
labels = try(each.value.labels, {})
annotations = try(each.value.annotations, {})
}
type = var.secret_type
type = each.value.secret_type
data = var.data
data = each.value.data
# Защита от перезаписи если указано в lifecycle
lifecycle {
ignore_changes = var.ignore_changes ? [data] : []
ignore_changes = [data]
}
depends_on = [random_password.secrets]
}
resource "kubernetes_secret" "without_ignore" {
for_each = local.secrets_without_ignore
metadata {
name = each.value.name
namespace = each.value.namespace
labels = try(each.value.labels, {})
annotations = try(each.value.annotations, {})
}
type = each.value.secret_type
data = each.value.data
depends_on = [random_password.secrets]
}

View File

@ -1,9 +1,19 @@
output "secret_name" {
description = "Имя созданного секрета"
value = kubernetes_secret.this.metadata[0].name
output "secrets" {
description = "Информация о созданных секретах"
value = merge(
{
for name, secret in kubernetes_secret.with_ignore : name => {
id = secret.id
name = secret.metadata[0].name
namespace = secret.metadata[0].namespace
}
output "secret_id" {
description = "ID созданного секрета"
value = kubernetes_secret.this.id
},
{
for name, secret in kubernetes_secret.without_ignore : name => {
id = secret.id
name = secret.metadata[0].name
namespace = secret.metadata[0].namespace
}
}
)
}

View File

@ -1,38 +1,59 @@
variable "secret_name" {
description = "Имя секрета"
type = string
variable "secrets" {
description = "Список секретов для создания"
type = list(object({
name = string
namespace = string
secret_type = string
registry_url = optional(string, "")
dependencies = optional(object({
cluster = optional(string, "")
db = optional(string, "")
user = optional(string, "")
}), {})
custom_keys = optional(map(string), {})
random_keys = optional(map(object({
length = optional(number, 32)
special = optional(bool, false)
})), {}) # Случайные ключи
labels = optional(map(string), {})
annotations = optional(map(string), {})
ignore_changes = optional(bool, false)
}))
}
variable "namespace" {
description = "Kubernetes namespace"
type = string
variable "s3_outputs" {
description = "Outputs от модуля S3"
type = object({
bucket_name = optional(string, "")
access_key = optional(string, "")
secret_key = optional(string, "")
})
default = {
bucket_name = ""
access_key = ""
secret_key = ""
}
}
variable "secret_type" {
description = "Тип секрета (Opaque, kubernetes.io/dockerconfigjson, etc.)"
type = string
default = "Opaque"
variable "database_outputs_map" {
description = "Outputs от модулей Database, ключ = cluster_id:database_name:user_name"
type = map(object({
host = string
database_name = string
user_name = string
password = string
}))
default = {}
}
variable "data" {
description = "Данные секрета (map строк)"
type = map(string)
}
variable "labels" {
description = "Labels для секрета"
variable "constants" {
description = "Константные значения (CA сертификаты, endpoints и т.д.)"
type = map(string)
default = {}
}
variable "annotations" {
description = "Annotations для секрета"
variable "env_vars" {
description = "Environment variables"
type = map(string)
default = {}
}
variable "ignore_changes" {
description = "Игнорировать изменения после создания (не перезаписывать)"
type = bool
default = false
}

View File

@ -1,10 +1,4 @@
terraform {
required_version = ">= 1.0"
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.23"
}
}
}

View File

@ -0,0 +1,21 @@
resource "kubernetes_secret" "this" {
for_each = {
for idx, secret in var.secrets : secret.name => secret
}
metadata {
name = each.value.name
namespace = each.value.namespace
labels = try(each.value.labels, {})
annotations = try(each.value.annotations, {})
}
type = each.value.secret_type
data = each.value.data
lifecycle {
ignore_changes = each.value.ignore_changes ? [data] : []
}
}

View File

@ -0,0 +1,10 @@
output "secrets" {
description = "Map созданных секретов"
value = {
for k, v in kubernetes_secret.this : k => {
name = v.metadata[0].name
namespace = v.metadata[0].namespace
id = v.id
}
}
}

View File

@ -0,0 +1,12 @@
variable "secrets" {
description = "Список секретов для создания"
type = list(object({
name = string
namespace = string
secret_type = string
data = map(string)
labels = optional(map(string))
annotations = optional(map(string))
ignore_changes = optional(bool, false)
}))
}

View File

@ -1,7 +1,4 @@
# Модуль для работы с существующим PostgreSQL кластером
# Создает базу данных и пользователя в существующем кластере
# Генерация пароля для пользователя
resource "random_password" "user_password" {
length = var.password_length
special = var.password_special
@ -10,12 +7,24 @@ resource "random_password" "user_password" {
numeric = true
}
# Получение информации о существующем кластере
data "yandex_mdb_postgresql_cluster" "existing" {
cluster_id = var.cluster_id
}
# Создание базы данных
resource "yandex_mdb_postgresql_user" "this" {
cluster_id = var.cluster_id
name = var.user_name
password = random_password.user_password.result
conn_limit = var.conn_limit
dynamic "permission" {
for_each = length(var.permissions) > 0 ? var.permissions : []
content {
database_name = permission.value.database_name
}
}
}
resource "yandex_mdb_postgresql_database" "this" {
cluster_id = var.cluster_id
name = var.database_name
@ -23,6 +32,8 @@ resource "yandex_mdb_postgresql_database" "this" {
lc_collate = var.lc_collate
lc_type = var.lc_type
depends_on = [yandex_mdb_postgresql_user.this]
dynamic "extension" {
for_each = var.extensions
content {
@ -30,17 +41,3 @@ resource "yandex_mdb_postgresql_database" "this" {
}
}
}
# Создание пользователя
resource "yandex_mdb_postgresql_user" "this" {
cluster_id = var.cluster_id
name = var.user_name
password = random_password.user_password.result
dynamic "permission" {
for_each = var.permissions
content {
database_name = permission.value.database_name
}
}
}

View File

@ -50,3 +50,9 @@ variable "permissions" {
}))
default = []
}
variable "conn_limit" {
description = "Лимит подключений для пользователя"
type = number
default = 10
}

View File

@ -1,14 +1,4 @@
terraform {
required_version = ">= 1.0"
required_providers {
yandex = {
source = "yandex-cloud/yandex"
version = "~> 0.100"
}
random = {
source = "hashicorp/random"
version = "~> 3.1"
}
}
}

View File

@ -21,10 +21,10 @@ resource "yandex_iam_service_account_static_access_key" "sa_key" {
}
# Создание S3 бакета
# Bucket создается от имени текущего пользователя (YC_TOKEN), не сервисного аккаунта
# Ключи сервисного аккаунта используются только для доступа к bucket после создания
resource "yandex_storage_bucket" "this" {
bucket = var.bucket_name
access_key = yandex_iam_service_account_static_access_key.sa_key.access_key
secret_key = yandex_iam_service_account_static_access_key.sa_key.secret_key
acl = var.acl
# Версионирование объектов
@ -46,6 +46,4 @@ resource "yandex_storage_bucket" "this" {
max_age_seconds = try(var.cors_config.max_age_seconds, 3600)
}
}
depends_on = [yandex_resourcemanager_folder_iam_member.storage_editor]
}

View File

@ -1,10 +1,4 @@
terraform {
required_version = ">= 1.0"
required_providers {
yandex = {
source = "yandex-cloud/yandex"
version = "~> 0.100"
}
}
}

925
theory.md
View File

@ -1,925 +0,0 @@
Архитектурные паттерны IaC на основе источников
1.1. Декларативный подход и идемпотентность
Terraform использует декларативный язык HCL, где инженер описывает целевое конечное состояние («что должно существовать»), в то время как инструмент самостоятельно вычисляет граф зависимостей и шаги для его достижения [22], [4]. Это обеспечивает идемпотентность: многократный запуск одного и того же кода приводит к идентичному результату, исключая риск дублирования ресурсов [21]. В отличие от процедурных инструментов (Ansible, Bash), где повторный запуск может потребовать дополнительных проверок, Terraform сравнивает текущее состояние (state) со свежим кодом и вносит только необходимые изменения [21], [16]. Начиная с версии 0.12 (HCL 2.0), синтаксис был упрощен: переменные больше не требуют обязательной интерполяции ${} везде, где они используются, что сделало код более читаемым для человека [1], [4]. Декларативный подход позволяет уйти от ручного управления («ClickOps») и превратить инфраструктуру в «живую» документацию [1], [5], [29].
1.2. Минимизация «радиуса поражения» (Blast Radius)
Монолитная инфраструктура, где все ресурсы описаны в одном файле или управляются единым файлом состояния, признана критическим антипаттерном [2]. Научное исследование SMELLS-SUS показывает, что «монолитный запах» (Monolithic Infrastructure smell) является наиболее распространенной проблемой и встречается в 9,67% проанализированных сценариев [2]. Ошибка в коде одного компонента (например, опечатка в Security Group) в монолитном стейте может привести к каскадному удалению или повреждению критически важных ресурсов, таких как базы данных всего ландшафта [15]. Эксперты рекомендуют разделять инфраструктуру на мелкие независимые стейты — «переборки» [16]. Опыт компании Selectel подтверждает, что распилка монолита на гранулярные компоненты позволяет ускорить деплой инфраструктуры в 20 раз за счет уменьшения времени опроса API облачного провайдера [5].
1.3 Слоистая архитектура (Layered Architecture)
Рекомендуется разделение ресурсов на независимые логические уровни (слои), каждый из которых имеет свой жизненный цикл и границы ответственности [17], [4609. «Владимир Дроздецкий — Эффективное управление инфрой»], [29]. Типичная иерархия включает следующие слои: Global (IAM, DNS), Network (VPC, подсети), Platform (K8s, базы данных) и Application (микросервисы) [17], [20]. Слой сети является фундаментальным, так как без него невозможна работа большинства других компонентов [29]. Связь между слоями осуществляется через передачу выходных данных (outputs), что в Terragrunt реализуется через блоки dependency, обеспечивающие автоматическую оркестрацию и соблюдение порядка развертывания [16], [18], [29]. Использование команды run-all позволяет развернуть всю иерархию модулей в правильной последовательности одним действием [1], [18], [27].
2. Организация и структура репо
1. Стратегия разделения: Modules и Live
Общепринятым корпоративным стандартом является разделение реализации инфраструктуры от ее непосредственного развертывания по разным репозиториям [20], [30]:
Repository «Modules»: Библиотека универсальных, версионируемых «чертежей» (blueprints). Они не содержат специфических данных окружения (IP-адресов, имен) и предназначены для многократного использования [20], [7].
Repository «Live»: Описание реальных «зданий», построенных по чертежам из модулей. Здесь фиксируются конкретные параметры для каждого ландшафта (dev, stage, prod) [7], [20].
2. Иерархическая структура каталогов (Live-репозиторий)
Для обеспечения изоляции и прозрачности рекомендуется древовидная структура, отражающая логику облака [20], [30]:
Account (Учетная запись) → Region (Регион) → Environment (Окружение) → Category (Категория) → Component (Компонент)
Account: На верхнем уровне разделяются облачные аккаунты (например, prod-account, stage-account), что обеспечивает максимальную изоляцию в рамках безопасности [7], [20].
Region: Внутри аккаунта создаются папки регионов (например, us-east-1, eu-central-1) [20]. Папка _global на этом уровне используется для ресурсов, доступных во всех регионах аккаунта (IAM, DNS) [7].
Environment: Разделение на dev, qa, stage и prod [20], [6]. Каждое окружение обычно соответствует отдельному VPC [7].
Category: Группировка ресурсов по назначению: networking (VPC, подсети), services (приложения, K8s), data-stores (БД, кэши) [20], [29].
Component: Конечная директория с файлом terragrunt.hcl или main.tf, управляющая конкретным ресурсом (например, mysql или vpc) [20].
3. Организация файлов внутри компонента
Для поддержания чистоты кода (принцип DRY) и удобства навигации рекомендуется разделение на функциональные файлы [6], [23]:
main.tf: Вызов модулей и описание основных ресурсов [6].
variables.tf: Объявление входных переменных с обязательным описанием (description) и типами [6].
outputs.tf: Описание выходных данных для передачи их между слоями или модулями [6].
providers.tf: Конфигурация провайдеров (AWS, Azure, Yandex) и их версий [23].
backend.tf: Настройка удаленного хранения стейта (S3, GCS) [6].
versions.tf: Фиксация версий Terraform и провайдеров для стабильности сборок [6].
4. Иерархическое управление переменными в Terragrunt
Terragrunt позволяет избежать дублирования за счет автоматического наследования конфигураций по иерархии папок [27], [30]:
root.hcl: Находится в корне и содержит общую конфигурацию remote_state и провайдеров для всех ландшафтов [27].
env.hcl / environment.yaml: Хранит переменные, общие для всего окружения (например, env = "prod") [18], [30].
region.hcl / region.yaml: Переменные конкретного региона (например, maintenance_window) [27], [30].
Для автоматического поиска этих файлов используется функция find_in_parent_folders() [30].
5. Изоляция состояний (State Isolation)
Иерархическая структура напрямую влияет на управление состоянием. Gruntwork и эксперты рекомендуют управлять стейтом на уровне каждого отдельного юнита (директории) [20], [30]. Использование Workspaces для Production-сред не рекомендуется, так как они используют один бэкенд и скрывают структуру в CLI [27]. Разделение по папкам гарантирует, что каждый компонент имеет свой изолированный файл состояния, что критически снижает «радиус поражения» (blast radius) в случае ошибки [16], [24].
6. Нововведения в структуре (Stacks)
В 2025 году появилась концепция Terragrunt Stacks, позволяющая упаковывать коллекции связанных юнитов в переиспользуемые стеки [27], [19]. Это переводит переиспользование с уровня отдельных модулей на уровень целых инфраструктурных паттернов (например, «App + DB + Monitoring»), полностью устраняя копипаст конфигураций между ландшафтами [27].
3. Модульность и стратегии переиспользования
Согласно исследованиям и best-practices, модульность является краеугольным камнем масштабируемой и поддерживаемой инфраструктуры. Она позволяет инкапсулировать ее сложность, обеспечивать переиспольщование и минимизировать дублирование кода, что напрямую снижает риск возникновения «монолитного запаха» (Monolithic Infrastructure smell) в проектах [2, 6].
3.1. Понятие и структура модуля
Как описывается в руководствах, в Terraform модулем может считаеться любая директория, содержащая конфигурационные файлы .tf [6]. Модули работают по принципу функций в программировании: они инкапсулируют логику создания определенного набора ресурсов (например, виртуальной машины или кластера БД), принимают входные параметры через переменные и возвращают результаты через выходные значения [21]. Это позволяет абстрагироваться от деталей реализации.
Как показано в примерах, для создания переиспользуемого компонента его необходимо выделить в отдельную директорию. Например:
modules/object-storage/bucket/main.tf:
resource "yandex_storage_bucket" "this" {
bucket = var.bucket_name
acl = var.acl
dynamic "server_side_encryption_configuration" {
for_each = var.kms_key_id != null ? [1] : []
content {
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = var.kms_key_id
sse_algorithm = "aws:kms"
}
}
}
}
lifecycle_rule {
enabled = true
expiration {
days = var.object_expiration_days
}
}
}
Дабы вызвать данный модуль и корневого каталога, можно сделать следующее:
module "static_assets_bucket" {
source = "./modules/object-storage/bucket"
bucket_name = "myapp-static-assets-prod"
acl = "private"
kms_key_id = yandex_kms_symmetric_key.bucket_key.id
}
Источники подчеркивают, что такой подход превращает инфраструктуру в набор переиспользуемых «кирпичиков лего» [6, 21].
3.2. Использование публичных и готовых модулей
Для ускорения разработки и следования лучшим практикам настоятельно рекомендуется использовать готовые модули. Хотя публичный реестр Terraform для Yandex Cloud менее обширен, чем для AWS, существуют как официальные модули от Yandex, так и проверенные сообществом, которые можно найти на GitHub. Использование таких модулей, как отмечается в источниках, позволяет избежать написания сотен строк стандартного кода для настройки, например, виртуальных частных облаков (VPC) или групп безопасности [6, 23]. Тем самым нам не нужно в большинстве случаев изобретать вилосипед.
Адаптированный пример того, как можно использовать структурированный модуль для быстрого развертывания сети:
module "vpc" {
source = "terraform-yc-modules/vpc/yc"
version = "~> 1.0"
name = "production-network"
description = "VPC for production environment"
# Определение подсетей в разных зонах доступности
subnets = {
"ru-central1-a" = "10.0.1.0/24",
"ru-central1-b" = "10.0.2.0/24",
"ru-central1-c" = "10.0.3.0/24"
}
# Включение NAT для выхода в интернет из приватных подсетей
enable_nat = true
}
Использование версионированных модулей из проверенных источников является всеобщей стандартной практикой [6, 23].
3.3. Стратегии проектирования
Сложные инфраструктурные решения строятся по принципу композиции («Lego bricks»). Как описывается в литературе, выходные данные одного модуля (например, идентификаторы подсетей) должны передаваться на вход другому (например, модулю создания виртуальной машины), формируя явные и управляемые зависимости [6, 30]. Это обеспечивает слабую, но хоть какую-то связанность компонентов.
# 1. Модуль создания кластера Managed PostgreSQL
module "prod_postgresql" {
source = "../../../modules/data-stores/postgresql"
cluster_name = "app-db-prod"
network_id = data.yandex_vpc_network.default.id
subnet_ids = [yandex_vpc_subnet.private_a.id]
host_class = "s2.micro"
}
# 2. Модуль развертывания приложения, использующий вывод модуля БД
module "backend_app" {
source = "../../../modules/services/backend-app"
name = "backend-api"
subnet_id = yandex_vpc_subnet.public_a.id
image_id = "blahblahblah"
db_host = module.prod_postgresql.cluster_hosts_fqdn[0]
db_name = module.prod_postgresql.database_name
}
Такой подход исключает хардкод значений и делает инфраструктурный код декларативным и прозрачным [30].
3.4. Динамическое создание ресурсов (итерирование)
Для массового создания однотипных ресурсов без копирования кода используются count и for_each. В руководствах отмечается, что for_each предпочтительнее для работы с коллекциями (map, set), так как он создает ресурсы с уникальными идентификаторами в стейте, что делает их безопасными для последующих изменений [6, 10].
# variables.tf
variable "vm_instances" {
description = "Конфигурация создаваемых виртуальных машин"
type = map(object({
cores = number
memory = number
boot_disk_gb = number
image_family = string
user_data = string # Cloud-init
}))
}
# main.tf
resource "yandex_compute_instance" "vm" {
for_each = var.vm_instances
name = each.key
platform_id = "standard-v3"
zone = "ru-central1-a"
resources {
cores = each.value.cores
memory = each.value.memory
}
boot_disk {
initialize_params {
size = each.value.boot_disk_gb
image_id = data.yandex_compute_image.this[each.key].id
}
}
network_interface {
subnet_id = yandex_vpc_subnet.app.id
nat = true # Для ВМ, которым нужен внешний IP
}
metadata = {
user-data = each.value.user_data
}
}
# Использование data source для поиска актуального образа по семейству
data "yandex_compute_image" "this" {
for_each = var.vm_instances
family = each.value.image_family
}
Это позволяет позволяет гибко управлять множеством ресурсов через изменение одной переменной [10].
Как это работает?
Допустим, в файле terraform.tfvars или в Terragrunt inputs мы передадим
vm_instances = {
"web-server-01" = {
cores = 2
memory = 4
boot_disk_gb = 30
image_family = "ubuntu-2204-lts"
user_data = "#cloud-config\npackage_update: true"
},
"database-01" = {
cores = 4
memory = 8
boot_disk_gb = 100
image_family = "centos-7"
user_data = "#cloud-config\ndisable_root: false"
},
"monitoring-01" = {
cores = 2
memory = 2
boot_disk_gb = 20
image_family = "debian-11"
user_data = "#cloud-config\npackages: ['prometheus']"
}
}
Terraform возьмет каждый элемент этой мапы и создаст отдельный ресурс yandex_compute_instance:
Итерация 1:
Создается ВМ с именем web-server-01, 2 ядрами, 4 ГБ памяти
Итерация 2:
Создается ВМ с именем database-01, 4 ядрами, 8 ГБ памяти
Итерация 3:
Создается ВМ с именем monitoring-01 и т.д
3.5. Версионирование как гарантия стабильности
Для надежности production-сред в особненности, источники категорически настаивают на фиксировании версии как внешних провайдеров, так и собственных модулей. Это защищает от непреднамеренных изменений, которые могут быть внесены в апстрим репозитории [6, 21]. Семантическое версионирование (SemVer) позволяет контролировать процесс обновлений.
module "network" {
source = "git::https://gitlab.sarex.io/terraform/yc-network-module.git?ref=v2.5.1"
vpc_name = "production"
cidr = "10.100.0.0/16"
}
3.6. Оркестрация и соблюдение принципа DRY через Terragrunt
Для управления сложными, многоуровневыми деплойментами в облаках и соблюдения принципа DRY рекомендуется использовать инструменты оркестрации, такие как Terragrunt [27, 30]. Он позволяет устранить дублирование в конфигурациях бэкенда и провайдеров, а также явно управлять зависимостями между компонентами.
# Автоматическое включение конфигураций из родительских папок
include "root" {
path = find_in_parent_folders("root.hcl")
}
# Объявление зависимости от модуля сети
dependency "vpc" {
config_path = "../network"
# Mock-выходы для команд `plan` без доступа к реальному state
mock_outputs = {
subnet_ids = ["mock-subnet-id-a", "mock-subnet-id-b"]
network_id = "mock-network-id"
}
}
# Локальные переменные для удобства
locals {
common_vars = read_terragrunt_config(find_in_parent_folders("common.hcl")).locals
}
# Входные переменные для Terraform-модуля
inputs = {
environment = local.common_vars.environment
region = local.common_vars.region
subnet_id = dependency.vpc.outputs.subnet_ids[0]
# ... другие переменные
}
Файл root.hcl:
hcl
# Настройка удаленного бэкенда в Yandex Object Storage с блокировкой через YDB
remote_state {
backend = "s3"
config = {
endpoint = "storage.yandexcloud.net"
bucket = "company-terraform-state-prod"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ru-central1"
skip_region_validation = true
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
force_path_style = true
# Настройка блокировок состояний через Yandex Database (YDB)
dynamodb_endpoint = "https://docapi.serverless.yandexcloud.net/ru-central1/b1gxxxxxxxx/etn03j3j45678"
dynamodb_table = "terraform_locks"
}
}
# Генерация общего провайдера для Yandex Cloud
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "yandex" {
folder_id = "${local.folder_id}"
zone = "ru-central1-a"
}
EOF
}
В источниках отмечается, что такой подход, реализуемый Terragrunt, позволяет централизованно управлять настройками для сотен компонентов, обеспечивая согласованность и безопасность [27, 30].
4. Управление состоянием (State Management)
Файл состояния (state) является критически важным компонентом Terraform, который выполняет роль «памяти» для IaC. Этот JSON-файл содержит точное сопоставление декларативных определений в файлах .tf и реальными физическими ресурсами в облачной среде [6, 16, 24]. Без корректного файла состояния Terraform не может определить, какие ресурсы уже развернуты, какие требуют обновления, а какие должны быть удалены, что ведет к катастрофическим последствиям для инфраструктуры [6, 16, 21].
4.1. Состав и структура файла состояния
В источниках подробно описывается, что файл состояния (обычно terraform.tfstate) содержит не только перечень ресурсов, но и метаданные, необходимые для отслеживания зависимостей между ними и поддержания целостности инфраструктурного графа [6, 16, 21].
{
"version": 4,
"terraform_version": "1.6.0",
"serial": 5,
"lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"outputs": {
"vpc_id": {
"value": "enp0s9d2v1abc",
"type": "string"
}
},
"resources": [
{
"mode": "managed",
"type": "yandex_compute_instance",
"name": "app_server",
"provider": "provider[\"registry.terraform.io/yandex-cloud/yandex\"]",
"instances": [
{
"attributes": {
"id": "fhm0b28lgm4qabcde",
"name": "app-prod-01",
"platform_id": "standard-v3",
"resources": {
"cores": 2,
"memory": 4
},
"boot_disk": [
{
"disk_id": "fhm0b28lgm4qabcdefg"
}
]
},
"private": "bnR5cGU6IHN0cmluZwp2YWx1ZTogaGFzaGljb3JwL2xpbnV4LWFtaQ=="
}
]
}
]
}
Как разъясняется в источниках, ключевыми элементами структуры являются:
version: версия формата данных состояния, которая меняется при обновлении Terraform [21].
serial: возрастающий номер, увеличивающийся при каждом успешном применении изменений (terraform apply), что позволяет отслеживать историю изменений [6, 21].
lineage: уникальный идентификатор UUID, генерируемый при инициализации бэкенда и предотвращающий случайное смешивание разных состояний проекта [6, 16].
resources: детализированный список всех управляемых ресурсов с их полными атрибутами, включая чувствительные данные, которые могут храниться в открытом виде [6, 16, 24].
4.2. Remote Backends
Хранение файла состояния локально на ПК допустимо только в тестовых целях. В командной работе это неизбежно ведет к потере данных, конфликтам версий [3, 6, 16]. Рекомендуемой практикой является использование удаленных бэкендов (remote backends), которые обеспечивают безопасное хранение, шифрование данных, версионирование и совместный доступ к состоянию [6, 16, 23].
Как показано в официальной документации Yandex Cloud и практических примерах, для надежного хранения состояния необходимо использовать Yandex Object Storage в сочетании с Yandex Database (YDB) для локов [10, 28].
terraform {
backend "s3" {
endpoint = "storage.yandexcloud.net"
bucket = "company-name-terraform-state-prod"
key = "global/network/terraform.tfstate"
region = "ru-central1"
# Настройки для работы с Yandex Cloud S3-совместимым хранилищем
skip_region_validation = true
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
force_path_style = true
# Блокировка состояния через Yandex Database (YDB)
dynamodb_endpoint = "https://serverless.yandexcloud.net/ru-central1/b1g8..."
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
В источниках отмечается, что использование YDB для блокировок является обязательным для production-сред, так как предотвращает одновременное изменение состояния несколькими пользователями [10, 28].
4.3. Блокировка состояния (State Locking)
Механизм блокировки состояния предотвращает состояние race condition, когда несколько инженеров или CI/CD-пайплайнов одновременно пытаются изменить одну и ту же инфраструктуру [3, 16, 28]. Если процесс выполнения terraform apply прервется аварийно (например, из-за сетевого сбоя), блокировка может остаться активной. Для ее снятия вручную, согласно документации, используется команда:
terraform force-unlock <LOCK_ID>
В руководства=х предостерегают от частого использования этой команды и рекомендуют сначала убедиться, что ни один другой процесс не использует файл стейта [16, 21].
4.4. Изоляция окружений (Isolation)
Для обеспечения надежного разделения сред разработки (dev), тестирования (stage) и production (prod) в источниках рассматриваются два основных подхода:
Workspaces: Позволяют использовать одну и ту же конфигурацию кода для нескольких окружений, но хранят состояния всех окружений в одном бэкенде. Этот подход может подходить для небольших проектов, но не рекомендуется для production из-за риска случайного воздействия на несколько сред и отсутствия полной изоляции [6, 23].
Структура каталогов (Рекомендуемый подход): Обеспечивает полную изоляцию за счет использования разных директорий и, соответственно, разных файлов состояния для каждого окружения. В сочетании с Terragrunt этот подход автоматизируется с помощью функции path_relative_to_include() для динамического формирования ключей (key) в Object Storage [7, 20, 27].
Пример структуры каталогов с Terragrunt:
live/
├── prod/
│ ├── ru-central1/
│ │ ├── vpc/
│ │ │ └── terragrunt.hcl # key = "prod/ru-central1/vpc/terraform.tfstate"
│ │ └── k8s/
│ │ └── terragrunt.hcl # key = "prod/ru-central1/k8s/terraform.tfstate"
│ └── _global/
│ └── iam/
│ └── terragrunt.hcl # key = "prod/_global/iam/terraform.tfstate"
└── dev/
└── ru-central1/
└── vpc/
└── terragrunt.hcl # key = "dev/ru-central1/vpc/terraform.tfstate"
Как подчеркивается в источниках Gruntwork, такая структура минимизирует «радиус поражения» (blast radius) и обеспечивает максимальную безопасность [7, 20].
4.5. Работа с данными из других стейтов
Для создания зависимостей между изолированными компонентами инфраструктуры (например, когда модулю приложений нужен ID VPC из модуля сети) в источниках описывается два подхода:
Нативный подход Terraform: Использование data source terraform_remote_state для чтения выходных значений из состояния другого модуля.
data "terraform_remote_state" "network" {
backend = "s3"
config = {
endpoint = "storage.yandexcloud.net"
bucket = "company-name-terraform-state-prod"
key = "prod/ru-central1/vpc/terraform.tfstate"
region = "ru-central1"
skip_region_validation = true
skip_credentials_validation = true
}
}
resource "yandex_compute_instance" "app" {
name = "app-server"
platform_id = "standard-v3"
zone = "ru-central1-a"
network_interface {
subnet_id = data.terraform_remote_state.network.outputs.subnet_ids[0]
}
}
Однако этот подход требует жесткого указания путей к бэкенду и может создать скрытые зависимости [6, 16].
Подход с использованием Terragrunt: Использование блока dependency, который автоматически управляет зависимостями и порядком развертывания.
# terragrunt.hcl для модуля приложения
dependency "vpc" {
config_path = "../vpc"
}
inputs = {
subnet_id = dependency.vpc.outputs.subnet_ids[0]
}
4.6. Манипуляции со стейтом через CLI и код
Никогда не следует редактировать файл состояния вручную с помощью текстового редактора, так как это почти гарантированно приведет к повреждению данных и неработоспособности инфры [3, 6, 16]. Для безопасного управления состоянием предназначены CLI-команды Terraform:
Импорт существующих ресурсов: Привязка ресурсов, созданных вручную или другим способом, под управление Terraform.
terraform import yandex_compute_instance.app fhm0b28lgm4qabcde
Как описывается в источниках, эта команда добавляет ресурс в состояние, но не создает код, который нужно написать отдельно [6, 21].
Безопасный рефакторинг с moved: Современный способ переименования ресурсов или изменения их адреса без физического пересоздания.
# В конфигурации Terraform
moved {
from = yandex_compute_instance.old_name
to = yandex_compute_instance.new_name
}
Этот блок в книге Robert Hafner'а указывает Terraform на необходимость перенести состояние, а не удалять и создавать заново [21].
Удаление ресурса из управления Terraform: Команда state rm удаляет ресурс из состояния, но не удаляет его физически в облаке.
bash
terraform state rm yandex_compute_instance.temporary
Эта операция должна выполняться с крайней осторожностью!! [16, 21].
Downstream Pipelines и автоматизация в CI/CD
При управлении крупной инфраструктурой, состоящей из сотен компонентов, классический подход с единым CI/CD-пайплайном становится неприемлемым. Его выполнение превращается в многочасовую процедуру, а ошибка в одном компоненте приводит к остановке всего процесса и сложностям в локализации проблемы. Как показывают практические кейсы, Downstream (дочерние) Pipelines решают эту проблему, превращая монолитный деплой в набор независимых, параллельных задач, что сокращает время развертывания с часов до минут и кардинально упрощает операционную деятельность [5, 29].
5.1. Динамическая генерация пайплайна: принцип работы и практическая реализация
Суть динамической генерации заключается в том, что структура пайплайна не задается жестко в YAML-файле, а создается скриптом на лету на основе актуальной файловой структуры репозитория live/, как было уже указано выше. Это обеспечивает полное соответствие между инфраструктурным кодом и процессом его доставки.
Контекст и исходная структура:
Предположим, инфраструктура организована по принципу, описанному в материале Gruntworks - Folder Structure, и структурно выглядит как Окружение/Регион/Компонент:
live/
├── prod/
│ ├── ru-central1-a/
│ │ ├── network/ # Компонент A: VPC, subnets
│ │ │ └── terragrunt.hcl
│ │ └── k8s-cluster/ # Компонент B: k8s кластер
│ │ └── terragrunt.hcl
│ └── _global/
│ └── object-storage/ # Компонент C: s3 buckets
│ └── terragrunt.hcl
└── dev/... # Аналогичная структура для других каталогов
Шаг 1: Создание скрипта-генератора
Скрипт generate-pipeline.sh выполняет ключевую роль. Он:
Сканирует всю иерархию live/ в поисках файлов terragrunt.hcl, игнорируя служебные директории (например, .terragrunt-cache/).
Для каждого найденного компонента извлекает из пути его метаданные: окружение (prod, dev), регион (ru-central1-a, _global) и имя компонента (network).
Генерирует три стандартные job GitLab CI для каждого компонента: validate, plan и apply.
Автоматически назначает переменные окружения (например, TG_ROOT) и, что критически важно, настраивает правило when: manual для стадии apply в production-окружениях. Это обязательная мера безопасности, чтобы production-изменения не катились автоматически.
#!/bin/bash
echo "stages: [validate, plan, apply]" > .gitlab-ci.generated.yml
find live -name "terragrunt.hcl" -not -path "*/.terragrunt-cache/*" | while read CONFIG_FILE; do
COMPONENT_DIR=$(dirname "$CONFIG_FILE")
ENV=$(echo "$COMPONENT_DIR" | cut -d'/' -f2)
REGION=$(echo "$COMPONENT_DIR" | cut -d'/' -f3)
COMPONENT_NAME=$(basename "$COMPONENT_DIR")
for STAGE in validate plan apply; do
JOB_NAME="${STAGE}-${ENV}-${REGION}-${COMPONENT_NAME}"
cat >> .gitlab-ci.generated.yml << EOF
${JOB_NAME}:
stage: ${STAGE}
variables:
TG_ROOT: "${COMPONENT_DIR}"
ENVIRONMENT: "${ENV}"
script:
- cd \${TG_ROOT}
- terragrunt init -reconfigure -input=false
- terragrunt \${CI_JOB_STAGE} -input=false --terragrunt-non-interactive
EOF
# Ключевое правило безопасности для production
if [[ "$STAGE" == "apply" && "$ENV" == "prod" ]]; then
echo " when: manual" >> .gitlab-ci.generated.yml
fi
echo "" >> .gitlab-ci.generated.yml
done
done
Шаг 2: Оркестрация в основном пайплайне
Основной файл .gitlab-ci.yml становится минимальным и выполняет лишь две функции — запуск генератора и триггер downstream пайплайна.
yaml
stages:
- generate
- trigger
generate-config:
stage: generate
script: ./generate-pipeline.sh
artifacts:
paths:
- .gitlab-ci.generated.yml
expire_in: 1 hour
trigger-downstream:
stage: trigger
trigger:
include:
- artifact: .gitlab-ci.generated.yml
job: generate-config
strategy: depend
Следовательно, при каждом пуше в репозиторий сначала выполняется джоба generate-config, которая создает актуальный файл .gitlab-ci.generated.yml и сохраняет его как артефакт. Затем job trigger-downstream запускает новый пайплайн, используя сгенерированный файл в качестве .gitlab-ci.yml.
Шаг 3: Что происходит в сгенерированном downstream пайплайне
В результате для структуры из трех компонентов будет создано 9 независимых job, организованных в трех стейджах:
-=ВСЕ ДЖОБЫ ВЫПОЛНЯЮТСЯ ПАРАЛЛЕЛЬНО
validate:
• validate-prod-ru-central1-a-network
• validate-prod-ru-central1-a-k8s-cluster
• validate-prod-global-object-storage
plan (если validate завершился успешно):
• plan-prod-ru-central1-a-network
• plan-prod-ru-central1-a-k8s-cluster
• plan-prod-global-object-storage
Стадия 'apply' (manual для prod или manual для всех apply):
• apply-prod-ru-central1-a-network (manual)
• apply-prod-ru-central1-a-k8s-cluster (manual)
• apply-prod-global-object-storage (manual)
А вот так будет выглядеть сгенерированный gitlab-ci для компонента network:
validate-prod-ru-central1-a-network:
stage: validate
variables:
TG_ROOT: "live/prod/ru-central1-a/network"
ENVIRONMENT: "prod"
YC_FOLDER_ID: "${YC_PROD_FOLDER_ID}"
before_script:
- cd ${TG_ROOT}
script:
- terragrunt init -reconfigure --terragrunt-non-interactive
- terragrunt validate -input=false --terragrunt-non-interactive
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
tags:
- yc
interruptible: true
plan-prod-ru-central1-a-network:
stage: plan
variables:
TG_ROOT: "live/prod/ru-central1-a/network"
ENVIRONMENT: "prod"
YC_FOLDER_ID: "${YC_PROD_FOLDER_ID}"
before_script:
- cd ${TG_ROOT}
script:
- terragrunt init -reconfigure --terragrunt-non-interactive
- terragrunt plan -input=false --terragrunt-non-interactive -out=tfplan
rules:
- if: '$CI_COMMIT_BRANCH == "master"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
tags:
- yc
interruptible: true
artifacts:
paths:
- ${TG_ROOT}/*.plan
when: always
expire_in: 1 week
apply-prod-ru-central1-a-network:
stage: apply
variables:
TG_ROOT: "live/prod/ru-central1-a/network"
ENVIRONMENT: "prod"
YC_FOLDER_ID: "${YC_PROD_FOLDER_ID}"
before_script:
- cd ${TG_ROOT}
script:
- terragrunt init -reconfigure --terragrunt-non-interactive
- terragrunt apply -input=false --terragrunt-non-interactive tfplan
rules:
- if: '$CI_COMMIT_BRANCH == "master"'
when: manual
tags:
- yc
interruptible: true
Такой подход обеспечивает максимальный параллелизм. Например, пока выполняется plan для network, одновременно может идти validate для object-storage. Стадия apply для всего production-окружения блокируется и требует ручного нажатия кнопки деплоя для каждого компонента в отдельности, что дает полный контроль над разверткой.
5.2. Ключевые преимущества и операционные выгоды подхода
Экспоненциальный рост скорости: Вместо последовательного выполнения команд для всех компонентов, они обрабатываются параллельно. Это преобразует время деплоя из линейной зависимости O(n) в константную O(1). Яркий пример — кейс компании Selectel, где внедрение Downstream Pipelines сократило время полного развертывания инфраструктуры с 8 часов до 24 минут [5].
Минимизация «радиуса поражения» (Blast Radius): При использовании единого пайплайна с командой terragrunt run-all ошибка в любом модуле приводит к остановке всего процесса, а откат изменений может быть проблематичным занятием. При гранулярном подходе падение джобы типа apply-prod-ru-central1-a-k8s-cluster никак не повлияет на уже завершенное или еще не начатое развертывание компонентов network или object-storage. Можно точечно исправить ошибку в одном компоненте и перезапустить только его, не затрагивая остальные стабильные части системы [5, 16].
Прозрачность и простой траблшутинг: В интерфейсе GitLab CI/CD каждый компонент представлен отдельной джобой с собственным логом. Это кардинально отличается от гаргантюанского вывода run-all, где логи десятков модулей идут единым потоком. При возникновении проблемы инженер мгновенно видит, в каком именно компоненте и на каком стейдже произошел сбой, и вследствие может посмотреть его лог предметно. [5, 29].
<езопасность и контроль: Правило when: manual для apply в production является не просто рекомендацией, а обязательным guardrail, согласно истоичникам. Оно исключает возможность автоматического применения потенциально разрушительных изменений из-за ошибки в коде или сбоя в CI-системе. Каждое изменение подтвержадется руками, что соответствует принципам GitOps и compliance-требованиям для сред, критичных к аптайму.
Управление зависимостями и порядком выполнения: Важным аспектом, который решается уже на уровне конфигурации Terragrunt, а не самого пайплайна, являются зависимости между компонентами. Например, k8s-cluster зависит от выходных данных network (ID подсетей). В файле terragrunt.hcl компонента k8s-cluster это описывается через блок dependency. Во время выполнения terragrunt apply в сгенерированной джобе Terragrunt автоматически прочитает состояние network из remote стейта, получит необходимые выходные переменные и передаст их в модуль. Таким образом, оркестрация зависимостей и параллелизм не конфликтуют: пайплайн может запустить джобы параллельно, а Terragrunt внутри джобы будет ждать, пока состояние зависимого компонента (network) не станет актуальным [18, 27].
5.3. Статический анализ и валидация
Динамическую генерацию можно расширить для создания предварительных стадий проверки. Например, можно создать отдельную стадию lint, которая для каждого компонента запускает:
terragrunt validate — проверка синтаксиса Terraform.
terragrunt hclfmt --terragrunt-check — проверка соответствия формату.
checkov -d . — статический анализ безопасности на наличие уязвимых конфигураций (например, открытых security groups, незашифрованных дисков) [5, 23].
Эти проверки выполняются за секунды и позволяют «отсеять» очевидные проблемы до запуска длительных операций plan и apply, экономя время и ресурсы.
6. Рефакторинг инфры
Рефакторинг в Terraform долгое время был доовльно рискованной операцией. Изменение идентификатора ресурса в коде (например, с aws_instance.web на aws_instance.server) воспринималось TF как удаление старого объекта и создание нового, что приводило к простоям и потере данных у ресурсов с состоянием (БД, S3-бакеты) [21, 24]. С выходом версии 1.1 Terraform представил блоки moved — декларативный способ управления состоянием прямо в коде, который пришел на замену ручным и опасным командам в CLI [6, 21].
6.1. Деструктивный рефакторинг
Terraform связывает идентификатор ресурса в файлах .tf с уникальным ID в облаке через стейт [16, 21]. Если мы просто переименуем ресурс в коде, Terraform интерпретирует это как удаление ресурса и создание другого. Это приводит к печальным последствиям — удалению работающего инстанса виртуальной машины, базы данных или бакета [16, 21, 24].
6.2. Блок moved
Блок moved сообщает Terraform, что ресурс сменил свое расположение или имя внутри кода. Это позволяет перепривязать существующую запись в стейте к новому имени без негативных последствий на реальную инфраструктуру [6, 21]. Синтаксис блока прост:
hcl
moved {
from = <СТАРЫЙ_АДРЕС_РЕСУРСА>
to = <НОВЫЙ_АДРЕС_РЕСУРСА>
}
6.3. Основные сценарии использования
В руководствах выделяют несколько ключевых сценариев, где блок moved становится незаменимым.
Сценарий 1: Простое переименование ресурса
Наиболее частый случай — приведение имен ресурсов к единому стандарту.
# Было: resource "yandex_compute_instance" "vm" { ... }
# Стало:
resource "yandex_compute_instance" "app_server" {
name = "production-app-01"
platform_id = "standard-v3"
# ... остальная конфигурация
}
moved {
from = yandex_compute_instance.vm
to = yandex_compute_instance.app_server
}
После применения terraform apply в стейте Terraform запись для ВМ будет обновлена, а сама ВМ в облаке останется нетронутой.
Сценарий 2: Перемещение ресурса внутрь модуля
В процессе развития проекта монолитный код разбивается на модули. Блок moved позволяет безопасно инкапсулировать ресурс.
# В корневом main.tf (старая структура)
resource "yandex_vpc_network" "this" {
name = "my-network"
}
# В новом модуле modules/network/main.tf
resource "yandex_vpc_network" "this" {
name = var.network_name
}
# В корневом main.tf (новая структура)
module "network" {
source = "./modules/network"
network_name = "my-network"
}
moved {
from = yandex_vpc_network.this
to = module.network.yandex_vpc_network.this
}
Сценарий 3: Рефакторинг вызова модулей
Можно переименовывать не только ресурсы, но и вызовы самих модулей для улучшения читаемости кода.
moved {
from = module.obscure_name_xyz
to = module.kubernetes_cluster
}
6.4. Разница между moved и terraform state mv?
До появления блоков moved использовали команду terraform state mv. Однако у декларативного подхода есть большие преимущества:
Автоматизация для всей команды: Блок moved является частью кода. Когда разработчик обновляет репозиторий и запускает terraform apply, миграция состояния происходит автоматически. Не нужно запоминать и выполнять последовательность команд вручную [6, 21].
Безопасность и проверка через plan: Изменения стейта отображаются в выводе terraform plan. Вы можете заранее убедиться, что Terraform планирует именно перемещение (в логе будет указано # resource has moved), а не удаление и создание (# resource will be destroyed, # resource will be created) [6, 21].
История изменений в Git: Факт рефакторинга фиксируется в системе контроля версий. Любой член команды, просматривая историю коммитов, увидит, когда и почему ресурс был переименован, что делает эволюцию инфраструктуры полностью прозрачной [21].
6.5. Интеграция рефакторинга в рабочий процесс с Terragrunt
Terragrunt, выступая оркестратором, накладывает строгие ограничения на структуру проекта, что делает рефакторинг более предсказуемым и безопасным [27, 30].
1. Изоляция состояния и минимизация Blast Radius
Как уже было сказано, Terragrunt стимулирует дробление инфраструктуры на мелкие, независимые компоненты, каждый со своим файлом состояния [7, 20, 27].
Преимущество: Опять же, при ошибке в рефакторинге (например, ошибка в блоке moved) затронет состояние только одного компонента. Это предотвращает каскадные сбои во всей инфраструктуре [16, 24, 30].
Пример: При переименовании ресурса в модуле k8s-cluster состояние модулей network и database останутся полностью нетронутыми, что обеспечивает спокойный сон запускающего Terragrunt.
2. Согласованный рефакторинг в нескольких окружениях
При использовании иерархической структуры live/ один и тот же модуль может использоваться в dev, stage и prod. Блок moved, добавленный в исходный код модуля, будет автоматически применен во всех окружениях при их следующем обновлении. Terragrunt позволяет выполнить массовую проверку с помощью terragrunt run-all plan и после аппрува terragrunt run-all apply, обеспечивая консистентность изменений во всех средах [7, 20].
3. Управление зависимостями во время рефакторинга
Если рефакторинг изменяет outputs модуля (например, переименовывает выходную переменную vpc_id в network_id), зависимые модули могут временно сломаться. В Terragrunt можно решить эту проблему через mock_outputs [29].
# В конфигурации модуля приложения, зависящего от network
dependency "network" {
config_path = "../network"
mock_outputs = {
# Предполагаем, что после рефакторинга vpc_id стал называться network_id
network_id = "mock-temp-id"
subnet_ids = ["mock-subnet-a", "mock-subnet-b"]
}
mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}
inputs = {
# Используем новое имя переменной
vpc_id = dependency.network.outputs.network_id
}
Это позволяет выполнить terragrunt plan в модуле приложения до того, как рефакторинг в модуле сети будет применен, и проверить, корректно ли обновлены ссылки на выходные данные.
6.6. Рекомендации по использованию moved
Не удалять блоки moved сразу: В отличие от блоков import, блоки moved рекомендуется оставлять в коде на длительный срок, особенно в публичных модулях. Это позволяет пользователям, которые обновляются нерегулярно, бесшовно мигрировать со старых версий [21].
Использовать модули публичные: Включение блоков moved в публичные и внутренние модули — это золотой стандарт, который обеспечивает обратную совместимость и беспроблемное обновление для консьюмеров модуля [6, 21].
Всегда внимательно проверять план (plan): Перед применением рефакторинга с помощью terraform apply или terragrunt apply обязательно нужно запустить terraform plan. Убедиться, что в выводе присутствует только запись о перемещении ресурсов (# ... has moved to ...) и отсутствуют операции destroy [16, 21].
6.7. Практический пример: комплексный рефакторинг
Рассмотрим сценарий, где необходимо переименовать несколько ресурсов и перенести конфигурацию сети в отдельный модуль.
Исходная конфигурация:
# main.tf (устаревший монолит)
resource "yandex_vpc_network" "main" {
name = "prod-net"
}
resource "yandex_vpc_subnet" "private" {
name = "private-subnet"
v4_cidr_blocks = ["10.0.1.0/24"]
}
resource "yandex_compute_instance" "app" {
name = "app-old-name"
# ... ссылка на subnet: yandex_vpc_subnet.private.id
}
Целевая конфигурация после рефакторинга:
# Модуль: modules/network/main.tf
resource "yandex_vpc_network" "this" {
name = var.name
}
resource "yandex_vpc_subnet" "private" {
name = "${var.name}-private"
v4_cidr_blocks = var.private_subnets
}
# Корневой main.tf
module "network" {
source = "./modules/network"
name = "prod-network"
private_subnets = ["10.0.1.0/24"]
}
resource "yandex_compute_instance" "application_server" {
name = "prod-app-01"
# ... ссылка теперь на выход модуля: module.network.private_subnet_id
}
Блоки moved для безопасного перехода:
# В корневом main.tf, рядом с вызовом модуля
moved {
from = yandex_vpc_network.main
to = module.network.yandex_vpc_network.this
}
moved {
from = yandex_vpc_subnet.private
to = module.network.yandex_vpc_subnet.private
}
moved {
from = yandex_compute_instance.app
to = yandex_compute_instance.application_server
}
После выполнения terraform apply все существующие ресурсы будут корректно перенесены в стейт без их пересоздания в облаке.