CI/CD for Infrastructure
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”pipeline มาตรฐานสำหรับ infrastructure รัน plan อัตโนมัติทุกครั้งที่มี pull request เพื่อให้คน review และเก็บ apply ไว้หลัง merge เข้า main branch เท่านั้น — โดยใช้ AWS credential แบบ short-lived ที่ federate ผ่าน OIDC แทน access key แบบ long-lived ที่เก็บไว้ใน CI
Plan ทุก pull request, apply หลัง merge เท่านั้น
หัวข้อที่มีชื่อว่า “Plan ทุก pull request, apply หลัง merge เท่านั้น”pattern ที่ทีมส่วนใหญ่ลงเอยด้วยกันนั้นคล้าย CI/CD ของ application แต่มีจุดต่างสำคัญตรงว่าแต่ละ stage ทำอะไรจริง ๆ
- เปิดหรืออัปเดต pull request → รัน
terraform plan(หรือterragrunt run --all planสำหรับ setup ที่มีหลาย unit ใน Terragrunt) แล้ว post diff ที่ได้เป็น comment บน pull request ยังไม่มีอะไรเปลี่ยนบน AWS จริง ๆ ตรงนี้เป็นแค่ 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: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform aws-region: us-east-1 - 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: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform aws-region: us-east-1 - 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 AWS access key ที่เก็บไว้เลย ทั้งสอง job assume role ผ่าน OIDC ซึ่งจะพูดถึงต่อไป
ทำไม plan ต้อง review ละเอียดกว่า code diff ทั่วไป
หัวข้อที่มีชื่อว่า “ทำไม plan ต้อง review ละเอียดกว่า code diff ทั่วไป”pull request ของ application code จะได้รับการ review เรื่อง logic, test, และ style — worst case คือ bug หลุดไปแล้วต้อง roll back แต่ infrastructure apply ทำอะไรที่ code deploy ทำไม่ได้ คือทำลาย resource จริงที่มี state บน AWS แล้วสร้างขึ้นใหม่ทั้งหมด บางครั้งแค่เพราะ change บรรทัดเดียว
Terraform เรียกสิ่งนี้ว่า forced replacement argument บางตัวของ resource บางประเภทอัปเดตแบบ in place ไม่ได้ — เปลี่ยนค่าเหล่านั้นแปลว่า provider ไม่มี API call สำหรับ “แก้ attribute นี้” มีแต่ “ทำลายแล้วสร้างใหม่” identifier ของ RDS instance, availability_zone ของ EBS volume หรือแม้แต่ชื่อของ resource เองใน provider บางตัวก็เข้าข่ายนี้ทั้งนั้น เมื่อเจอแบบนี้ terraform plan จะทำเครื่องหมาย resource นั้นด้วย -/+ แทนที่จะเป็น ~ แบบ in-place
# terraform plan output — this is the line to stop and read carefully # aws_db_instance.main must be replaced-/+ resource "aws_db_instance" "main" { ~ identifier = "app-db" -> "app-db-renamed" # forces replacement engine = "postgres" instance_class = "db.t3.micro" }-/+ บน resource ที่มี state อย่าง database หรือ persistent volume แปลว่าอาจมี data loss จริงหรือ downtime จริงทันทีที่มีคนกด approve — resource เก่าหายไปก่อนที่ resource ใหม่จะมีอยู่ด้วยซ้ำ นี่คือเหตุผลที่ review infrastructure plan จะ skim ผ่าน ๆ ไม่ได้ reviewer ต้องสแกนหาบรรทัด -/+ โดยเฉพาะ แล้วถามว่า replacement นั้น expected และ safe หรือเปล่า ไม่ใช่แค่อ่าน plan แบบเดียวกับที่อ่าน code diff เพื่อดู style กับ logic
OIDC credential แบบ short-lived แทน access key ที่เก็บไว้
หัวข้อที่มีชื่อว่า “OIDC credential แบบ short-lived แทน access key ที่เก็บไว้”pipeline รุ่นเก่าเก็บ AWS access key กับ secret แบบ long-lived ไว้เป็น CI secret แล้ว export เป็น environment variable ก่อนรันทุกครั้ง key นั้นใช้ได้ทั้ง plan และ apply เหมือนกันหมด ซึ่งนั่นแหละคือปัญหา ถ้าหลุดออกไปเมื่อไหร่ — จาก log ที่ config ผิด, dependency ที่ถูก compromise, หรือ workflow file ที่ echo environment variable ออกมา — ก็ยังใช้งานได้ต่อไปจนกว่าจะมีคน rotate หรือ revoke ด้วยมือ
practice ปัจจุบันแทนที่ key นั้นด้วย OIDC federation GitHub Actions ส่ง identity token แบบ short-lived ที่ sign ด้วย cryptographic ให้ AWS ได้ในทุก workflow run IAM OIDC identity provider ใน AWS account เชื่อถือ token issuer ของ GitHub และ trust policy ของ IAM role อนุญาตเฉพาะ repository และ branch ที่กำหนดให้ assume ได้ aws-actions/configure-aws-credentials แลก token นั้นเป็น AWS credential ชั่วคราวที่ scope ไว้กับ role ที่ assume มา อายุเท่ากับ job นั้นเท่านั้น ไม่มากไปกว่านั้น
permissions: id-token: write contents: read
jobs: plan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Assume AWS role via OIDC uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-terraform aws-region: us-east-1
- name: Terraform plan run: terraform planไม่ต้องมี AWS access key ที่เก็บเป็น CI secret เลยสักตัว — ไม่มีอะไร long-lived ให้หลุด ให้ rotate หรือให้ลืม permissions: id-token: write คือตัวที่ทำให้ workflow ขอ identity token นั้นได้ตั้งแต่แรก ถ้าไม่มีบรรทัดนี้ การแลก OIDC ก็ไม่มีอะไรให้ยื่นให้ AWS
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["GitHub OIDC provider"] -->|short-lived credentials| plan oidc -->|short-lived credentials| apply