Github Actions와 함께 하는 Terraform CI/CD


Github Actions와 함께 하는 Terraform CI/CD

시작 하며

안녕하세요. 파이썬과 함께 데이터를 주무르고 있는 주니어 개발자 입니다.

개요

사이드 프로젝트를 진행 하게 되면 인프라를 관리하는 역할은 대개 서버 파트에서 많이 하는 것 같습니다.

매번 혼자 작업 해와서 개인 환경에서 프로비저닝 하고 코드만 깃허브 프라이빗 레포지토리로 관리 해왔었는데요.

이번 프로젝트에서는 인프라를 다루는 동료와 협업을 하게 되어 코드 리뷰와 리소스 프로비저닝을 같이할 수 있는 GitOps 방식을 활용했습니다.

대상 독자

이 글을 읽는 대상은 Github Actions로 테라폼 워크플로우를 실행 하는데 관심이 있는 개발자 입니다.

테라폼은 위 이미지와 같은 워크플로우를 띄고 있습니다.

이 과정은 혼자 작업할 때 불편함은 전혀 없습니다. 
온전히 제가 작성한 코드가 정상적으로 프로비저닝 되면 되기 때문이죠.

하지만, 동료와 같이 작업할 때는 이러한 문제를 마주할 수 있습니다.

  1. 동시성 문제

2. 환경변수 파일 관리 문제

3. 코드 품질 비평준화

깃허브 액션 구성

terraform 공식문서

깃허브 액션을 구성하기 전 아래 목록이 필요합니다.

1. AWS credential keys

2. Slack channel (Optional)

github action secret variables

  1. 테라폼 환경 변수 등록하기
env:
  TF_VERSION: 1.9.5
  working-directory: {project-directory} // change me
  AWS_REGION: "ap-northeast-2"

“테라폼을 실행 시킬 때 리소스 별 파일을 어떻게 관리할까?”

테라폼을 실행 시키기 위한 전제 조건 중 하나가, *.tf 파일이 존재 해야 한다는 것인데요.

만약, 프로젝트 내부에 이런 디렉터리 구조를 갖고 있다고 가정 해보고 테라폼을 실행 시키면 어떻게 실행 될지 확인 해보겠습니다.

terraform
  - aws
      - project
          - modules
            - vpc
              - main.tf
              - variables.tf
              - outputs.tf
            - ec2
              - main.tf
              - variables.tf
              - outputs.tf
          - vpc
            - main.tf
            - backend.tf
            - providers.tf
            - outputs.tf
            - config.yaml
          - ec2
            - main.tf
            - backend.tf
            - providers.tf
            - outputs.tf
            - config.yaml

이렇게 구성 되어 있다면 테라폼은 루트 디렉터리에서 아무런 .tf 파일도 찾지 못하게 되어 “No configuration files found”와 같은 메시지를 출력하며 초기화 작업이 진행되지 않습니다.

“그렇다면 github-actions 에서 어느 디렉터리에서 init — plan — apply 를 해야할지에 대한 고민을 하게 됩니다.”

위 문제에 대해 아래 2가지 방식으로 접근할 수 있을 것 같습니다.

프로젝트 루트 디렉터리에 실행 가능한 .tf 파일을 작성하고 모든 리소스를 main.tf 안에서 참조한다

terraform
  - aws
      - project
          - modules
            - vpc
              - main.tf
              - variables.tf
              - outputs.tf
            - ec2
              - main.tf
              - variables.tf
              - outputs.tf
          - vpc
            - main.tf
            - backend.tf
            - providers.tf
            - outputs.tf
            - config.yaml
          - ec2
            - main.tf
            - backend.tf
            - providers.tf
            - outputs.tf
            - config.yaml
          
          - main.tf
          - providers.tf
          - backend.tf

프로젝트 루트 디렉터리에 리소스를 개별 .tf 파일로 분리한다

terraform
  - aws
      - modules
        - vpc
          - main.tf
          - variables.tf
          - outputs.tf
        - ec2
          - main.tf
          - variables.tf
          - outputs.tf
      - project
        - init
          - init.tf
        - config
          - vpc
            - config.yaml
          - ec2
            - config.yaml
        - sources
          - etc files
        - ec2.tf
        - vpc.tf
        - providers.tf
        - backend.tf

2가지 방법 중 현재는 “리소스를 개별 .tf 파일로 분리한다” 방법을 적용 해서 진행 중입니다.

어차피 모듈에서 대부분 관리가 되기 때문에 개별 리소스에서 작성 되는 코드의 양이 많지 않을 것이라고 생각이 들기 때문에 위 방법을 선택하게 되었습니다.

2. 테라폼 환경 구성 하기

