Terraform CI/CD Automation: Pipeline สำหรับ Plan และ Apply

การรัน Terraform ด้วยมือจากเครื่อง developer แต่ละคนเป็นวิธีที่ไม่ยั่งยืนเมื่อทีมเริ่มขยาย ปัญหาที่ตามมาคือ state drift, credential หลุดบนเครื่องพนักงาน, ไม่มี audit trail, และ apply ทับกันจนเกิด conflict การนำ Terraform เข้าสู่ pipeline CI/CD จึงเป็นการยกระดับ Infrastructure as Code ให้มีมาตรฐานเทียบเท่า application code ทั่วไป คือทุก change ต้องผ่าน review, automated check, และ deploy ผ่านระบบเดียวกันทั้งทีม

บทความนี้อธิบายสถาปัตยกรรม pipeline ที่เหมาะกับ IaC พร้อมตัวอย่างจริงบน GitHub Actions, GitLab CI, Jenkins และเทคนิคสำคัญอย่าง OIDC authentication, plan artifact review, manual approval gate, และ rollback strategy

สถาปัตยกรรม Pipeline สำหรับ Terraform

Pipeline มาตรฐานสำหรับ Terraform แบ่งเป็น 2 phase หลักที่ trigger คนละเหตุการณ์ Pull Request phase (PR stage) ทำหน้าที่ validate โค้ดและแสดง plan ให้ reviewer เห็นผลกระทบก่อน merge ส่วน Main branch phase (Apply stage) จะรัน apply หลัง PR ถูก merge เข้า main แล้วเท่านั้น แยกสิทธิ์และ trigger ชัดเจน ช่วยป้องกันการ apply โดยไม่ได้ตั้งใจ

  • PR Phase: fmt check → validate → lint (TFLint) → security scan (Checkov/tfsec) → plan → comment plan กลับ PR
  • Apply Phase: plan อีกครั้ง → (manual approval สำหรับ prod) → apply → save state → notify
  • Scheduled Phase (optional): drift detection รายวัน รัน plan แล้วแจ้งเตือนถ้ามี diff ที่ไม่ได้เกิดจาก code

ตัวอย่าง GitHub Actions

GitHub Actions เป็น CI ที่ integrate กับ GitHub repository ได้โดยตรง ตัวอย่าง workflow แยกเป็น 2 ไฟล์เพื่อให้จัดการสิทธิ์แยกกันได้

# .github/workflows/terraform-pr.yml
name: Terraform Plan (PR)

on:
  pull_request:
    paths:
      - 'infra/**'

