Пайплайны IaC: plan на PR, policy-as-code и дрейф
Безопасный запуск IaC — это пайплайн, а не команда: plan на PR (дифф и есть ревью), apply под env-гейтом, беcключевая OIDC-аутентификация, policy-as-code для блокировки плохой инфры до apply, плюс плановое обнаружение дрейфа и блокировка состояния.
В 02:00 дежурный инженер гасит инцидент самым быстрым известным ему способом: открывает консоль EC2 и расширяет security group, чтобы новый IP партнёра достучался до API. Алерт гаснет, все спят. Через три дня вливается несвязанный PR, деплой-пайплайн запускает terraform apply, и Terraform — сравнивая закоммиченный конфиг с реальностью — видит правило security group, которое он никогда не создавал. Он «исправляет» дрейф, удаляя ночной хотфикс. Интеграция с партнёром ломается в середине рабочего дня, а откат, который её вызвал, с точки зрения пайплайна был совершенно корректным apply, делавшим ровно свою работу: приводил мир в соответствие коду. Изменения в консоли в коде не было, поэтому для пайплайна оно никогда не существовало. Вот чего в итоге стоит запуск IaC руками или через пайплайн без ограничителей. Лечение — не «перестать трогать консоль», а сделать пайплайн единственным писателем и дать ему глаза.
Пайплайн plan/apply: дифф и есть артефакт ревью
Предыдущие уроки учили писать IaC — CloudFormation и CDK, Terraform с заблокированным удалённым бэкендом, операции над состоянием, модули по аккаунтам. Этот — о том, как запускать его безопасно, и форма, которая делает это безопасным, — GitOps для инфраструктуры: каждое изменение проходит через pull request, а с AWS говорит пайплайн, а не ноутбук.
Механизм разбивает рабочий процесс на два триггера пайплайна. На PR CI выполняет read-only половину: terraform plan (или cdk diff, или change set CloudFormation), затем публикует получившийся дифф комментарием в PR. На мердж в дефолтную ветку CI выполняет write-половину: terraform apply. Ключевая идея в том, что план и есть артефакт ревью. Ревьюер, апрувящий PR, апрувит не HCL в абстракции — он апрувит конкретный набор изменений, который напечатал план: эти три ресурса обновляются на месте, этот заменяется (удаляется и пересоздаётся), и больше ничего. Код-ревью только .tf-файлов необходимо, но недостаточно, потому что один и тот же код порождает разный план в зависимости от текущего состояния. План закрывает этот разрыв, показывая буквальную дельту, которая попадёт в аккаунт.
# .github/workflows/infra.yml — plan на PR, apply при мердже
name: infra
on:
pull_request: # PR: только plan, публикуем дифф для ревью
push:
branches: [main] # мердж: apply
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -no-color -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
# …затем шаг публикует plan.txt как комментарий к PR (артефакт ревью)
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: prod # гейт: требует ручной апрув до запуска джоба
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform apply -auto-approveКомпромисс живёт в той строке environment: prod. Обязательный ручной апрув на apply безопасен — человек перечитывает план и кликает до того, как prod мутирует, — но медленнее, и ставит человека в цикл на каждое изменение. Авто-apply на мердж быстр и полностью GitOps-чист, но плохой мердж уходит прямо в продакшен без перечитывания плана. Режим отказа чистого авто-apply — мердж в 3 ночи, уничтожающий базу, потому что план показал замену -/+, которую никто не разглядел. Сеньорский дефолт асимметричен: plan на PR всегда, а apply под env-гейтом — авто-apply для dev (дёшево сломать, быстрая обратная связь), ручной апрув для prod. Ты покупаешь скорость там, где ошибки дёшевы, и гейт там, где они дороги.
▸Почему это работает
Почему настаивать, что план — это артефакт ревью, а не доверять код-ревью .tf/шаблонов? Потому что IaC — не чистая функция от своего исходника, а функция исходника и текущего состояния. Один и тот же коммит порождает «обновить один тег» против одного состояния и «заменить продакшен-базу» против другого, потому что кто-то изменил реальность вне процесса, источник данных теперь резолвится иначе или апгрейд провайдера переинтерпретировал атрибут. Ревью только кода — это апрув намерения; ревью плана — апрув конкретной операции. Поэтому зрелые сетапы блокируют мердж, пока к нему не приложен свежий план, и перезапускают план в момент apply, отказываясь применять, если только что вычисленный план отличается от апрувленного, — апрув привязан к конкретной дельте, а не к надежде на то, что код сделает.
CI к AWS без долгоживущих ключей: OIDC-федерация
Теперь пайплайн говорит с AWS на каждом мердже, а значит ему нужны учётки — и неверный способ их дать тот, с которого начинает большинство команд: вставить AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY долгоживущего IAM-пользователя в секреты CI. Этот ключ валиден, пока кто-то его не ротирует, а это часто никогда, и лежит в твоём CI-провайдере в ожидании утечки: в лог сборки через дамп env, через скомпрометированный сторонний action с доступом к секрету или через неправильно настроенный PR из форка. Когда утекает статический админ-ключ, радиус поражения — весь аккаунт, немедленно и бессрочно.
Механизм, убирающий постоянный секрет, — OIDC-федерация (OpenID Connect — открытый стандарт аутентификации поверх OAuth 2.0). GitHub Actions (и GitLab CI) могут выпустить короткоживущий OIDC-токен — подписанный JWT, описывающий запуск воркфлоу, — и обменять его в AWS STS через AssumeRoleWithWebIdentity на временные учётки. Ты один раз регистрируешь OIDC-провайдера GitHub в IAM, затем привязываешь к роли trust policy, которая говорит «выпускай учётки только для токена, чьи claims совпадают с этим репозиторием на этой ветке». Никакого секрета нигде не хранится; учётки, которые AWS отдаёт, истекают примерно через час (макс. сессия роли, настраивается), так что перехваченный из лога токен бесполезен через считаные минуты.
apply:
runs-on: ubuntu-latest
environment: prod
permissions:
id-token: write # разрешить джобу запрашивать OIDC-токен
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/ci-terraform-prod
aws-region: eu-central-1
# нигде нет AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEYВся граница безопасности — это условие в trust policy. Сузь claim sub до конкретного репозитория и ref, чтобы форк, фича-ветка или воркфлоу другой организации не могли занять твою prod-роль:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/infra:ref:refs/heads/main"
}
}
}Компромисс мягкий — OIDC требует одноразовой настройки IAM (зарегистрировать провайдера, написать trust policy) и дисциплинированных условий по sub — против огромной выгоды: нет ротации, нет постоянного секрета, а учётка ограничена, короткоживуща и аудируема. Классический режим отказа — trust policy с sub вроде repo:acme/infra:* (любая ветка, любой ref): теперь любая ветка, в которую контрибьютор может запушить, включая атакующего, заведшего PR-ветку, может занять prod-роль. Прибей ветку, а для более сильной изоляции привяжи роль к GitHub-окружению (...:environment:prod), чтобы правила защиты самого окружения гейтили assume.
Policy-as-code: блокируй плохую инфру до apply
Прошедший ревью план всё равно зависит от того, поймает ли человек каждую опасную строку, а люди упускают «этот S3-бакет публичный» в шесть вечера в пятницу. Policy-as-code переносит это суждение в пайплайн: автоматические правила гоняются по плану (или шаблону) и проваливают сборку до apply, если инфраструктура нарушает правило. План, который ты и так генерируешь для ревью, становится входом для движка политик.
Инструменты группируются по экосистемам: OPA/Conftest, Checkov и tfsec/Trivy оценивают план Terraform в JSON; HashiCorp Sentinel — платный, нативный для Terraform вариант; AWS CloudFormation Guard (cfn-guard) проверяет шаблоны CloudFormation и любой JSON/YAML компактным DSL для правил. У всех этих инструментов одна задача: превратить предположение «кто-нибудь бы это заметил» в детерминированный гейт, который срабатывает до прода. Правила кодируют то, что ты никогда не хочешь отгрузить, — никаких публичных S3-бакетов, обязательное шифрование, никаких 0.0.0.0/0 на порт 22, обязательные теги для аллокации затрат, — и ловят весь класс багов, а не один экземпляр, который случайно заметил ревьюер.
# Правило cfn-guard: ни один S3-бакет не может быть публичным, шифрование обязательно
rule s3_must_be_private when Resources.*.Type == "AWS::S3::Bucket" {
Resources.*[ Type == "AWS::S3::Bucket" ] {
Properties.PublicAccessBlockConfiguration exists
Properties.PublicAccessBlockConfiguration.BlockPublicAcls == true
Properties.PublicAccessBlockConfiguration.IgnorePublicAcls == true
Properties.BucketEncryption exists
}
}# Аналог Conftest/OPA для плана `terraform show -json`
package main
deny[msg] {
r := input.resource_changes[_]
r.type == "aws_security_group_rule"
r.change.after.cidr_blocks[_] == "0.0.0.0/0"
r.change.after.to_port == 22
msg := sprintf("SSH open to the world on %s", [r.address])
}Компромисс — трение против покрытия. Гейты политик ловят классы багов до прода и кодируют племенное знание как принудительные правила, но добавляют шаг, который проваливает сборки, а шумное правило с ложными срабатываниями приучает команду игнорировать или обходить его — худший исход, потому что регулярно обходимый гейт — это театр. Режим отказа — выкатить двадцать строгих правил в первый день: пайплайн краснеет на легитимных изменениях, люди начинают добавлять skip-аннотации, и слой политик мёртв в течение спринта. Выкатывай высокоценные правила первыми (публичный S3, открытый SSH, нешифрованные тома) в режиме warn, измеряй долю ложных срабатываний, затем переключай в enforce, когда они станут чистыми. Несколько принудительных правил, которым доверяют, лучше пятидесяти, которые обходят.
Дрейф и конкурентность: два способа, которыми кусает сам apply
Hook — это каноничный инцидент IaC, и у него есть имя: дрейф — реальность расходится с состоянием, потому что что-то изменило AWS вне пайплайна (хотфикс в консоли, скрипт, другая команда). Следующий план видит расхождение и хочет его откатить, чтобы соответствовать коду, молча отменяя ручное изменение; или, если ручное изменение конфликтует с конфигом, apply падает с ошибкой. В любом случае внепроцессное исправление потеряно или вызывает сюрприз. Механизм, чтобы быть впереди него, — плановое обнаружение дрейфа: гонять terraform plan (или нативное обнаружение дрейфа CloudFormation) по ночному расписанию, и если план непустой, когда ни один PR не открыт, алертить команду — дрейф, пойманный в 03:00 кроном, — это тикет; дрейф, пойманный внезапным откатом посреди деплоя, — это инцидент. Поймай его рано, и ты осознанно решаешь, кодифицировать ли изменение (вписать в конфиг) или откатить, вместо того чтобы пайплайн решал за тебя.
drift:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
# detailed-exitcode: 0 = нет дрейфа, 2 = дрейф есть, 1 = ошибка
- run: terraform plan -detailed-exitcode -no-color || echo "DRIFT" >> alert
# …непустой план без открытого PR пейджит дежурного
on:
schedule:
- cron: "0 3 * * *" # ночной скан дрейфаВторая опасность — конкурентность. Два пайплайна, применяющие одно и то же состояние одновременно, гоняются: оба читают одинаковое стартовое состояние, оба пишут, второй затирает первого, и файл состояния теперь не соответствует реальности — осиротевшие ресурсы, которые Terraform больше не отслеживает, счётчик serial, который больше не сходится. Защита — блокировка состояния из урока про Terraform: DynamoDB-лок (или use_lockfile) для S3-бэкенда и собственная серверная блокировка стека CloudFormation, — плюс сериализация apply на одно состояние в CI (concurrency-группа, чтобы два apply-джоба на одно состояние не пересекались). Режим отказа, который привносит сама блокировка: CI-джоб умирает посреди apply (раннер убит, сеть отвалилась) и оставляет лок удержанным, так что каждый последующий apply блокируется — Error acquiring the state lock — пока кто-то не разберётся. Опасный рефлекс — terraform force-unlock, чтобы «просто разблокировать команду». Если этот лок действительно принадлежит apply, всё ещё мутирующему AWS, force-unlock даёт второму apply работать конкурентно и по-настоящему портит состояние. Делай force-unlock только после того, как подтвердил, что удерживающий джоб действительно мёртв; застрявший лок раздражает, а ошибочный force-unlock — это испорченный файл prod-состояния.
Платформенная команда из 30 инженеров гоняет Terraform для dev и prod через GitHub Actions. Им нужна быстрая итерация в dev, но никаких случайных изменений prod, и они выбирают стратегию apply по окружениям. Выбери политику.
Почему для джоба apply предпочесть OIDC-федерацию долгоживущему IAM-ключу доступа, хранящемуся в секретах CI?
- 01Опиши пайплайн plan-на-PR / apply-на-мердж и объясни, почему артефакт ревью — план, а не код.
- 02Как OIDC-федерация заменяет долгоживущие CI-ключи и каковы режимы отказа дрейфа и конкурентности у IaC-пайплайна?
Написание IaC — это предыдущие уроки; этот — о безопасном запуске, и безопасная форма — GitOps для инфраструктуры, где единственный писатель в AWS — пайплайн, а не ноутбук. Рабочий процесс разбит по триггерам: на PR CI выполняет read-only половину — terraform plan, cdk diff или change set CloudFormation — и публикует дифф, потому что план — это артефакт ревью; ревьюеры апрувят конкретную дельту, а не код в абстракции, ведь один коммит порождает разный план против разного состояния. На мердж CI выполняет apply, и apply гейтится по окружению — авто-apply для dev, где ошибки дёшевы, человеческий апрув для prod, где нет. Пайплайн аутентифицируется в AWS через OIDC-федерацию, а не долгоживущим ключом доступа: он выпускает токен на запуск и обменивает его в STS на учётки, истекающие примерно через час, ограниченные trust policy конкретным репозиторием и веткой, так что утечка сжимается с бессрочного доступа ко всему аккаунту до токена на минуты, а режим отказа — слабый claim sub, позволяющий любой ветке занять роль. Policy-as-code — OPA/Conftest, Checkov, tfsec, Sentinel, cfn-guard — гоняется по плану и проваливает сборку на целых классах плохой инфры (публичный S3, открытый SSH, нешифрованные тома, отсутствие тегов), выкатываемый сначала в warn, затем в enforce, чтобы шумное правило не обходили. Наконец, у самого apply два режима отказа: дрейф, когда внепроцессное изменение в консоли молча откатывается следующим apply — ловится ночным плановым plan, который алертит, чтобы ты осознанно кодифицировал или откатил, — и конкурентность, когда два apply гоняются и портят состояние, защищаемая блокировкой и сериализацией apply, с оговоркой, что мёртвый джоб может оставить лок удержанным, а force-unlock во время настоящего apply портит состояние навсегда. Plan везде, гейть по радиусу поражения, иди беcключево, гейть на политиках и следи за дрейфом. Теперь, когда увидишь коллегу, запускающего terraform apply с ноутбука, или AWS_SECRET_ACCESS_KEY в секретах CI, — ты будешь знать не просто, что это неверно, но и к какому конкретно инциденту это ведёт и какого слоя пайплайна не хватает.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.