держать 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 уже есть.

GitHub Actions · scheduled drift detection
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.

Bash · allowlisted auto-remediation script
#!/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.

HCL · remote state backend с locking
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
HCL · lifecycle rules для intentional exceptions
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  lifecycle {
    ignore_changes = [
      instance_type,
      tags["LastManualUpdate"],
    ]
    prevent_destroy = true
  }
}
Rego · Conftest deny public SSH on security groups
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 слишком прост?

GitHub Actions · classify and remediate drift
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 для платформенных команд.