본문 바로가기

Development/Web

[AWS] NestJS & Elastic Beanstalk CI/CD 구축

들어가며

CI/CD 툴에는 Jenkins, Travis, Circle CI 등 다양하게 있지만, 나는 주로 Github Action을 이용하는데, 그 이유는 매우 간편하기 때문이다(위에서 언급한 다양한 CI/CD 툴은 나중에 직접 사용해보고 블로그에 정리하겠다).

EB를 선택한 이유

AWS에서 배포 서버를 구축하는 방법으로는 EC2(+ CodeDeploy), Elastic Container Service(ECS), Elastic Beanstalk(EB) 등 다양하게 있다. AWS EB를 알기 전까지는 주로 EC2 인스턴스에서 CodeDeploy를 통해 배포하곤 했는데, Node.js, CodeDeployAgent를 설치하는데 소요되는 시간이 결코 적지 않았다. 따라서, 코드 작성에 조금 더 집중하기 위해 ECS와 EB 중에서 고민해보다가 다음과 같은 이유로 EB를 선택하게 되었다.

  • 자동으로 배포 환경을 생성해주기 때문에 편리하다.
  • S3를 통해 버전 관리가 되므로, 비교적 버전 관리가 쉽다.
  • 서비스를 운영하면서도 Load Balancer, Auto Scaling, Health Checker 등을 쉽게 설정할 수 있다.
  • Rolloing, Canary, Immutable, Blue-green 등 다양한 배포 방식을 제공한다.

AWS Elastic Beanstalk 설정

AWS에서 Elastic Beanstalk 대시보드에 접속한 후 Create Application 버튼을 클릭한다.

환경 생성

웹 앱 생성 페이지로 이동한 후, 애플리케이션 이름을 입력하고, 플랫폼을 선택한다. 본 글에서는 NestJS 서버 애플리케이션을 배포할 것이기 때문에 가장 최신 버전인 Node.js 16(5.5.3) 플랫폼을 선택하였다. 이어서 하단에 있는 추가 옵션 구성 버튼을 클릭하여 환경 구성을 설정해주록 하자.

환경 구성

본 글에서는 단일 인스턴스(프리 티어 사용 가능)를 선택하였다(아직 취준생이기 때문에...). 이와 같이 단일 인스턴스로 환경을 구성하는 경우에는 로드 밸런서를 사용할 수 없다. 로드 밸런서를 사용하려면 고가용성으로 설정해주어야 한다. 만약, https redirect 방식을 적용하고자 한다면 최초 환경 구성 시 고가용성으로 선택하는 것을 권장한다. 그 이유는 환경 생성 중에만 로드 밸런서 유형(Classic, Application, Network)을 선택할 수 있기 때문이다.

롤링 업데이트와 배포 수정

배포 방식은 한 번에 모두(All at once)와 변경 불가능(Immutable)이 있다. '한 번에 모두'는 말 뜻 그대로 인스턴스(로드 밸런서를 사용하는 경우에는 모든 인스턴스)에 배포하는 방식을 말하며, 배포가 수행되는 중에는 인스턴스가 잠시 중지된다. '변경 불가능'은 새 버전을 새로운 인스턴스 그룹에 배포하는 방식을 말한다. 따라서, 개발 환경에서는 빠른 확인을 위해 한 번에 모두 방식으로 배포하고, 운영 환경에서는 서비스가 중지되면 안 되므로 변경 불가능 방식으로 배포한다고 한다.

보안 수정

보안 그룹은 환경이 생성 시 자동으로 지정이 되어 있을 수 있으나, 그렇지 않다면 aws-elasticbeanstalk-service-role로 설정해주도록 한다. 또한, 나중에 https 적용 시 nginx의 설정을 수정해주는 등 직접 EC2 인스턴스 내부로 접속할 경우가 있을 수 있으니, EC2 키 페어까지 설정해주도록 하자.

여기까지 설정을 완료했다면, 환경 생성 버튼을 클릭하여 환경을 생성해주도록 한다.

IAM 설정

환경이 설정될 때까지 약간의 시간이 소요되는데, 그 동안 Github Action을 통해 AWS에 접근할 수 있도록 IAM 계정을 생성해주도록 하자. 먼저, AWS IAM 대시보드로 이동하고, 좌측 메뉴에서 사용자를 클릭하여 사용자 관리 페이지로 이동한다. 그리고 사용자 추가 버튼을 클릭하여 사용자 추가 페이지로 이동하고, 사용자 이름과 엑세스 유형을 선택한다. 엑세스 유형은 프로그래밍 방식 엑세스와 AWS Management Console 엑세스 방식이 있는데, 이 글에서는 Github Action을 통해 AWS에 접근할 것이므로, 프로그래밍 방식을 선택하도록 한다.

