Skip to content
Published on

TerraformとOpenTofu IaCドリフト検出と自動回復操作ガイド

Authors
  • Name
    Twitter

Terraform OpenTofu Drift Detection

入り

Infrastructure as Code(IaC)の重要な約束は、「コードで宣言された状態がまもなくインフラの実際の状態」ということだ。しかし、現実のプロダクション環境ではこの約束が容易に壊れる。コンソールでの緊急修正、他のオートメーションツールの介入、AWS Auto Scalingの動的変更、さらにはAPIのデフォルトのバージョン間の違いまで - コードと実際のインフラストラクチャ間のギャップが発生する現象を「インフラストラクチャドリフト」と呼びます。

2026年現在、Terraform 1.11とOpenTofu 1.9がリリースされ、ドリフト検出と管理機能が大幅に強化されました。しかし、ツール自体のplan命令だけでは組織レベルのドリフト管理体系を構築することが難しい。この記事では、ドリフトの本質的な原因を分析し、自動検出パイプラインの構築、CI / CD統合、リカバリ戦略、およびSpaceliftやTerramateなどのプロフェッショナルツールの活用まで、本番運用に必要なすべてをカバーします。

インフラストラクチャドリフトの概念と種類

ドリフトとは

インフラストラクチャドリフトは、IaCコードで宣言された**期待状態(Desired State)とクラウドプロバイダに実際に存在する現在の状態(Actual State)**との間の不一致を意味します。 TerraformのStateファイルは、最後に知られているインフラストラクチャの状態を保存します。これら3つ(コード、State、実際のインフラストラクチャ)の間に不整合が発生すると、ドリフトが存在します。

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
HCL 코드       │     │  Terraform State  │     │  실제 인프라      │
   (Desired)  (Last Known)  (Actual)│                  │     │                   │     │                  │
│  instance_type   │     │  instance_type    │     │  instance_type   │
= "t3.medium"   │     │  = "t3.medium"    │     │  = "t3.xlarge"│                  │     │                   │     │  ← 콘솔에서 변경  │
└─────────────────┘     └──────────────────┘     └─────────────────┘
        ▲                        ▲                        ▲
        │                        │                        │
        └─── 일치 ──────────────┘─── 불일치(드리프트) ────┘

ドリフトの3種類

1. 構成ドリフト (Configuration Drift)

リソースの属性がコードで宣言された値と異なる場合です。たとえば、セキュリティグループルールがコンソールに追加された場合、またはEC2インスタンスタイプが変更された場合に該当します。

2. 存在ドリフト(Existence Drift)

コードに宣言されているが実際に削除されたリソース、またはコードにはないが実際に存在するリソースです。誰かがコンソールからS3バケットを削除した場合、またはコード外でRDSインスタンスを作成した場合に該当します。

3. 依存性ドリフト(Dependency Drift)

リソース間参照関係が壊れた場合である。たとえば、Subnetが削除されたが、そのSubnetを参照するEC2インスタンスが別のSubnetに移動した場合です。

ドリフトが発生する一般的な原因

人的要因

最も頻繁なドリフトの原因は人の直接介入です。障害状況で緊急にコンソールやCLIを介してインフラストラクチャを変更する場合が代表的である。

# 장애 긴급 대응 -- 흔히 발생하는 드리프트 시나리오
# 1. Security Group에 디버깅용 포트 개방
aws ec2 authorize-security-group-ingress \
  --group-id sg-0abc123def456 \
  --protocol tcp \
  --port 5432 \
  --cidr 0.0.0.0/0  # 위험: PostgreSQL 포트를 전체 공개

# 2. RDS 인스턴스 클래스 긴급 스케일업
aws rds modify-db-instance \
  --db-instance-identifier prod-main-db \
  --db-instance-class db.r6g.2xlarge \
  --apply-immediately

# 3. Auto Scaling Group의 desired count 수동 변경
aws autoscaling set-desired-capacity \
  --auto-scaling-group-name prod-web-asg \
  --desired-capacity 10

これらの変更は緊急事態では避けられないかもしれませんが、変更がコードに反映されない場合は、terraform apply 意図しないロールバックが発生します。

システム要因

  • Auto Scaling: AWS ASG、EKS Node Groupなどが自動的にインスタンス数を変更
  • プロバイダのデフォルト値の変更:AWS APIがリソースを作成するときにデフォルトのタグまたは設定を自動的に追加する
  • サービス連動: CloudTrail、Config Rulesなどが自動的に設定を修正
  • Terraformプロバイダのアップグレード:新しいバージョンで追加されたプロパティがデフォルトに設定されている

