держать live-инфраструктуру согласованной с Terraform desired state
Дрифт инфраструктуры: обнаружение и коррекция с Terraform
13 минут
Ручные правки в консоли и устаревший state незаметно расходятся с Terraform-кодом. В материале — scheduled drift scan, классификация low-risk изменений для auto-remediation и guardrails: locking, lifecycle и policy-as-code.
Почему drift инфраструктуры — операционная неизбежность
Production-эстейты дрифтят. Инженеры меняют security group во время инцидента, провайдеры обновляют defaults, атрибуты эволюционируют вне IaC-workflow. Разрыв между объявленной Terraform-конфигурацией и live-состоянием облака даёт загадочные outage, провалы аудита и plan'ы, которые удивляют on-call. Drift делится на три типа: configuration drift от правок в консоли или CLI, state drift от параллельных apply или ручного редактирования state, external drift когда defaults провайдера сдвигаются под вами. Scheduled terraform plan сравнивает desired configuration и state с обновлёнными данными провайдера — он не мутирует инфраструктуру. Цель не нулевой drift, а быстрое обнаружение, явное решение по remediation и prevention, который не даёт расхождению копиться незаметно.
Трёхслойная стратегия: detect, remediate, prevent
Эффективное управление drift строится на трёх возможностях. Detection запускает read-only terraform plan по расписанию — обычно каждые четыре–шесть часов на окружение. Для обновления state из live API без предложения изменений используйте terraform plan -refresh-only. Remediation сводит только pre-approved low-risk resource types через сохранённый plan file, никогда blind auto-apply и никогда когда plan включает delete или replace. Prevention направляет мутации через CI/CD с remote state locking, lifecycle rules для обоснованно изменяемых полей и policy-as-code на plan JSON до apply. Terraform Cloud и HCP Terraform дают managed drift detection для зарегистрированных workspace; Spacelift и env0 — похожие view. Паттерны GitHub Actions ниже переносимы на любой orchestrator.
Scheduled drift detection с detailed exit codes
terraform plan -detailed-exitcode возвращает 0 когда изменений нет, 2 при drift и 1 при ошибке. Сохраняйте human-readable output и binary tfplan. Для классификации в скриптах запускайте terraform show -json tfplan — plan -out и plan -json решают разные задачи, а saved plan file — источник истины и для apply, и для JSON. В scheduled workflow предпочитайте GitHub OIDC к AWS вместо long-lived access keys; в hashicorp/setup-terraform ставьте terraform_wrapper: false, если перенаправляете plan output в файлы. Коррелируйте находки с CloudTrail или audit log. Дедуплицируйте алерты — не создавайте новый issue, если открытый drift issue уже есть.
name: Infrastructure Drift Detection
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
permissions:
contents: read
issues: write
id-token: write
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
terraform_wrapper: false
- name: Terraform init
run: terraform init -input=false
- name: Terraform plan
id: plan
run: |
set +e
terraform plan -detailed-exitcode -input=false -out=tfplan 2>&1 | tee plan_output.txt
EXIT_CODE=${PIPESTATUS[0]}
set -e
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
if [ "$EXIT_CODE" -eq 2 ]; then
echo "drift_detected=true" >> $GITHUB_OUTPUT
terraform show -json tfplan > plan.json
fi
- name: Create issue on drift
if: steps.plan.outputs.drift_detected == 'true'
uses: actions/github-script@v7
with:
script: |
const { data: openIssues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'drift',
});
if (openIssues.length > 0) return;
const fs = require('fs');
const planOutput = fs.readFileSync('plan_output.txt', 'utf8');
const truncated = planOutput.length > 60000
? planOutput.substring(0, 60000) + '\n\n... (truncated)'
: planOutput;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Infrastructure drift detected - ${new Date().toISOString().split('T')[0]}`,
body: [
'Scheduled drift scan found resources diverging from Terraform state.',
'',
'### Plan output',
'```',
truncated,
'```',
'',
'### Next steps',
'1. Decide: adopt in code, import, or revert live change',
'2. Correlate with CloudTrail or audit logs',
'3. Add ignore_changes only for documented exceptions'
].join('\n'),
labels: ['drift', 'infrastructure']
});Risk-based auto-remediation для allowlisted resource types
Не весь drift нужно auto-heal'ить. Парсите resource_changes[].type из plan JSON — никогда не выводите type из address, потому что module.vpc.aws_security_group.web разберётся неверно. Блокируйте любой plan с delete или replace actions. Изменённый порог CloudWatch alarm — low risk; изменённая security group или IAM binding — нет. Apply только сохранённый tfplan после проверок allowlist и actions.
#!/bin/bash
set -euo pipefail
ALLOWED_TYPES=(
aws_cloudwatch_metric_alarm
aws_sns_topic
)
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
set +e
terraform plan -detailed-exitcode -input=false -out=tfplan
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" -eq 0 ]; then
log "No drift detected."
exit 0
fi
if [ "$EXIT_CODE" -ne 2 ]; then
log "Plan failed with exit code $EXIT_CODE."
exit 1
fi
terraform show -json tfplan > plan.json
if jq -e '.resource_changes[]? | select(.change.actions | index("delete"))' plan.json >/dev/null; then
log "Delete actions require manual review."
exit 1
fi
while IFS= read -r TYPE; do
ALLOWED=false
for allowed in "${ALLOWED_TYPES[@]}"; do
if [ "$TYPE" = "$allowed" ]; then ALLOWED=true; break; fi
done
if [ "$ALLOWED" = false ]; then
log "Manual review required for resource type $TYPE"
exit 1
fi
done < <(jq -r '.resource_changes[]? | select(.change.actions != ["no-op"]) | .type' plan.json | sort -u)
log "Applying allowlisted drift remediation from saved plan"
terraform apply -input=false tfplan
log "Auto-remediation completed"Prevention guardrails: remote state, lifecycle и policy
Централизуйте state в S3 с DynamoDB locking — или cloud-эквивалент — и блокируйте ungated apply с ноутбуков. lifecycle ignore_changes только для задокументированных operational exceptions, не как blanket-механизм сокрытия drift. Sentinel интегрируется с HCP Terraform и Terraform Enterprise; Conftest с Rego — практичный open-source gate на terraform show -json output в любом CI. Требуйте plan-before-apply: terraform plan -out=tfplan, policy check, затем terraform apply tfplan.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
lifecycle {
ignore_changes = [
instance_type,
tags["LastManualUpdate"],
]
prevent_destroy = true
}
}package terraform.security
deny[msg] {
rc := input.resource_changes[_]
rc.type == "aws_security_group"
ingress := rc.change.after.ingress[_]
ingress.cidr_blocks[_] == "0.0.0.0/0"
ingress.from_port <= 22
ingress.to_port >= 22
msg := sprintf("public SSH denied on %s", [rc.address])
}Как разрешать drift после подтверждения
Detection показывает расхождение state, но не выбирает fix. Три пути решения. Случайная правка в консоли: apply saved plan, чтобы свести live к коду, или вручную откатить изменение в облаке, если apply слишком рискован. Намеренное operational change: обновить Terraform configuration в pull request, пройти review, apply через CI. Ресурс есть в облаке, но не в state: import через terraform import или code-first import block, затем проверить plan. Шум от provider-default или read-only атрибутов: terraform plan -refresh-only для выравнивания state или узкий ignore_changes с привязкой к тикету. Никогда не auto-remediate, когда plan уничтожает или заменяет networking, identity или data resources — нужны human review и запись в audit trail.
End-to-end pipeline: классификация drift и ветвление remediation
Объедините detection и remediation: сохраните tfplan, экспортируйте JSON через terraform show -json, классифицируйте по resource type, auto-apply только без high-risk types и без delete actions, security-labeled issue в остальных случаях. Логируйте каждый run даже без drift, чтобы отслеживать drift rate и time-to-remediation как platform health indicators. Каждый drift event — сигнал процесса: manual change был необходим потому что Terraform слишком жёсткий, или потому что обход CI слишком прост?
name: Drift Detection and Remediation
on:
schedule:
- cron: '0 */4 * * *'
jobs:
detect-and-remediate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Init and plan
id: plan
run: |
set +e
terraform init -input=false
terraform plan -detailed-exitcode -input=false -out=tfplan
EXIT_CODE=$?
set -e
echo "exit=$EXIT_CODE" >> $GITHUB_OUTPUT
if [ "$EXIT_CODE" -eq 2 ]; then
terraform show -json tfplan > plan.json
fi
- name: Classify drift risk
if: steps.plan.outputs.exit == '2'
id: classify
run: |
HIGH_RISK=false
if jq -e '.resource_changes[]? | select(.change.actions | index("delete"))' plan.json >/dev/null; then
HIGH_RISK=true
fi
while IFS= read -r TYPE; do
case "$TYPE" in
aws_security_group|aws_vpc_security_group_*|aws_network_acl|aws_vpc|aws_subnet|aws_kms_key)
HIGH_RISK=true
;;
esac
if [[ "$TYPE" == aws_iam_* ]]; then HIGH_RISK=true; fi
done < <(jq -r '.resource_changes[]? | select(.change.actions != ["no-op"]) | .type' plan.json | sort -u)
echo "high_risk=$HIGH_RISK" >> $GITHUB_OUTPUT
- name: Auto-remediate low-risk drift
if: steps.plan.outputs.exit == '2' && steps.classify.outputs.high_risk == 'false'
run: terraform apply -input=false tfplan
- name: Create issue for high-risk drift
if: steps.plan.outputs.exit == '2' && steps.classify.outputs.high_risk == 'true'
uses: actions/github-script@v7
with:
script: |
const changes = JSON.parse(require('fs').readFileSync('plan.json', 'utf8'));
const drift = changes.resource_changes.filter(r => !r.change.actions.includes('no-op'));
const lines = drift.map(r => `- ${r.address} (${r.type}): ${r.change.actions.join(', ')}`);
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `High-risk infrastructure drift - ${new Date().toISOString().split('T')[0]}`,
body: ['Manual review required:', '', ...lines].join('\n'),
labels: ['drift', 'security', 'high-priority']
});Drift-проверки дополняют pre-merge валидацию из гайда по тестированию Terraform и Kitchen-Terraform.
Когда drift сигнализирует о сбое процесса, отслеживайте latency remediation вместе с практиками SLO, SLI и error budget для платформенных команд.
