[Infra] NestJS & Github Action 빌드 개선하기 (2 : CD build - cd.yml 파일 개선)

2024. 5. 27. 16:05Programming/Infra Structure


목차

  1. 목적
  2. CD 빌드 살펴보기
  3. CD 빌드에서 불필요한 점 찾기
    1. Dockerfile 실행 시간 최적화
      a. Dockerfile 살펴보기
      b. 불필요한 부분 제거 및 변경
    2. AWS EC2 배포
      a. cd.yml 파일의 AWS EC2 단계 살펴보기
      b. 불필요한 부분 제거 및 변경
  4. 결론
  5. RECOMMEND FOR YOU & REFERENCE

목적

매우 오래 걸리는 CD 빌드 환경

 

저번 포스팅에서 CI 배포 시간을 약 15% 이상 줄였다.
성능이 상당히 향상되었지만, 여전히 이를 실제로 배포하는 CD 빌드의 속도는 확연히 느리다.

CD 빌드 성능을 개선해보자.


CD 빌드 살펴보기

우선 cd.yml 파일이 어떤 구조를 갖고 있는지 알아보자.

name: CD

on:
  push:
    branches:
      - prod

jobs:
  cd-build:
    runs-on: ubuntu-20.04

    steps:
      - name: checkout
        uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 20

      - name: install package manager - pnpm
        run: npm install -g pnpm

      - name: env set
        working-directory: ./
        run: |
          touch .env
          echo POSTGRESQL_DB_URL=${{secrets.POSTGRESQL_DB_URL}} >> .env
          echo JWT_PRIVATE=${{secrets.JWT_PRIVATE}} >> .env
          echo JWT_SECRET=${{secrets.JWT_SECRET}} >> .env
          echo PORT=${{secrets.PORT}} >> .env
          echo SALT=${{secrets.SALT}} >> .env
          echo ORIGIN=${{ secrets.ORIGIN }} >> .env
          echo AWS_USER=${{ secrets.PROD_AWS_USER }} >> .env
          echo AWS_HOST=${{ secrets.PROD_AWS_HOST }} >> .env

      - name: Install package
        run: pnpm i --no-frozen-lockfile

      - name: prisma set
        run: npx prisma generate

      - name: build
        run: |
          pnpm build
          cp -r src/prisma/client dist/prisma/client
          cp package.prod.json dist/package.json
          cp ecosystem.config.js dist/ecosystem.config.js

      - name: docker
        run: docker build ./dist -t ${{ secrets.DOCKERHUB_USERNAME }}/dauth:latest -f ./Dockerfile

      - name: Login to Docker Hub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      - name: push to Docker Hub
        run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/dauth:latest

      - name: Get GitHub Actions IP
        id: ip
        uses: haythem/public-ip@v1.2

      - name: configuring
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ${{ secrets.AWS_REGION }}
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Add Github Actions IP to Security group
        env:
          AWS_DEFAULT_REGION: ap-northeast-2
        run: |
          aws ec2 authorize-security-group-ingress \
          --group-id ${{ secrets.AWS_EC2_SG_ID }} \
          --protocol tcp --port 22 \
          --cidr ${{ steps.ip.outputs.ipv4 }}/32 \

      - name: EC2 Production Server Deploy
        uses: appleboy/ssh-action@master
        with:
          key: ${{ secrets.PROD_AWS_SSH_KEY }}
          host: ${{ secrets.PROD_AWS_HOST }}
          port: ${{ secrets.AWS_PORT }}
          username: ubuntu
          script: |
            groupadd docker
            sudo usermod -aG docker $USER
            newgrp docker
            echo "${{ secrets.DOCKERHUB_PASSWORD }}" >> ~/my_password.txt
            cat ~/my_password.txt | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/dauth:latest
            touch .env
            echo POSTGRESQL_DB_URL=${{secrets.POSTGRESQL_DB_URL}} >> .env
            echo JWT_PRIVATE=${{secrets.JWT_PRIVATE}} >> .env
            echo JWT_SECRET=${{secrets.JWT_SECRET}} >> .env
            echo PORT=${{secrets.PORT}} >> .env
            echo SALT=${{secrets.SALT}} >> .env
            echo ORIGIN=${{ secrets.ORIGIN }} >> .env
            echo AWS_USER=${{ secrets.PROD_AWS_USER }} >> .env
            echo AWS_HOST=${{ secrets.PROD_AWS_HOST }} >> .env
            docker run -d -p 8080:8080 --env-file .env iixanx/dauth:latest
            y | docker rm $(docker ps -a -f status=exited -q)
            y | docker image prune

      - name: Remove Github Actions IP From Security Group
        run: |
          aws ec2 revoke-security-group-ingress \
          --group-id ${{ secrets.AWS_EC2_SG_ID }} \
          --protocol tcp --port 22 \
          --cidr ${{ steps.ip.outputs.ipv4 }}/32

 

yml 파일의 작동 방식은 다음과 같다.

 