プロセス要因

  • State ファイルの破損: S3 バックエンドの State ファイルが誤って上書きされたり削除される
  • パラレルapply:同じStateに対して複数のパイプラインが同時にapplyを実行する
  • 部分 apply: terraform apply 途中で失敗し、一部のリソースのみを変更

ネイティブドリフト検出方法

terraform planを活用した基本検出

terraform plan は最も基本的なドリフト検出ツールです。-detailed-exitcode フラグを使用すると、プログラム的にドリフトするかどうかを判断できます。

#!/bin/bash
# drift-detect.sh -- Terraform 드리프트 감지 스크립트

set -euo pipefail

WORKSPACE_DIR="${1:-.}"
SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
LOG_FILE="/var/log/terraform/drift-$(date +%Y%m%d-%H%M%S).log"

cd "$WORKSPACE_DIR"

echo "[$(date)] 드리프트 감지 시작: $WORKSPACE_DIR" | tee -a "$LOG_FILE"

# terraform init (백엔드 재초기화 방지)
terraform init -input=false -no-color >> "$LOG_FILE" 2>&1

# -detailed-exitcode 종료 코드:
#   0 = 변경 없음 (드리프트 없음)
#   1 = 오류 발생
#   2 = 변경 있음 (드리프트 감지)
terraform plan -detailed-exitcode -input=false -no-color -out=drift.plan >> "$LOG_FILE" 2>&1
EXIT_CODE=$?

case $EXIT_CODE in
  0)
    echo "[$(date)] 드리프트 없음" | tee -a "$LOG_FILE"
    ;;
  1)
    echo "[$(date)] 오류 발생 -- 수동 확인 필요" | tee -a "$LOG_FILE"
    if [ -n "$SLACK_WEBHOOK_URL" ]; then
      curl -s -X POST "$SLACK_WEBHOOK_URL" \
        -H 'Content-Type: application/json' \
        -d "{\"text\": \":rotating_light: Terraform 드리프트 감지 오류\\n워크스페이스: $WORKSPACE_DIR\\n로그: $LOG_FILE\"}"
    fi
    exit 1
    ;;
  2)
    echo "[$(date)] 드리프트 감지됨!" | tee -a "$LOG_FILE"

    # plan 출력에서 변경 요약 추출
    DRIFT_SUMMARY=$(terraform show -no-color drift.plan | grep -E "^  #|Plan:|~ |+ |- " | head -30)

    if [ -n "$SLACK_WEBHOOK_URL" ]; then
      curl -s -X POST "$SLACK_WEBHOOK_URL" \
        -H 'Content-Type: application/json' \
        -d "{\"text\": \":warning: 인프라 드리프트 감지\\n워크스페이스: $WORKSPACE_DIR\\n\`\`\`$DRIFT_SUMMARY\`\`\`\"}"
    fi
    exit 2
    ;;
esac

OpenTofuの改善されたドリフト検出

OpenTofu 1.8以降では tofu plan 追加のドリフト関連オプションがサポートされています。特にState暗号化と組み合わせて、より安全なドリフト検出が可能です。

# backend.tf -- OpenTofu State 암호화 설정
terraform {
  encryption {
    method "aes_gcm" "state_enc" {
      keys = key_provider.aws_kms.state_key
    }

    state {
      method   = method.aes_gcm.state_enc
      enforced = true
    }

    plan {
      method   = method.aes_gcm.state_enc
      enforced = true
    }
  }

  backend "s3" {
    bucket         = "myorg-tofu-state"
    key            = "prod/infrastructure.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "tofu-state-lock"
    encrypt        = true
  }
}

# key_provider 설정
key_provider "aws_kms" "state_key" {
  kms_key_id = "alias/tofu-state-encryption"
  region     = "ap-northeast-2"
  key_spec   = "AES_256"
}

terraform plan JSON出力解析

JSON形式のplan出力を活用すると、プログラム的にドリフトを詳細に分析できます。

#!/usr/bin/env python3
"""
drift_analyzer.py -- Terraform Plan JSON 드리프트 분석기
terraform show -json drift.plan > plan.json 으로 생성한 파일을 분석한다.
"""

import json
import sys
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional


class DriftSeverity(Enum):
    CRITICAL = "critical"
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"


class DriftAction(Enum):
    CREATE = "create"
    UPDATE = "update"
    DELETE = "delete"
    REPLACE = "replace"
    READ = "read"


@dataclass
class DriftItem:
    address: str
    resource_type: str
    action: DriftAction
    severity: DriftSeverity
    changed_attributes: list = field(default_factory=list)
    description: str = ""


