ข้ามไปยังเนื้อหา

CI/CD for Infrastructure

pipeline มาตรฐานสำหรับ infrastructure รัน plan อัตโนมัติทุกครั้งที่มี pull request เพื่อให้คน review และเก็บ apply ไว้หลัง merge เข้า main branch เท่านั้น โดยใช้ Azure credential แบบ short-lived ที่ federate ผ่าน OIDC แทน client secret แบบ long-lived ที่เก็บไว้ใน CI

pattern ที่ทีมส่วนใหญ่ลงเอยด้วยกันนั้นคล้าย CI/CD ของ application แต่มีจุดต่างสำคัญตรงว่าแต่ละ stage ทำอะไรจริง ๆ

  • เปิดหรืออัปเดต pull request → รัน terraform plan (หรือ terragrunt run --all plan สำหรับ setup ที่มีหลาย unit ใน Terragrunt) แล้ว post diff ที่ได้เป็น comment บน pull request ยังไม่มีอะไรเปลี่ยนบน Azure จริง ๆ ตรงนี้เป็นแค่ dry run ที่หน้าที่เดียวคือทำให้ change ที่เสนอมามองเห็นได้ก่อนที่ใครจะกด approve
  • merge เข้า main → รัน terraform apply (หรือ terragrunt run --all apply) จาก pipeline เอง ไม่ใช่จาก laptop ของใคร สำหรับ production โดยเฉพาะ step นี้มักถูก gate ไว้ด้วย manual approval reviewer หรือทีมที่กำหนดต้องกด “approve” ใน CI system ก่อนที่ apply job จะรันได้ ถึงแม้จะ review plan ไปแล้วตอน pull request
name: terraform
on:
pull_request:
paths:
- 'infra/**'
push:
branches:
- main
paths:
- 'infra/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v3
- run: terragrunt run --all plan -no-color | tee plan.txt
- run: gh pr comment ${{ github.event.pull_request.number }} --body-file plan.txt
env:
GH_TOKEN: ${{ github.token }}
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v3
- run: terragrunt run --all apply --non-interactive

บรรทัด environment: production คือตัวที่ทำให้เกิด manual approval gate GitHub Actions จะกัน apply job ไว้จนกว่าคนที่มีสิทธิ์บน environment นั้นจะ approve run นี้ สังเกตว่าไม่มี job ไหน checkout client secret ที่เก็บไว้เลย ทั้งสอง job authenticate ผ่าน OIDC ซึ่งจะพูดถึงต่อไป

pull request ของ application code จะได้รับการ review เรื่อง logic, test, และ style worst case คือ bug หลุดไปแล้วต้อง roll back แต่ infrastructure apply ทำอะไรที่ code deploy ทำไม่ได้ คือทำลาย resource จริงที่มี state บน Azure แล้วสร้างขึ้นใหม่ทั้งหมด บางครั้งแค่เพราะ change บรรทัดเดียว

Terraform เรียกสิ่งนี้ว่า forced replacement argument บางตัวของ resource บางประเภทอัปเดตแบบ in place ไม่ได้ เปลี่ยนค่าเหล่านั้นแปลว่า provider ไม่มี API call สำหรับ “แก้ attribute นี้” มีแต่ “ทำลายแล้วสร้างใหม่” การเปลี่ยนชื่อ azurerm_linux_virtual_machine, การเปลี่ยน account_tier ของ storage account หรือการย้าย resource บางตัวไป location อื่นก็เข้าข่ายนี้ แล้วแต่ resource เมื่อเจอแบบนี้ terraform plan จะทำเครื่องหมาย resource นั้นด้วย -/+ แทนที่จะเป็น ~ แบบ in-place

Terminal window
# terraform plan output — this is the line to stop and read carefully
# azurerm_mssql_database.main must be replaced
-/+ resource "azurerm_mssql_database" "main" {
~ name = "app-db" -> "app-db-renamed" # forces replacement
sku_name = "S1"
max_size_gb = 50
}