우선 Node.js를 세팅해주고, pnpm을 설치한다. 환경변수를 설정하고 pnpm으로 다른 패키지도 같이 설치해준 이후 PrismaORM을 세팅한다.
그 후 빌드해서 ts 파일을 js로 컴파일해주고, prisma client 파일과 package.json 파일, ecosystem 파일을 복사해서 dist 폴더로 옮긴다.
다음으로, Dockerfile을 실행 및 빌드하고 도커허브로 push한 뒤, AWS EC2 인바운드 보안 그룹에 GH Actions의 IP를 추가해 접근 권한을 만든다.
마지막으로 EC2에 새 도커 이미지를 pull 받아 컨테이너를 실행시키며, 작업이 끝나면 AWS EC2 보안 그룹에서 GH Actions의 IP 주소를 제거한다.


CD Build에서 불필요한 점 찾기

매우 길다...
그리고 문제점이 한 눈에 보인다.

  1. .env 파일에 두 번씩이나 값을 넣고 있다... 메모리 낭비같아 보인다.
  2. 아직 패키지 캐싱을 하지 않아 매 push마다 새로이 패키지를 다운로드받고 있다.
  3. 아직 PrismaORM 명령어도 간소화하지 않아 매 번 DB에 새로 push 및 migration을 진행하고 있다.

또한, CD 빌드 실행 시의 시간 비율을 확인해보면 추가로 문제점을 찾을 수 있다.

배포 한 번에 걸리는 시간이 3m 3s

 

가장 최근에 실행시킨 CD 빌드를 기준으로 보자.

 

  1. Dockerfile 실행 시간이 너무 길다. 무려 전체 실행 시간의 27% 이상을 차지하고 있었다. (50s)
  2. Docker Hub에 push하는 시간도 상당히 길다. 전체 실행 시간의 19% 이상을 차지한다. (35s)
  3. AWS EC2에 실제로 액세스해 배포하는 시간이 꽤나 걸렸다. 전체 실행 시간 중 25% 이상을 차지한다. (47s)

3개 스텝만 줄여도 실행 시간이 절반 이하로 줄어들 것으로 추측할 수 있다.

 

우선 Dockerfile은 직접 파일을 수정해서 실행 시간을 줄여야 할 것으로 추측한다. 다만 단계를 줄이면 레이어가 줄어들기 때문에, 이를 통해 시간을 단축할 수 있을 것으로 예상한다.
또한 Docker Hub pushDockerfile의 레이어를 줄인다면 push할 용량 역시 줄어 자연스레 시간이 줄 것으로 추측할 수 있다.
AWS EC2에 액세스해 배포하는 단계는 cd.yml 파일의 해당 단계를 더욱 최적화해야 할 것이다.


Dockerfile 실행 시간 최적화

Dockerfile 살펴보기

Dockerfile은 어떻게 생겼을까?

FROM node:20-alpine AS base

FROM base AS set

ENV NODE_VERSION 20.12.2

RUN mkdir -p /app
WORKDIR /app

RUN apk update 
RUN apk add npm
RUN apk add tree
RUN npm i -g pm2
RUN npm i -g pnpm

FROM set AS build

COPY . .
RUN export NODE_ENV=prod
RUN pnpm install
RUN pnpm prisma generate
RUN cd ..

EXPOSE 8080

CMD [ "pnpm", "node:prod" ]

 

일반 Node 이미지보다 더 가벼운 alpine 이미지를 20버전으로 가져와서 사용하며, 해당 단계는 base라는 명칭으로 정의한다.

base 단계에서 가져와서 set 단계를 정의한다.
NODE_VERSION 환경변수를 20.12.2로 정의하고 서버를 실행할 디렉토리를 mkdir app으로 만들고, workdir로 지정한다.
이후 alpine에서 사용하는 설치 툴인 apk를 활용해 npm, tree를 설치하고, npm으로 pm2pnpm을 설치한다.

set 단계에서 build 단계를 정의하고, COPY 명령어를 통해 폴더 내부의 것들을 복사해온다.
이후 export를 통해 환경변수를 정의하고, pnpm을 이용해 패키지를 설치하고 PrismaORM을 설정한다.
현재 디렉토리를 상위 디렉토리로 변경하고 EXPOSE를 통해 포트를 정의한다.

마지막으로 CMD를 활용해 pnpm node:prod 명령어를 실행한다.

불필요한 부분 제거 및 변경

우선 가장 문제인 부분은 cd.yml 파일의 cd 빌드에서 실행한 부분을 여기에서도 실행하고 있다는 점이다.

중복된 실행은 실행 시간을 증가시킬 뿐더러 패키지를 중복으로 설치하므로 메모리 낭비가 발생할 수 있다.
따라서 cd.yml 파일에서 설치했던 pnpm, 과거 무중단 배포를 위해 설치했지만 현재는 사용하지 않는 pm2나 구조 확인을 위해 설치했던 tree도 설치 명령어를 삭제해준다.

 

또한 이미 실행했던 명령어인 pnpm install이나 pnpm prisma generate도 같이 삭제해준다.

 