# 심각도 판단 규칙
SEVERITY_RULES = {
    # 리소스 타입별 기본 심각도
    "aws_security_group_rule": DriftSeverity.CRITICAL,
    "aws_iam_role_policy": DriftSeverity.CRITICAL,
    "aws_iam_policy": DriftSeverity.CRITICAL,
    "aws_s3_bucket_policy": DriftSeverity.HIGH,
    "aws_db_instance": DriftSeverity.HIGH,
    "aws_instance": DriftSeverity.MEDIUM,
    "aws_autoscaling_group": DriftSeverity.LOW,
}

# 삭제/교체는 무조건 HIGH 이상
ACTION_SEVERITY_FLOOR = {
    DriftAction.DELETE: DriftSeverity.HIGH,
    DriftAction.REPLACE: DriftSeverity.HIGH,
}


def classify_severity(resource_type: str, action: DriftAction) -> DriftSeverity:
    """리소스 타입과 액션 기반으로 드리프트 심각도를 분류한다."""
    base = SEVERITY_RULES.get(resource_type, DriftSeverity.MEDIUM)
    floor = ACTION_SEVERITY_FLOOR.get(action)
    if floor and floor.value < base.value:
        return floor
    return base


def parse_plan(plan_path: str) -> list[DriftItem]:
    """Terraform Plan JSON을 파싱하여 드리프트 항목 목록을 반환한다."""
    with open(plan_path) as f:
        plan = json.load(f)

    drifts = []
    for change in plan.get("resource_changes", []):
        actions = change.get("change", {}).get("actions", [])

        # no-op은 건너뛴다
        if actions == ["no-op"] or actions == ["read"]:
            continue

        if "delete" in actions and "create" in actions:
            action = DriftAction.REPLACE
        elif "delete" in actions:
            action = DriftAction.DELETE
        elif "create" in actions:
            action = DriftAction.CREATE
        elif "update" in actions:
            action = DriftAction.UPDATE
        else:
            continue

        resource_type = change.get("type", "unknown")
        address = change.get("address", "unknown")

        # 변경된 속성 추출
        before = change.get("change", {}).get("before") or {}
        after = change.get("change", {}).get("after") or {}
        changed_attrs = []
        if isinstance(before, dict) and isinstance(after, dict):
            all_keys = set(list(before.keys()) + list(after.keys()))
            for key in all_keys:
                if before.get(key) != after.get(key):
                    changed_attrs.append(key)

        severity = classify_severity(resource_type, action)

        drifts.append(DriftItem(
            address=address,
            resource_type=resource_type,
            action=action,
            severity=severity,
            changed_attributes=changed_attrs,
        ))

    return drifts


def generate_report(drifts: list[DriftItem]) -> dict:
    """드리프트 분석 보고서를 생성한다."""
    report = {
        "total_drifts": len(drifts),
        "by_severity": {},
        "by_action": {},
        "critical_items": [],
        "details": [],
    }

    for d in drifts:
        report["by_severity"][d.severity.value] = \
            report["by_severity"].get(d.severity.value, 0) + 1
        report["by_action"][d.action.value] = \
            report["by_action"].get(d.action.value, 0) + 1

        if d.severity == DriftSeverity.CRITICAL:
            report["critical_items"].append({
                "address": d.address,
                "action": d.action.value,
                "changed_attributes": d.changed_attributes,
            })

        report["details"].append({
            "address": d.address,
            "type": d.resource_type,
            "action": d.action.value,
            "severity": d.severity.value,
            "changed_attributes": d.changed_attributes,
        })

    return report


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python drift_analyzer.py <plan.json>")
        sys.exit(1)

    drifts = parse_plan(sys.argv[1])
    report = generate_report(drifts)

    print(json.dumps(report, indent=2, ensure_ascii=False))

    # CRITICAL 항목이 있으면 종료 코드 1
    if report["by_severity"].get("critical", 0) > 0:
        sys.exit(1)

CI/CDパイプライン統合

GitHub Actionsベースの定期ドリフト検出

実戦ではドリフト検知を定期的に自動実行しなければならない。以下は、GitHub Actionsを活用したドリフト検出パイプラインです。

# .github/workflows/drift-detection.yml
name: Infrastructure Drift Detection

on:
  schedule:
    # 매일 오전 9시, 오후 3시 (KST) 실행
    - cron: '0 0,6 * * *'
  workflow_dispatch:
    inputs:
      workspace:
        description: '감지할 워크스페이스 (비워두면 전체)'
        required: false
        type: string

permissions:
  id-token: write
  contents: read
  issues: write

env:
  TF_VERSION: '1.11.0'
  TOFU_VERSION: '1.9.0'

