Terraform для GCP

Почему инфраструктуру в облаке пишут кодом, а не собирают мышкой, — и как выглядит первый рабочий проект Terraform для GCP.

Terraform — инструмент «инфраструктура как код» (IaC, Infrastructure as Code): вы описываете желаемое состояние облака в текстовых файлах, а Terraform сам вычисляет, что нужно создать, изменить или удалить, чтобы реальность совпала с описанием.

Зачем это на практике: три беды кликов

Пока проект маленький, консоль Google Cloud кажется удобной: нажал «Создать бакет», выбрал регион, поставил галочку — готово. Проблемы начинаются позже, и всегда одни и те же.

Беда первая — невоспроизводимость. Через полгода вам нужен второй такой же контур (staging рядом с prod). Вы открываете консоль и понимаете, что не помните: какой класс хранилища был у бакета? включён ли uniform access? сколько инстансов у Cloud Run минимум? Приходится сравнивать вручную, и staging всё равно получается «почти таким же». А «почти такой же» стенд — это стенд, на котором баг не воспроизводится.

Беда вторая — нет ревью и истории. Коллега открыл настройки сервиса, поменял переменную окружения, ушёл в отпуск. Прод лёг. Кто менял? Что именно? Audit Logs, конечно, ответят — но это расследование, а не защита. Код в git даёт и то и другое: изменение приходит пул-реквестом, его читают до применения.

Беда третья — дрейф. Реальная конфигурация постепенно расходится с тем, что все думают о ней. Terraform умеет этот дрейф ловить: он сравнивает описание с фактом и показывает разницу.

Terraform бесплатен (open source), платите вы только за сами ресурсы, которые он создаёт. То есть это редкий случай, когда инструмент ничего не стоит, а экономит — много: главным образом на «забытых» ресурсах, о которых речь ниже.

Из чего состоит проект Terraform

Проект — это просто папка с файлами .tf. Terraform читает их все скопом, порядок и имена файлов роли не играют, поэтому раскладку выбирают по смыслу:

ФайлЧто обычно внутри
main.tfнастройки Terraform, провайдер, ресурсы
variables.tfвходные параметры (project_id, регион, размер машины)
outputs.tfчто вернуть наружу: URL сервиса, имя бакета
terraform.tfvarsзначения переменных для конкретного контура

Язык называется HCL. Он декларативный: вы не пишете «сначала создай бакет, потом сервис», вы описываете, что должно существовать.

Провайдер google и аутентификация

Провайдер — плагин, который знает, как разговаривать с API конкретного облака. Для GCP это hashicorp/google. Блок terraform фиксирует его версию, чтобы у вас и у коллеги подтянулся один и тот же плагин.

terraform {
  required_version = ">= 1.5"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"
    }
  }

  backend "gcs" {
    bucket = "tfstate-my-shop"
    prefix = "prod"
  }
}

provider "google" {
  project = var.project_id
  region  = "europe-west1"
}

Откуда провайдер берёт доступ к вашему проекту? Из ADC — Application Default Credentials. Локально их выдаёт одна команда:

gcloud auth application-default login

# проверяем, что Terraform видит нужный проект
gcloud config get-value project

В CI логиниться человеком нельзя. Там используют сервисный аккаунт: либо Workload Identity Federation (без файла ключа — правильный путь), либо, если совсем никак, JSON-ключ в секрете. Скачанный ключ сервисного аккаунта в репозитории — классическая утечка, за которую потом платят чужие майнеры.

Ресурсы и ссылки между ними

Каждый ресурс — блок resource "тип" "локальное_имя". Локальное имя нужно только внутри Terraform, чтобы ссылаться на ресурс из других мест.

variable "project_id" {
  type        = string
  description = "ID проекта GCP"
}

resource "google_storage_bucket" "uploads" {
  name                        = "${var.project_id}-uploads"
  location                    = "EUROPE-WEST1"
  uniform_bucket_level_access = true
  force_destroy               = false

  lifecycle_rule {
    condition {
      age = 30
    }
    action {
      type = "Delete"
    }
  }
}

resource "google_cloud_run_v2_service" "api" {
  name     = "shop-api"
  location = "europe-west1"

  template {
    containers {
      image = "europe-west1-docker.pkg.dev/${var.project_id}/apps/shop-api:latest"

      env {
        name  = "UPLOADS_BUCKET"
        value = google_storage_bucket.uploads.name
      }
    }
  }
}

output "bucket_name" {
  value = google_storage_bucket.uploads.name
}

Обратите внимание на строку value = google_storage_bucket.uploads.name. Мы не вписали имя бакета руками — мы сослались на ресурс. Из таких ссылок Terraform строит граф зависимостей и сам понимает: бакет надо создать раньше сервиса. Никаких «сначала», «потом» писать не нужно.

State: файл, который нельзя потерять

State — файл, в котором Terraform хранит соответствие «мой блок google_storage_bucket.uploads = вот этот реальный бакет в облаке».

Без state Terraform не отличил бы «создать новый бакет» от «этот бакет я уже создавал, надо его изменить». Отсюда два железных правила.

Правило первое: state не хранится в git. В нём лежат plain-text значения — в том числе пароли к базе, если вы их создавали Terraform'ом. Плюс два человека, работающие с локальными копиями state, гарантированно передерутся.