permissions:
  id-token: write   # สำคัญ: OIDC ต้อง write
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
          aws-region: ap-southeast-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - run: terraform fmt -check -recursive
        working-directory: infra

      - run: terraform init
        working-directory: infra

      - run: terraform validate
        working-directory: infra

      - run: terraform plan -no-color -out=tfplan
        working-directory: infra

      - run: terraform show -no-color tfplan > plan.txt
        working-directory: infra

      - uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('infra/plan.txt', 'utf8');
            const body = `### Terraform Plan\n\`\`\`\n${plan.slice(0, 60000)}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            });

ส่วน apply workflow จะ trigger เฉพาะตอน push เข้า main branch และใช้ environment ที่ตั้ง protection rule ไว้ ทำให้ต้องมีการ approve ก่อนจริงๆ

# .github/workflows/terraform-apply.yml
name: Terraform Apply (main)

on:
  push:
    branches: [main]
    paths:
      - 'infra/**'

permissions:
  id-token: write
  contents: read

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: production    # protection rule + required reviewers
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
          aws-region: ap-southeast-1
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
        working-directory: infra
      - run: terraform apply -auto-approve
        working-directory: infra

OIDC Authentication (ไม่ต้องเก็บ Access Key)

จุดสำคัญของ pipeline คือ credential ที่ใช้เข้าถึง cloud provider แบบเดิมที่เก็บ static access key เป็น secret ใน CI เป็นวิธีที่เสี่ยง ถ้า secret หลุดคือใช้งานได้ไม่หมดอายุ วิธีที่ปลอดภัยกว่าคือใช้ OIDC (OpenID Connect) ให้ CI runner ขอ token ชั่วคราวจาก cloud provider โดยอิงกับ identity ของ job นั้น ไม่ต้องเก็บ key ใดๆ

# ฝั่ง AWS: สร้าง OIDC provider + IAM Role
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" "terraform_ci" {
  name = "terraform-ci"
  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 = {
          "token.actions.githubusercontent.com:sub" = "repo:myorg/infra:ref:refs/heads/main"
        }
      }
    }]
  })
}

Condition จำกัดว่า role นี้ assume ได้เฉพาะจาก repo และ branch ที่กำหนด ช่วยกัน privilege escalation ถ้ามีคนสร้าง repo ใหม่ชื่อคล้ายกัน หรือ branch ที่ไม่ได้ protect ก็ assume ไม่ได้

ตัวอย่าง GitLab CI

# .gitlab-ci.yml
stages: [validate, plan, apply]

variables:
  TF_ROOT: infra
  TF_STATE_NAME: ${CI_PROJECT_NAME}

image: hashicorp/terraform:1.7

before_script:
  - cd $TF_ROOT
  - terraform init

validate:
  stage: validate
  script:
    - terraform fmt -check
    - terraform validate

plan:
  stage: plan
  script:
    - terraform plan -out=tfplan
  artifacts:
    paths: ["$TF_ROOT/tfplan"]
    expire_in: 1 week

apply:
  stage: apply
  script:
    - terraform apply tfplan
  dependencies: [plan]
  when: manual        # ต้องกด play เท่านั้น
  only:
    refs: [main]

ข้อดีของ GitLab คือมี managed backend ให้ใช้เก็บ state ผ่าน HTTP backend และ when: manual ทำให้ apply stage ต้องกด play ก่อนถึงจะรัน เหมาะสำหรับ environment ที่ sensitive

Jenkins Pipeline

// Jenkinsfile
pipeline {
  agent any
  options { timestamps() }
  environment {
    AWS_DEFAULT_REGION = 'ap-southeast-1'
  }
  stages {
    stage('Init & Validate') {
      steps {
        sh 'terraform init'
        sh 'terraform fmt -check'
        sh 'terraform validate'
      }
    }
    stage('Plan') {
      steps {
        sh 'terraform plan -out=tfplan'
        archiveArtifacts artifacts: 'tfplan'
      }
    }
    stage('Approval') {
      when { branch 'main' }
      steps {
        input message: 'Apply changes to production?', ok: 'Apply'
      }
    }
    stage('Apply') {
      when { branch 'main' }
      steps {
        sh 'terraform apply tfplan'
      }
    }
  }
}

Plan Artifact กับ Review Process

หัวใจของ IaC pipeline คือการให้ reviewer เห็น plan ก่อน apply เสมอ เทคนิคที่นิยมใช้มี 3 แบบ แบบแรกคือ save plan เป็น binary file (-out=tfplan) แล้วใช้ไฟล์เดียวกันใน apply เพื่อให้แน่ใจว่าสิ่งที่ reviewer เห็นกับสิ่งที่ execute เป็นอันเดียวกัน แบบที่สองคือ parse plan เป็น JSON (terraform show -json tfplan) เพื่อใช้กับ policy check หรือ compliance scan แบบที่สามคือ render plan เป็น markdown comment กลับไปยัง PR ให้ reviewer อ่านได้ใน UI เดียวกับ code review

# Parse plan เป็น JSON เพื่อ automation
terraform show -json tfplan | jq '.resource_changes[] | {address, actions: .change.actions}'

# ตัวอย่าง output
# { "address": "aws_instance.web", "actions": ["create"] }
# { "address": "aws_s3_bucket.old", "actions": ["delete"] }

Secret Management ใน Pipeline

แม้จะใช้ OIDC แล้ว ก็ยังมี secret บางตัวที่ต้องส่งเข้า Terraform เช่น database password, API key ของบริการภายนอก วิธีที่แนะนำคือเก็บใน secret manager (AWS Secrets Manager, HashiCorp Vault, GitHub Encrypted Secrets) แล้วให้ pipeline inject เข้ามาเป็น environment variable ตอนรัน ห้ามเขียน secret ตรงๆ ใน workflow file หรือ print ค่าออกใน log

# GitHub Actions: ส่ง secret เป็น TF_VAR_
- run: terraform apply -auto-approve
  env:
    TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
    TF_VAR_api_token: ${{ secrets.API_TOKEN }}

# ห้ามเขียนแบบนี้ (ถูก log)
- run: echo "password=${{ secrets.DB_PASSWORD }}"

Manual Approval Gate สำหรับ Production

สำหรับ environment ที่สำคัญ ควรมีคน approve ก่อน apply แยกบทบาท developer ที่ merge code กับ operator ที่อนุมัติ deploy GitHub Actions ใช้ Environment Protection Rules, GitLab ใช้ when: manual, Jenkins ใช้ input step ทุกแพลตฟอร์มรองรับการตั้ง required reviewer และ timeout หลัง approve

  • ตั้ง required reviewer อย่างน้อย 2 คน สำหรับ prod
  • Dev environment ให้ apply อัตโนมัติได้ เพื่อให้ feedback loop เร็ว
  • ตั้ง timeout ไม่ให้ pending ค้างนานเกินไป (เช่น 24 ชั่วโมง)
  • เก็บ audit log ว่าใครเป็นคน approve พร้อม timestamp

Drift Detection Schedule

Drift คือสถานะที่ cloud resource ถูกแก้ไขนอก Terraform เช่นคนเข้าไปแก้ใน console โดยตรง วิธีจับคือตั้ง scheduled job รัน terraform plan รายวัน ถ้าผลลัพธ์ไม่ใช่ No changes ก็แจ้ง Slack/email ให้ทีมทราบ

# .github/workflows/drift-detect.yml
on:
  schedule:
    - cron: '0 2 * * *'   # 09:00 เวลาไทย

jobs:
  detect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
        working-directory: infra
      - id: plan
        run: |
          terraform plan -detailed-exitcode -out=tfplan || echo "exit=$?" >> $GITHUB_OUTPUT
        working-directory: infra
      - if: steps.plan.outputs.exit == '2'
        run: |
          curl -X POST -H 'Content-type: application/json' \
            --data '{"text":"⚠️ Infrastructure drift detected"}' \
            ${{ secrets.SLACK_WEBHOOK }}

Flag -detailed-exitcode จะ return 0 (no change), 1 (error), 2 (มี diff) ทำให้แยกกรณี drift ออกจาก error จริงได้

Rollback Strategy

Terraform เป็น declarative ไม่มี rollback command โดยตรง การย้อนกลับคือ revert code ใน git แล้ว apply ใหม่ วิธีที่ช่วยให้ rollback ทำได้จริงประกอบด้วย เก็บ state version ใน backend (S3 versioning + DynamoDB lock, Terraform Cloud) เพื่อกู้ state เดิมได้ถ้าจำเป็น, ใช้ git tag ทุก release เพื่อ checkout โค้ดกลับจุดที่ต้องการได้ทันที, และแยก change ที่เป็น destructive (drop database, delete volume) ไว้ใน PR ต่างหาก เพื่อให้ reviewer โฟกัสเป็นพิเศษ

  • Tag ทุก apply สำเร็จเป็น deploy-YYYYMMDD-HHMM เพื่อ reference
  • เก็บ plan output และ apply log อย่างน้อย 90 วันสำหรับ audit
  • ทดสอบ rollback workflow ใน staging เป็นระยะ

Terraform Cloud/Enterprise Remote Run

ถ้าไม่อยากสร้าง pipeline เอง Terraform Cloud (TFC) มี remote execution ในตัว เขียน backend เป็น cloud {} แล้วทุกคำสั่ง plan/apply จะวิ่งใน worker ของ HashiCorp มี UI แสดง plan, approval flow, notification, cost estimation, และ policy (Sentinel/OPA) พร้อมใช้ ไม่ต้องเขียน YAML เอง

terraform {
  cloud {
    organization = "my-org"
    workspaces {
      name = "production"
    }
  }
}

เหมาะกับทีมที่ไม่ต้องการ maintain CI เอง แต่มีค่าใช้จ่ายตาม resource หรือจำนวน run ต่อเดือน การตัดสินใจระหว่าง self-hosted CI กับ TFC ขึ้นกับขนาดทีม กับงบ และระดับการ integrate ที่ต้องการ

ข้อควรระวังในการสร้าง Pipeline

  • อย่าใช้ static credential: ใช้ OIDC หรือ short-lived token เสมอ
  • อย่า apply parallel: state lock จะ conflict ใช้ concurrency rule จำกัด 1 run ต่อ workspace
  • อย่า print state/plan ออก log: อาจมีข้อมูล sensitive รั่ว ใช้ -no-color ใน CI แต่อย่า echo plan ตรงๆ
  • Version pinning: ทั้ง Terraform version และ provider version ต้องถูก pin ไว้ ไม่งั้น CI กับ local ผลต่างกัน
  • Auto-approve เฉพาะ non-prod: prod ต้องผ่านคนเสมอ
  • Monitor CI runner IAM: permission ที่ให้ runner ควรเป็น least privilege และ review ทุกไตรมาส

สรุป

การนำ Terraform เข้าสู่ pipeline CI/CD เปลี่ยน IaC จากเครื่องมือใช้งาน ad-hoc เป็นกระบวนการ deploy ที่มีมาตรฐาน ทุก change ถูก version control, review, ผ่าน automated test, และ apply โดย identity ที่เราควบคุมได้ โครงสร้าง 2 phase (PR plan + main apply) ร่วมกับ OIDC, manual approval สำหรับ prod, และ scheduled drift detection คือ pattern ที่ผ่านการทดสอบในองค์กรจริงมาแล้ว

ไม่ว่าจะเลือก GitHub Actions, GitLab CI, Jenkins หรือ Terraform Cloud หลักการเดียวกันคือ separation of duties ระหว่าง developer กับ operator, no static secret ใน pipeline, plan ต้อง review ได้ก่อน apply, และมี audit trail ทุก deploy สำหรับทีมที่เพิ่งเริ่มต้น ให้เริ่มจาก workflow แบบง่ายบน single environment แล้วค่อยขยายเป็น multi-env, manual gate, และ drift detection ตามลำดับ