feat: Add Terraform infrastructure for pulse project

- Add modules: yc-s3, yc-postgresql, k8s-namespace, k8s-secrets
- Add live configuration for stage environment
- Add GitLab CI with downstream pipelines
- Implement best practices from theory.md
This commit is contained in:
kochetkov.s 2026-01-19 15:15:14 +03:00
commit cd68e73e40
27 changed files with 2031 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Terraform
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
*.tfplan
*.tfplan.*
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc
# Terragrunt
.terragrunt-cache/
terragrunt.hcl.backup
# Generated files
.gitlab-ci.generated.yml
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

40
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,40 @@
# Основной GitLab CI пайплайн для Terraform инфраструктуры
# Использует downstream pipelines для динамической генерации джоб
stages:
- generate
- trigger
# Генерация динамического пайплайна на основе структуры live/
generate-pipeline:
stage: generate
image: alpine:latest
before_script:
- apk add --no-cache bash findutils
script:
- ./scripts/generate-pipeline.sh
artifacts:
paths:
- .gitlab-ci.generated.yml
expire_in: 1 hour
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_BRANCH =~ /^feature\/.*/'
tags:
- yc
# Запуск downstream пайплайна с сгенерированной конфигурацией
trigger-downstream:
stage: trigger
trigger:
include:
- artifact: .gitlab-ci.generated.yml
job: generate-pipeline
strategy: depend
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_BRANCH =~ /^feature\/.*/'
tags:
- yc

130
README.md Normal file
View File

@ -0,0 +1,130 @@
# Terraform Infrastructure for Pulse Project
Этот репозиторий содержит инфраструктуру как код (IaC) для проекта Pulse, развернутого в Yandex Cloud.
## Структура репозитория
```
.
├── modules/ # Переиспользуемые Terraform модули
│ ├── yc-s3/ # Модуль для создания S3 бакета
│ ├── yc-postgresql/ # Модуль для создания PostgreSQL кластера
│ ├── k8s-namespace/ # Модуль для создания Kubernetes namespace
│ └── k8s-secrets/ # Модуль для создания Kubernetes секретов
├── live/ # Живая инфраструктура
│ ├── terragrunt.hcl # Глобальная конфигурация Terragrunt
│ ├── stage/ # Окружение stage
│ │ ├── env.hcl # Конфигурация окружения
│ │ ├── namespace/ # Namespace pulse
│ │ ├── s3/ # S3 бакет pulse
│ │ ├── postgresql/ # PostgreSQL база данных
│ │ └── secrets/ # Kubernetes секреты
│ ├── prod/ # Окружение production
│ └── preprod/ # Окружение preprod
├── scripts/ # Вспомогательные скрипты
│ └── generate-pipeline.sh # Генератор GitLab CI пайплайна
└── .gitlab-ci.yml # Основной GitLab CI конфигурация
```
## Требования
- Terraform >= 1.0
- Terragrunt >= 0.50.0
- Доступ к Yandex Cloud
- Доступ к Kubernetes кластеру
## Переменные окружения
Для работы с инфраструктурой необходимо установить следующие переменные окружения:
### Yandex Cloud
- `YC_TOKEN` - токен для доступа к Yandex Cloud API
- `YC_CLOUD_ID` - ID облака
- `YC_FOLDER_ID` - ID каталога (или `YC_STAGE_FOLDER_ID`, `YC_PROD_FOLDER_ID`, `YC_PREPROD_FOLDER_ID`)
### Terraform State
- `TF_STATE_BUCKET` - имя S3 бакета для хранения состояния Terraform
- `TF_STATE_DYNAMODB_ENDPOINT` - endpoint YDB для блокировок состояния
- `TF_STATE_DYNAMODB_TABLE` - имя таблицы YDB для блокировок
### Kubernetes
- `KUBECONFIG` - путь к конфигурации Kubernetes (по умолчанию `~/.kube/config`)
- `KUBE_CONTEXT` - контекст Kubernetes (опционально)
### Docker Registry
- `DOCKER_REGISTRY_URL` - URL Docker registry (по умолчанию `cr.yandex`)
- `DOCKER_REGISTRY_USERNAME` - имя пользователя Docker registry
- `DOCKER_REGISTRY_PASSWORD` - пароль или токен Docker registry
### Сеть
- `YC_NETWORK_ID` - ID сети для PostgreSQL
- `YC_SUBNET_ID` - ID подсети для PostgreSQL
## Использование
### Локальная разработка
1. Перейдите в директорию нужного компонента:
```bash
cd live/stage/namespace
```
2. Инициализируйте Terragrunt:
```bash
terragrunt init
```
3. Просмотрите план изменений:
```bash
terragrunt plan
```
4. Примените изменения:
```bash
terragrunt apply
```
### Развертывание всех компонентов
Для развертывания всех компонентов окружения используйте:
```bash
cd live/stage
terragrunt run-all apply
```
## GitLab CI
Проект использует динамическую генерацию GitLab CI пайплайнов через downstream pipelines. При каждом коммите:
1. Запускается джоба `generate-pipeline`, которая сканирует структуру `live/` и создает джобы для каждого компонента
2. Запускается downstream пайплайн с сгенерированными джобами
Для каждого компонента создаются три джобы:
- `validate-{env}-{component}` - валидация конфигурации
- `plan-{env}-{component}` - планирование изменений
- `apply-{env}-{component}` - применение изменений (manual для prod)
## Созданные ресурсы
### Stage окружение
- **Namespace**: `pulse` в Kubernetes
- **S3 бакет**: `pulse` с публичным доступом
- **PostgreSQL**: кластер `pulse-pg-stage` с базой `pulse_db` и пользователем `pulse`
- **Секреты Kubernetes**:
- `dockerhub` - доступ к Yandex Container Registry
- `pulse-s3-secret` - ключи доступа к S3
- `pulse-postgresql-secret` - параметры подключения к PostgreSQL
## Безопасность
⚠️ **Важно**: Все секреты (пароли, ключи доступа) хранятся в Terraform state в открытом виде. Убедитесь, что:
- Бэкенд для state зашифрован (AES-256)
- Доступ к state строго ограничен
- Используется блокировка состояния через YDB
## Лицензия
Внутренний проект компании.