jobs:
  detect-drift:
    name: Detect Drift - ${{ matrix.workspace }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        workspace:
          - environments/prod/networking
          - environments/prod/compute
          - environments/prod/database
          - environments/prod/security
          - environments/staging/networking
          - environments/staging/compute
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DRIFT_DETECTION_ROLE_ARN }}
          aws-region: ap-northeast-2
          role-session-name: drift-detection-${{ github.run_id }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false

      - name: Terraform Init
        working-directory: ${{ matrix.workspace }}
        run: terraform init -input=false -no-color

      - name: Detect Drift
        id: drift
        working-directory: ${{ matrix.workspace }}
        run: |
          set +e
          terraform plan -detailed-exitcode -input=false -no-color \
            -out=drift.plan 2>&1 | tee plan-output.txt
          echo "exit_code=$?" >> "$GITHUB_OUTPUT"

      - name: Analyze Drift
        if: steps.drift.outputs.exit_code == '2'
        working-directory: ${{ matrix.workspace }}
        run: |
          terraform show -json drift.plan > plan.json
          python3 ${{ github.workspace }}/scripts/drift_analyzer.py plan.json \
            > drift-report.json
          echo "## 드리프트 감지: ${{ matrix.workspace }}" >> "$GITHUB_STEP_SUMMARY"
          echo '```json' >> "$GITHUB_STEP_SUMMARY"
          cat drift-report.json >> "$GITHUB_STEP_SUMMARY"
          echo '```' >> "$GITHUB_STEP_SUMMARY"

      - name: Create Issue for Critical Drift
        if: steps.drift.outputs.exit_code == '2'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const report = JSON.parse(
              fs.readFileSync('${{ matrix.workspace }}/drift-report.json', 'utf8')
            );
            if (report.by_severity?.critical > 0) {
              await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: `[CRITICAL DRIFT] ${{ matrix.workspace }}`,
                body: `## 심각한 인프라 드리프트 감지\n\n` +
                      `**워크스페이스**: ${{ matrix.workspace }}\n` +
                      `**감지 시각**: ${new Date().toISOString()}\n\n` +
                      `### Critical 항목\n` +
                      '```json\n' +
                      JSON.stringify(report.critical_items, null, 2) +
                      '\n```\n\n' +
                      `### 요약\n` +
                      `- 총 드리프트: ${report.total_drifts}\n` +
                      `- Critical: ${report.by_severity.critical || 0}\n` +
                      `- High: ${report.by_severity.high || 0}\n`,
                labels: ['drift', 'critical', 'infrastructure'],
              });
            }

      - name: Notify Slack
        if: steps.drift.outputs.exit_code == '2'
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DRIFT_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "인프라 드리프트 감지: ${{ matrix.workspace }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":warning: *드리프트 감지*\n워크스페이스: `${{ matrix.workspace }}`\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|상세 보기>"
                  }
                }
              ]
            }

GitLab CIベースのドリフト検出

GitLab CIを使用する組織の設定例も付属しています。

# .gitlab-ci.yml -- 드리프트 감지 파이프라인
stages:
  - drift-detect
  - drift-report

.drift_template: &drift_template
  image: hashicorp/terraform:1.11
  before_script:
    - export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web-identity-token
    - echo "${CI_JOB_JWT_V2}" > $AWS_WEB_IDENTITY_TOKEN_FILE
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_PIPELINE_SOURCE == "web"

drift:prod-networking:
  <<: *drift_template
  stage: drift-detect
  variables:
    TF_WORKSPACE_DIR: environments/prod/networking
  script:
    - cd $TF_WORKSPACE_DIR
    - terraform init -input=false -no-color
    - |
      set +e
      terraform plan -detailed-exitcode -input=false -no-color 2>&1 | tee plan.txt
      EXIT_CODE=$?
      set -e
      if [ $EXIT_CODE -eq 2 ]; then
        echo "DRIFT_DETECTED=true" >> drift.env
        echo "DRIFT_WORKSPACE=$TF_WORKSPACE_DIR" >> drift.env
      elif [ $EXIT_CODE -eq 1 ]; then
        echo "DRIFT_ERROR=true" >> drift.env
        exit 1
      fi
  artifacts:
    reports:
      dotenv: drift.env
    paths:
      - $TF_WORKSPACE_DIR/plan.txt
    when: always

自動回復(Automated Remediation)戦略

回復戦略のスペクトル

ドリフトを検出した後に回復する方法はいくつかあります。組織の成熟度とリスク許容レベルに応じて適切な戦略を選択する必要があります。