다음 페이지로 이동하여 기존 정책 직접 연결을 선택하고, AdministratorAccess-AWSElasticBeanstalk를 항목으로 권한을 설정한다.

이후로는 계속 다음 버튼을 클릭하여 사용자를 생성해주도록 한다. 사용자를 생성하면, 아래 그림과 같이 액세스 키 ID비밀 액세스 키를 확인할 수 있는데, Github Action을 설정할 때 필요한 정보이니 어딘가에 잘 복사해놓도록 하자(만약, 복사하지 않고 페이지를 넘어갔다면, 키를 다시 생성해주어야 한다).

Github Action 설정

AWS 설정을 마쳤으니, 이제 Github Action 설정을 해주도록 하겠다. 먼저, 새로운 Repository를 생성한 후 .github/workflows 폴더에 deploy-aws-elastic-beanstalk.yaml 파일을 생성해주도록 하자. 현재 필자의 프로젝트 폴더 구조는 다음과 같이 구성되어 있다.

.github/
  workflows/
    deploy-aws-elastic-beanstalk.yaml
backend/...
database/...
frontend/...
README.md

즉, Github Repository에서 ./backend 폴더 내 파일만 수정되었을 때 Github Action이 실행되도록 설정해주어야 한다. 따라서, 설정 파일을 다음과 같이 작성해주었다.

name: Deploy Backend to AWS Elastic Beanstalk

on:
  push:
    branches:
      - master
    paths:
      - 'backend/**'

jobs:
  # CI Pipeline
  build:
    name: CI Pipeline
    runs-on: ubuntu-20.04
    env:
      working-directory: './backend'
    strategy:
      matrix:
        node-version: ['16.x']
    steps:
      # Checkout
      - uses: actions/checkout@v3

      # Install Node.js
      - name: Install Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      # Install Node.js dependencies
      - name: Install dependencies
        working-directory: ${{ env.working-directory }}
        run: npm install

      # Build Typescript
      - name: Run build
        working-directory: ${{ env.working-directory }}
        run: npm run build

  # CD Pipeline
  deploy:
    name: CD Pipeline
    runs-on: ubuntu-20.04
    env:
      working-directory: './backend'
    strategy:
      matrix:
        node-version: ['16.x']
    needs: build
    steps:
      # Checkout
      - uses: actions/checkout@v3

      # Create .common.env file
      - name: Create .common.env file
        working-directory: ${{ env.env-directory }}
        run: |
          touch .common.env
          echo TZ=Asia/Seoul >> .common.env
          echo PORT=8081 >> .common.env
          echo ORIGIN=${{ secrets.ORIGIN }} >> .common.env
          echo JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} >> .common.env
          echo SALT_ROUNDS=${{ secrets.SALT_ROUNDS }} >> .common.env
          cat .common.env

      # Create .kakao.env file
      - name: Create .kakao.env file
        working-directory: ${{ env.env-directory }}
        run: |
          touch .kakao.env
          echo KAKAO_REDIRECT_URL=${{ secrets.KAKAO_REDIRECT_URL }} >> .kakao.env
          echo KAKAO_CLIENT_KEY=${{ secrets.KAKAO_CLIENT_KEY }} >> .kakao.env
          echo KAKAO_ADMIN_KEY=${{ secrets.KAKAO_ADMIN_KEY }} >> .kakao.env
          cat .kakao.env

      # Create .database.env file
      - name: Create .database.env file
        working-directory: ${{ env.env-directory }}
        run: |
          touch .database.env
          echo MYSQL_DB_HOST=${{ secrets.MYSQL_DB_HOST }} >> .database.env
          echo MYSQL_DB_PORT=${{ secrets.MYSQL_DB_PORT }} >> .database.env
          echo MYSQL_DB_USER=${{ secrets.MYSQL_DB_USER }} >> .database.env
          echo MYSQL_DB_PASSWORD=${{ secrets.MYSQL_DB_PASSWORD }} >> .database.env
          echo MYSQL_DB_DATABASE=${{ secrets.MYSQL_DB_DATABASE }} >> .database.env
          echo MYSQL_DB_SYNC=${{ secrets.MYSQL_DB_SYNC }} >> .database.env
          cat .database.env

      # Install Node.js
      - name: Install Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      # Install Node.js dependencies
      - name: Install dependencies
        working-directory: ${{ env.working-directory }}
        run: npm install

      # Build Typescript
      - name: Run build
        working-directory: ${{ env.working-directory }}
        run: npm run build

      # Install AWS CLI 2
      - name: Install AWS CLI 2
        working-directory: ${{ env.working-directory }}
        run: |
          curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
          unzip awscliv2.zip
          which aws
          sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update

      # Configure AWS credentials
      - name: Configure AWS credentials
        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: ${{ secrets.AWS_REGION }}

      # Make to ZIP file with source code
      # -x 명령어 뒤에 해당하는 파일은 zip 파일 생성 시 제외
      - name: Generate deployment Beanstalk
        working-directory: ${{ env.working-directory }}
        run: zip -r deploy.zip . -x '*.git*' './src/*' './aws/*' awscliv2.zip

      # Get Current Time
      - name: Get Current time
        uses: josStorer/get-current-time@v2
        id: current-time
        with:
          format: YYYYMMDD-HH-mm-ss
          utcOffset: '+09:00'

      # Deploy to Elastic Beanstalk
      - name: Deploy to EB
        uses: einaregilsson/beanstalk-deploy@v20
        with:
          region: ${{ secrets.AWS_REGION }}
          aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          application_name: ${{ secrets.AWS_APPLICATION_NAME }}
          environment_name: ${{ secrets.AWS_ENVIRONMENT_NAME }}
          version_label: ${{ steps.current-time.outputs.formattedTime }}
          deployment_package: ./backend/deploy.zip
          wait_for_environment_recovery: 300