jobs:
  Terraform:
    name: Terraform
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    defaults:
      run:
        working-directory: ${{ env.working-directory }}
    steps:
      - name: Check out code
        uses: actions/checkout@v3

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

3. 테라폼 코드에 대한 검증하기

      - name: Terraform init
        id: init
        run: terraform init -upgrade

      - name: Terraform validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform plan
        id: plan
        run: terraform plan -no-color -parallelism=30
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          TF_LOG: info

4. 테라폼 실행 계획을 PR 코멘트에 남기기

      - name: Plan output
        id: output
        uses: actions/github-script@v5
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `
            ## 🪧 Terraform CI/CD 결과
            - **Terraform Format and Style 🖌**: \`${{ steps.fmt.outcome }}\`
            - **Terraform Initialization ⚙️**: \`${{ steps.init.outcome }}\`
            - **Terraform Plan 📖**: \`${{ steps.plan.outcome }}\`
            - **Terraform Validate 📖**: \`${{ steps.validate.outcome }}\`

            <details><summary>Terraform Plan 결과 보기</summary>

            \`\`\`hcl
            ${process.env.PLAN}
            \`\`\`

            </details>

            **🚀 Pusher**: @${{ github.actor }}  
            **📌 Action Type**: ${{ github.event_name }}
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

코멘트에 남길 문구는 자유롭게 수정 해서 보기 편한 방식을 이용하시면 됩니다.

“각 워크플로 실행이 시작될 때 GitHub은(는) 워크플로에서 사용할 고유한 GITHUB_TOKEN 비밀을 자동으로 만듭니다. GITHUB_TOKEN은 워크플로 실행에서 인증하는 데 사용할 수 있습니다.” — Github 공식문서

별도의 GITHUB_TOKEN 을 작성하지 않더라도 해당 워크플로우에서 자동 생성 됩니다.

4–1. 테라폼 실행 계획 슬랙 알람 발송하기 (Optional)

      - name: Slack alert create pull request
        id: slack-alert-pr-create
        if: github.event_name == 'pull_request'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "channel": "${{ secrets.SLACK_CHANNEL_ID }}",
              "username": "terraform-alert",
              "attachments": [
                {
                  "color": "#36a64f",
                  "text": "🚀 *Terraform Plan 실행 완료!*",
                  "fields": [
                    {
                      "title": "Result",
                      "value": "✅ Success",
                      "short": true
                    },
                    {
                      "title": "📌 Repository",
                      "value": "${{ github.repository }}",
                      "short": false
                    },
                    {
                      "title": "🔗 PR Link",
                      "value": "<${{ github.event.pull_request.html_url }}|PR 보기>",
                      "short": false
                    }
                  ],
                  "footer": "👤 Pusher: @${{ github.actor }}"
                }
              ]
            }

5. 테라폼 코드 반영하기

      - name: Terraform apply
        id: apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

PR을 등록만 했는데도 코드가 반영 되는 불상사가 일어나면 안되겠죠. 그렇기 때문에 “메인” 브랜치에 “Push” 이벤트가 동작하는 경우 코드를 반영 하도록 작성합니다.

5–1. 테라폼 코드 반영 내용 슬랙 알람 발송하기 (Optional)

      - name: Slack alert pull request merge
        id: slack-alert-pr-merge
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "channel": "${{ secrets.SLACK_CHANNEL_ID }}",
              "username": "terraform-alert",
              "attachments": [
                {
                  "color": "${{ steps.apply.outcome == 'success' && '#36a64f' || '#ff0000' }}",
                  "text": "🚀 *Terraform Apply 실행 결과*",
                  "fields": [
                    {
                      "title": "Result",
                      "value": "${{ steps.apply.outcome == 'success' && '✅ Success' || '❌ Failure' }}",
                      "short": true
                    },
                    {
                      "title": "📌 Repository",
                      "value": ${{ github.repository }},
                      "short": false
                    },
                    {
                      "title": "🔗 GitHub Actions",
                      "value": "<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow 실행 링크>",
                      "short": false
                    }
                  ],
                  "footer": "👤 Pusher: @${{ github.actor }}"
                }
              ]
            }

Github Actions 전체 코드 보기

name: Terraform CI/CD

on:
  push:
    branches:
      - main
    paths:
      - terraform/aws/**
  pull_request:
    paths:
      - terraform/aws/**

env:
  TF_VERSION: 1.9.5
  working-directory: ./terraform/aws/project
  AWS_REGION: "ap-northeast-2"

jobs:
  Terraform:
    name: Terraform
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    defaults:
      run:
        working-directory: ${{ env.working-directory }}
    steps:
      - name: Check out code
        uses: actions/checkout@v3

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Fmt
        id: fmt
        run: terraform fmt -recursive -check
        continue-on-error: true
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform init
        id: init
        run: terraform init -upgrade

      - name: Terraform validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform plan
        id: plan
        run: terraform plan -no-color -parallelism=30
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          TF_LOG: info

      - name: Plan output
        id: output
        uses: actions/github-script@v5
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `
            ## 🪧 Terraform CI/CD 결과
            - **Terraform Format and Style 🖌**: \`${{ steps.fmt.outcome }}\`
            - **Terraform Initialization ⚙️**: \`${{ steps.init.outcome }}\`
            - **Terraform Plan 📖**: \`${{ steps.plan.outcome }}\`
            - **Terraform Validate 📖**: \`${{ steps.validate.outcome }}\`

            <details><summary>Terraform Plan 결과 보기</summary>

            \`\`\`hcl
            ${process.env.PLAN}
            \`\`\`

            </details>

            **🚀 Pusher**: @${{ github.actor }}  
            **📌 Action Type**: ${{ github.event_name }}
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Slack alert create pull request
        id: slack-alert-pr-create
        if: github.event_name == 'pull_request'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "channel": "${{ secrets.SLACK_CHANNEL_ID }}",
              "username": "terraform-alert",
              "attachments": [
                {
                  "color": "#36a64f",
                  "text": "🚀 *Terraform Plan 실행 완료!*",
                  "fields": [
                    {
                      "title": "Result",
                      "value": "✅ Success",
                      "short": true
                    },
                    {
                      "title": "📌 Repository",
                      "value": "${{ github.repository }}",
                      "short": false
                    },
                    {
                      "title": "🔗 PR Link",
                      "value": "<${{ github.event.pull_request.html_url }}|PR 보기>",
                      "short": false
                    }
                  ],
                  "footer": "👤 Pusher: @${{ github.actor }}"
                }
              ]
            }
      - name: Terraform apply
        id: apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Slack alert pull request merge
        id: slack-alert-pr-merge
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "channel": "${{ secrets.SLACK_CHANNEL_ID }}",
              "username": "terraform-alert",
              "attachments": [
                {
                  "color": "${{ steps.apply.outcome == 'success' && '#36a64f' || '#ff0000' }}",
                  "text": "🚀 *Terraform Apply 실행 결과*",
                  "fields": [
                    {
                      "title": "Result",
                      "value": "${{ steps.apply.outcome == 'success' && '✅ Success' || '❌ Failure' }}",
                      "short": true
                    },
                    {
                      "title": "📌 Repository",
                      "value": ${{ github.repository }},
                      "short": false
                    },
                    {
                      "title": "🔗 GitHub Actions",
                      "value": "<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow 실행 링크>",
                      "short": false
                    }
                  ],
                  "footer": "👤 Pusher: @${{ github.actor }}"
                }
              ]
            }

인프라 리소스를 정리하는 액션 구성 작성하기

name: Terraform Destroy
on:
  workflow_dispatch:
env:
  TF_VERSION: 1.9.5
  working-directory: ./terraform/aws/project
  
jobs:
  Terraform:
    name: Terraform
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ env.working-directory }}
    steps:
      - name: Check out code
        uses: actions/checkout@v3
      
      - uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform init
        id: init
        run: terraform init -upgrade

      - name: Terraform validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform destroy
        id: Destroy
        run: terraform destroy -auto-approve
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

위 Github Action 을 구성하시고 나면 깃허브 홈페이지의 Actions 탭에서 수동으로 트리거를 발동 시켜 리소스를 모두 정리할 수 있습니다.

실제 적용 사례

깃허브 Pull Request 코멘트

슬랙 알람

고민 해볼만한 내용

아무래도 config.yaml 에 담겨 있는 변수 파일을 어떻게 관리할지 고민이 많이 되는데요.

실제로는 Private Repository 에 있으니 스스럼 없이 파일을 모두 올려버리고는 합니다.

이 부분에 대한 해결책은 다양하게 접근할 수 있으나 한 번 고민 해볼만한 내용 중 하나인 것 같습니다.

요약

  1. Github Actions 를 구성하게 되면 동료와 협업 하여 테라폼 코드를 리뷰하고 프로비저닝 할 수 있습니다.
  2. Github Actions 구성 내에서 테라폼 워크플로우를 실행 시킬 디렉터리를 특정 짓게 되는 특성 때문에 리소스 구성 파일들을 관리하는 방법에 대한 고민이 필요합니다.
  3. Private Repository 에서 환경 변수를 관리할 때 파일에 대한 보안 사항 없이 업로드 할 수 있지만 암호화 툴이나 깃허브에서 제공하는 시크릿 변수를 이용하여 관리할 수 있습니다.

참고 자료