Terraform에 전용 파이프라인이 필요한 이유

달리다 terraform apply 개발자의 노트북에서 수동으로 인프라에서 가장 위험한 패턴입니다. 자동화된 워크플로가 없으면 문제가 발생합니다. 일반적인 사항은 상태 파일이 업데이트되지 않음, 로컬 자격 증명이 CI 환경과 다름, 신청하기 전에 계획을 검토하지 않으며, 누가 언제 무엇을 적용했는지 추적할 수 없습니다.

전문적인 Terraform 파이프라인은 이러한 모든 문제를 해결합니다. 계획은 다음과 같습니다. 자동으로 생성되어 Pull Request에서 검토되며 이후에만 적용이 발생합니다. 버전 관리 시스템에 영구 로그가 포함된 승인. 이 기사에서는 우리는 두 가지 접근 방식으로 이 파이프라인을 구축합니다. GitHub 작업 팀을 위한 작고 아틀란티스 중간 및 대규모 팀용.

무엇을 배울 것인가

  • GitHub Actions 파이프라인: fmt, verify, tflint, PR 계획 및 병합 시 적용
  • OIDC를 통한 안전한 클라우드 자격 증명 관리(장기 비밀 없음)
  • Atlantis: 설치, atlantis.yaml 및 협업 PR 워크플로우
  • 관리형 대안으로서의 Terraform Cloud/HCP Terraform
  • 드리프트 감지: 상태와 실제 인프라 간의 불일치를 감지하는 크론 작업
  • 다중 환경 파이프라인을 위한 매트릭스 전략(개발/스테이징/프로덕션)
  • 계획 및 드리프트 경고에 대한 Slack/Teams 알림

Terraform 저장소의 구조

파이프라인을 구축하기 전에 명확한 저장소 구조가 필요합니다. 패턴 가장 일반적인 것은 환경별로 별개의 디렉토리로 분리하는 것입니다. 모듈은 별도의 디렉토리에서 공유됩니다.

terraform-infra/
  modules/
    vpc/
      main.tf
      variables.tf
      outputs.tf
    eks-cluster/
    rds-postgres/
  environments/
    dev/
      main.tf         # Usa i moduli con variabili dev
      variables.tf
      terraform.tfvars
      backend.tf      # Backend S3 per dev
    staging/
      main.tf
      terraform.tfvars
      backend.tf
    production/
      main.tf
      terraform.tfvars
      backend.tf
  .terraform-version  # tfenv: specifica la versione di Terraform
  .tflint.hcl         # Configurazione tflint
  .github/
    workflows/
      terraform.yml
# .terraform-version
# Usato da tfenv per installare la versione corretta
1.7.5

GitHub Actions: 파이프라인 완성

우리가 구축하는 GitHub Actions 파이프라인에는 두 가지 워크플로가 있습니다. 하나는 풀 요청(lint + 계획)에 의해 트리거되고 다른 하나는 메인 병합(적용)에 의해 트리거됩니다.

# .github/workflows/terraform.yml
name: Terraform CI/CD

on:
  push:
    branches:
      - main
    paths:
      - 'environments/**'
      - 'modules/**'
  pull_request:
    branches:
      - main
    paths:
      - 'environments/**'
      - 'modules/**'

permissions:
  contents: read
  pull-requests: write  # Per commentare il plan sul PR
  id-token: write       # Per OIDC authentication con AWS

env:
  TF_VERSION: '1.7.5'
  TFLINT_VERSION: 'v0.50.0'