위의 코드 중에서 환경변수(.env)를 설정해주는 부분을 보면 포트 번호가 8080이 아닌 것을 확인할 수 있다. 이는 웹 서비스 및 Amazon Linux AMI 플랫폼 버전을 사용하는 경우의 기본 포트가 8081이기 때문이다. 또한, 위의 workflow는 어딘가에 저장된 환경변수 불러온 후 실행되므로, workflow에 적용되는 환경변수를 어딘가에 저장해주어야 한다. 이는 Repository Settings 페이지의 좌측 메뉴에 있는 Secrets에서 관리해줄 수 있다. 아래 그림에서 우측에 위치하는 New repository secret 버튼을 클릭하여 애플리케이션을 동작시킬 때 필요한 환경변수를 추가해주도록 하자.

환경변수를 모두 입력 후 Repository에 코드를 푸쉬해보면 아무 일도 일어나지 않을 것이다. 왜냐하면, 아래 코드 부분에서 backend 폴더 안의 파일이 변경되고 master 브랜치에 푸쉬가 발생할 때만 action이 실행되도록 설정했기 때문이다.

on:
  push:
    branches:
      - master
    paths:
      - 'backend/**'

테스트를 위해 backend 폴더에 위치하는 README.md 파일을 수정 후 푸쉬해보도록 하겠다. 그러면 아래 그림과 같이 workflow가 실행되는 것을 확인할 수 있다.

AWS Elastic Beanstalk 로그 확인

위의 workflow 실행이 완료되기까지 약 4분 정도 소요되었으며, 정상적으로 배포까지 완료된 것을 확인할 수 있었다.

배포한 서버의 Swagger 페이지를 확인하기 위해 배포한 웹 사이트로 접속해보았는데, 502 Bad Gateway 오류가 발생한 것을 확인하였다.

무엇이 문제인지 확인하기 위해서 AWS Elastic Beanstalk 대시보드 페이지에서 로그 페이지로 이동하였다. 이어서 우측에 위치하는 로그 요청 버튼을 클릭한 후 전체 로그 또는 마지막 100줄로 로그를 다운로드 받을 수 있다.

로그를 확인한 결과, TypeORM으로 MySQL 접속이 실패한 상태임을 알 수 있었다. 이렇게 편리하게 로그를 확인할 수 있다는 점이 내가 Elastic Beanstalk를 자주 이용하는 이유가 아닐까 싶다.

마치며

다른 글을 보니, 불과 1년 전까지만 해도 여러 스타트업에서 EB를 많이 사용했었는데, MSA가 핫해진 요즘에는 Amazon Elastic Kubernetes Service(EKS)를 많이 사용한다고 한다. 일단, 현재 개발하고 있는 프로젝트가 마무리되면, EKS를 사용해보고, 이에 대한 글도 게시하도록 하겠다.

참고 자료