-/+ บน resource ที่มี state อย่าง database หรือ managed disk แปลว่าอาจมี data loss จริงหรือ downtime จริงทันทีที่มีคนกด approve resource เก่าหายไปก่อนที่ resource ใหม่จะมีอยู่ด้วยซ้ำ นี่คือเหตุผลที่ review infrastructure plan จะ skim ผ่าน ๆ ไม่ได้ reviewer ต้องสแกนหาบรรทัด -/+ โดยเฉพาะ แล้วถามว่า replacement นั้น expected และ safe หรือเปล่า ไม่ใช่แค่อ่าน plan แบบเดียวกับที่อ่าน code diff เพื่อดู style กับ logic

pipeline รุ่นเก่าเก็บ Azure AD application client secret แบบ long-lived ไว้เป็น CI secret แล้วส่งให้ terraform login หรือ azurerm provider block ใช้ทุกครั้งที่รัน secret นั้นใช้ได้ทั้ง plan และ apply เหมือนกันหมด ซึ่งนั่นแหละคือปัญหา ถ้าหลุดออกไปเมื่อไหร่ จาก log ที่ config ผิด, dependency ที่ถูก compromise, หรือ workflow file ที่ echo environment variable ออกมา ก็ยังใช้งานได้ต่อไปจนกว่าจะมีคน rotate หรือ revoke ด้วยมือ

practice ปัจจุบันแทนที่ secret นั้นด้วย OIDC ผ่าน federated credential บางคนเรียกว่า Workload Identity Federation สำหรับ Azure AD GitHub Actions ส่ง identity token แบบ short-lived ที่ sign ด้วย cryptographic ให้ Azure AD ได้ในทุก workflow run federated credential ที่ config ไว้บน Azure AD App Registration (หรือ user-assigned managed identity) เชื่อถือ token issuer ของ GitHub เฉพาะ repository และ branch ที่กำหนด azure/login action แลก token นั้นเป็น Azure credential ชั่วคราวที่ scope ไว้กับ role ที่กำหนด อายุเท่ากับ job นั้นเท่านั้น ไม่มากไปกว่านั้น

permissions:
id-token: write
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Azure via OIDC
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Terraform plan
run: terraform plan

client-id, tenant-id, และ subscription-id เป็นแค่ identifier ไม่ใช่ secret แค่บอก Azure AD ว่าจะใช้ App Registration และ subscription ไหน ที่เก็บเป็น GitHub Actions secret ก็เพื่อความสะดวกและ scope environment เท่านั้น ไม่ใช่เพราะตัวค่าเองมี sensitivity ไม่ต้องมี Azure client secret ที่เก็บเป็น CI secret เลยสักตัว ไม่มีอะไร long-lived ให้หลุด ให้ rotate หรือให้ลืม permissions: id-token: write คือตัวที่ทำให้ workflow ขอ identity token นั้นได้ตั้งแต่แรก ถ้าไม่มีบรรทัดนี้ การแลก OIDC ก็ไม่มีอะไรให้ยื่นให้ Azure AD

flowchart LR
  pr["Pull request"] -->|triggers| plan["terragrunt run --all plan"]
  plan -->|posted as| comment["PR comment for review"]
  comment -->|approved and merged| main["Merge to main"]
  main -->|triggers, gated by approval| apply["terragrunt run --all apply"]
  oidc["Azure AD federated credential"] -->|short-lived credentials| plan
  oidc -->|short-lived credentials| apply
pull request trigger plan ให้ review, merge เข้า main trigger apply หลัง approval gate, OIDC federated credential ส่ง credential แบบ short-lived ให้ทั้งสองฝั่ง
ใน IaC CI/CD pipeline มาตรฐาน อะไรควร trigger terraform plan เทียบกับ terraform apply
ทำไม infrastructure plan ต้อง review ละเอียดกว่า code diff ของ application ทั่วไป
-/+ ข้าง resource ใน terraform plan output แปลว่าอะไร
ทำไม OIDC federated credential ถึงดีกว่า Azure client secret แบบ long-lived ที่เก็บเป็น CI secret