|戦略説明リスクオートメーションレベル適切な環境 | --------------- | ---------------------------------------- | ------ | ----------- | ---------------------------- | | 通知のみ |ドリフトを検出したときに通知のみを送信|低い|低い|初期導入、規制環境 | コード同期 |実際の状態をコードに反映する(import/refresh)中中意図的な変更が多い環境 | 自動apply |コード状態でインフラストラクチャを自動復元する高い|高い|コードが単一の真実ソースである環境 | オプションの回復 |重大度に応じて自動/手動回復を区別します。中中〜高|ほとんどの本番環境

オプションの自動回復の実装

実践で最もよく使用されるパターンは、重大度に基づく選択的回復です。 CRITICALドリフトはすぐに通知を送信し、手動レビューを要求し、LOW重大度ドリフトは自動的に回復します。

#!/bin/bash
# selective-remediation.sh -- 심각도 기반 선택적 드리프트 복구

set -euo pipefail

WORKSPACE_DIR="$1"
DRIFT_REPORT="$2"       # drift_analyzer.py의 출력 JSON
AUTO_APPLY="${3:-false}"  # true로 설정 시 LOW/MEDIUM 자동 복구

cd "$WORKSPACE_DIR"

# 드리프트 보고서에서 심각도별 카운트 추출
CRITICAL_COUNT=$(jq -r '.by_severity.critical // 0' "$DRIFT_REPORT")
HIGH_COUNT=$(jq -r '.by_severity.high // 0' "$DRIFT_REPORT")
MEDIUM_COUNT=$(jq -r '.by_severity.medium // 0' "$DRIFT_REPORT")
LOW_COUNT=$(jq -r '.by_severity.low // 0' "$DRIFT_REPORT")

echo "=== 드리프트 분석 결과 ==="
echo "  CRITICAL: $CRITICAL_COUNT"
echo "  HIGH:     $HIGH_COUNT"
echo "  MEDIUM:   $MEDIUM_COUNT"
echo "  LOW:      $LOW_COUNT"
echo "=========================="

# CRITICAL 또는 HIGH 드리프트가 있으면 자동 복구 중단
if [ "$CRITICAL_COUNT" -gt 0 ] || [ "$HIGH_COUNT" -gt 0 ]; then
  echo "[BLOCKED] CRITICAL/HIGH 드리프트 감지 -- 수동 검토 필요"
  echo "다음 리소스에 대해 수동 검토를 진행하세요:"
  jq -r '.details[] | select(.severity == "critical" or .severity == "high") |
    "  - [\(.severity | ascii_upcase)] \(.address) (\(.action)): \(.changed_attributes | join(", "))"' \
    "$DRIFT_REPORT"
  exit 2
fi

# LOW/MEDIUM만 있고 자동 복구가 활성화된 경우
if [ "$AUTO_APPLY" = "true" ]; then
  echo "[AUTO-REMEDIATE] LOW/MEDIUM 드리프트 자동 복구 시작"

  # 타겟 리소스만 선택적으로 apply
  TARGETS=$(jq -r '.details[] | select(.severity == "low" or .severity == "medium") |
    "-target=\(.address)"' "$DRIFT_REPORT")

  if [ -n "$TARGETS" ]; then
    echo "복구 대상: $TARGETS"
    # shellcheck disable=SC2086
    terraform apply -auto-approve -input=false -no-color $TARGETS
    echo "[DONE] 자동 복구 완료"
  fi
else
  echo "[INFO] 자동 복구 비활성화 -- 알림만 발송"
  exit 0
fi

terraform importを利用したリバース同期

コンソールで生成されたリソースをコードにインポートするリバース同期も重要な回復戦略です。 Terraform 1.5以上 import ブロックを活用すれば、コードレベルで宣言的にインポートを行うことができる。

# imports.tf -- 선언적 import 블록 (Terraform 1.5+, OpenTofu 1.6+)

# 콘솔에서 생성된 Security Group을 코드로 가져오기
import {
  to = aws_security_group.emergency_debug_sg
  id = "sg-0abc123def456"
}

# 콘솔에서 생성된 S3 버킷을 코드로 가져오기
import {
  to = aws_s3_bucket.manual_backup
  id = "prod-manual-backup-20260307"
}

# import된 리소스에 대한 코드 선언
resource "aws_security_group" "emergency_debug_sg" {
  name        = "emergency-debug-sg"
  description = "Emergency debugging SG - created during incident INC-2026-0307"
  vpc_id      = module.networking.vpc_id

  # import 후 plan으로 실제 설정과 비교하여 코드를 맞춘다
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]  # 내부 네트워크로 제한
    description = "PostgreSQL access from internal network"
  }

  tags = {
    Name        = "emergency-debug-sg"
    ManagedBy   = "terraform"
    CreatedBy   = "manual-import"
    Incident    = "INC-2026-0307"
    ReviewDate  = "2026-03-14"  # 1주 후 검토
  }

  lifecycle {
    # import 후 첫 apply에서 의도치 않은 삭제 방지
    prevent_destroy = true
  }
}

