[개발 일지] Github Action + AWS codedeploy로 롤백 가능한 CI-CD환경 구성하기

Rudy·2021년 11월 20일
5
post-thumbnail

이 글은 하나의 AWS EC2 인스턴스에서 무료로 무중단 배포 환경을 구성하기 위해 codedeploy를 이용하는 과정에서 더 효율적인 방법을 제안하기 위해 작성되었습니다.
여건이 되신다면 AWS의 EB(Elastic BeanStalk), ECS(Elastic Container Service) 등을 이용하시는 것도 좋은 선택지가 될 수 있습니다.

Github Action

빌드 위치에 대한 고민

당근마켓 MVP인턴십을 시작하면서 먼저 저희 팀의 프론트엔드와 백엔드에서 어떤 기술 스택을 이용할지에 대해 이야기하는 시간을 가졌습니다. 짧은 시간 내에 빠르게 프로덕트를 만들어내야했기 때문에 새로운 시도보다는 각자 자신있는 기술을 선택하기로 했고, 프론트엔드는 React.js / 백엔드는 Nest.js를 이용하게 되었습니다.
인프라까지 모두 제가 담당하기로 결정했고, 기존에 '42SEOUL 출입관리 시스템' 개발에서 사용했던 스택이었기 때문에 큰 부담은 없었습니다.
다만 백엔드 인턴끼리 이야기를 나누던 중 기존에 가지고 있었던 한가지 고민에 대해 이야기를 나눴습니다. React와 Nest 모두 빌드를 필요로 하다보니 어느 시점에 어디에서 빌드가 이뤄져야하는지에 대한 고민이었습니다.
지금까지 로컬에서 빌드하고 git 에 빌드한 결과물까지 같이 올리는 방법, EC2에서 직접 빌드하는 방법 모두 써봤는데 각자 문제점이 있었습니다.

로컬 빌드의 문제점

먼저 로컬에서 빌드해서 git에 push하는 경우 특정 조건에서 알 수 없는 이유로 파일이 원하는 방식으로 동작하지 않는 경우가 있었습니다. 아마도 개발하는 환경(MacOS)과 실제 서비스가 구동되는 환경(Linux)이 다르다보니 내부적으로 조금씩 차이가나는 부분이 생기고 이로 인해 원하지 않는 결과를 가져오는 것이 아닐까 분석했습니다.

EC2에서 빌드하는 것의 문제점

다음으로 EC2에서 빌드하는 경우 인스턴스의 자원에 큰 영향을 준다는 문제점이 있었습니다. 지금까지의 프로젝트에서 최대한 서버비를 아끼기 위해 작은 용량의 인스턴스를 사용해왔습니다. 그러다보니 메모리 등의 자원이 많이 필요한 빌드 과정이 종종 운영되고 있는 서비스 자체에 영향을 주는 경우가 생겼고, 또한 원격에서 빌드되다보니 오류가 날 경우 추적이 쉽지않은 문제가 있었습니다.

Github Action에서 빌드를 해볼까?

이 이야기를 들은 백엔드 인턴 동료분들이 Github Action에서 빌드해볼 것을 추천해주셨습니다. 가상 머신 위에서 동작하기 때문에 npm 모듈 설치부터, 도커 빌드까지 모든 것이 가능하다는 말에 얼른 정보를 찾아봤고 쉽게 스크립트를 작성할 수 있었습니다. 사실 과거에 사용했던 Travis CI에서도 이런 동작들을 할 수 있었겠지만 제가 제대로 활용하지 못했다는 것을 깨달았습니다. 역시 다른 사람들과 고민과 지식을 공유하는 과정이 정말 중요하다는 것을 느꼈습니다.

Github Action Script 작성