그리고 마지막 줄의 CMD ["pnpm", "node:prod"]도 변경할 것이다.
현재 package.prod.json에 정의해둔 명령어에서 node:prod는 ts 파일을 통째로 실행하기 때문에 굳이 ts 파일들을 js 파일로 컴파일한 이유가 없고, 실행 속도에 유의미한 차이가 있을 것으로 예상한다.
따라서 이를 node main.js로 변경한다.

 

결과적으로 Dockerfile은 다음과 같은 형태를 띄게 되었다.

 

FROM node:20-alpine AS base

FROM base AS set

ENV NODE_VERSION 20.12.2

RUN mkdir -p /app
WORKDIR /app

RUN apk update 

FROM set AS build

COPY . .
RUN export NODE_ENV=prod
RUN cd ..

EXPOSE 8080

CMD [ "node", "main.js" ]

 

수정 이후 배포를 시도해보았을 때, 기존에 50s 가량 걸리던 단계가 6s로 무려 88% 가량 감소했다!!

또한 도커허브로 push하던 단계는 35s에서 9s로 74% 가량 감소했다.

 

전체 70s 정도를 Dockerfile 최적화로 줄인 것이다.

Docker Build 과정은 88% 감소, Docker Hub로 push하는 부분은 74% 감소했다.


AWS EC2 배포

cd.yml 파일의 AWS EC2 단계 살펴보기

cd 빌드를 실행하는 yml 파일은 어떤 단계를 거칠까?

      - name: EC2 Production Server Deploy
        uses: appleboy/ssh-action@master
        with:
          key: ${{ secrets.PROD_AWS_SSH_KEY }}
          host: ${{ secrets.PROD_AWS_HOST }}
          port: ${{ secrets.AWS_PORT }}
          username: ubuntu
          script: |
            groupadd docker
            sudo usermod -aG docker $USER
            newgrp docker
            echo "${{ secrets.DOCKERHUB_PASSWORD }}" >> ~/my_password.txt
            cat ~/my_password.txt | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/dauth:latest
            touch .env
            echo POSTGRESQL_DB_URL=${{secrets.POSTGRESQL_DB_URL}} >> .env
            echo JWT_PRIVATE=${{secrets.JWT_PRIVATE}} >> .env
            echo JWT_SECRET=${{secrets.JWT_SECRET}} >> .env
            echo PORT=${{secrets.PORT}} >> .env
            echo SALT=${{secrets.SALT}} >> .env
            echo ORIGIN=${{ secrets.ORIGIN }} >> .env
            echo AWS_USER=${{ secrets.PROD_AWS_USER }} >> .env
            echo AWS_HOST=${{ secrets.PROD_AWS_HOST }} >> .env
            docker run -d -p 8080:8080 --env-file .env iixanx/dauth:latest
            y | docker rm $(docker ps -a -f status=exited -q)
            y | docker image prune

 

해당 단계가 실행되는 과정은 다음과 같다.

 

도커에 권한을 우선적으로 추가해준다.
이후 DockerHub 비밀번호를 txt 파일에 저장해주고, --password-stdin 옵션을 활용해 입력해준다.
새로이 push되었던 도커 이미지를 pull받고 .env 파일을 생성해 환경변수들을 저장해준다.
그 다음으로는 docker run 명령어를 통해 도커파일을 실행시키며, 이전의 이미지와 그를 바탕으로 실행되던 컨테이너를 삭제해준다.

불필요한 부분 제거 및 변경

파일 I/O에는 생각보다 많은 시간이 걸린다.
이에 대한 원인은 다양하게 확인할 수 있는데, 하드웨어 문제나 운영체제의 오버헤드 등으로 인한 것이 가장 크다.

 

이러한 환경을 고려했을 때, .env 파일을 생성 및 작성하는 데에는 상당한 자원과 시간이 소모될 것으로 추측한다.
즉 중복된 환경변수 파일 생성 단계를 삭제하기만 해도 훨씬 시간 소요가 줄어들 수 있음을 의미한다.

 

해당 부분을 삭제해주고 다시 CD 빌드를 작동시켜보자.

기존에 47s 걸리던 배포가 13s로 줄어들었다! (72% 가량 감소)

 

빌드를 작동시키고 결과를 확인하였을 때, 47초 가량 걸리던 EC2 배포가 13초 가량으로 줄었다.

파일 I/O를 하지 않는 것 만으로도 실행 시간 중 34초가 줄어들었다.


결론

결론적으로 전체 배포 시간이 70s 정도로 줄어들었다. 기존 (183s) 대비 61% 가량을 감소시킨 것이다.

비효율적인 코드가 그동안 생산성을 얼마나 저하시켰는지 알 수 있다.

 

앞으로도 코드 작성에서만 끝낼 것이 아닌, 효율적인 리소스 사용 및 성능 개선을 위한 리팩토링을 지속적으로 진행하며 성능 최적화를 고려하며 코드를 작성하고 싶다.


RECOMMEND FOR YOU

[Infra] NestJS & Github Action 빌드 개선하기 (1 : CI build - ci.yml 개선) : https://iio-nff.tistory.com/4

REFERENCE