resource "aws_s3_bucket" "manual_backup" {
  bucket = "prod-manual-backup-20260307"

  tags = {
    Name      = "prod-manual-backup"
    ManagedBy = "terraform"
    CreatedBy = "manual-import"
  }
}

ツール比較:Spacelift vs Terramate vsネイティブ

機能比較表

ドリフト管理専用ツールを使用すると、ネイティブ方式に対する運用負担を大幅に削減できます。主なツールの機能を比較します。

| 機能Terraformネイティブ | OpenTofuネイティブ | Spacelift | Terramate | Env0 | | ----------------------- | ------------------------------ | ------------------- | ------------------------------ | ------------------------- | ------------------------------ | | ドリフト自動検出 | plan + cron手動設定 | plan + cron手動設定 | 組み込み(スケジュールベース) | CLI + CI統合 | 組み込み(スケジュールベース) | | ドリフト可視化 | planテキスト出力 | planテキスト出力 | Web UIダッシュボード | CLIレポート | Web UIダッシュボード | | 自動回復 | スクリプト直接実装 | スクリプト直接実装 | ポリシーベースの自動適用 | Orchestration連動 | ポリシーベースの自動適用 | | ポリシーエンジン | Sentinel(有料) | OPA統合 | OPA + Rego内蔵 | なし(外部連動) | OPA統合 | | State暗号化 | 未サポート(バックエンド依存) | ネイティブサポート | マネージド州 | なし(バックエンド依存) | マネージド州 | | マルチスタック管理 | ワークスペース | ワークスペース | Stackユニット管理 | Stackオーケストレーション | Environmentユニット | | 費用 | 無料(OSS) | 無料(OSS) | 有料(フリーティアあり) | 無料(OSS)+有料クラウド | 有料(フリーティアあり) | | VCS統合 | なし(CI / CD依存) | なし(CI / CD依存) | GitHub / GitLabディープ統合 | GitHub / GitLab統合 | GitHub / GitLab統合 | | 通知チャンネル | 直接実装 | 直接実装 | スラック、チーム、ウェブフック | Slack、Webhook | スラック、チーム、ウェブフック |

Spacelift ドリフト検出設定

Spaceliftは、最も成熟したTerraform / OpenTofu管理プラットフォームの1つです。スタック単位でドリフト検出を設定できます。

# spacelift.tf -- Spacelift 스택에서 드리프트 감지 활성화

resource "spacelift_stack" "prod_networking" {
  name        = "prod-networking"
  repository  = "infrastructure"
  branch      = "main"
  project_root = "environments/prod/networking"

  # Terraform 또는 OpenTofu 선택
  terraform_version = "1.11.0"
  # opentofu_version = "1.9.0"  # OpenTofu 사용 시

  autodeploy  = false  # PR merge 시 자동 apply 여부
  autoretry   = true   # 일시적 오류 시 재시도

  labels = ["prod", "networking", "drift-enabled"]
}

# 드리프트 감지 스케줄 설정
resource "spacelift_drift_detection" "prod_networking" {
  stack_id  = spacelift_stack.prod_networking.id
  reconcile = false  # true로 설정 시 자동 복구 (주의!)
  schedule  = ["0 0 * * *", "0 6 * * *"]  # UTC 기준 매일 2회
  timezone  = "Asia/Seoul"
  ignore_state = false
}

# 드리프트 감지 시 알림 정책
resource "spacelift_policy" "drift_notification" {
  name = "drift-notification-policy"
  type = "NOTIFICATION"
  body = <<-EOT
    package spacelift

    # 드리프트 감지 시 Slack 알림
    webhook[{"endpoint_id": endpoint_id, "payload": payload}] {
      input.run_type == "DRIFT_DETECTION"
      input.run_state == "FINISHED"
      input.drift_detection.drifted == true

      endpoint_id := "${spacelift_webhook.slack_drift.id}"
      payload := {
        "text": sprintf(":warning: 드리프트 감지: %s\n변경 리소스: %d개",
          [input.stack.name, input.drift_detection.resources_drifted])
      }
    }
  EOT
}

resource "spacelift_policy_attachment" "drift_notification" {
  policy_id = spacelift_policy.drift_notification.id
  stack_id  = spacelift_stack.prod_networking.id
}

Terramateを活用したマルチスタックドリフトオーケストレーション

Terramateは、複数のTerraform / OpenTofuスタックをオーケストレーションするオープンソースツールです。変更検出と実行順序管理に強みがあります。

