Table of contents
왜 CI/CD가 필요한가
Terraform을 혼자 노트북에서 돌리면 빠르고 편하다. 하지만 팀이 되는 순간부터 문제가 쌓인다.
- 누가 언제 무엇을 apply했는지 안 남는다: 장애가 터지면 원인 추적이 어렵다
- 로컬 credential이 필요: 모든 팀원이 프로덕션 AWS 키를 가져야 한다
- 승인 절차가 없다: 프로덕션에 즉흥적 변경이 들어가기 쉽다
- state 잠금 충돌: 여러 사람이 동시에 작업하면 꼬인다
CI/CD 파이프라인은 이 모든 문제를 한꺼번에 정리한다. 모든 변경은 PR을 통과하고, plan 결과가 리뷰어에게 보이고, 승인된 뒤에만 apply된다. credential은 CI 서버에만 있고 개인 노트북에는 없다.
flowchart LR
Dev["개발자"] -->|"PR 생성"| PR["Pull Request"]
PR -->|"자동 실행"| Plan["terraform plan"]
Plan -->|"결과 코멘트"| Review["코드 리뷰"]
Review -->|"승인 후 머지"| Merge["main 브랜치"]
Merge -->|"자동 또는 수동 트리거"| Apply["terraform apply"]
Apply --> Infra["클라우드 변경"]
이 편에서는 대표적인 두 가지 접근을 살펴본다. GitHub Actions와 Atlantis다.
GitHub Actions 기본 파이프라인
가장 흔하게 쓰이는 방식이다. GitHub 레포에 워크플로우 YAML을 두면 PR과 push에 반응해서 Terraform이 자동으로 돈다.
기본 디렉토리 구조부터 보자.
infra/
├── .github/
│ └── workflows/
│ └── terraform.yml
└── envs/
├── dev/
└── prod/
간단한 workflow 예시다.
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths:
- 'envs/**'
- 'modules/**'
push:
branches: [main]
paths:
- 'envs/**'
- 'modules/**'
permissions:
contents: read
pull-requests: write
id-token: write # OIDC용
jobs:
plan:
name: Plan (${{ matrix.env }})
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
env: [dev, prod]
defaults:
run:
working-directory: envs/${{ matrix.env }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-tf-${{ matrix.env }}
aws-region: ap-northeast-2
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.8.0
- name: Format Check
run: terraform fmt -check -recursive
- name: Init
run: terraform init
- name: Validate
run: terraform validate
- name: Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Comment Plan on PR
uses: actions/github-script@v7
env:
PLAN: ${{ steps.plan.outputs.stdout }}
with:
script: |
const output = `#### Terraform Plan: \`${{ matrix.env }}\` 📖
\`\`\`
${process.env.PLAN}
\`\`\`
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
- name: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
apply:
name: Apply (${{ matrix.env }})
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [plan]
strategy:
matrix:
env: [dev, prod]
environment:
name: ${{ matrix.env }} # prod는 수동 승인 게이트 적용
defaults:
run:
working-directory: envs/${{ matrix.env }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-tf-${{ matrix.env }}
aws-region: ap-northeast-2
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.8.0
- name: Init
run: terraform init
- name: Apply
run: terraform apply -auto-approve
흐름을 정리하면 이렇다. PR을 열면 모든 환경에서 plan이 돌고 결과가 PR에 코멘트로 달린다. 머지되면 main에서 apply가 실행된다. prod는 GitHub Environment의 승인 게이트를 거쳐야 한다.
sequenceDiagram
participant Dev as 개발자
participant GH as GitHub
participant CI as GitHub Actions
participant AWS as AWS
Dev->>GH: PR 생성
GH->>CI: pull_request 이벤트
CI->>AWS: OIDC 인증
CI->>AWS: terraform plan
AWS-->>CI: plan 결과
CI->>GH: PR에 plan 코멘트
Dev->>GH: 리뷰 & 승인, 머지
GH->>CI: push 이벤트
CI->>CI: dev apply 자동 실행
CI->>GH: prod environment 승인 대기
Dev->>GH: 수동 승인
GH->>CI: apply 재개
CI->>AWS: terraform apply
OIDC로 credential 제거하기
위 예시에서 aws-actions/configure-aws-credentials가 access key 없이 OIDC로 AWS에 인증한다. 이게 요즘의 표준 방식이다.
GitHub Actions가 IAM Role을 assume하는 구조다. AWS에 GitHub OIDC provider를 한 번 등록하고, 그 provider를 신뢰하는 IAM Role을 만든다. 해당 Role의 trust policy에서 특정 레포의 특정 브랜치만 허용한다.
# GitHub OIDC provider
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1",
"1c58a3a8518e8759bf075b76b750d4f2df264fcd",
]
}
# IAM Role trust policy
data "aws_iam_policy_document" "github_actions_assume" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:my-org/infra-repo:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "github_actions_tf" {
name = "github-actions-tf-prod"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
}
이렇게 해두면 GitHub Actions에 AWS access key를 저장할 필요가 없다. 키 회전 걱정도 없고, 유출 리스크도 낮다.
PR 코멘트로 plan 보여주기
위 워크플로우의 Comment Plan on PR 스텝이 이 역할을 한다. 리뷰어는 PR을 열면 바로 “이 변경이 실제로 뭘 바꾸는지” 확인할 수 있다.
#### Terraform Plan: `prod` 📖
Terraform will perform the following actions:
aws_security_group_rule.allow_https will be created
- resource “aws_security_group_rule” “allow_https” {
- from_port = 443
- to_port = 443
- protocol = “tcp”
- cidr_blocks = [“0.0.0.0/0”]
- security_group_id = “sg-0abc123”
- type = “ingress” }
Plan: 1 to add, 0 to change, 0 to destroy.
이게 있으면 코드 리뷰의 품질이 확 올라간다. “이 PR이 실제로 뭘 만드는 거지?”라는 질문이 사라진다. 특히 destroy가 생기는 경우, 리뷰어가 즉시 경계할 수 있다.
plan 출력이 너무 길면 GitHub 코멘트 크기 제한(65536자)에 걸린다. 긴 경우 요약만 보여주거나 <details> 태그로 접는 방식이 흔하다.
const planSummary = `${process.env.PLAN}`.slice(0, 50000);
const output = `#### Plan: \`${{ matrix.env }}\`
<details><summary>상세 보기</summary>
\`\`\`diff
${planSummary}
\`\`\`
</details>`;
시크릿 관리
Terraform 코드나 state에 민감 정보가 들어가면 안 된다. CI에서 다뤄야 할 시크릿은 주로 두 가지다.
1) 클라우드 credential
OIDC를 쓰면 제거할 수 있다(위 참조). 어쩔 수 없이 access key를 써야 한다면 GitHub Secrets에 저장하고, 환경별 분리 접근을 건다.
2) Terraform 변수 중 민감한 것
DB 비밀번호, 외부 API 키 등이다. 이걸 CI에서 어떻게 전달할까.
- name: Apply
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
TF_VAR_slack_webhook: ${{ secrets.SLACK_WEBHOOK }}
run: terraform apply -auto-approve
TF_VAR_<변수명> 환경변수는 Terraform의 입력 변수로 자동 주입된다. .tfvars 파일에 평문으로 쓰지 않아도 된다.
다만 이렇게 넣으면 state 파일에는 평문으로 저장된다. state를 보호하는 건 백엔드 쪽 암호화(S3 server-side encryption)로 처리한다.
더 나아가려면 시크릿을 Terraform 바깥에 두는 방식이 있다. AWS Secrets Manager나 HashiCorp Vault에 저장하고, Terraform은 참조만 한다.
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "prod/rds/password"
}
resource "aws_db_instance" "db" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
여전히 state에는 기록되지만, 회전이 필요할 때 Secrets Manager에서 바꾸고 apply만 다시 치면 된다.
Atlantis — PR 기반 Terraform 자동화
GitHub Actions로도 충분하지만, 더 정교한 Terraform 전용 자동화 도구가 있다. Atlantis다.
Atlantis는 PR 코멘트로 Terraform을 조작하는 도구다. PR에 atlantis plan이라고 코멘트를 남기면 Atlantis가 plan을 실행하고 결과를 PR에 자동으로 올려준다. atlantis apply로 apply를 실행할 수도 있다.
flowchart LR
PR["PR 생성"] --> Auto["Atlantis 자동 plan"]
Auto --> Comment["PR에 plan 코멘트"]
Comment --> Human["리뷰어 확인"]
Human -->|"atlantis apply\n코멘트"| Apply["Atlantis가 apply 실행"]
Apply --> Lock["PR 잠금\n(머지 자동화)"]
Atlantis의 장점은 이렇다.
- PR 단위로 state 잠금: 두 PR이 같은 디렉토리를 건드리면 하나를 끝낼 때까지 다른 하나가 대기
- apply 전 plan이 최신인지 검증: plan 이후 다른 사람이 apply해서 state가 바뀌면 재검증 요구
- 세밀한 권한 제어: 특정 디렉토리에는 특정 팀만 apply 가능
- 멀티 레포/멀티 워크스페이스 지원
기본 설정 파일은 레포 루트의 atlantis.yaml이다.
version: 3
automerge: false
projects:
- name: dev
dir: envs/dev
autoplan:
when_modified:
- "*.tf"
- "../../modules/**/*.tf"
enabled: true
apply_requirements:
- approved
- mergeable
- name: prod
dir: envs/prod
autoplan:
when_modified:
- "*.tf"
- "../../modules/**/*.tf"
enabled: true
apply_requirements:
- approved
- mergeable
workflow: prod-workflow
workflows:
prod-workflow:
plan:
steps:
- init
- plan
apply:
steps:
- run: echo "프로덕션 apply — 각별한 주의"
- apply
apply_requirements에 approved를 걸면 PR이 승인돼야만 apply가 가능하다. mergeable은 머지 가능 상태(충돌 없고 CI 통과)를 요구한다.
Atlantis는 Kubernetes에 배포하거나, ECS, 혹은 단일 EC2에 Docker로도 올릴 수 있다. 웹훅을 받을 수 있는 공인 엔드포인트가 필요하다.
GitHub Actions vs Atlantis
둘을 언제 써야 할까.
| 항목 | GitHub Actions | Atlantis |
|---|---|---|
| 설치/운영 부담 | 없음 (GitHub가 제공) | 서버 직접 운영 |
| Terraform 전용 기능 | 직접 구현 | 내장 |
| state 잠금 관리 | 직접 처리 | 자동 |
| 멀티 디렉토리 조율 | 수동 설정 | 자동 감지 |
| 러닝커브 | 낮음 | 중간 |
| 비용 | GitHub Actions 사용료 | 서버 운영비 |
GitHub Actions가 맞는 경우
- Terraform 외에도 다른 CI/CD 파이프라인이 GitHub Actions에 있음
- 인프라 디렉토리가 단순하고 변경 빈도가 낮음
- 별도 서버를 운영하고 싶지 않음
Atlantis가 맞는 경우
- 인프라 레포가 크고 동시 PR이 많음
- Terraform 전용 기능(plan 최신 검증, 세밀한 권한)이 필요
- DevOps 팀이 따로 있어 전용 도구 운영 가능
작게 시작한다면 GitHub Actions로 충분하고, 팀이 커지고 동시 작업이 잦아지면 Atlantis 도입을 검토하는 순서가 자연스럽다.
몇 가지 실전 팁
1) plan과 apply를 같은 workflow run에 묶는 아이디어는 접는다
“머지되면 자동으로 apply”는 쉽다. 하지만 “plan과 apply 사이에 사람이 개입해서 승인하는 흐름”은 GitHub Actions에서 바로 되지 않는다. Environment의 승인 게이트로 우회하거나, Atlantis를 쓴다.
2) plan을 파일로 저장하고 apply 때 그 파일을 쓴다
terraform plan -out=tfplan
terraform apply tfplan
-out으로 plan을 파일로 저장하면, apply 시점에 state가 plan 당시와 달라졌다면 실패한다. “내가 리뷰한 plan과 실제 apply가 다를 가능성”을 원천 차단하는 장치다. 단, tfplan 파일은 민감한 정보를 포함할 수 있어 아티팩트로 관리할 때 주의한다.
3) CI 실행 시간에 timeout을 건다
jobs:
apply:
timeout-minutes: 30
뭔가 잘못돼서 apply가 무한 대기에 빠지는 걸 방지한다.
4) drift 감지 잡을 따로 둔다
정기적으로(하루 한 번) 모든 환경에서 terraform plan -refresh-only를 돌려서 drift를 Slack에 알리는 워크플로우를 두면 좋다.
on:
schedule:
- cron: '0 9 * * *' # 매일 오전 9시
jobs:
drift-check:
# ... plan -refresh-only -detailed-exitcode
콘솔에서 누군가 몰래 설정을 바꿔놓은 걸 빠르게 잡아낼 수 있다.
5) 모듈이 있는 레포는 버전 태깅을 자동화한다
PR이 머지되면 자동으로 SemVer 태그를 붙이는 워크플로우를 두면, 모듈 사용자가 특정 버전으로 안정적으로 고정할 수 있다. release-please나 semantic-release 같은 도구가 도움된다.
CI/CD는 Terraform 운영의 신뢰성을 지탱하는 기둥이다. PR → plan → 리뷰 → 머지 → apply의 사이클이 자리잡으면, 누가 언제 뭘 바꿨는지 투명해지고 사고 가능성이 크게 줄어든다. 크든 작든 팀이라면 이 파이프라인을 반드시 구축하고 넘어가야 한다.
다음 편에서는 테스트와 정책 검증을 다룬다. Terratest, Checkov, tfsec, OPA로 Terraform 코드의 품질과 보안을 어떻게 보증하는지 살펴본다.


Loading comments...