Правило второе: state лежит в удалённом backend. Для GCP это бакет GCS — блок backend "gcs" из примера выше. Бонусом GCS даёт блокировку: пока один apply идёт, второй не стартует. Бакету со state обязательно включают версионирование — это ваша страховка.

gsutil mb -l europe-west1 gs://tfstate-my-shop
gsutil versioning set on gs://tfstate-my-shop

Цикл работы: init, plan, apply

# 1. скачать провайдер и подключить backend (один раз на машине)
terraform init

# 2. посмотреть, что будет сделано, и сохранить план в файл
terraform plan -out=tfplan

# 3. применить ровно этот план
terraform apply tfplan

# 4. снести всё, что описано в этой папке (осторожно!)
terraform destroy

terraform plan — самая ценная команда во всём инструменте. Это сухой прогон: ничего не меняется, но вы видите будущее.

Результат:

Terraform will perform the following actions:

  # google_storage_bucket.uploads will be created
  + resource "google_storage_bucket" "uploads" {
      + name     = "my-shop-uploads"
      + location = "EUROPE-WEST1"
    }

  # google_cloud_run_v2_service.api will be created
  + resource "google_cloud_run_v2_service" "api" {
      + name     = "shop-api"
      + location = "europe-west1"
    }

Plan: 2 to add, 0 to change, 0 to destroy.

Читать план нужно с конца — со строки Plan: X to add, Y to change, Z to destroy. Если вы правили одну переменную окружения, а Terraform собирается что-то уничтожить — стоп, не подтверждайте. Разберитесь, что именно.

Символы в плане: + создать, - удалить, ~ изменить на месте, а самый опасный — -/+, «удалить и создать заново» (в выводе это подписано как forces replacement).

Как это работает

Под капотом каждый plan проходит три шага.

  1. Refresh. Terraform берёт state и по каждому ресурсу дёргает GET-запрос к API Google: «а ты вообще ещё существуешь, и какой ты сейчас?». Так он видит дрейф — например, что кто-то руками поменял регион.
  2. Diff. Сравниваются три вещи: ваш код (желаемое), state (что Terraform создавал) и ответ API (что есть на самом деле). Разница и есть план.
  3. Apply. Terraform обходит граф зависимостей и вызывает CREATE/UPDATE/DELETE в API. Независимые ресурсы создаются параллельно (по умолчанию до 10 штук одновременно).

Ключевая деталь — ForceNew-атрибуты. У части полей нет операции «изменить» в самом API Google: нельзя переименовать бакет или сменить регион Cloud SQL. Если вы правите такое поле, у Terraform остаётся ровно один способ добиться описанного состояния — снести ресурс и создать новый. Для бакета это потеря файлов, для базы — потеря данных. Именно поэтому план читают глазами, а не жмут «да» на автомате.

Отсюда же вытекает идемпотентность: запустите apply десять раз подряд без изменений в коде — девять раз он честно скажет No changes. Terraform применяет не «действия», а разницу.

Частые ошибки

  • Забыть terraform destroy на тестовом стенде — и платить. Самая дорогая ошибка новичка. Подняли Cloud SQL «на посмотреть», закрыли ноутбук, вспомнили через месяц. Инстанс базы тикает круглосуточно, даже если в него никто не ходит. Правило: тестовый контур поднимается и сносится одной командой, а на биллинге стоит бюджетный алерт.
  • terraform apply -auto-approve где попало. В CI это допустимо только после того, как план был сохранён в файл и просмотрен человеком в пул-реквесте. «Автоапрув» на ветке разработчика однажды снесёт прод-базу, и это будет обидно.
  • state в git. Секреты утекут, история конфликтов замучает. Сразу заводите backend в GCS.
  • Правки мышкой поверх Terraform. Один раз «быстренько поправил в консоли» — и следующий apply молча вернёт всё обратно. Либо всё через код, либо ресурс явно исключён из Terraform.
  • Нет deletion_protection на важных ресурсах. Для Cloud SQL и продовых Cloud Run это дешёвая страховка от опечатки в имени ресурса.
  • Один общий state на всё. Прод и dev в одном state — значит, любой эксперимент требует плана по проду. Разделяйте контуры хотя бы разными prefix в backend.

Итоги

  • Terraform описывает желаемое состояние облака; что делать для его достижения, он решает сам.
  • Провайдер hashicorp/google + ADC (gcloud auth application-default login) — минимум для старта.
  • Ссылки между ресурсами (google_storage_bucket.uploads.name) автоматически строят граф зависимостей.
  • State — карта «код ↔ реальность». Только в GCS, только с версионированием, никогда в git.
  • plan перед apply — обязательный ритуал. Ищите глазами destroy и forces replacement.
  • Забытый destroy на тестовом стенде — самая частая строчка в неожиданном счёте от Google.
Проверьте себя
1. Что показывает terraform plan?
AСписок изменений, которые будут применены, но ничего не меняет в облаке
BСтоимость ресурсов в рублях за месяц
CЛоги уже созданных ресурсов
DОн сразу создаёт ресурсы, просто выводит их список
2. Где правильно хранить файл state в проекте на GCP?
AВ git-репозитории рядом с .tf файлами
BВ удалённом backend — бакете GCS с включённым версионированием
CНа локальном ноутбуке каждого разработчика
DВ переменных окружения CI