12
live/stage/env.hcl Normal file
View File

@ -0,0 +1,12 @@
# Конфигурация для окружения stage
locals {
environment = "stage"
folder_id = get_env("YC_STAGE_FOLDER_ID", "")
# Общие параметры для всех компонентов в stage
common_tags = {
Environment = "stage"
Project = "pulse"
ManagedBy = "terraform"
}
}

View File

@ -0,0 +1,28 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//k8s-namespace"
}
# Входные переменные
inputs = {
namespace_name = "pulse"
labels = {
environment = local.environment
project = "pulse"
}
annotations = {
"managed-by" = "terraform"
}
}

View File

@ -0,0 +1,33 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//yc-postgresql"
}
# Входные переменные
inputs = {
cluster_name = "pulse-pg-${local.environment}"
folder_id = local.folder_id
network_id = get_env("YC_NETWORK_ID", "")
subnet_id = get_env("YC_SUBNET_ID", "")
zone = "ru-central1-a"
environment = "PRODUCTION"
postgresql_version = "15"
resource_preset_id = "s2.micro"
disk_type_id = "network-ssd"
disk_size = 10
database_name = "pulse_db"
database_user = "pulse"
deletion_protection = false
}

View File

@ -0,0 +1,23 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//yc-s3"
}
# Входные переменные
inputs = {
bucket_name = "pulse"
folder_id = local.folder_id
versioning_enabled = false
}

View File