여러 블로그를 참고하여 아래와 같이 스크립트를 작성했습니다.

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the main branch
  push:
    branches: [main]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-18.04
    env:
      client-directory: ./client
      server-directory: ./server

    strategy:
      matrix:
        node-version: ["14.x"]

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v2

      - name: install Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install client dependencies
        run: yarn
        working-directory: ${{ env.client-directory }}

      - name: Run client build
        run: yarn build
        working-directory: ${{ env.client-directory }}
        env:
          REACT_APP_NAVER_MAPS_CLIENT_ID: ${{ secrets.REACT_APP_NAVER_MAPS_CLIENT_ID }}
          REACT_APP_APP_ID: ${{ secrets.REACT_APP_APP_ID }}
          REACT_APP_ENDPOINT: ${{ secrets.REACT_APP_ENDPOINT }}
          REACT_APP_LOGIN: ${{ secrets.REACT_APP_LOGIN }}
          REACT_APP_MIXPANEL_TOKEN: ${{ secrets.REACT_APP_MIXPANEL_TOKEN }}

      - name: Install server dependencies
        run: npm install
        working-directory: ${{ env.server-directory }}

      - name: Run server build
        run: npm run build
        working-directory: ${{ env.server-directory }}

      - name: configure AWS
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2

      # - name: Login to ECR
      #   id: login-ecr
      #   uses: aws-actions/amazon-ecr-login@v1

      # - name: Build, tag, and push image to ECR
      #   env:
      #     ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
      #     ECR_REPOSITORY: mymapdeploy
      #     IMAGE_TAG: ${{ github.sha }}
      #   run: 
      #     docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
      #     docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      #     echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
      #     find ./ -name "*docker-compose.*.yml" -exec perl -pi -e "s/tagname/$IMAGE_TAG/g" {} \;
      
      - name: S3 Upload
        run: aws deploy push --application-name mymap-deploy --description "mymap server deploy" --s3-location s3://blabla/deploy.zip --source .

      - name: code deploy
        run: aws deploy create-deployment --application-name mymap-deploy --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name server --s3-location bucket=blabla,bundleType=zip,key=deploy.zip

도커 빌드는 어떻게 처리할지 고민이 되었지만 DockerHub, ECR 등 여러 클라우드 레포지토리들이 존재했고 저는 ECR에 업로드한 후 배포 스크립트에서 ECR에 로그인해 가져오는 방식을 택했습니다.

AWS codedeploy

CodeDeploy를 설정하는 방법은 다음 블로그에서 정말 잘 설명해주고 계십니다. 저도 이 블로그를 참고하여 환경을 구성했으니 여러분도 참고하시면 좋을 것 같습니다.

롤백의 중요성

기존 롤백 방법

서비스를 빠르게 개발하다보면 종종 롤백을 해야하는 상황이 생깁니다. 개발 단계에서는 괜찮지만 서비스가 시작되는 순간부터 모든 배포는 '달리는 자동차의 바퀴를 갈아끼우는 과정'이기 때문에 매우 조심스러워집니다.
아무리 테스트를 해보더라도 라이브 환경과 로컬환경의 차이와 같은 환경적인 이유부터 사소한 실수까지 서비스가 멈춰버릴 가능성은 언제든 존재합니다. 따라서 오류를 안내는 것도 중요하지만 빠르게 오류를 복구하는 것도 정말 중요하고 저도 이 부분을 서비스가 출시되기 전에 보완해야겠다고 생각했습니다.
기존에는 롤백을 하기 위해 git에서 revert 하는 방식을 이용했습니다. 하지만 이 방법에는 치명적인 문제가 있었습니다.

기존 방법의 문제점

git revert에 대해 잘 몰랐던 저는 급하게 롤백할 일이 생겨 무작정 해당 commit을 revert 했고 이후 변경사항이 아무리 push를 해도 적용되지 않는 문제에 봉착했습니다.
이미 꼬일대로 꼬여버린 commit tree를 복구하는 것보다 새로 레포를 파는 것이 빠르겠다고 결정했고(서비스 개발 초기였기 때문에) 그 때까지의 commit을 모두 날려버리는 아픔을 겪어야했습니다.
또한 그렇게 롤백하더라도 다시 빌드를 거쳐야하기 때문에 시간이 정말 오래걸린다는 것도 깨달았습니다. 이후 롤백 방법에 대한 개선이 정말 절실함을 깨달았습니다.

해결방법

빌드된 파일을 남겨둬야겠다

지금까지는 빌드된 결과물인 deplop.zip이 항상 덮어쓰기 되어 하나만 존재했습니다. 하지만 이 파일을 배포마다 분리하여 관리하면 빠르게 롤백할 수 있지 않을까 생각했습니다.

실제로 codedeploy에서는 매 배포마다 배포 재시도라는 기능을 통해 롤백을 지원하고 있습니다.

