Skip to content

필사 모드: Terraform과 OpenTofu IaC 드리프트 감지와 자동 복구 운영 가이드

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

Infrastructure as Code(IaC)의 핵심 약속은 "코드에 선언된 상태가 곧 인프라의 실제 상태"라는 것이다. 그러나 현실의 프로덕션 환경에서는 이 약속이 쉽게 깨진다. 콘솔에서의 긴급 수정, 다른 자동화 도구의 개입, AWS Auto Scaling의 동적 변경, 심지어 API 기본값의 버전 간 차이까지 -- 코드와 실제 인프라 사이에 간극이 벌어지는 현상을 **인프라 드리프트(Infrastructure Drift)**라고 부른다.

2026년 현재 Terraform 1.11과 OpenTofu 1.9가 릴리스되면서, 드리프트 감지와 관리 기능이 크게 강화되었다. 그러나 도구 자체의 `plan` 명령만으로는 조직 수준의 드리프트 관리 체계를 구축하기 어렵다. 이 글에서는 드리프트의 본질적 원인을 분석하고, 자동 감지 파이프라인 구축, CI/CD 통합, 복구 전략, 그리고 Spacelift와 Terramate 같은 전문 도구 활용까지 실전 운영에 필요한 모든 것을 다룬다.

인프라 드리프트의 개념과 유형

드리프트란 무엇인가

인프라 드리프트는 IaC 코드로 선언한 **기대 상태(Desired State)**와 클라우드 프로바이더에 실제 존재하는 **현재 상태(Actual State)** 사이의 불일치를 의미한다. Terraform의 State 파일은 마지막으로 알려진 인프라 상태를 저장하는데, 이 세 가지(코드, State, 실제 인프라) 사이에 불일치가 발생하면 드리프트가 존재하는 것이다.

┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐

│ HCL 코드 │ │ Terraform State │ │ 실제 인프라 │

│ (Desired) │ │ (Last Known) │ │ (Actual) │

│ │ │ │ │ │

│ instance_type │ │ instance_type │ │ instance_type │

│ = "t3.medium" │ │ = "t3.medium" │ │ = "t3.xlarge" │

│ │ │ │ │ ← 콘솔에서 변경 │

└─────────────────┘ └──────────────────┘ └─────────────────┘

▲ ▲ ▲

│ │ │

└─── 일치 ──────────────┘─── 불일치(드리프트) ────┘

드리프트의 세 가지 유형

**1. 구성 드리프트(Configuration Drift)**

리소스의 속성이 코드에 선언된 값과 달라진 경우이다. 예를 들어 Security Group 규칙이 콘솔에서 추가되거나, 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 으로 생성한 파일을 분석한다.

"""

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` 블록을 활용하면 코드 레벨에서 선언적으로 import를 수행할 수 있다.

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

콘솔에서 생성된 Security Group을 코드로 가져오기

to = aws_security_group.emergency_debug_sg

id = "sg-0abc123def456"

}

콘솔에서 생성된 S3 버킷을 코드로 가져오기

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 텍스트 출력 | 웹 UI 대시보드 | CLI 보고서 | 웹 UI 대시보드 |

| **자동 복구** | 스크립트 직접 구현 | 스크립트 직접 구현 | 정책 기반 자동 apply | Orchestration 연동 | 정책 기반 자동 apply |

| **정책 엔진** | Sentinel (유료) | OPA 통합 | OPA + Rego 내장 | 없음 (외부 연동) | OPA 통합 |

| **State 암호화** | 미지원 (백엔드 의존) | 네이티브 지원 | 관리형 State | 없음 (백엔드 의존) | 관리형 State |

| **멀티 스택 관리** | Workspaces | Workspaces | Stack 단위 관리 | Stack 오케스트레이션 | Environment 단위 |

| **비용** | 무료 (OSS) | 무료 (OSS) | 유료 (Free tier 있음) | 무료 (OSS) + 유료 Cloud | 유료 (Free tier 있음) |

| **VCS 통합** | 없음 (CI/CD 의존) | 없음 (CI/CD 의존) | GitHub/GitLab 깊은 통합 | GitHub/GitLab 통합 | GitHub/GitLab 통합 |

| **알림 채널** | 직접 구현 | 직접 구현 | Slack, Teams, Webhook | Slack, Webhook | Slack, Teams, Webhook |

Spacelift 드리프트 감지 설정

Spacelift는 가장 성숙한 Terraform/OpenTofu 관리 플랫폼 중 하나이다. 스택 단위로 드리프트 감지를 설정할 수 있다.

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: 긴급 변경 후 코드 동기화 실패

**상황**: 장애 대응 중 콘솔에서 Security Group 규칙을 추가했으나, 이후 `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 같은 전문 도구의 도움이 필요해진다. 어떤 도구를 선택하든, 핵심 원칙은 동일하다. **코드가 단일 진실 소스(Single Source of Truth)**라는 원칙을 지키되, 현실의 예외 상황을 체계적으로 수용하는 프로세스를 갖추는 것이다.

긴급 장애 대응에서 콘솔 접속을 완전히 금지하는 것은 비현실적이다. 대신, 긴급 변경 후 24시간 이내에 코드를 동기화하는 문화를 정착시키고, 자동화된 드리프트 감지가 이를 감시하도록 구축하는 것이 실용적인 접근이다. 드리프트는 기술적 문제인 동시에 프로세스와 문화의 문제이다. 도구와 프로세스를 함께 갖추어야 비로소 IaC의 약속을 지킬 수 있다.

참고자료

1. [Terraform CLI - Plan Command](https://developer.hashicorp.com/terraform/cli/commands/plan)

2. [OpenTofu - State Encryption](https://opentofu.org/docs/language/state/encryption/)

3. [Spacelift - Drift Detection](https://docs.spacelift.io/concepts/stack/drift-detection)

4. [Terramate - Orchestration](https://terramate.io/docs/cli/orchestration)

5. [HashiCorp - Import Configuration](https://developer.hashicorp.com/terraform/language/import)

6. [AWS Provider - Lifecycle ignore_changes](https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle)

7. [OpenTofu GitHub Repository](https://github.com/opentofu/opentofu)

8. [Env0 - Drift Detection](https://docs.env0.com/docs/drift-detection)

현재 단락 (1/663)

Infrastructure as Code(IaC)의 핵심 약속은 "코드에 선언된 상태가 곧 인프라의 실제 상태"라는 것이다. 그러나 현실의 프로덕션 환경에서는 이 약속이 쉽게 깨진...

작성 글자: 0원문 글자: 21,695작성 단락: 0/663