Terraform CI/CD 구축 (2): Jenkinsfile로 Plan 결과를 PR에 남기기
변경된 Terraform 디렉토리만 찾아 Plan을 실행하고, 결과를 PR 댓글과 체크 상태로 돌려주는 Jenkinsfile을 단계별로 정리한다.
시리즈 · Infra3 / 3
- 접속 불가 상태의 LightSail 인스턴스에서 데이터 복구하기
- Terraform CI/CD 구축 (1): GitHub PR과 Jenkins 연동
- Terraform CI/CD 구축 (2): Jenkinsfile로 Plan 결과를 PR에 남기기
1편에서 GitHub Webhook과 Jenkins 트리거를 설정했다. 이번에는 Jenkinsfile을 작성해 실제 Pipeline을 만든다.
Jenkinsfile은 Jenkins UI의 Script Path에 지정한 경로에 만들어야 한다. 나는 scripts/jenkinsFile로 지정했다.
전체 구조
Pipeline은 세 개의 stage와 PR 상태를 갱신하는 post 블록, 그리고 PR에 댓글을 다는 함수 하나로 구성된다.
pipeline {
agent any
environment { /* Credentials */ }
stages {
stage('Set PR to Pending') { /* ... */ }
stage('Detect Changed Terraform Directories') { /* ... */ }
stage('Terraform Plan for Each Changed Directory') { /* ... */ }
}
post {
success { /* PR 상태를 SUCCESS로 */ }
failure { /* PR 상태를 FAILURE로 */ }
}
}
def postCommentToGitHubPR(String dir, String comment) { /* ... */ }
environment
노출되어서는 안 되는 값들이며, Jenkins Credentials로 관리한다.
environment {
AWS_ACCESS_KEY_ID = credentials('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = credentials('AWS_SECRET_ACCESS_KEY')
ASSUME_ROLE_ARN = credentials('ASSUME_ROLE_ARN')
GITHUB_TOKEN = credentials('GITHUB_TOKEN')
}
ASSUME_ROLE_ARN은 디렉토리마다 권한을 다르게 만들어 승격시키면서 써야 하는데, 이 부분은 아직 TODO로 남아 있다.
Stage 1: PR을 Pending으로 변경
PR의 체크 상태를 Pending으로 만드는 단계다. Jenkins UI에서 Set status before build를 체크해 두었지만, 그 옵션을 쓰다 보니 PR에 check가 2개 생기는 문제가 있었다. 그래서 Pipeline에서 직접 Pending으로 만들어 준다.
stage('Set PR to Pending') {
steps {
script {
def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
githubNotify(
account: 'account',
repo: 'Terraform',
context: 'Terraform/CI',
description: 'Terraform Plan Queued',
status: 'PENDING',
sha: commitSha,
credentialsId: 'Github-Common'
)
}
}
}
Stage 2: 변경된 Terraform 디렉토리 찾기
이 부분이 가장 중요하다. 저장소는 다음과 같이 리소스와 환경별로 디렉토리가 나뉘어 있다.
Project/
├─ scripts/
│ └─ jenkinsFile
├─ terraform/
│ ├─ ec2/
│ │ ├─ _module/
│ │ ├─ app_d_apnortheast2/
│ │ ├─ app_p_apnortheast2/
│ │ └─ app_s_apnortheast2/
│ ├─ rds/
│ │ ├─ app_p_apnortheast2/
│ │ └─ app_s_apnortheast2/
│ ├─ security_group/
│ └─ vpc/
├─ .gitignore
└─ README.md
즉 terraform plan을 실행해야 하는 디렉토리가 PR마다 다르다. 수정된 파일의 위치를 파악해서 그곳에서 Plan을 실행해야 한다.
stage('Detect Changed Terraform Directories') {
steps {
script {
// main 대비 바뀐 .tf, .tfvars 파일 추출
def diffFiles = sh(
script: '''git diff --name-only origin/main...HEAD | grep -E '\\.tf$|\\.tfvars$' || true''',
returnStdout: true
).trim().split('\n').findAll { it?.trim() }
// 바뀐 파일이 없다면 SUCCESS로 종료
if (diffFiles.isEmpty()) {
echo "No changed .tf or .tfvars files detected."
postCommentToGitHubPR('.', 'No Terraform changes detected.')
currentBuild.result = 'SUCCESS'
return
}
// Plan을 실행할 디렉토리에는 반드시 provider.tf가 존재한다
def tfDirs = diffFiles
.collect {
def path = it.contains('/') ? it.substring(0, it.lastIndexOf('/')) : '.'
return path
}
.unique()
.findAll { dir ->
fileExists("${dir}/provider.tf")
}
if (tfDirs.isEmpty()) {
echo "No relevant Terraform directories found."
postCommentToGitHubPR('.', 'No Terraform changes detected.')
currentBuild.result = 'SUCCESS'
return
}
env.CHANGED_DIRS = tfDirs.join(',')
echo "Detected changed Terraform directories: ${env.CHANGED_DIRS}"
}
}
}
변경된 파일의 디렉토리를 모은 뒤 provider.tf가 있는 디렉토리만 남긴다. Plan을 실행하는 단위 디렉토리에는 항상 provider.tf가 있다는 규칙을 이용한 것이다. 결과는 CHANGED_DIRS 환경 변수로 다음 stage에 넘긴다.
Stage 3: Terraform Plan
찾아낸 디렉토리로 이동해 Terraform Plan을 수행한다. 이때 GitHub에는 올릴 수 없는 값들을 Jenkins Credentials에서 꺼내 terraform.tfvars의 플레이스홀더에 채워 넣는다. 변수 관리 방식은 개선이 필요하지만 일단은 이렇게 진행했다.
stage('Terraform Plan for Each Changed Directory') {
when {
allOf {
expression { return env.CHANGED_DIRS }
expression { return params.GITHUB_PR_NUMBER != null }
}
}
steps {
script {
def secrets = [
'assume_role_arn',
]
def creds = secrets.collect {
string(credentialsId: "TF_VAR_${it}", variable: "TF_VAR_${it}")
}
def dirs = env.CHANGED_DIRS.tokenize(',')
for (dir in dirs) {
echo "Running plan for ${dir}"
withCredentials(creds) {
def sedScript = secrets.collect {
"sed -i 's|\\\${${it}}|'\"\$TF_VAR_${it}\"'|' terraform.tfvars || true"
}.join('\n')
sh """
cd ${dir}
${sedScript}
terraform init -input=false
terraform validate
terraform plan -input=false -no-color > plan_output.txt
"""
def output = readFile("${dir}/plan_output.txt")
def escaped = output.take(60000).replaceAll(/[`\\]/, '\\\\$0')
postCommentToGitHubPR(dir, escaped)
}
}
}
}
}
Plan 출력은 -no-color로 색상 코드를 제거해 파일로 저장하고, 60,000자까지만 잘라 PR 댓글로 보낸다.
PR에 댓글 달기
postCommentToGitHubPR은 GitHub API로 PR에 댓글을 다는 함수다. Plan 결과를 코드 블록으로 감싸 본문을 만들고, payload를 JSON 파일로 쓴 뒤 curl로 전송한다.
def postCommentToGitHubPR(String dir, String comment) {
if (!params.GITHUB_PR_NUMBER?.trim()) return
def header = (dir == '.' || dir.trim() == '')
? "### ✅ Terraform File No Changed"
: "### ✅ Terraform Plan Result for `${dir}`"
def body = """${header}
```
${comment}
```
"""
writeFile file: 'payload.json', text: groovy.json.JsonOutput.toJson([body: body])
withCredentials([string(credentialsId: 'GITHUB_TOKEN', variable: 'GITHUB_TOKEN')]) {
sh """
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
--data @payload.json \
https://api.github.com/repos/${params.GITHUB_PR_SOURCE_REPO_OWNER}/Terraform/issues/${params.GITHUB_PR_NUMBER}/comments
"""
}
}
post: PR 상태 변경
모든 stage가 끝나면 결과에 따라 PR의 체크 상태를 SUCCESS 또는 FAILURE로 변경한다. targetUrl에 빌드 URL을 넣어 PR에서 바로 Jenkins 빌드로 이동할 수 있게 했다.
post {
success {
script {
def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
githubNotify(
account: 'account',
repo: 'Terraform',
context: 'Terraform/CI',
description: 'Terraform Plan SUCCESS',
status: 'SUCCESS',
targetUrl: "${env.BUILD_URL}",
sha: commitSha,
credentialsId: 'Github-Common'
)
}
}
failure {
script {
def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
githubNotify(
account: 'account',
repo: 'Terraform',
context: 'Terraform/CI',
description: 'Terraform Plan FAILURE',
status: 'FAILURE',
targetUrl: "${env.BUILD_URL}",
sha: commitSha,
credentialsId: 'Github-Common'
)
}
}
}
결과
이제 PR을 올릴 때마다 Terraform Plan 결과가 댓글로 작성된다.

남은 과제
- 디렉토리별로 권한을 분리한 Assume Role 적용
sed로 tfvars를 치환하는 변수 관리 방식 개선