다음과 같이 매 배포는 개정의 위치 정보를 가지고 있으며 이것이 곧 배포되는 빌드된 파일입니다.
기존에 deploy.zip이 덮어쓰기되는 이유는 이름을 같게 설정되어있기 때문일 것이라고 생각했고, 제 생각이 맞는지 테스트 해보기 위해 Github Action Script를 조금 수정했습니다.

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the main branch
  push:
    branches: [main]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-18.04
    env:
      client-directory: ./client
      server-directory: ./server
      IMAGE_TAG: ${{ github.sha }}

    strategy:
      matrix:
        node-version: ["14.x"]

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v2

      - name: install Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install client dependencies
        run: yarn
        working-directory: ${{ env.client-directory }}

      - name: Run client build
        run: yarn build
        working-directory: ${{ env.client-directory }}
        env:
          REACT_APP_NAVER_MAPS_CLIENT_ID: ${{ secrets.REACT_APP_NAVER_MAPS_CLIENT_ID }}
          REACT_APP_APP_ID: ${{ secrets.REACT_APP_APP_ID }}
          REACT_APP_ENDPOINT: ${{ secrets.REACT_APP_ENDPOINT }}
          REACT_APP_LOGIN: ${{ secrets.REACT_APP_LOGIN }}
          REACT_APP_MIXPANEL_TOKEN: ${{ secrets.REACT_APP_MIXPANEL_TOKEN }}

      - name: Install server dependencies
        run: npm install
        working-directory: ${{ env.server-directory }}

      - name: Run server build
        run: npm run build
        working-directory: ${{ env.server-directory }}

      - name: configure AWS
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2

      # - name: Login to ECR
      #   id: login-ecr
      #   uses: aws-actions/amazon-ecr-login@v1

      # - name: Build, tag, and push image to ECR
      #   env:
      #     ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
      #     ECR_REPOSITORY: mymapdeploy
      #     IMAGE_TAG: ${{ github.sha }}
      #   run: 
      #     docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
      #     docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      #     echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
      #     find ./ -name "*docker-compose.*.yml" -exec perl -pi -e "s/tagname/$IMAGE_TAG/g" {} \;
      
      - name: S3 Upload
        run: aws deploy push --application-name mymap-deploy --description "mymap server deploy" --s3-location s3://blabla/deploy_$IMAGE_TAG.zip --source .

      - name: code deploy
        run: aws deploy create-deployment --application-name mymap-deploy --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name server --s3-location bucket=blabla,bundleType=zip,key=deploy_$IMAGE_TAG.zip

마지막 줄에서 보실 수 있듯이 S3에 업로드하는 파일 이름에 무작위 생성되는 문자열을 github.sha를 통해 넣어줬고,

보시는 것처럼 덮어쓰기 문제를 해결할 수 있었습니다.

일정기간 지나면 삭제하기

위 사진에서 보시는 것처럼 소스코드를 포함해 빌드된 파일까지 압축한 deploy파일을 상당히 크기가 큽니다.
S3 비용을 줄이기 위해 Lambda를 이용해 일정기간이 지나면 deploy파일을 삭제해주는 함수를 작성해줬습니다.

const aws = require('aws-sdk');
const moment = require('moment');

const s3 = new aws.S3({apiVersion: '2006-03-01'});
s3.config.update({
    accessKeyId: process.env.accessKeyId,
    secretAccessKey: process.env.secretAccessKey,
    signatureVersion: 'v4',
    region: 'ap-northeast-2'
});

exports.handler = async (event) => {
    // TODO implement
    try {
        
        const params = {
            Bucket: 'BUCKET_NAME',
        };
        const result = await s3.listObjectsV2(params).promise()
        await result.Contents.forEach(async function(item) {
            if (item.Size > 0 && moment().diff(moment(item.LastModified),'days') >= 10) {
                const params2 = {
                    Bucket: 'BUCKET_NAME',
                    Key: item.Key
                }
                console.log(params2)
                const data = await s3.deleteObject(params2).promise()
                console.log(data)
            }
        })
    } catch (error) {
        console.log(error);
        return error;
    }
};

여전히 존재하는 문제점

Push하면 돌아간다(병목)

지금까지 설명한 롤백방식은 정말 빠르게, 원하는 시점에 롤백할 수 있지만 치명적인 단점이 있습니다.
바로 다시 Push하면 에러코드가 반영된 채로 배포된다는 것입니다.
오류를 바로 발견해서 고쳤다면 다행이지만 어떨 때는 오류를 찾는 데에만 몇시간을 쓸 때도 있습니다.
지금은 두 명이 개발하니까 괜찮을지 몰라도 팀 규모가 커진다면 본인의 코드가 병목이 될 수도, 제대로 의사소통이 안된다면 다시 에러가 날 수도 있습니다.
codedeploy를 통해 롤백한 후 github revert 등을 이용해 빠르게 해당코드를 제거하는 작업이 필요합니다. 이 부분도 동시에 개선할 수 있는 방법을 고민하고 있고, 혹시 좋은 아이디어가 있으신 분은 댓글 남겨주시면 정말 감사하겠습니다.

느린 속도

저희 팀은 하루에도 수십 번씩 배포를 합니다. 하지만 매 배포마다 거의 10분이 소요되어 생산성이 떨어진다고 느낄 때가 많습니다.
다행히 다른 백엔드 인턴 동료분이 캐싱을 할 수 있는 github action이 있다고 소개해주셔서 다음에는 그 방법을 적용해보고 글을 써보려고 합니다.

긴 글 읽어주셔서 정말 감사합니다.

profile
Run, as you always do

0개의 댓글