# terramate.tm.hcl -- Terramate 스택 설정

terramate {
  config {
    run {
      env {
        TF_PLUGIN_CACHE_DIR = "/tmp/terraform-plugin-cache"
      }
    }

    cloud {
      organization = "myorg"
    }
  }
}

# 스택 정의
stack {
  name        = "prod-networking"
  description = "Production VPC and networking resources"
  id          = "prod-networking-001"

  tags = ["prod", "networking", "drift-check"]

  # 의존성 정의 -- networking이 먼저 감지되어야 함
  after = []
  before = [
    "/stacks/prod/compute",
    "/stacks/prod/database",
  ]
}
# Terramate CLI를 활용한 멀티 스택 드리프트 감지

# 모든 prod 스택에서 드리프트 감지 (의존성 순서 준수)
terramate run \
  --filter tags:prod \
  --filter tags:drift-check \
  --parallel 4 \
  -- terraform plan -detailed-exitcode -input=false -no-color

# 변경된 스택만 선택적으로 감지 (Git diff 기반)
terramate run \
  --changed \
  -- terraform plan -detailed-exitcode -input=false

# Terramate Cloud와 연동하여 드리프트 결과 동기화
terramate run \
  --filter tags:prod \
  --sync-drift \
  -- terraform plan -detailed-exitcode -out=drift.plan

操作時の注意事項

ドリフト検出の副作用

ドリフト検出自体が動作上の問題を引き起こす可能性があります。特に次の点に注意しなければならない。

1. API Rate Limiting

terraform plan はすべての管理リソースに対してプロバイダAPIを呼び出します。大規模なインフラストラクチャで頻繁な検出を実行すると、AWS API Rate Limitにかかる可能性があります。

2. State Lock 衝突

ドリフト検出中はStateへの読み取りロックがかかります。同時に terraform apply が実行されると、競合が発生する可能性があります。

3. 機密データの公開

Plan出力には、データベースパスワード、APIキーなどの機密情報を含めることができます。ログを保存するときは必ずフィルタリングしてください。

# 민감 데이터가 plan 출력에 노출되지 않도록 방지
# Terraform 1.10+ ephemeral variables 활용

variable "database_password" {
  type      = string
  sensitive = true
  ephemeral = true  # State에 저장되지 않음 (Terraform 1.10+)
}

resource "aws_db_instance" "main" {
  identifier     = "prod-main-db"
  engine         = "postgres"
  engine_version = "16.4"
  instance_class = "db.r6g.xlarge"
  password       = var.database_password

  # lifecycle ignore_changes로 외부 변경이 예상되는 속성 제외
  lifecycle {
    ignore_changes = [
      # ASG가 관리하는 속성은 드리프트 감지에서 제외
      # (이를 제외하지 않으면 false positive가 발생)
    ]
  }
}

ignore_changesの戦略的活用

すべてのドリフトを検出しようとすると、false positiveがあふれます。ignore_changes を戦略的に使用して意図的なドリフトを可能にすることが重要です。

# Auto Scaling Group -- desired_capacity는 ASG가 관리
resource "aws_autoscaling_group" "web" {
  name                = "prod-web-asg"
  min_size            = 2
  max_size            = 20
  desired_capacity    = 4
  vpc_zone_identifier = module.networking.private_subnet_ids

  launch_template {
    id      = aws_launch_template.web.id
    version = "$Latest"
  }

  lifecycle {
    ignore_changes = [
      desired_capacity,  # ASG 스케일링 정책이 관리
      target_group_arns, # ALB 연동 시 동적으로 변경
    ]
  }
}

# EKS 노드 그룹 -- 클러스터 오토스케일러가 관리하는 속성 제외
resource "aws_eks_node_group" "workers" {
  cluster_name    = aws_eks_cluster.main.name
  node_group_name = "workers"
  node_role_arn   = aws_iam_role.node_group.arn
  subnet_ids      = module.networking.private_subnet_ids

  scaling_config {
    desired_size = 3
    max_size     = 10
    min_size     = 2
  }

  lifecycle {
    ignore_changes = [
      scaling_config[0].desired_size,  # Cluster Autoscaler가 관리
    ]
  }
}

障害事例と回復手順

ケース 1: State ファイルの破損による完全なドリフト

状況:S3バックエンドのStateファイルが破損し、すべてのリソースがドリフトとして検出される

# 복구 절차 1: S3 버전닝에서 이전 State 복구
aws s3api list-object-versions \
  --bucket myorg-terraform-state \
  --prefix prod/infrastructure.tfstate \
  --query 'Versions[*].[VersionId,LastModified,Size]' \
  --output table