jobs:
  # Job 1: Lint e validazione statica (tutti gli ambienti in parallelo)
  lint-validate:
    name: Lint & Validate (${{ matrix.environment }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, production]
      fail-fast: false

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

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

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: ${{ env.TFLINT_VERSION }}

      - name: Terraform Format Check
        run: terraform fmt -check -recursive
        working-directory: .

      - name: Terraform Init (solo backend locale per lint)
        run: terraform init -backend=false

      - name: Terraform Validate
        run: terraform validate

      - name: TFLint
        run: |
          tflint --init
          tflint --format compact

  # Job 2: Plan su Pull Request
  plan:
    name: Plan (${{ matrix.environment }})
    runs-on: ubuntu-latest
    needs: lint-validate
    if: github.event_name == 'pull_request'
    strategy:
      matrix:
        environment: [dev, staging]  # Non pianifichiamo prod su ogni PR
      fail-fast: false

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

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

      # OIDC: nessun secret long-lived necessario
      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsterraform-${{ matrix.environment }}
          aws-region: eu-west-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
          echo "PLAN_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV"

      # Commenta il plan sul Pull Request
      - name: Comment Plan on PR
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const fs = require('fs');
            const planOutput = fs.readFileSync('environments/${{ matrix.environment }}/plan_output.txt', 'utf8');
            const truncated = planOutput.length > 65000
              ? planOutput.substring(0, 65000) + '\n... (truncated)'
              : planOutput;

            const body = `## Terraform Plan - ${{ matrix.environment }}

            \`\`\`
            ${truncated}
            \`\`\`

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;

            // Cerca commenti precedenti dello stesso workflow e aggiorna
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const existingComment = comments.data.find(c =>
              c.user.login === 'github-actions[bot]' &&
              c.body.includes('Terraform Plan - ${{ matrix.environment }}')
            );

            if (existingComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existingComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

  # Job 3: Apply su merge in main
  apply:
    name: Apply (${{ matrix.environment }})
    runs-on: ubuntu-latest
    needs: lint-validate
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    strategy:
      # Apply sequenziale: prima dev, poi staging, poi production
      max-parallel: 1
      matrix:
        environment: [dev, staging, production]

    environment:
      name: ${{ matrix.environment }}  # Richiede approvazione manuale per production

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

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

      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsterraform-${{ matrix.environment }}
          aws-region: eu-west-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        run: terraform apply -auto-approve -no-color

OIDC: 비밀이 없는 자격 증명은 오래 지속됩니다.

GitHub Actions를 AWS에 인증하는 최신 모범 사례는 다음과 같습니다. OIDC(오픈ID 커넥트): GitHub는 AWS가 서명한 JWT 토큰을 생성합니다. 외울 필요 없이 받아들여 AWS_ACCESS_KEY_ID 비밀에.

# Configurazione IAM Role per GitHub Actions OIDC
# Crea questo con Terraform stesso (bootstrap)

# iam.tf
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1"
  ]
}

resource "aws_iam_role" "github_actions_terraform" {
  for_each = toset(["dev", "staging", "production"])

  name = "GitHubActionsterraform-${each.key}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          # Permetti solo da questo repo specifico
          "token.actions.githubusercontent.com:sub" = "repo:myorg/terraform-infra:*"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "terraform_permissions" {
  for_each = aws_iam_role.github_actions_terraform

  role       = each.value.name
  policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
  # In produzione usa policy più ristrette con least-privilege
}

Atlantis: PR을 통한 협업 계획/적용

아틀란티스 GitHub/GitLab/Bitbucket 웹후크를 수신하는 서버입니다. PR의 댓글에 응답합니다. 당신이 쓸 때 atlantis plan 댓글에서, 아틀란티스가 처형되다 terraform plan 그리고 출력으로 응답합니다. 이것은 PR에서 워크플로를 완전히 추적하고 검토할 수 있습니다.

# atlantis.yaml - Configurazione nella root del repository
version: 3

# Abilita auto-planning: piano automatico quando il PR tocca file .tf
automerge: false
parallel_plan: true
parallel_apply: false

projects:
  - name: dev
    dir: environments/dev
    workspace: default
    autoplan:
      when_modified: ["*.tf", "*.tfvars", "../../modules/**/*.tf"]
      enabled: true
    apply_requirements:
      - approved       # Richiede almeno 1 approvazione
      - mergeable      # Il PR deve essere mergeable

  - name: staging
    dir: environments/staging
    workspace: default
    autoplan:
      when_modified: ["*.tf", "*.tfvars", "../../modules/**/*.tf"]
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: production
    dir: environments/production
    workspace: default
    autoplan:
      enabled: false    # Non pianifica automaticamente in prod
    apply_requirements:
      - approved        # Richiede almeno 2 approvazioni
      - mergeable
    allowed_override_dentists:  # Solo questi utenti possono fare apply in prod
      - alice
      - bob
# Comandi Atlantis nel Pull Request (come commento)

# Esegui plan per il progetto 'dev'
atlantis plan -p dev

# Esegui plan per tutti i progetti modificati
atlantis plan

# Applica dopo approvazione del PR
atlantis apply -p dev

# Annulla un plan in corso
atlantis unlock

# Mostra lo stato attuale
atlantis status
# docker-compose.yml per Atlantis self-hosted
version: '3.8'

services:
  atlantis:
    image: ghcr.io/runatlantis/atlantis:v0.27.0
    ports:
      - "4141:4141"
    environment:
      ATLANTIS_GH_USER: atlantis-bot           # GitHub user del bot
      ATLANTIS_GH_TOKEN: ${GH_TOKEN}            # Personal Access Token del bot
      ATLANTIS_GH_WEBHOOK_SECRET: ${WEBHOOK_SECRET}
      ATLANTIS_REPO_ALLOWLIST: "github.com/myorg/terraform-infra"
      ATLANTIS_AUTOMERGE: "false"
      ATLANTIS_WRITE_GIT_CREDS: "true"

      # Credenziali AWS (oppure usa IAM Instance Profile)
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      AWS_DEFAULT_REGION: eu-west-1

    volumes:
      - atlantis-data:/home/atlantis
      - ./atlantis.yaml:/home/atlantis/atlantis.yaml:ro

volumes:
  atlantis-data:

드리프트 감지: 불일치 감지를 위한 크론 작업

Il 경향 실제 인프라가 다를 때입니다. Terraform 상태 파일에 설명된 내용에서 드리프트 발생 누군가가 AWS 콘솔에서 리소스를 수동으로 편집할 때, 리소스가 외부 프로세스에 의해 변경되거나 클라우드 API의 동작이 변경되는 경우.

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

on:
  schedule:
    - cron: '0 8 * * 1-5'  # Ogni giorno lavorativo alle 8:00 UTC
  workflow_dispatch:          # Esecuzione manuale

permissions:
  contents: read
  issues: write               # Per aprire issue se drift rilevato
  id-token: write

jobs:
  detect-drift:
    name: Detect Drift (${{ matrix.environment }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, production]
      fail-fast: false

    defaults:
      run:
        working-directory: environments/${{ matrix.environment }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.7.5'

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsReadOnly-${{ matrix.environment }}
          aws-region: eu-west-1

      - name: Terraform Init
        run: terraform init -no-color

      - name: Terraform Plan (Drift Detection)
        id: drift
        run: |
          # Esci con codice 0 se no changes, 1 se error, 2 se changes (drift)
          terraform plan -detailed-exitcode -no-color 2>&1 | tee drift_output.txt
          echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV"

      - name: Alert on Drift
        if: env.EXIT_CODE == '2'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const output = fs.readFileSync('environments/${{ matrix.environment }}/drift_output.txt', 'utf8');

            // Crea un issue GitHub per il drift rilevato
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: '[DRIFT] Infrastructure drift detected in ${{ matrix.environment }}',
              body: `## Drift rilevato in ambiente: ${{ matrix.environment }}

            Data: ${new Date().toISOString()}

            \`\`\`
            ${output.substring(0, 60000)}
            \`\`\`

            **Azione richiesta:** Analizza le modifiche e riconcilia lo state.
            - Se la modifica è intenzionale: aggiorna il codice Terraform e fai un PR
            - Se è una modifica non autorizzata: ripristina con \`terraform apply\``,
              labels: ['infrastructure', 'drift', '${{ matrix.environment }}'],
            });

      - name: Notify Slack on Drift
        if: env.EXIT_CODE == '2'
        uses: slackapi/slack-github-action@v1.26.0
        with:
          payload: |
            {
              "text": ":warning: Terraform drift rilevato in *${{ matrix.environment }}*!\nControlla il <${{ github.server_url }}/${{ github.repository }}/issues|GitHub Issue> creato.",
              "channel": "#infra-alerts"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

TFLint: HCL용 고급 Linting

# .tflint.hcl - Configurazione TFLint
plugin "aws" {
  enabled = true
  version = "0.29.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

plugin "azurerm" {
  enabled = true
  version = "0.26.0"
  source  = "github.com/terraform-linters/tflint-ruleset-azurerm"
}

# Regole generali
rule "terraform_deprecated_interpolation" {
  enabled = true
}

rule "terraform_documented_outputs" {
  enabled = true
}

rule "terraform_documented_variables" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
  format  = "snake_case"
}

rule "terraform_required_providers" {
  enabled = true
}

rule "terraform_required_version" {
  enabled = true
}

# Regole AWS specifiche
rule "aws_instance_invalid_type" {
  enabled = true
}

rule "aws_instance_previous_type" {
  enabled = true
}

Terraform Cloud: 관리형 대안

아틀란티스를 직접 관리하고 싶지 않다면, HCP 테라폼 (이전의 Terraform Cloud) 원격 계획/적용, 상태관리, SSO, 정책(Sentinel)을 서비스로 제공합니다.

# terraform.tf - Configurazione HCP Terraform come backend
terraform {
  required_version = "~> 1.7"

  # HCP Terraform (gestisce state, plan e apply in cloud)
  cloud {
    organization = "my-organization"

    workspaces {
      # Workspace per environment con tagging
      tags = ["terraform-infra", "aws"]
    }
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Con HCP Terraform:
# - Nessun backend S3 da gestire
# - Plan/Apply avvengono nei server HCP (non in CI)
# - Variabili e secrets gestiti nel workspace
# - VCS integration: trigger automatico da PR
# - Team-based access control
# - Run history permanente

프로덕션의 Terraform 파이프라인 모범 사례

Terraform 파이프라인 체크리스트

  • 미국 OIDC CI 암호의 수명이 긴 AWS 자격 증명 대신
  • 달리다 terraform fmt -check 형식화되지 않은 코드로 PR을 차단하려면
  • 미국 terraform validate 구문 오류를 잡기 전에
  • 바이너리 계획을 저장합니다(-out=tfplan) 이를 적용에 사용하면 재생성되지 않습니다.
  • 병렬이 아닌 순차적 순서(개발 → 스테이징 → 프로덕션)로 적용을 실행합니다.
  • GitHub 환경 보호 규칙을 사용하여 프로덕션에 대한 수동 승인 요청
  • 수동 변경 사항을 감지하기 위해 예약된 드리프트 감지 구현
  • 추가하다 prevent_destroy = true 중요한 상태 저장 리소스에 대해
  • 모든 계획을 기록하고 CI 로그에 타임스탬프와 작성자를 적용하세요.

결론 및 다음 단계

잘 설계된 Terraform 파이프라인은 인프라의 기초입니다. 안전하고 추적 가능합니다. PR의 계획-검토-적용 워크플로는 인프라 수정은 검토 없이 발생하지만 드리프트 감지는 계획되지 않은 변경 사항이 문제가 되기 전에 포착하세요.

시리즈의 다음 기사

  • 제5조: IaC 테스트 — Terratest, Terraform 테스트 네이티브 계약 테스트: Terraform 모듈에 대한 자동화된 테스트를 작성하는 방법.
  • 제6조: IaC 보안 — Checkov, Trivy 및 OPA 코드형 정책: 보안 스캐닝을 파이프라인의 사전 적용 게이트에 통합합니다.