IaC CI CD 파이프라인 구현 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki
지금은 인프라 변경을 인프라 팀원이 자신의 PC에서 terraform apply로 하고, 상태 파일도 그 PC에만 있다. 이것을 PR로 변경을 검토하고 머지하면 승인 후 자동 적용되는 구조로 바꾼다.
앱 레포의 CI/CD와 같은 원리인데, 대상이 이미지 배포가 아니라 인프라 변경이다.
브랜치에서 Terraform 수정
→ PR
→ terraform-plan.yml: 형식·문법 검사 + 환경별 plan을 PR 댓글로 표시 (읽기 전용, 인프라를 바꾸지 않음)
→ 리뷰 (댓글의 plan을 보고 무엇이 바뀌는지 확인)
→ main에 머지
→ terraform-apply.yml: 바뀐 환경만 plan 생성 → 환경별 승인 → 그 plan 그대로 apply
→ (매주) terraform-drift.yml: 코드와 실제 인프라가 달라졌는지 확인
| 파일 | 언제 | 하는 일 | 권한 |
|---|---|---|---|
| terraform-plan.yml | PR | fmt, validate, 환경별 plan 댓글 | 읽기 전용 서비스 계정 |
| terraform-apply.yml | main 머지, 수동 | 바뀐 환경만 plan 후 승인받고 apply | plan은 읽기 전용, apply는 관리자 계정 |
| terraform-drift.yml | 매주 월요일, 수동 | 운영 plan으로 차이 확인, Discord 알림 | 읽기 전용 서비스 계정 |
설계 원칙
- 승인 전에 보여준 plan을 그대로 적용한다. plan을 파일로 저장했다가 apply하기 때문에, 승인한 내용과 적용되는 내용이 다를 수 없다. 그 사이 상태가 바뀌면 apply가 실패한다.
- 삭제나 교체가 들어 있는 plan은 자동으로 적용하지 않는다. 수동 실행에서 allow_destroy를 켠 경우에만 진행한다. (위험한 명령어이기 때문에)
- 한 환경의 상태는 한 번에 하나만 바꾼다. 환경별 concurrency로 apply와 plan이 겹치지 않게 한다.
- 환경(tf-staging, tf-prod)마다 승인자를 지정한다. 운영은 승인 없이 적용되지 않는다. (승인은 인프라팀 팀원이 진행) 개발(dev)은 이번에 연결하지 않고 지금처럼 로컬에서 apply한다. 코드 문법 검사(fmt, validate)만 개발 코드도 같이 돈다.
CI/CD를 붙이기 전에 아래 세 가지가 먼저 필요하다.
지금 상태(terraform.tfstate)는 로컬에만 있다(루카스의 개인 PC). 따라서 상태 파일을 GitHub Actions가 보려면 원격 저장소에 저장해야 한다. 로컬에 상태가 없는 CI에서 Plan을 돌리면 모든 리소스를 새로 만들겠다는 plan이 나온다.
- 상태 버킷을 만든다.
gcloud storage buckets create gs://mulo-v2-tfstate-953840519216 \
--project=project-7b071b48-8637-467a-80d --location=asia-northeast3 \
--uniform-bucket-level-access --public-access-preventiongcloud storage buckets update gs://mulo-v2-tfstate-953840519216 --versioning- 버전 관리 켜 두면 상태를 덮어써도 이전 버전으로 되돌릴 수 있다.
- 환경마다 versions.tf의 terraform 블록에 백엔드를 선언한다. (environments/staging, prod)
terraform {
required_version = ">= 1.6.0"
backend "gcs" {}
required_providers {
google = {
source = "hashicorp/google"
version = "~> 7.0"
}
}
}- 환경마다 backend.hcl을 만들어 커밋한다. 값은 비밀이 아니다.
# environments/prod/backend.hcl
bucket = "mulo-v2-tfstate-953840519216"
prefix = "prod"스테이징은 prefix를 staging으로 한다. (lukas가 상태를 옮길 때 쓴 prefix가 prod, staging이다)
- 상태 이전은 운영과 스테이징 모두 끝나 있다. 아직 안 한 환경이 있다면 lukas가 상태가 있는 PC에서 환경마다 한 번씩 옮긴다. (개발은 제외)
cd environments/prod
terraform init -migrate-state -backend-config=backend.hcl- 옮길 때 "기존 로컬 상태를 복사할까요?"라고 물으면 yes로 답한다.
- 옮긴 뒤 terraform plan에서 변경이 없다고 나오는지 확인한다.
- 이후 로컬에서도 terraform init -backend-config=backend.hcl 로 초기화한다.
환경 입력값(terraform.tfvars)은 .gitignore에 있어서 레포에 없다. CI는 비밀이 아닌 값만 GitHub 저장소 변수에 두고 실행할 때 파일로 만든다.
- 변수 이름: TFVARS_staging, TFVARS_prod (Settings > Secrets and variables > Actions > Variables)
- 값: 각 환경의 terraform.tfvars 전체 내용
- 필수 입력값은 staging이 project_id, domain, image_bucket_name이고, prod는 여기에 existing_backend_mysql_instance_name, existing_ai_postgres_instance_name이 더해진다.
- 비밀번호나 키는 넣지 않는다. (원래도 Terraform에 비밀번호는 없다. DB 사용자와 비밀번호는 직접 만든다)
- 시크릿(Secrets)이 아니라 변수(Variables)를 쓰는 이유: 시크릿에 넣으면 프로젝트 ID 같은 값이 plan 출력에서 모두 ***로 가려져서 읽을 수 없다.
- 이 변수가 앞으로 유일한 기준이 된다. 로컬 terraform.tfvars를 바꾸면 변수도 같이 바꿔야 한다. 특히 스테이징의 runtime_enabled(DB와 VM 전원)를 바꿀 때는 TFVARS_staging을 먼저 고친다.
| 서비스 계정 | 용도 | 권한 |
|---|---|---|
| terraform-plan | PR plan, drift 확인 | roles/viewer, roles/iam.securityReviewer, roles/secretmanager.viewer, 상태 버킷의 roles/storage.objectAdmin(잠금 파일 때문) |
| terraform-apply | apply | roles/compute.admin, roles/iam.serviceAccountAdmin, roles/iam.serviceAccountUser, roles/resourcemanager.projectIamAdmin, roles/secretmanager.admin, roles/serviceusage.serviceUsageAdmin, roles/cloudsql.admin, roles/storage.admin, roles/cloudtasks.admin, roles/servicenetworking.networksAdmin |
PROJECT=project-7b071b48-8637-467a-80d
BUCKET=mulo-v2-tfstate-953840519216
gcloud iam service-accounts create terraform-plan --project=$PROJECT
gcloud iam service-accounts create terraform-apply --project=$PROJECTfor ROLE in viewer iam.securityReviewer secretmanager.viewer; do
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:terraform-plan@$PROJECT.iam.gserviceaccount.com" --role="roles/$ROLE"
done
for ROLE in compute.admin iam.serviceAccountAdmin iam.serviceAccountUser resourcemanager.projectIamAdmin \
secretmanager.admin serviceusage.serviceUsageAdmin cloudsql.admin storage.admin cloudtasks.admin \
servicenetworking.networksAdmin; do
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:terraform-apply@$PROJECT.iam.gserviceaccount.com" --role="roles/$ROLE"
done
for SA in terraform-plan terraform-apply; do
gcloud storage buckets add-iam-policy-binding gs://$BUCKET \
--member="serviceAccount:$SA@$PROJECT.iam.gserviceaccount.com" --role="roles/storage.objectAdmin"
doneGitHub가 이 계정들을 쓸 수 있게 기존 WIF 풀(github-pool)에 Cloud 레포를 연결한다.
POOL=projects/953840519216/locations/global/workloadIdentityPools/github-pool
for SA in terraform-plan terraform-apply; do
gcloud iam service-accounts add-iam-policy-binding $SA@$PROJECT.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/$POOL/attribute.repository/100-hours-a-week/KTB4-16th-Cloud"
done- terraform-apply는 거의 소유자 수준의 권한이다. 이 계정을 쓸 수 있는 곳을 최대한 좁혀야 한다. 위 명령은 저장소 전체를 허용하는 가장 단순한 형태이고, 더 좁히려면 apply 계정만 환경 이름으로 제한한다.
gcloud iam service-accounts add-iam-policy-binding terraform-apply@$PROJECT.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principal://iam.googleapis.com/$POOL/subject/repo:100-hours-a-week/KTB4-16th-Cloud:environment:tf-prod"- 이 방식은 풀 공급자의 속성 매핑이 google.subject에 assertion.sub를 쓰는 경우에만 동작한다. 콘솔의 Workload Identity 제휴에서 공급자 매핑을 확인한다. 안 맞으면 저장소 전체 허용으로 두고, 대신 GitHub 환경의 승인자 지정과 브랜치 보호로 막는다.
| 이름 | 종류 | 값 |
|---|---|---|
| TF_WIF_PROVIDER | 변수 | projects/953840519216/locations/global/workloadIdentityPools/github-pool/providers/github-provider |
| TF_PLAN_SA | 변수 | terraform-plan@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com |
| TF_APPLY_SA | 변수 | terraform-apply@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com |
| TFVARS_staging, TFVARS_prod | 변수 | 각 환경의 terraform.tfvars 내용 (GitHub는 변수 이름을 대문자로 저장하지만 워크플로의 vars 조회는 대소문자를 구분하지 않는다) |
| DISCORD_WEBHOOK | 시크릿 | drift 알림용 (선택) |
- tf-staging
- tf-prod
- 두 환경 모두 승인자에 인프라 팀원 2명이 들어가있다. (lukas, lulu)
- 본인 승인 방지는 설정하지 않았다. 개발 일정이 빠듯하기 때문에 추가하지 않았다.
- PR로만 머지, 직접 push 금지
- 승인 1개 이상
- 필수 상태 검사: fmt and validate, plan (prod)
- plan (staging)은 DB가 꺼져 있으면 실패할 수 있어서 필수 검사에 넣지 않는다.
PR에서 검사와 plan을 한다. 환경 3개를 각각 돌리고 결과를 PR 댓글로 올린다.
name: Terraform Plan
# PR에서 코드 형식과 문법을 검사하고, 환경별로 terraform plan을 돌려서 PR 댓글로 보여준다.
# plan은 읽기 전용 서비스 계정으로 실행하므로 인프라를 바꾸지 않는다.
on:
pull_request:
branches: [main]
paths:
- "environments/**"
- "modules/**"
- ".github/workflows/terraform-*.yml"
permissions:
contents: read
id-token: write
pull-requests: write
concurrency:
group: tf-plan-${{ github.ref }}
cancel-in-progress: true
env:
TF_VERSION: "1.16.4"
jobs:
check:
name: fmt and validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Format check
run: terraform fmt -check -recursive
# 백엔드와 인증 없이 문법만 검사한다. 개발(dev)은 CI/CD로 plan, apply를 하지 않지만 코드 문법 검사는 같이 한다.
- name: Validate each environment
run: |
for e in dev staging prod; do
echo "== $e"
terraform -chdir="environments/$e" init -backend=false -input=false
terraform -chdir="environments/$e" validate
done
plan:
name: plan (${{ matrix.env }})
needs: check
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
env: [staging, prod]
# 스테이징은 DB를 꺼 두면 Terraform이 조회하지 못해서 plan이 실패할 수 있다. 실패해도 PR을 막지 않는다.
continue-on-error: ${{ matrix.env == 'staging' }}
defaults:
run:
working-directory: environments/${{ matrix.env }}
steps:
- uses: actions/checkout@v7
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.TF_WIF_PROVIDER }}
service_account: ${{ vars.TF_PLAN_SA }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
terraform_wrapper: false
# 환경별 입력값(tfvars)은 비밀이 아닌 값만 GitHub 변수 TFVARS_<환경>에 둔다.
- name: Write tfvars
env:
TFVARS: ${{ vars[format('TFVARS_{0}', matrix.env)] }}
run: printf '%s\n' "$TFVARS" > terraform.tfvars
- name: Init
run: terraform init -input=false -backend-config=backend.hcl
- name: Plan
id: plan
run: |
set -o pipefail
terraform plan -input=false -lock-timeout=60s -no-color -out=tfplan 2>&1 | tee plan.txt
- name: Summarize changes
id: sum
if: steps.plan.outcome == 'success'
run: |
terraform show -json tfplan > plan.json
ADD=$(jq '[.resource_changes[]? | select(.change.actions==["create"])] | length' plan.json)
CHG=$(jq '[.resource_changes[]? | select(.change.actions==["update"])] | length' plan.json)
DEL=$(jq '[.resource_changes[]? | select(.change.actions | index("delete"))] | length' plan.json)
echo "text=추가 ${ADD}, 변경 ${CHG}, 삭제 또는 교체 ${DEL}" >> "$GITHUB_OUTPUT"
if [ "$DEL" -gt 0 ]; then
echo "warn=삭제 또는 교체가 포함되어 있습니다. 내용을 꼭 확인하세요." >> "$GITHUB_OUTPUT"
fi
- name: Comment on PR
if: always()
uses: actions/github-script@v7
env:
ENV_NAME: ${{ matrix.env }}
SUMMARY: ${{ steps.sum.outputs.text }}
WARN: ${{ steps.sum.outputs.warn }}
PLAN_OUTCOME: ${{ steps.plan.outcome }}
with:
script: |
const fs = require('fs');
const env = process.env.ENV_NAME;
const file = `environments/${env}/plan.txt`;
let plan = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '(plan 출력 없음)';
const max = 60000;
if (plan.length > max) plan = plan.slice(0, max) + '\n... (너무 길어서 잘림)';
const status = process.env.PLAN_OUTCOME === 'success' ? process.env.SUMMARY : 'plan 실패';
const warn = process.env.WARN ? `\n> ${process.env.WARN}\n` : '';
const marker = `<!-- tf-plan-${env} -->`;
const body = `### Terraform plan: ${env}\n\n${status}\n${warn}\n<details><summary>plan 보기</summary>\n\n\`\`\`\n${plan}\n\`\`\`\n</details>\n\n${marker}`;
const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: context.issue.number });
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
}- 코드 형식 검사(terraform fmt)에서 실패하면 로컬에서 terraform fmt -recursive 를 실행해서 고친다.
- 스테이징은 DB를 꺼 두면 Terraform이 조회하지 못해 plan이 실패할 수 있다. 이때는 PR을 막지 않는다(continue-on-error).
main에 머지되면 바뀐 환경만 plan을 만들고, 환경별 승인 후 그 plan을 적용한다. 수동 실행으로 특정 환경만 적용할 수도 있다.
name: Terraform Apply
# main에 머지되면 바뀐 환경만 plan을 만들고, 환경별 승인을 받은 뒤 그 plan 그대로 apply한다.
# 수동 실행(workflow_dispatch)으로 원하는 환경만 적용할 수도 있다.
on:
push:
branches: [main]
paths:
- "environments/**"
- "modules/**"
workflow_dispatch:
inputs:
env:
description: "적용할 환경"
required: true
type: choice
options: [staging, prod]
allow_destroy:
description: "삭제 또는 교체가 포함된 plan도 적용한다"
required: false
type: boolean
default: false
permissions:
contents: read
id-token: write
env:
TF_VERSION: "1.16.4"
jobs:
detect:
name: 바뀐 환경 찾기
runs-on: ubuntu-latest
outputs:
envs: ${{ steps.pick.outputs.envs }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
- id: pick
env:
EVENT: ${{ github.event_name }}
INPUT_ENV: ${{ inputs.env }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ]; then
echo "envs=[\"$INPUT_ENV\"]" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only HEAD^ HEAD)
JSON="[]"
for e in staging prod; do
if echo "$CHANGED" | grep -qE "^(environments/${e}/|modules/${e}_modules/)"; then
JSON=$(echo "$JSON" | jq -c --arg e "$e" '. + [$e]')
fi
done
echo "바뀐 환경: $JSON"
echo "envs=$JSON" >> "$GITHUB_OUTPUT"
plan:
name: plan (${{ matrix.env }})
needs: detect
if: needs.detect.outputs.envs != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
env: ${{ fromJson(needs.detect.outputs.envs) }}
concurrency:
group: tf-state-${{ matrix.env }}
cancel-in-progress: false
defaults:
run:
working-directory: environments/${{ matrix.env }}
steps:
- uses: actions/checkout@v7
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.TF_WIF_PROVIDER }}
service_account: ${{ vars.TF_PLAN_SA }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
terraform_wrapper: false
- name: Write tfvars
env:
TFVARS: ${{ vars[format('TFVARS_{0}', matrix.env)] }}
run: printf '%s\n' "$TFVARS" > terraform.tfvars
- name: Init
run: terraform init -input=false -backend-config=backend.hcl
- name: Plan
run: |
set -o pipefail
terraform plan -input=false -lock-timeout=60s -no-color -out=tfplan 2>&1 | tee plan.txt
- name: Summarize changes
id: sum
run: |
terraform show -json tfplan > plan.json
ADD=$(jq '[.resource_changes[]? | select(.change.actions==["create"])] | length' plan.json)
CHG=$(jq '[.resource_changes[]? | select(.change.actions==["update"])] | length' plan.json)
DEL=$(jq '[.resource_changes[]? | select(.change.actions | index("delete"))] | length' plan.json)
echo "$DEL" > destroy.txt
{
echo "### ${{ matrix.env }}: 추가 ${ADD}, 변경 ${CHG}, 삭제 또는 교체 ${DEL}"
echo '```'
head -c 60000 plan.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.env }}
path: |
environments/${{ matrix.env }}/tfplan
environments/${{ matrix.env }}/plan.txt
environments/${{ matrix.env }}/destroy.txt
retention-days: 1
apply:
name: apply (${{ matrix.env }})
needs: [detect, plan]
if: needs.detect.outputs.envs != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
env: ${{ fromJson(needs.detect.outputs.envs) }}
# 환경(tf-staging, tf-prod)에 지정한 승인자가 승인해야 시작한다.
environment: tf-${{ matrix.env }}
concurrency:
group: tf-state-${{ matrix.env }}
cancel-in-progress: false
defaults:
run:
working-directory: environments/${{ matrix.env }}
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v4
with:
name: tfplan-${{ matrix.env }}
path: environments/${{ matrix.env }}
# 삭제 또는 교체가 들어 있으면 수동 실행에서 allow_destroy를 켠 경우에만 진행한다.
- name: Guard destroy
env:
ALLOW: ${{ inputs.allow_destroy }}
run: |
DESTROY=$(cat destroy.txt)
if [ "${DESTROY:-0}" -gt 0 ] && [ "$ALLOW" != "true" ]; then
echo "plan에 삭제 또는 교체가 ${DESTROY}건 있습니다. workflow_dispatch에서 allow_destroy를 켜고 다시 실행하세요."
exit 1
fi
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.TF_WIF_PROVIDER }}
service_account: ${{ vars.TF_APPLY_SA }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
terraform_wrapper: false
- name: Write tfvars
env:
TFVARS: ${{ vars[format('TFVARS_{0}', matrix.env)] }}
run: printf '%s\n' "$TFVARS" > terraform.tfvars
- name: Init
run: terraform init -input=false -backend-config=backend.hcl
# 승인 전에 보여준 plan 그대로 적용한다. 그 사이 상태가 바뀌었으면 실패한다.
- name: Apply
run: terraform apply -input=false -lock-timeout=60s tfplan- 바뀐 환경은 변경된 파일 경로로 판단한다. environments/prod나 modules/prod_modules가 바뀌면 prod, staging도 같은 방식이다. 개발(environments/dev, modules/dev_modules)이 바뀌면 대상 환경이 없어서 plan, apply는 아무것도 하지 않는다.
- 운영 apply는 tf-prod 환경의 승인자가 승인해야 시작한다. 승인하기 전에 Actions 요약에서 plan 내용과 삭제 건수를 확인한다.
- 삭제 또는 교체가 있으면 자동 apply가 멈춘다. 정말 필요하면 Actions에서 Terraform Apply를 수동 실행하면서 allow_destroy를 켠다.
- plan 파일은 아티팩트로 하루 동안만 보관한다.
운영 인프라가 코드와 달라졌는지 매주 확인한다. 콘솔에서 직접 바꾼 변경을 찾기 위한 것이다.
name: Terraform Drift
# 코드와 실제 인프라가 달라졌는지(콘솔에서 직접 바꾼 경우 등) 매주 확인한다.
on:
schedule:
- cron: "0 0 * * 1" # 매주 월요일 09:00 KST
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
TF_VERSION: "1.16.4"
jobs:
drift:
name: drift (${{ matrix.env }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
env: [prod]
defaults:
run:
working-directory: environments/${{ matrix.env }}
steps:
- uses: actions/checkout@v7
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.TF_WIF_PROVIDER }}
service_account: ${{ vars.TF_PLAN_SA }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
terraform_wrapper: false
- name: Write tfvars
env:
TFVARS: ${{ vars[format('TFVARS_{0}', matrix.env)] }}
run: printf '%s\n' "$TFVARS" > terraform.tfvars
- name: Init
run: terraform init -input=false -backend-config=backend.hcl
# 종료 코드 0은 변경 없음, 2는 차이 있음, 1은 오류다.
- name: Plan
id: plan
run: |
set +e
terraform plan -input=false -lock-timeout=60s -no-color -detailed-exitcode > plan.txt 2>&1
echo "code=$?" >> "$GITHUB_OUTPUT"
- name: Notify Discord
if: steps.plan.outputs.code != '0'
env:
WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
CODE: ${{ steps.plan.outputs.code }}
ENV_NAME: ${{ matrix.env }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$WEBHOOK" ]; then exit 0; fi
if [ "$CODE" = "2" ]; then MSG="코드와 실제 인프라가 다릅니다"; else MSG="plan 실행에 실패했습니다"; fi
jq -n --arg t "Terraform drift (${ENV_NAME})" --arg d "$MSG" --arg u "$RUN_URL" \
'{username:"MURO_BOT", embeds:[{title:$t, description:$d, url:$u, color:15158332}]}' \
| curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK" || true
- name: Fail on error
if: steps.plan.outputs.code == '1'
run: exit 1- 종료 코드 2(차이 있음)면 Discord로 알린다. 필요하면 matrix의 env에 staging을 추가한다. (스테이징은 DB를 꺼 두면 plan이 실패하니 켜 둘 때만 의미가 있다)
- 콘솔에서 직접 인프라를 바꾸지 않는다. 바꿨다면 drift에 잡히고, 다음 apply에서 되돌아갈 수 있다.
- 스테이징과 운영은 로컬에서 apply하지 않는다. 급할 때 외에는 PR을 거친다. 로컬로 했다면 반드시 같은 변경을 코드로 PR에 올린다. (상태는 원격에 하나뿐이라 충돌은 없다)
- 개발(dev)은 CI/CD 대상이 아니다. 지금처럼 lukas가 로컬에서 apply하고 상태 파일도 그 PC에 있다. 개발 환경을 Terraform으로 다시 만들거나 여러 사람이 바꿀 일이 생기면 그때 같은 방식으로 상태를 옮기고 환경을 추가한다.
- 스테이징 전원 관리(mig-power.sh, runtime_enabled)는 기존 방식을 쓰되, runtime_enabled를 바꿀 때 TFVARS_staging 변수도 같이 바꾼다. 안 바꾸면 다음 CI apply가 이전 값으로 되돌린다.
- 시크릿 값, 비밀번호, API 키는 Terraform 코드와 변수에 넣지 않는다.
- terraform-apply 계정은 권한이 크다. 환경 승인자 지정과 브랜치 보호가 실제 방어선이다.
- Terraform 상태에는 리소스의 속성(IP, 이름 등)이 들어 있다. 상태 버킷은 비공개이고 접근 권한은 두 서비스 계정과 인프라 담당자로 제한한다.
- 상태 이전은 한 번만 하는 위험한 작업이다. 이전 전에 로컬 상태 파일을 따로 백업하고, 이전 후 plan이 변경 없음으로 나오는지 확인한다.
- 운영 환경의 기존 DB(imports.tf로 가져온 것)는 삭제 보호가 걸려 있지만, 그래도 plan에서 삭제나 교체가 나오면 멈추는 가드를 둔다.
- CI가 처음 도는 PR은 파일을 추가하는 PR이라 plan이 아직 없다. 첫 PR은 상태 이전과 워크플로 추가를 같이 하고 머지 후 확인한다.