# 정상적인 이전 버전의 VersionId를 확인한 후 복원
aws s3api get-object \
  --bucket myorg-terraform-state \
  --key prod/infrastructure.tfstate \
  --version-id "abc123def456" \
  restored-state.tfstate

# State 파일 무결성 확인
terraform show restored-state.tfstate | head -20

# 복원된 State로 교체 (기존 State 백업 후)
aws s3 cp \
  s3://myorg-terraform-state/prod/infrastructure.tfstate \
  s3://myorg-terraform-state/prod/infrastructure.tfstate.corrupted-backup

aws s3 cp \
  restored-state.tfstate \
  s3://myorg-terraform-state/prod/infrastructure.tfstate

# State Lock 해제 (필요 시)
terraform force-unlock <LOCK_ID>

# plan으로 드리프트 상태 확인
terraform plan -detailed-exitcode

ケース2:緊急変更後のコード同期に失敗しました

状況:障害対応中にコンソールにセキュリティグループルールを追加しましたが、terraform apply がそのルールを削除

# 복구 절차: refresh로 현재 상태를 State에 반영한 후 코드 수정

# 1. 현재 실제 인프라 상태를 State에 반영
terraform refresh

# 2. 드리프트 확인
terraform plan

# 3. 긴급 변경 사항을 코드에 반영
# (코드 수정 후)
terraform plan  # 변경 사항 없음(No changes) 확인
terraform apply

ケース3:プロバイダのアップグレード後の一括ドリフト

状況:AWSプロバイダを5.xから6.xにアップグレードした後、デフォルト値の変更により何百ものリソースにドリフトが検出される

この場合 terraform plan の出力を慎重に検討し、実際のインフラストラクチャの変更が必要なのか、単にState属性を追加するのかを区別する必要があります。ほとんどの場合 terraform apply を実行するとStateだけが更新され、実際のインフラストラクチャは変更されません。

オペレーションチェックリスト

本番環境でIaCドリフト管理スキームを構築するときは、次のチェックリストを参照してください。

ベース構築段階

  • State バックエンドにバージョンニングとロックが設定されているか
  • Stateファイルの定期的なバックアップが行われているか
  • すべてのインフラ変更がPRベースでレビューされるワークフローがあるか
  • コンソール/CLI直接アクセスの監査ログ(CloudTrailなど)が有効になっていますか

ドリフト検出ステップ

  • 定期的なドリフト検知スケジュールが設定されているか(最小日1回)
  • ドリフト検出結果の通知チャネルが設定されているか
  • 重大度ベースの分類体系が定義されているか
  • false positiveを減らすため ignore_changes 政策が整理されているか
  • API Rate Limitを考慮した検出周期が設定されているか

回復プロセスステップ

  • ドリフトタイプ別復旧手順(Runbook)が文書化されているか
  • 緊急変更後のコード同期プロセスが定義されているか
  • Stateファイルの破損時に修復手順がテストされたか
  • 自動回復の範囲と条件が明確に定義されているか

ガバナンスフェーズ

  • ドリフト検知結果の定期レビュー会議があるか
  • ドリフト推移を追跡するメトリック/ダッシュボードがあるか
  • 繰り返し発生するドリフトの根本原因分析(RCA)が行われるか
  • チーム間IaC変更ルール(コードレビュー必須、コンソール変更禁止など)が合意されているか

終了

インフラドリフトは、IaCを導入したすべての組織が直面する避けられない現実です。重要なのは、ドリフトを完全に排除するのではなく、迅速に感知し、体系的に管理する能力を備えることだ。

TerraformとOpenTofuのネイティブ機能だけでも基本的なドリフト検出は可能だが、組織規模が大きくなるほどSpaceliftやTerramateなどの専門ツールの助けが必要になる。どのツールを選択しても、コアの原則は同じです。 コードが単一の真実ソースという原則を守るが、現実の例外状況を体系的に受け入れるプロセスを備えることだ。

緊急障害対応でコンソール接続を完全に禁止することは非現実的です。代わりに、緊急の変更後24時間以内にコードを同期する文化を解決し、自動化されたドリフト検出がそれを監視するように構築することが実用的なアプローチです。ドリフトは技術的問題であると同時にプロセスと文化の問題である。ツールとプロセスを一緒に備えなければ、初めてIaCの約束を守ることができる。

参考資料

  1. Terraform CLI - Plan Command
  2. OpenTofu - State Encryption
  3. Spacelift - Drift Detection
  4. Terramate - Orchestration
  5. HashiCorp - Import Configuration
  6. AWS Provider - Lifecycle ignore_changes
  7. OpenTofu GitHub Repository
  8. Env0 - Drift Detection