@ -0,0 +1,72 @@
# Включение корневой конфигурации
include "root" {
path = find_in_parent_folders()
}
# Включение конфигурации окружения
include "env" {
path = find_in_parent_folders("env.hcl")
expose = true
merge_strategy = "deep"
}
# Зависимости от других компонентов
dependency "namespace" {
config_path = "../namespace"
mock_outputs = {
name = "pulse"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
dependency "s3" {
config_path = "../s3"
mock_outputs = {
bucket_name = "pulse"
access_key = "mock-access-key"
secret_key = "mock-secret-key"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
dependency "postgresql" {
config_path = "../postgresql"
mock_outputs = {
host = "mock-host.example.com"
database_name = "pulse_db"
database_user = "pulse"
password = "mock-password"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
# Путь к модулю
terraform {
source = "${get_parent_terragrunt_dir()}/../../modules//k8s-secrets"
}
# Входные переменные
inputs = {
namespace = dependency.namespace.outputs.name
# Docker Hub (Yandex Registry) credentials
docker_registry_url = get_env("DOCKER_REGISTRY_URL", "cr.yandex")
docker_registry_username = get_env("DOCKER_REGISTRY_USERNAME", "")
docker_registry_password = get_env("DOCKER_REGISTRY_PASSWORD", "")
# S3 credentials from dependency
s3_access_key = dependency.s3.outputs.access_key
s3_secret_key = dependency.s3.outputs.secret_key
s3_bucket_name = dependency.s3.outputs.bucket_name
s3_endpoint = "https://storage.yandexcloud.net"
# PostgreSQL credentials from dependency
db_host = dependency.postgresql.outputs.host
db_port = dependency.postgresql.outputs.port
db_name = dependency.postgresql.outputs.database_name
db_user = dependency.postgresql.outputs.database_user
db_password = dependency.postgresql.outputs.password
}

63
live/terragrunt.hcl Normal file
View File

@ -0,0 +1,63 @@
# Глобальная конфигурация Terragrunt
# Настройка удаленного бэкенда для хранения состояния
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
endpoint = "storage.yandexcloud.net"
bucket = get_env("TF_STATE_BUCKET", "terraform-state-pulse")
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 = 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"
contents = <<EOF
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 = get_env("YC_TOKEN", "")
cloud_id = get_env("YC_CLOUD_ID", "")
folder_id = get_env("YC_FOLDER_ID", "")
zone = "ru-central1-a"
}
provider "kubernetes" {
config_path = get_env("KUBECONFIG", "~/.kube/config")
config_context = get_env("KUBE_CONTEXT", "")
}
EOF
}

View File

@ -0,0 +1,13 @@
# Создание namespace в Kubernetes
resource "kubernetes_namespace" "pulse" {
metadata {
name = var.namespace_name
labels = merge(
{
name = var.namespace_name
},
var.labels
)
annotations = var.annotations
}
}

View File

@ -0,0 +1,9 @@
output "name" {
description = "Name of the created namespace"
value = kubernetes_namespace.pulse.metadata[0].name
}
output "id" {
description = "ID of the created namespace"
value = kubernetes_namespace.pulse.id
}

View File

@ -0,0 +1,16 @@
variable "namespace_name" {
description = "Name of the Kubernetes namespace"
type = string
}
variable "labels" {
description = "Labels for the namespace"
type = map(string)
default = {}
}
variable "annotations" {
description = "Annotations for the namespace"
type = map(string)
default = {}
}

View File

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

View File

@ -0,0 +1,56 @@
# Секрет для доступа к Docker Hub (Yandex Registry)
resource "kubernetes_secret" "dockerhub" {
metadata {
name = "dockerhub"
namespace = var.namespace
}
type = "kubernetes.io/dockerconfigjson"
data = {
".dockerconfigjson" = jsonencode({
auths = {
"${var.docker_registry_url}" = {
username = var.docker_registry_username
password = var.docker_registry_password
auth = base64encode("${var.docker_registry_username}:${var.docker_registry_password}")
}
}
})
}
}
# Секрет для доступа к S3
resource "kubernetes_secret" "pulse_s3" {
metadata {
name = "pulse-s3-secret"
namespace = var.namespace
}
type = "Opaque"
data = {
access_key = base64encode(var.s3_access_key)
secret_key = base64encode(var.s3_secret_key)
bucket = base64encode(var.s3_bucket_name)
endpoint = base64encode(var.s3_endpoint)
}
}
# Секрет для доступа к PostgreSQL
resource "kubernetes_secret" "pulse_postgresql" {
metadata {
name = "pulse-postgresql-secret"
namespace = var.namespace
}
type = "Opaque"
data = {
host = base64encode(var.db_host)
port = base64encode(tostring(var.db_port))
database = base64encode(var.db_name)
user = base64encode(var.db_user)
password = base64encode(var.db_password)
}
}

View File

@ -0,0 +1,14 @@
output "dockerhub_secret_name" {
description = "Name of the dockerhub secret"
value = kubernetes_secret.dockerhub.metadata[0].name
}
output "s3_secret_name" {
description = "Name of the S3 secret"
value = kubernetes_secret.pulse_s3.metadata[0].name
}
output "postgresql_secret_name" {
description = "Name of the PostgreSQL secret"
value = kubernetes_secret.pulse_postgresql.metadata[0].name
}

View File

@ -0,0 +1,71 @@
variable "namespace" {
description = "Kubernetes namespace where secrets will be created"
type = string
}
variable "docker_registry_url" {
description = "Docker registry URL (e.g., cr.yandex)"
type = string
default = "cr.yandex"
}
variable "docker_registry_username" {
description = "Docker registry username"
type = string
}
variable "docker_registry_password" {
description = "Docker registry password or token"
type = string
sensitive = true
}
variable "s3_access_key" {
description = "S3 access key"
type = string
sensitive = true
}
variable "s3_secret_key" {
description = "S3 secret key"
type = string
sensitive = true
}
variable "s3_bucket_name" {
description = "S3 bucket name"
type = string
}
variable "s3_endpoint" {
description = "S3 endpoint URL"
type = string
default = "https://storage.yandexcloud.net"
}
variable "db_host" {
description = "PostgreSQL host"
type = string
}
variable "db_port" {
description = "PostgreSQL port"
type = number
default = 6432
}
variable "db_name" {
description = "PostgreSQL database name"
type = string
}
variable "db_user" {
description = "PostgreSQL user"
type = string
}
variable "db_password" {
description = "PostgreSQL password"
type = string
sensitive = true
}

View File

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

View File

@ -0,0 +1,55 @@
# Генерация пароля для PostgreSQL (32 символа без специальных)
resource "random_password" "pg_password" {
length = 32
special = false
upper = true
lower = true
numeric = true
}
# Создание кластера PostgreSQL
resource "yandex_mdb_postgresql_cluster" "pulse" {
name = var.cluster_name
description = "PostgreSQL cluster for pulse project"
environment = var.environment
network_id = var.network_id
folder_id = var.folder_id
config {
version = var.postgresql_version
resources {
resource_preset_id = var.resource_preset_id
disk_type_id = var.disk_type_id
disk_size = var.disk_size
}
postgresql_config = var.postgresql_config
}
database {
name = var.database_name
owner = var.database_user
}
user {
name = var.database_user
password = random_password.pg_password.result
permission {
database_name = var.database_name
}
}
host {
zone = var.zone
subnet_id = var.subnet_id
}
maintenance_window {
type = var.maintenance_window_type
day = var.maintenance_window_day
hour = var.maintenance_window_hour
}
deletion_protection = var.deletion_protection
}

View File

@ -0,0 +1,35 @@
output "cluster_id" {
description = "ID of the PostgreSQL cluster"
value = yandex_mdb_postgresql_cluster.pulse.id
}
output "cluster_fqdn" {
description = "FQDN of the PostgreSQL cluster"
value = yandex_mdb_postgresql_cluster.pulse.host[0].fqdn
}
output "host" {
description = "Host address of the PostgreSQL cluster"
value = yandex_mdb_postgresql_cluster.pulse.host[0].fqdn
}
output "database_name" {
description = "Name of the database"
value = var.database_name
}
output "database_user" {
description = "Name of the database user"
value = var.database_user
}
output "password" {
description = "Password for the database user"
value = random_password.pg_password.result
sensitive = true
}
output "port" {
description = "Port of the PostgreSQL cluster"
value = 6432
}

View File

@ -0,0 +1,97 @@
variable "cluster_name" {
description = "Name of the PostgreSQL cluster"
type = string
}
variable "folder_id" {
description = "Yandex Cloud folder ID"
type = string
}
variable "network_id" {
description = "Network ID for the PostgreSQL cluster"
type = string
}
variable "subnet_id" {
description = "Subnet ID for the PostgreSQL cluster"
type = string
}
variable "zone" {
description = "Availability zone"
type = string
default = "ru-central1-a"
}
variable "environment" {
description = "Environment (PRODUCTION, PRESTABLE)"
type = string
default = "PRODUCTION"
}
variable "postgresql_version" {
description = "PostgreSQL version"
type = string
default = "15"
}
variable "resource_preset_id" {
description = "Resource preset ID"
type = string
default = "s2.micro"
}
variable "disk_type_id" {
description = "Disk type ID"
type = string
default = "network-ssd"
}
variable "disk_size" {
description = "Disk size in GB"
type = number
default = 10
}
variable "database_name" {
description = "Name of the database"
type = string
default = "pulse_db"
}
variable "database_user" {
description = "Name of the database user"
type = string
default = "pulse"
}
variable "postgresql_config" {
description = "PostgreSQL configuration"
type = map(any)
default = {}
}
variable "maintenance_window_type" {
description = "Maintenance window type"
type = string
default = "WEEKLY"
}
variable "maintenance_window_day" {
description = "Maintenance window day"
type = string
default = "SAT"
}
variable "maintenance_window_hour" {
description = "Maintenance window hour"
type = number
default = 3
}
variable "deletion_protection" {
description = "Enable deletion protection"
type = bool
default = false
}

View File

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

44
modules/yc-s3/main.tf Normal file
View File

@ -0,0 +1,44 @@
# Создание сервисного аккаунта для S3
resource "yandex_iam_service_account" "sa" {
name = "${var.bucket_name}-sa"
description = "Service account for ${var.bucket_name} bucket"
}
# Назначение роли storage.editor сервисному аккаунту
resource "yandex_resourcemanager_folder_iam_member" "storage_editor" {
folder_id = var.folder_id
role = "storage.editor"
member = "serviceAccount:${yandex_iam_service_account.sa.id}"
}
# Создание статического ключа доступа
resource "yandex_iam_service_account_static_access_key" "sa_key" {
service_account_id = yandex_iam_service_account.sa.id
description = "Static access key for ${var.bucket_name} bucket"
}
# Создание S3 бакета с публичным доступом
resource "yandex_storage_bucket" "pulse" {
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 = "public-read"
# Настройка CORS для внешнего доступа
cors {
allowed_headers = ["*"]
allowed_methods = ["GET", "PUT", "POST", "DELETE", "HEAD"]
allowed_origins = ["*"]
expose_headers = ["ETag"]
max_age_seconds = 3600
}
# Версионирование объектов
versioning {
enabled = var.versioning_enabled
}
depends_on = [yandex_resourcemanager_folder_iam_member.storage_editor]
}

21
modules/yc-s3/outputs.tf Normal file
View File

@ -0,0 +1,21 @@
output "bucket_name" {
description = "Name of the created bucket"
value = yandex_storage_bucket.pulse.bucket
}
output "access_key" {
description = "Access key for S3 bucket"
value = yandex_iam_service_account_static_access_key.sa_key.access_key
sensitive = true
}
output "secret_key" {
description = "Secret key for S3 bucket"
value = yandex_iam_service_account_static_access_key.sa_key.secret_key
sensitive = true
}
output "service_account_id" {
description = "ID of the service account"
value = yandex_iam_service_account.sa.id
}

View File

@ -0,0 +1,15 @@
variable "bucket_name" {
description = "Name of the S3 bucket"
type = string
}
variable "folder_id" {
description = "Yandex Cloud folder ID"
type = string
}
variable "versioning_enabled" {
description = "Enable versioning for the bucket"
type = bool
default = false
}

10
modules/yc-s3/versions.tf Normal file
View File

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

182
scripts/generate-pipeline.sh Executable file
View File

@ -0,0 +1,182 @@
#!/bin/bash
set -e
# Скрипт для динамической генерации GitLab CI пайплайна
# Сканирует структуру live/ и создает джобы для каждого компонента
OUTPUT_FILE=".gitlab-ci.generated.yml"
# Функция для экранирования YAML
yaml_escape() {
echo "$1" | sed 's/"/\\"/g'
}
# Начало файла
cat > "$OUTPUT_FILE" << 'EOF'
# Автоматически сгенерированный файл GitLab CI
# НЕ РЕДАКТИРУЙТЕ ВРУЧНУЮ! Этот файл генерируется скриптом scripts/generate-pipeline.sh
stages:
- validate
- plan
- apply
EOF
# Поиск всех terragrunt.hcl файлов в live/
find live -name "terragrunt.hcl" -not -path "*/.terragrunt-cache/*" | sort | while read -r config_file; do
# Извлечение пути компонента
component_dir=$(dirname "$config_file")
relative_path=$(echo "$component_dir" | sed 's|^live/||')
# Парсинг окружения и компонента из пути
# Формат: live/{env}/{component}/terragrunt.hcl
env=$(echo "$relative_path" | cut -d'/' -f1)
component=$(echo "$relative_path" | cut -d'/' -f2)
# Пропускаем если это не компонент (например, env.hcl)
if [ -z "$component" ] || [ "$component" = "$env" ]; then
continue
fi
# Формирование имени джобы
job_prefix="${env}-${component}"
# Определение переменных окружения для Yandex Cloud
case "$env" in
stage)
folder_var="YC_STAGE_FOLDER_ID"
;;
prod)
folder_var="YC_PROD_FOLDER_ID"
;;
preprod)
folder_var="YC_PREPROD_FOLDER_ID"
;;
*)
folder_var="YC_FOLDER_ID"
;;
esac
# Джоба validate
cat >> "$OUTPUT_FILE" << EOF
validate-${job_prefix}:
stage: validate
variables:
TG_ROOT: "${component_dir}"
ENVIRONMENT: "${env}"
YC_FOLDER_ID: "\${${folder_var}}"
before_script:
- apk add --no-cache curl unzip
- |
if [ ! -f /usr/local/bin/terragrunt ]; then
TERRAFORM_VERSION=1.6.0
TERRAGRUNT_VERSION=0.50.0
curl -fsSL https://releases.hashicorp.com/terraform/\${TERRAFORM_VERSION}/terraform_\${TERRAFORM_VERSION}_linux_amd64.zip -o terraform.zip
unzip terraform.zip -d /usr/local/bin/
chmod +x /usr/local/bin/terraform
curl -fsSL https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt
chmod +x /usr/local/bin/terragrunt
rm -f terraform.zip
fi
- cd \${TG_ROOT}
script:
- terragrunt init -reconfigure -input=false --terragrunt-non-interactive
- terragrunt validate-inputs --terragrunt-non-interactive
- terragrunt validate --terragrunt-non-interactive
rules:
- if: '\$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '\$CI_COMMIT_BRANCH == "master" || \$CI_COMMIT_BRANCH == "main"'
tags:
- yc
interruptible: true
EOF
# Джоба plan
cat >> "$OUTPUT_FILE" << EOF
plan-${job_prefix}:
stage: plan
variables:
TG_ROOT: "${component_dir}"
ENVIRONMENT: "${env}"
YC_FOLDER_ID: "\${${folder_var}}"
before_script:
- apk add --no-cache curl unzip
- |
if [ ! -f /usr/local/bin/terragrunt ]; then
TERRAFORM_VERSION=1.6.0
TERRAGRUNT_VERSION=0.50.0
curl -fsSL https://releases.hashicorp.com/terraform/\${TERRAFORM_VERSION}/terraform_\${TERRAFORM_VERSION}_linux_amd64.zip -o terraform.zip
unzip terraform.zip -d /usr/local/bin/
chmod +x /usr/local/bin/terraform
curl -fsSL https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt
chmod +x /usr/local/bin/terragrunt
rm -f terraform.zip
fi
- cd \${TG_ROOT}
script:
- terragrunt init -reconfigure -input=false --terragrunt-non-interactive
- terragrunt plan -input=false --terragrunt-non-interactive -out=tfplan
rules:
- if: '\$CI_COMMIT_BRANCH == "master" || \$CI_COMMIT_BRANCH == "main"'
- if: '\$CI_PIPELINE_SOURCE == "merge_request_event"'
tags:
- yc
interruptible: true
artifacts:
paths:
- "\${TG_ROOT}/tfplan"
- "\${TG_ROOT}/.terragrunt-cache/**/*"
expire_in: 1 week
when: always
EOF
# Джоба apply (manual для prod, автоматическая для остальных)
if [ "$env" = "prod" ]; then
when_clause="manual"
else
when_clause="on_success"
fi
cat >> "$OUTPUT_FILE" << EOF
apply-${job_prefix}:
stage: apply
variables:
TG_ROOT: "${component_dir}"
ENVIRONMENT: "${env}"
YC_FOLDER_ID: "\${${folder_var}}"
before_script:
- apk add --no-cache curl unzip
- |
if [ ! -f /usr/local/bin/terragrunt ]; then
TERRAFORM_VERSION=1.6.0
TERRAGRUNT_VERSION=0.50.0
curl -fsSL https://releases.hashicorp.com/terraform/\${TERRAFORM_VERSION}/terraform_\${TERRAFORM_VERSION}_linux_amd64.zip -o terraform.zip
unzip terraform.zip -d /usr/local/bin/
chmod +x /usr/local/bin/terraform
curl -fsSL https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt
chmod +x /usr/local/bin/terragrunt
rm -f terraform.zip
fi
- cd \${TG_ROOT}
script:
- terragrunt init -reconfigure -input=false --terragrunt-non-interactive
- terragrunt apply -input=false --terragrunt-non-interactive tfplan
rules:
- if: '\$CI_COMMIT_BRANCH == "master" || \$CI_COMMIT_BRANCH == "main"'
when: ${when_clause}
tags:
- yc
interruptible: true
dependencies:
- plan-${job_prefix}
EOF
done
echo "Pipeline generated successfully: $OUTPUT_FILE"
echo "Found components:"
find live -name "terragrunt.hcl" -not -path "*/.terragrunt-cache/*" | sed 's|live/||; s|/terragrunt.hcl||' | sort

925
theory.md Normal file
View File

@ -0,0 +1,925 @@
Архитектурные паттерны 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 все существующие ресурсы будут корректно перенесены в стейт без их пересоздания в облаке.