- Authors
- Name
- 들어가며
- 인프라 드리프트의 개념과 유형
- 드리프트가 발생하는 일반적 원인
- 네이티브 드리프트 감지 방법
- CI/CD 파이프라인 통합
- 자동 복구(Automated Remediation) 전략
- 도구 비교: Spacelift vs Terramate vs 네이티브
- 운영 시 주의사항
- 장애 사례와 복구 절차
- 운영 체크리스트
- 마치며
- 참고자료

들어가며
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 으로 생성한 파일을 분석한다.
"""
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 블록을 활용하면 코드 레벨에서 선언적으로 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 텍스트 출력 | 웹 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의 약속을 지킬 수 있다.