[Terraform] HCL을 처음 만진 날, bootstrap 삽질기[Terraform] HCL을 처음 만진 날, bootstrap 삽질기

[Terraform] HCL을 처음 만진 날, bootstrap 삽질기

생성 일시
Aug 16, 2026 05:01 PM
최종 편집 일시
Last updated August 16, 2026
태그
AWS
terraform
작성자
@조준환
Date

[Terraform] HCL을 처음 만진 날, bootstrap 삽질기

서론

앞선 글(근두운 기술 블로그근두운 기술 블로그[Terraform] HCL을 처음 만진 날, bootstrap 삽질기)에서 IAM 역할 생성을 PR 리뷰 기반으로 옮기기로 했다. 그러려면 먼저 Terraform이 굴러갈 토대부터 세워야 했다. 저장소 구조는 두 개의 독립된 루트 모듈로 나뉜다.
├── bootstrap/ # state 버킷 + GitHub Actions OIDC 롤 (사람이 1회 실행) └── platform/ # 실제 인프라 (이후 CI가 관리)
본 글은 이 중 HCL을 처음 접하고 bootstrap을 처음부터 apply 진행한 내용이다.

HCL의 기본 구조

Terraform 설정은 HCL이라는 문법 위에 쓰인다. 정확히 하면 HCL이 정의하는 것은 아래의 블록 구조까지고, resourcevariable 같은 블록의 의미는 Terraform이 부여한다.
블록타입 "라벨" { 인자 = 값 }
블록
역할
비유
variable
외부에서 받을 입력값
함수의 매개변수
resource
실제로 만들 인프라
함수 본문
output
실행 후 내보낼 값
함수의 반환값
provider / terraform
연결 설정, 버전 고정
환경 설정
파일 이름(variables.tf, main.tf, outputs.tf)은 관례일 뿐이고, Terraform은 디렉터리 안의 .tf 파일을 전부 합쳐서 하나로 읽는다. nginx의 conf와 유사하다고 느꼈다.
variable 블럭에서 default = null로 두면 기본 문자열이 생기는 것이 아니라, 기본적으로 그 기능을 쓰지 않게 된다. null을 리소스 인자에 넘기면 그 인자를 아예 설정하지 않은 것과 동일하게 취급된다.
variable "permissions_boundary_arn" { description = "CI 롤에 붙일 권한 경계 정책 ARN" type = string default = null # 값을 안 주면 경계를 안 붙인다 }
변수는 default가 없으면 필수, 있으면 선택으로 취급된다. tfvars 파일에 값을 주면 default를 덮어쓴다. 그래서 variables.tf는 값 저장소라기보다 모듈의 입력 인터페이스, 함수로 치면 매개변수 선언부처럼 느껴졌다.

실행 순서와 의존성 그래프

HCL은 React와 같이 선언형 시스템을 사용한다고 한다. 이 순서로 실행하라가 아니라, 최종적으로 기대하는 상태를 나타낸다. 그래서 Terraform은 코드에서 참조 관계를 읽어 의존성 그래프를 만들고, 참조가 없는 것들은 병렬로(기본 10개 동시) 생성한다고 한다. 생산성과 성능 두 마리를 잡은 기발한 설계라고 생각했다.
resource "aws_s3_bucket" "tfstate" { bucket = var.state_bucket_name } resource "aws_s3_bucket_versioning" "tfstate" { bucket = aws_s3_bucket.tfstate.id # 참조가 있으니 버킷이 먼저 만들어진다 versioning_configuration { status = "Enabled" } }
우리의 경우 tfstate를 저장하기 위한 S3 버킷과 테라폼 배포를 위한 OIDC provider 설정을 동시에 진행해야 했는데, 둘은 서로 참조가 없어서 실제로도 병렬로 생성됐다.

루트 모듈 간 값 공유

조직 이름 같은 공유 값은 bootstrap 밖 상위 폴더에 있어야 하지 않나 생각했는데, Terraform에는 루트 모듈을 가로지르는 전역 변수라는 개념 자체가 없다. bootstrap과 platform은 각자 state를 가지고 각자 따로 apply되는 독립된 단위라고 한다.
여러 루트 모듈이 값을 공유해야 할 때는 -var-file=../common.tfvars로 공유 tfvars를 양쪽에서 명시적으로 로드하거나, 한쪽이 output으로 내보낸 값을 terraform_remote_state로 읽거나, 상수만 담은 작은 모듈을 양쪽에서 가져온다.
# platform 쪽에서 bootstrap의 output을 읽는 예 data "terraform_remote_state" "bootstrap" { backend = "s3" config = { bucket = "team-tfstate" key = "bootstrap/terraform.tfstate" region = "ap-northeast-2" } }

인증과 state의 선후 문제

bootstrap은 CI가 쓸 인증(OIDC)을 만든다. 그런데 그 코드를 실행하려면 인증이 필요하다. 닭과 달걀 문제 같지만, 최초 1회만 사람이 자기 자격 증명으로 실행하면 된다. terraform은 똑똑하게도 aws cli에서 생성된 인증 세션을 사용하기 때문에 의외로 인증 수행은 복잡하지 않았다.
[profile team-admin] # 팀 계정 전용 region = ap-northeast-2
aws sts get-caller-identity --profile team-admin AWS_PROFILE=team-admin terraform apply ...
state도 같은 문제가 발생했다. state를 담을 버킷을 만드는 apply인데 그때 생성된 state를 둘 곳이 없었다. 이는 단순하게 S3를 생성한 이후에 init을 수행함으로써 해결했다.
terraform { backend "s3" { bucket = "team-tfstate" # 로컬 state 단계에서 만든 그 버킷 key = "bootstrap/terraform.tfstate" region = "ap-northeast-2" use_lockfile = true encrypt = true } }
terraform init -migrate-state
덧붙이면, state 잠금을 찾아보면 하나같이 S3에 DynamoDB 테이블을 곁들이라고 나온다. AI에게 물어도 같은 답이 돌아왔다. 그런데 Terraform 1.11부터 S3 조건부 쓰기 기반의 use_lockfile이 정식 지원되어, 테이블 없이 S3만으로 잠금이 된다. 오래 축적된 표준 답안이 최신 릴리스를 아직 못 따라온 경우였다. 그래서 required_version을 1.11 이상으로 못 박았다.

IAM description의 문자 제약

첫 apply가 이 에러로 터졌다.
api error ValidationError: Value at 'description' failed to satisfy constraint: Member must satisfy regular expression pattern: [ -~¡-ÿ]*
정규식을 해석해 보면 IAM의 description 필드가 ASCII/Latin-1 문자만 허용하고 있었다. 롤 설명을 한글로 적어 둔 것이 원인이었다.
- description = "GitHub Actions PR plan 전용" + description = "GitHub Actions PR plan only"
주석의 한글은 자유지만, AWS로 전송되는 값에는 한글이 안 되는 필드가 있었다. HCL 문법 오류가 아니라 AWS API의 제약이라 plan에서 잡히지 않았던 것이다.

세 가지 lock 파일

plan만 돌렸는데 lock 파일이 생겨서 당황했다. 각 목적은 다음과 같다. (셋 중 하나는 lock이 아니라 캐시였다.)
파일
만든 주체
정체
git 취급
.terraform.lock.hcl
init
provider 버전 잠금(package-lock.json 격)
커밋
.terraform/
init
provider 바이너리 캐시(수백 MB)
ignore
.tflock(S3 안)
plan/apply 실행 중
state 동시 실행 잠금, 끝나면 삭제
해당 없음

OIDC 신뢰 설계

이번 bootstrap의 목적은 GitHub Actions가 영구 키 없이 AWS에 들어오는 구조를 만드는 것이다.
sequenceDiagram
    participant GHA as GitHub Actions 워크플로
    participant GH as GitHub OIDC 발급자
    participant STS as AWS STS

    GHA->>GH: 워크플로 시작, 토큰 요청
    GH-->>GHA: 서명된 OIDC 토큰 발급<br/>("나는 org/repo의 main 브랜치 워크플로다"가 sub에 박힘)
    GHA->>STS: 토큰 제시 (AssumeRoleWithWebIdentity)
    STS->>STS: 신뢰 조건 검증 (aud·sub)
    STS-->>GHA: 1시간짜리 임시 자격증명
저장되는 비밀이 없으니 GitHub Secrets에 넣을 것도 없다. 설계에서는 네 가지를 신경 썼다.
  • plan 롤은 sub 조건을 repo:<org>/<repo>:*(StringLike)로 두었다. 이 prefix 아래에는 브랜치 ref뿐 아니라 태그, PR(pull_request) 등 이 레포에서 발급되는 기본 sub 형식이 전부 들어오므로, 사실상 레포 단위에서 광범위하게 허용된다. 권한은 읽기 중심으로 제한했다(state 키 읽기·쓰기와 잠금 파일 관리가 포함되는데, 아래에서 다시 다룬다).
  • apply 롤은 repo:<org>/<repo>:ref:refs/heads/main(StringEquals, 와일드카드 없음)으로 좁혔다. main에서 돈 워크플로만 assume할 수 있다.
condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:sub" values = ["repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"] }
  • apply 롤에는 permissions boundary를 붙였다. admin급 정책을 달고 있어도 경계가 상한 역할을 하고, 팀 IAM에 경계 강제가 걸려 있으면 이 값 없이는 CreateRole 자체가 거부된다. 앞 글에서 만든 경계가 그대로 이 롤의 상한으로 작동한다.
마지막은 plan 롤의 읽기 범위인데, 넷 중 고민이 제일 길었다. plan은 기본 동작에 refresh가 포함되어, state에 기록된 모든 리소스를 실제 읽기 API로 재조회한다. 관리 대상을 읽을 권한이 모자라면 plan이 그 자리에서 죽기 때문에, platform에 무엇이 들어와도 버티도록 일단 ReadOnlyAccess를 붙였다.
문제는 ReadOnlyAccess가 메타데이터만이 아니라 s3:GetObject 같은 데이터 읽기까지 포함한다는 데 있다. 이 계정에서는 CloudTrail 로그 버킷까지 통째로 읽을 수 있게 된다. 게다가 plan 시점에는 프로바이더와 external data source의 코드가 실제로 실행되므로, 이를 끼운 PR을 여는 것만으로 plan 롤 자격 증명 아래에서 임의 코드가 돈다. sub가 repo:...:*라서 이 레포에서 발급되는 어떤 형태의 sub로든(브랜치·태그·PR) 이 롤을 쓸 수 있다. 그래서 명시적 Deny로 state 버킷 외의 객체 읽기를 차단했다.
statement { sid = "DenyObjectReadExceptState" effect = "Deny" actions = [ "s3:GetObject", "s3:GetObjectVersion", # 버저닝 버킷에서의 우회 방지 ] not_resources = ["${aws_s3_bucket.tfstate.arn}/*"] }
s3:GetObjectVersion을 함께 막은 것은 리뷰 중에 잡은 부분이다. 버저닝 버킷에서는 버전 지정 읽기로 GetObject Deny를 우회할 수 있다. 같은 데이터에 닿는 다른 액션을 놓치면 Deny에 우회로가 남는다는 걸 배웠다.
Deny로 좁혀도 남는 위험이 있다. plan은 state를 읽어야 동작하므로 state 접근만은 열어 둘 수밖에 없는데, state에는 관리 대상 리소스의 속성이 평문으로 담긴다. 즉 악성 PR이 plan 롤로 코드를 실행하면 최소한 tfstate 열람까지는 가능하고, 이 구조는 그 잔여 위험을 감수한다. 그래서 state에 시크릿을 넣지 않는 것을 전제로 삼았다. 접근 폭도 처음에는 state 버킷 전체 객체(버킷/*)에 Put/Delete까지 뭉쳐 있었는데, state 키(Get/Put)와 잠금 키(.tflock, Get/Put/Delete)를 분리해 CI 롤이 state 객체를 삭제하지는 못하게 좁혔다.
SecOps 프로젝트라는 점에서는 권한을 더 좁힐 이유도 있다. CI 롤은 정상 행동의 폭이 극단적으로 좁은 주체라, 권한이 좁을수록 "plan 롤이 읽기 계열 밖의 API를 호출하면 조사한다" 같은 탐지 룰의 베이스라인이 선명해진다. ReadOnlyAccess를 실제로 다루는 서비스의 읽기 권한으로 줄이는 일과 sub를 pull_request로 좁히는 일은, apply 롤과 나란히 리팩터링 목록에 올려 두었다.

CI 연결

bootstrap의 출력인 롤 ARN을 GitHub 저장소 Variables(AWS_PLAN_ROLE_ARN, AWS_APPLY_ROLE_ARN)로 등록하고, 워크플로 두 개를 연결했다. 계정 ID를 코드에 하드코딩하지 않기 위해서인데, 롤 ARN은 계정 ID와 이름일 뿐 비밀값이 아니라 Secrets까지는 필요 없다. PR이면 plan이, main에 머지되면 apply가 돈다.
permissions: id-token: write # OIDC 토큰 발급에 필수 contents: read pull-requests: write steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }} aws-region: ap-northeast-2
plan 워크플로에는 결과를 PR 코멘트로 남기는 스텝을 붙였다. 재푸시 때 코멘트가 쌓이지 않도록, 숨은 마커로 기존 코멘트를 찾아 갱신하게 했다. plan 스텝은 continue-on-error로 두었다가 코멘트를 남긴 뒤 실패 처리한다.
한편 GitHub 쪽에는 OIDC를 켜는 설정이 따로 없었다. 워크플로에 id-token: write를 선언하는 것이 전부였다. 대신 브랜치 보호 규칙을 손봤다. PR과 승인을 강제하고 force push와 관리자 우회를 막은 뒤, plan 워크플로를 required status check로 지정했다. 이러면 plan 통과와 리뷰 승인 없이는 merge가, merge 없이는 apply가 일어나지 않는다.
결국 이 보안 설계 전체는 main 브랜치 보호 규칙 하나에 기대고 있다. main 직접 푸시가 열려 있으면 리뷰를 통과한 코드만 apply된다는 전제부터 무너지기 때문이다.

bootstrap 이후의 운영

다음 궁금증은 레포를 추가하면 어떻게 되는가였다. Terraform은 상태를 기준으로 동작하니, 이미 존재하는 리소스는 알아서 무시하고 넘어갈까?
답은 state에 무엇이 기록되는지를 보면 명확해진다. state에는 AWS에 존재하는 모든 리소스가 아니라, Terraform이 관리 대상으로 알고 있는 리소스만 기록된다. plan은 코드, state, 실제 리소스를 대조하는데, 실제 상태를 확인할 때도 기본적으로 state에 기록된 리소스를 refresh해 다시 조회한다.
따라서 state에 이미 있고 코드와 실제 상태가 일치하는 리소스는 변경 없음으로 지나간다. bootstrap을 다시 apply해도 달라진 것이 없다면 아무 일도 일어나지 않는다. 반면 코드에 새 리소스를 추가했는데 state에는 없다면 Terraform은 이를 새로 만들어야 할 리소스로 판단한다. 이때 AWS에 같은 이름의 리소스가 이미 존재하면 EntityAlreadyExists 같은 오류로 실패한다. 기존 리소스를 Terraform의 관리 대상으로 편입하려면 import라는 별도 절차가 필요하다.
그래서 레포가 늘어날 때마다 bootstrap을 다시 apply하는 것 자체는 문제가 되지 않는다. 새 레포가 AWS에 접근해야 하더라도 OIDC provider를 매번 새로 만들 필요는 없고, 보통 늘어나는 것은 IAM role과 그 신뢰 정책이다. 용도와 권한이 같다면 기존 role의 condition value 목록에 레포를 추가하면 된다. 목록의 값들은 OR 조건으로 평가된다. 반대로 필요한 권한이나 용도가 다르면 전용 role을 새로 만든다.
repo:<조직>/*처럼 조직 전체를 허용하는 와일드카드는 사용하지 않기로 했다. 레포 하나가 침해됐을 때 같은 조직의 다른 레포까지 AWS 접근 경로를 공유하게 되기 때문이다. 번거롭더라도 허용할 레포를 명시적으로 적는 편이 경계를 유지하기 쉽다.
한 가지 원칙도 정했다. bootstrap만큼은 계속 사람이 로컬에서 apply한다. 이것까지 CI 파이프라인에 올리면 bootstrap용 role이 자기 자신의 trust policy와 권한을 수정할 수 있는 자기 수정 루프가 생길 수 있다. CI 자격 증명이 탈취되면 공격자가 권한 상승 경로를 스스로 만들 수 있게 된다. 그래서 CI가 사용할 role을 정의하는 코드만큼은 자동화 경계 밖에 두고, 사람이 직접 적용하기로 했다.
다만 실행 주체를 사람으로 두는 것만으로 자기 수정이 차단되지는 않는다. apply 롤에는 IAMFullAccess(iam:*)가 붙어 있어서, 자격 증명이 탈취되면 실행 경로와 무관하게 자기 trust policy와 경계를 바꿀 수 있기 때문이다. 그래서 apply 롤에 명시적 Deny(self-protect)를 추가해, 두 CI 롤·OIDC provider·경계 정책에 대한 변경(UpdateAssumeRolePolicy, PutRolePermissionsBoundary, 정책 attach/detach, CreatePolicyVersion 등)을 막았다. 경계 정책을 떼지 못해도 그 내용을 새 버전으로 덮어쓰면 무력화되므로, 정책 버전 변경까지 함께 막았다. 코드는 전체 코드 절에 있다.
이 외에도 아래 두 가지 사항들을 고려해봤다.
첫째, 레포 이름을 바꾸면 Git URL은 리다이렉트될 수 있지만 GitHub OIDC 토큰의 sub에는 새 레포 이름이 들어간다. 따라서 IAM trust policy를 함께 수정하지 않으면 이름을 바꾼 시점부터 파이프라인의 AssumeRole이 거부된다.
둘째, apply job에 GitHub Environments를 붙이면 OIDC의 sub 형식이 달라질 수 있다. 기존에 ref:refs/heads/main을 기준으로 trust condition을 작성해 두었다면, environment:<이름> 형태의 sub와 더 이상 일치하지 않는다. Environment를 도입할 때 워크플로만 바꾸면 trust policy 조건과 어긋난 채로 남아, 그때부터 AssumeRole이 거부된다.

전체 코드

실제 사용한 bootstrap 전체 코드다.

bootstrap/versions.tf

terraform { # use_lockfile 이 GA 된 첫 버전 required_version = ">= 1.11" required_providers { aws = { source = "hashicorp/aws" # aws_iam_openid_connect_provider 의 thumbprint_list 가 optional 이 된 # 버전은 v5.81.0 (2024-12) — 그 미만에서는 생략 시 검증 에러가 난다. version = ">= 5.81" } } } provider "aws" { region = "ap-northeast-2" }

bootstrap/variables.tf

variable "github_org" { description = "GitHub 조직 이름. OIDC sub 조건(repo:<org>/<repo>)의 기준이 된다." type = string } variable "github_repo" { description = "IaC 저장소 이름. 조직 소유 저장소여야 한다." type = string default = "team-infra" } variable "state_bucket_name" { description = <<-EOT Terraform state 버킷 이름. S3 버킷 이름은 전역 유일해야 한다. 변경 시 bootstrap/backend.tf 와 platform/backend.tf 의 bucket 값도 함께 바꿀 것 (backend 블록은 변수를 못 쓴다). EOT type = string default = "team-tfstate" } variable "permissions_boundary_arn" { description = <<-EOT CI 롤에 붙일 권한 경계 정책 ARN. 팀 IAM 설계상 "새로 생성되는 IAM 개체에 경계 강제"가 걸려 있다면 이 값 없이는 CreateRole 자체가 거부될 수 있다. 예: arn:aws:iam::<ACCOUNT_ID>:policy/<경계정책이름> EOT type = string default = null } variable "plan_role_policy_arns" { description = "plan 롤에 붙일 관리형 정책 (읽기 전용)" type = list(string) default = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] } variable "apply_role_policy_arns" { description = <<-EOT apply 롤에 붙일 관리형 정책. 기본값(PowerUser + IAMFull)은 사실상 관리자 수준의 임시 구성이다. 반드시 권한 경계와 함께 쓰고, "FullAccess -> 최소권한 리팩터링" 목록에 이 롤을 등재해 축소한다. EOT type = list(string) default = [ "arn:aws:iam::aws:policy/PowerUserAccess", "arn:aws:iam::aws:policy/IAMFullAccess", ] }

bootstrap/main.tf

# 원격 state 버킷 세트 # DynamoDB 없음 — 잠금은 backend 의 use_lockfile(S3 조건부 쓰기)이 담당한다. resource "aws_s3_bucket" "tfstate" { bucket = var.state_bucket_name lifecycle { # state 버킷이 destroy 에 쓸려 나가는 사고 방지 prevent_destroy = true } } resource "aws_s3_bucket_versioning" "tfstate" { bucket = aws_s3_bucket.tfstate.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" { bucket = aws_s3_bucket.tfstate.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } resource "aws_s3_bucket_public_access_block" "tfstate" { bucket = aws_s3_bucket.tfstate.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # .tflock 은 잠글 때 생성되고 풀 때 삭제된다. 버저닝 버킷에서 "삭제"는 # delete marker + noncurrent 버전을 남기므로, 치우는 규칙이 없으면 # plan/apply 마다 쓰레기가 쌓인다. resource "aws_s3_bucket_lifecycle_configuration" "tfstate" { bucket = aws_s3_bucket.tfstate.id depends_on = [aws_s3_bucket_versioning.tfstate] rule { id = "expire-noncurrent" status = "Enabled" filter {} # 과거 state 복구 가능 창 = 30일 noncurrent_version_expiration { noncurrent_days = 30 } expiration { expired_object_delete_marker = true } abort_incomplete_multipart_upload { days_after_initiation = 7 } } }

bootstrap/oidc.tf

resource "aws_iam_openid_connect_provider" "github" { url = "<https://token.actions.githubusercontent.com>" client_id_list = ["sts.amazonaws.com"] # thumbprint_list 생략: 2023-07부터 AWS가 신뢰 CA 라이브러리로 검증하며 # provider 5.81+ 에서 optional. } # ---- plan 롤: 이 레포의 모든 sub(브랜치·태그·PR)에서 assume 가능 ---- data "aws_iam_policy_document" "plan_trust" { 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:${var.github_org}/${var.github_repo}:*"] } } } resource "aws_iam_role" "plan" { name = "github-actions-plan" assume_role_policy = data.aws_iam_policy_document.plan_trust.json permissions_boundary = var.permissions_boundary_arn # IAM description 은 ASCII/Latin-1 만 허용 (한글 불가) description = "GitHub Actions PR plan only (read + state access)" } # ---- apply 롤: 보호된 main 브랜치에서만 assume 가능 ---- data "aws_iam_policy_document" "apply_trust" { 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 = "StringEquals" variable = "token.actions.githubusercontent.com:sub" values = ["repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"] } } } resource "aws_iam_role" "apply" { name = "github-actions-apply" assume_role_policy = data.aws_iam_policy_document.apply_trust.json permissions_boundary = var.permissions_boundary_arn description = "GitHub Actions main-branch apply only. Least-privilege refactoring target." } # ---- state 버킷 접근 (두 롤 공통) ---- # plan 도 기본 동작으로 state 잠금을 잡으므로 두 롤 모두 필요하다. # use_lockfile 은 락 파일(.tflock) 삭제 때문에 s3:DeleteObject 가 추가로 필요하다. # 버킷/* 로 뭉치지 않고 state 키와 락 키를 분리한다 — state 객체에는 # Delete 를 주지 않고, Delete 는 .tflock 에만 허용한다. # 루트 모듈이 늘면 이 목록에 키를 추가할 것. locals { tfstate_keys = [ "bootstrap/terraform.tfstate", "platform/terraform.tfstate", ] } data "aws_iam_policy_document" "tfstate_access" { statement { sid = "ListStateBucket" actions = ["s3:ListBucket"] resources = [aws_s3_bucket.tfstate.arn] } statement { sid = "ReadWriteState" actions = [ "s3:GetObject", "s3:PutObject", ] resources = [for k in local.tfstate_keys : "${aws_s3_bucket.tfstate.arn}/${k}"] } statement { sid = "ManageLockFile" actions = [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", ] resources = [for k in local.tfstate_keys : "${aws_s3_bucket.tfstate.arn}/${k}.tflock"] } } resource "aws_iam_policy" "tfstate_access" { name = "team-tfstate-access" description = "Terraform remote state + .tflock access" policy = data.aws_iam_policy_document.tfstate_access.json } resource "aws_iam_role_policy_attachment" "plan_state" { role = aws_iam_role.plan.name policy_arn = aws_iam_policy.tfstate_access.arn } resource "aws_iam_role_policy_attachment" "apply_state" { role = aws_iam_role.apply.name policy_arn = aws_iam_policy.tfstate_access.arn } # ---- plan 롤 읽기 가드 ---- data "aws_iam_policy_document" "plan_read_guard" { statement { sid = "DenyObjectReadExceptState" effect = "Deny" actions = [ "s3:GetObject", "s3:GetObjectVersion", ] not_resources = ["${aws_s3_bucket.tfstate.arn}/*"] } } resource "aws_iam_role_policy" "plan_read_guard" { name = "read-guard" role = aws_iam_role.plan.id policy = data.aws_iam_policy_document.plan_read_guard.json } # ---- apply 롤 자기 수정 차단 ---- # bootstrap 을 사람이 돌리는 것만으로는 self-modification 이 막히지 않는다. # apply 롤에는 IAMFullAccess(iam:*) 가 붙어 있으므로, CI 자격 증명이 # 탈취되면 자기 trust policy·경계·정책을 바꿔 권한 상승이 가능하다. data "aws_iam_policy_document" "apply_self_protect" { statement { sid = "DenyCiRoleModification" effect = "Deny" actions = [ "iam:UpdateAssumeRolePolicy", "iam:UpdateRole", "iam:UpdateRoleDescription", "iam:DeleteRole", "iam:PutRolePermissionsBoundary", "iam:DeleteRolePermissionsBoundary", "iam:AttachRolePolicy", "iam:DetachRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", ] resources = [ aws_iam_role.plan.arn, aws_iam_role.apply.arn, ] } statement { sid = "DenyTrustAnchorTampering" effect = "Deny" actions = [ "iam:DeleteOpenIDConnectProvider", "iam:UpdateOpenIDConnectProviderThumbprint", "iam:AddClientIDToOpenIDConnectProvider", "iam:RemoveClientIDFromOpenIDConnectProvider", ] resources = [aws_iam_openid_connect_provider.github.arn] } statement { sid = "DenyGuardPolicyTampering" effect = "Deny" actions = [ "iam:CreatePolicyVersion", "iam:DeletePolicy", "iam:DeletePolicyVersion", "iam:SetDefaultPolicyVersion", ] # 경계 정책 자체의 내용 변경도 권한 상승 경로다. resources = compact([ aws_iam_policy.tfstate_access.arn, var.permissions_boundary_arn, ]) } } resource "aws_iam_role_policy" "apply_self_protect" { name = "self-protect" role = aws_iam_role.apply.id policy = data.aws_iam_policy_document.apply_self_protect.json } resource "aws_iam_role_policy_attachment" "plan_managed" { for_each = toset(var.plan_role_policy_arns) role = aws_iam_role.plan.name policy_arn = each.value } resource "aws_iam_role_policy_attachment" "apply_managed" { for_each = toset(var.apply_role_policy_arns) role = aws_iam_role.apply.name policy_arn = each.value }

bootstrap/outputs.tf

output "oidc_provider_arn" { description = "GitHub OIDC 자격 증명 공급자 ARN" value = aws_iam_openid_connect_provider.github.arn } output "plan_role_arn" { description = "GitHub 저장소 Variables 에 AWS_PLAN_ROLE_ARN 으로 등록" value = aws_iam_role.plan.arn } output "apply_role_arn" { description = "GitHub 저장소 Variables 에 AWS_APPLY_ROLE_ARN 으로 등록" value = aws_iam_role.apply.arn } output "state_bucket" { description = "원격 state 버킷 이름" value = aws_s3_bucket.tfstate.id }

bootstrap/backend.tf

첫 apply 성공 후에 활성화한다. 버킷이 생기기 전엔 init이 실패한다.
terraform { backend "s3" { bucket = "team-tfstate" key = "bootstrap/terraform.tfstate" region = "ap-northeast-2" use_lockfile = true encrypt = true } }

bootstrap/example.tfvars

github_org = "<your-org>" github_repo = "team-infra" state_bucket_name = "team-tfstate" permissions_boundary_arn = "arn:aws:iam::<ACCOUNT_ID>:policy/<경계정책이름>"

.github/workflows/terraform-plan.yml

name: terraform plan on: pull_request: paths: - "platform/**" permissions: id-token: write # OIDC 토큰 발급에 필수 contents: read pull-requests: write # plan 결과를 PR 코멘트로 남기기 위해 필요 jobs: plan: runs-on: ubuntu-latest defaults: run: working-directory: platform steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }} aws-region: ap-northeast-2 - uses: hashicorp/setup-terraform@v3 - name: fmt check (repo 전체) working-directory: . run: terraform fmt -check -recursive - name: init run: terraform init - name: validate run: terraform validate - name: plan id: plan run: terraform plan -no-color -lock-timeout=60s continue-on-error: true - name: comment plan on PR uses: actions/github-script@v7 env: PLAN_STDOUT: ${{ steps.plan.outputs.stdout }} PLAN_STDERR: ${{ steps.plan.outputs.stderr }} with: script: | const marker = '<!-- terraform-plan-platform -->'; const ok = '${{ steps.plan.outcome }}' === 'success'; // 코멘트 최대 길이(65536자) 초과 대비 let output = ok ? process.env.PLAN_STDOUT : process.env.PLAN_STDERR; const LIMIT = 60000; if (output.length > LIMIT) { output = output.slice(0, LIMIT) + '\n... (출력이 잘렸습니다. 전체는 Actions 로그 참고)'; } const body = [ marker, `## platform terraform plan ${ok ? '✅' : '❌ 실패'}`, '', '<details><summary>plan 출력 펼치기</summary>', '', '```', output, '```', '', '</details>', '', `*commit ${context.sha.slice(0, 7)} ·` + ` [실행 로그](${context.serverUrl}/${context.repo.owner}/` + `${context.repo.repo}/actions/runs/${context.runId})*`, ].join('\n'); const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: context.issue.number, per_page: 100, }); const existing = comments.find(c => c.body.startsWith(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, }); } - name: fail if plan failed if: steps.plan.outcome == 'failure' run: exit 1

.github/workflows/terraform-apply.yml

name: terraform apply on: push: branches: [main] paths: - "platform/**" permissions: id-token: write contents: read concurrency: group: terraform-apply-platform # 같은 대상의 apply 중복 실행 방지 cancel-in-progress: false jobs: apply: runs-on: ubuntu-latest defaults: run: working-directory: platform steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_APPLY_ROLE_ARN }} aws-region: ap-northeast-2 - uses: hashicorp/setup-terraform@v3 - name: init run: terraform init - name: apply run: terraform apply -auto-approve -lock-timeout=60s