많은 팀에서 cicd 도구로 github action을 사용합니다. 저희 팀에서는 최근까지 젠킨스를 통한 CICD를 운영하다가 github action으로 CICD 작업을 옮기고 있는데요. 대부분의 프로젝트의 CICD를 옮기고 나서 운영하다보니 아래와 같은 문제점을 발견했습니다.
우선 이전에 운영되던 github action script는 아래와 같습니다. jobs는 크게 build
와 deploy
로 구분되며, build에서는 checkout
- install dependency
- run test code
- build and push
를 진행하고, deploy에서는 ecs service update
- notification
을 진행합니다.
Build and Deploy (DEV)
on:
push:
branches: [develop]
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: project
ECS_CLUSTER: project-dev
ECS_SERVICE: project-dev
jobs:
build:
runs-on: ubuntu-latest
environment: DEV
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go 1.21.4
uses: actions/setup-go@v3
with:
go-version: "1.21.4"
- name: Install dependencies
run: go mod download
- name: Display Go version
run: go version
- name: Run Test Code
run: go test -v ./...
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_DEV }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Create env file
run: |
echo "${{ secrets.ENV_FILE_DEV }}" > .env;
chmod 644 .env
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: develop
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
deploy:
runs-on: ubuntu-latest
environment: DEV
needs: [build]
if: always()
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_DEV }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }}
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to ECS
run: |
aws ecs update-service --cluster=${{ env.ECS_CLUSTER }} --service=${{ env.ECS_SERVICE }} --task-definition=${{ env.ECS_SERVICE }} --force-new-deployment;
- name: Set Status
id: set_status
run: |
if [ "${{ needs.build.result }}" == 'success' ] && [ "${{ job.status }}" == 'success' ]; then
echo "color=good" >> $GITHUB_OUTPUT
echo "status=success" >> $GITHUB_OUTPUT
echo "emoji=:gopher-dance:" >> $GITHUB_OUTPUT
else
echo "color=danger" >> $GITHUB_OUTPUT
echo "status=failure" >> $GITHUB_OUTPUT
echo "emoji=:gopherlift:" >> $GITHUB_OUTPUT
fi
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
env:
SLACK_COLOR: "${{ steps.set_status.outputs.color }}"
SLACK_ICON: https://s3.ap-northeast-2.amazonaws.com/slack/gopher.jpeg
SLACK_TITLE: Message
SLACK_MESSAGE: "deploy ${{ steps.set_status.outputs.status }} `develop` ${{ steps.set_status.outputs.emoji }}"
SLACK_USERNAME: "CICD Bot"
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL_DEV }}
github에서 지원하는 action UI상에서는 매우 간단해보이지만, 실제 스크립트를 보게되면 언뜻 보더라도 전체적인 CICD의 전체적인 맥락을 파악하기 어렵습니다. 만약 기존 플로우에서 push 하는 이미지의 tag 정책이 변경되거나, git tagging 같은 새로운 job이 들어간다면 개발/상용환경 스크립트를 동시에 수정해야하는 위험과 번거로움이 수반됩니다.
이러한 배경으로 각 작업을 모듈화하는 CICD 모듈화 작업을 진행하게되었습니다.
github action의 job은 workflow_call
trigger로 동작 시킬 수 있습니다. uses 통해 action을 custom action을 호출하게 되는데 이 때 inputs
과 secrets
를 통해 가변 인자를 넘길 수 있습니다. 각 작업을 모듈화 한 뒤, workflow_call
를 통해 호출하기로 했습니다.
먼저 큰 단계를 구성했습니다.
1. test
2. build-and-push
3. deploy
4. notification
5. git tagging / generate release
test는 아래와 같습니다. checkout 후, 배포할 코드의 테스트 도커 컨테이너를 생성하고 테스트 코드를 실행시킵니다.
on:
workflow_call:
inputs:
service:
required: true
type: string
secrets:
repository_access_token:
required: true
description: github private repository access token
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: |
docker build --no-cache --build-arg REPOSITORY_ACCESS_TOKEN=${{ secrets.repository_access_token }} --target test-stage --tag ${{ inputs.service }}:test .
- name: Run Test Code
run: docker run --rm ${{ inputs.service }}:test
- name: Clean up
run: docker rmi -f ${{ inputs.service }}:test
테스트 코드를 통과하게 되면 이미지를 빌드하고 빌드된 이미지를 ecr로 push하게 됩니다. 이 때 push되는 image의 tag는 latest입니다(task definition에서 latest 이미지를 바라보고 있습니다)
on:
workflow_call:
inputs:
service:
required: true
type: string
description: 서비스 이름
image_tag:
required: true
type: string
description: 이미지 태그
secrets:
env_file:
required: true
description: env file
repository_access_token:
required: true
description: github private repository access token
role_arn:
required: true
description: AWS ecr role arn
permissions:
id-token: write
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create env file
run: |
echo "${{ secrets.env_file }}" > .env;
chmod 644 .env
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.role_arn }}
aws-region: ap-northeast-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build & Push image to Amazon ECR
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.login-ecr.outputs.registry }}/${{ inputs.service }}:${{ inputs.image_tag }}
build-args:
REPOSITORY_ACCESS_TOKEN=${{ secrets.repository_access_token }}
정상적으로 image가 push된 후 해당 이미지를 통해 ecs 서비스 업데이트를 진행합니다. 기존에 사용 중이던 script를 실행시켜 진행했습니다.
on:
workflow_call:
inputs:
env:
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: deploy
run: |
make deploy env=${{ inputs.env }}
배포 결과를 슬랙 채널에 공유합니다.
on:
workflow_call:
inputs:
result:
required: true
type: string
description: 빌드 및 배포 결과. 성공, 실패(스킵-빌드 실패)
service:
required: true
type: string
description: 서비스 이름. slack 메시지에 서비스 이름 표시
branch:
required: true
type: string
description: 브랜치 이름. slack 메시지에 배포 브랜치 표시
secrets:
slack_webhook_url:
required: true
description: slack webhook url
jobs:
nofitication:
runs-on: ubuntu-latest
steps:
- name: Set Status
id: set_status
run: |
if [ "${{ inputs.result }}" == 'success' ]; then
echo "color=good" >> $GITHUB_OUTPUT
echo "status=success" >> $GITHUB_OUTPUT
echo "emoji=:gopher-dance:" >> $GITHUB_OUTPUT
else
echo "color=danger" >> $GITHUB_OUTPUT
echo "status=failure" >> $GITHUB_OUTPUT
echo "emoji=:gopherlift:" >> $GITHUB_OUTPUT
fi
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.slack_webhook_url }}
SLACK_COLOR: ${{ steps.set_status.outputs.color }}
SLACK_TITLE: Message
SLACK_MESSAGE: ${{ inputs.service }} deploy ${{ steps.set_status.outputs.status }} `${{ inputs.branch }}` ${{ steps.set_status.outputs.emoji }}
MSG_MINIMAL: ref, event
배포까지 성공하게 되면 배포된 코드의 tagging과 변경 사항에 대한 release를 자동으로 생성하게 됩니다. 그리고 이 작업의 결과로 새로운 tag를 output으로 내보내게 됩니다.
on:
workflow_call:
secrets:
repository_access_token:
required: true
description: github private repository access token
outputs:
new_tag:
value: ${{ jobs.auto-tagging-and-release.outputs.new_tag }}
jobs:
auto-tagging-and-release:
runs-on: ubuntu-latest
outputs:
new_tag: ${{ steps.set_output.outputs.new_tag }}
steps:
- name: Bump version and push tag
id: tag_version
uses: mathieudutour/github-tag-action@v6.1
with:
github_token: ${{ secrets.repository_access_token }}
custom_release_rules: hotfix:patch:preminor
- name: Create a GitHub release
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.tag_version.outputs.new_tag }}
name: Release ${{ steps.tag_version.outputs.new_tag }}
body: ${{ steps.tag_version.outputs.changelog }}
- name: Set output
id: set_output
run: echo "new_tag=${{ steps.tag_version.outputs.new_tag }}" >> $GITHUB_OUTPUT
이렇게 나눈 각 기능들을 아래와 같이 필요한 동작만 조합해서 사용할 수 있습니다.
name: CI/CD-PROD
on:
push:
branches: [ main ]
jobs:
test:
uses: ./.github/workflows/test.yml
with:
service: mock-project
secrets:
repository_access_token: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
build-and-push:
needs: [ test ]
uses: ./.github/workflows/build-and-push.yml
with:
service: mock-project
image_tag: latest
secrets:
env_file: ${{ secrets.ENV_FILE }}
repository_access_token: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
role_arn: ${{ secrets.AWS_ROLE_ARN }}
deploy:
needs: [ build-and-push ]
uses: ./.github/workflows/deploy.yml
with:
env: prod
tag-and-release:
needs: [ deploy ]
if: ${{ needs.deploy.result == 'success' }}
uses: ./.github/workflows/tag-and-release.yml
secrets:
repository_access_token: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
notification:
needs: [ deploy ]
if: always()
uses: ./.github/workflows/notification.yml
with:
result: ${{ needs.deploy.result }}
service: mock-project
branch: main
secrets:
slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
build-new-tag-image-and-push:
needs: [ tag-and-release ]
uses: ./.github/workflows/build-and-push.yml
with:
service: mock-project
image_tag: ${{ needs.tag-and-release.outputs.new_tag }}
secrets:
env_file: ${{ secrets.ENV_FILE }}
repository_access_token: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
role_arn: ${{ secrets.AWS_ROLE_ARN }}
모듈화 하기 전 스크립트와 비교해보면 어떤가요 ? action UI상 단계는 늘어났지만, 코드 상으로 각 세부 단계나 전체적인 플로우를 파악하기 용이해진 것 같네요. 또한 만약 개발환경 CICD를 구성한다고 했을 때는 개발환경에서 필요없는 tag-and-release
나 build-new-tag-image-and-push
는 제외하고 입력 파라미터만 수정하고, 필요한 구성으로만 각 동작을 조합하여 새로운 CICD를 쉽게 구성 할 수 있습니다.
또한 새로운 스크립트의 마지막을 보면 build-new-tag-image-and-push
동작이 추가되었는데요. tag-and-release
의 결과로 산출된 새로운 태그를 붙여 ecr 이미지로 push하는 동작입니다. 이는 이미지 히스토리를 남기고, 롤백 상황 발생시 최대한 빠르게 전 코드를 반영하게 하기 위함입니다. (build-and-push
는 latest tag로 ecr에 푸쉬합니다. task definition에서는 latest를 바라보고 있습니다)
build-new-tag-image-and-push:
needs: [ tag-and-release ]
uses: ./.github/workflows/build-and-push.yml
with:
service: mock-project
image_tag: ${{ needs.tag-and-release.outputs.new_tag }}
secrets:
env_file: ${{ secrets.ENV_FILE }}
repository_access_token: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
role_arn: ${{ secrets.AWS_ROLE_ARN }}
만약 위의 동작을 모듈화하지 않았다면, 거의 동일한 코드가 중복되어 사용되어 유지보수의 난이도가 높아졌을 것입니다. 하지만 모듈화를 통해 image tag만 변경하여 해당 모듈을 호출하게 되어 보다 쉽게 새로운 동작을 추가했습니다. 무엇보다 CICD 스크립트 내에서 각 동작의 목적이나 의도도 확실하게 나타나고, 중복 코드도 줄일 수 있다는 것이 가장 큰 얻음이었습니다 !