백준봇 개발기(2) - 귀찮아서 CI/CD 구축하기

Jiwan Ahn·2023년 10월 18일
1

백준봇 개발기

목록 보기
2/3

귀차니즘

귀찮은 것을 하지 않기 위해 귀찮은 짓을 한 썰

틈틈이 백준봇을 개발하면서, 느낀 것이 있다. 배포가 진짜 너무 귀찮다. 지금 현재 내 디코봇은 의존성 문제 때문에 컨테이너에서 돌아가도록 했는데, 그러다보니까 정말 사소한 변경사항 하나 때문에 ec2에 들어가서 컨테이너 지우고...이미지 삭제하고...파일 받아서 다시 띄우고...뭐 솔직히 끽해봐야 2분 3분 걸리지만, 그냥 그 과정이 너무너무 귀찮았다.

그래서 생각한게, "어? 이참에 배운 CI/CD 파이프라인을 이제 한번 써볼까?" 라는 생각이 들었다. 지금까지 보아즈 사람들을 만나면서 수없이 많이 들은 CI/CD인데 (사실 이 사람들은 인프라에 빠진 분들...), 이 기회에 한번 적용해봐야 겠다라고 생각했다.

우습게도 이 생각은, 자려고 침대에 누웠을 때 머릿속을 갑자기 스쳐 지나갔다. 역시 개발자들은 자려 할 때 기막힌 생각을 갑자기 한다더니...

뭘 쓰지?

일단 CI/CD 파이프라인을 만들겠다고 생각할 때 가장 먼저 떠오른게 Jenkins였다. 단일 컨테이너로 돌아가고 있는 내 디코봇 서버를 Kubernetes위에 띄운 다음, Jenkins + Gitops로 CI/CD를 구축한다면?

(당시는 정말 미친 생각이었다)

평소부터 Jenkins를 써보고 싶었고, 때마침 Kubernetes를 본격적으로 사용할 수 있는 계기 (라기보단 핑계?) 가 생겨, 이참에 싹 갈아엎을 생각으로 그때 당시는 진짜로 도입하려고 했다.

허나, 생각해보니까 뭐 디코봇 서버를 여러개 띄우는 것도 아니고, 고작 백준봇 하나 띄우는 거에 Kubernetes를 도입하고 Jenkins에 Argo CD까지 도입한다? 게다가 지금 현재 캡스톤까지 하고 있는 학부생이라 시간이 넉넉하지가 않은데, 당장 해야 할 것이 산더미처럼 싸여 있는데, 어느 세월에 이걸 다 도입하고 앉았냐 이말이다.

스포를 하자면, 당시 이 글을 쓰기 시작한 시점 (10.2) 으로부터 2주가 지난 지금 (10.18), 저 위에 말한 CI/CD를 구현하기 위해 홈서버를 샀다...

Github Actions

그러다 내 머리를 스친 것이 Github Actions였다. 현재 서버 규모 해봤자 겨우 디스코드 봇 하나이고, 모니터링까지 필요한 수준은 아닌 아주 가벼운 서버인 것을 고려하면, 굳이 저 위처럼 복잡한 CI/CD 파이프라인을 구축해야 할 이유가 있나라는 생각이 들었다.

원래 내가 CI/CD를 도입하려 했던 이유는, 커밋하고 푸시하자 마자 유닛 테스트를 진행하고, 자동으로 빌드한 후에 ec2에 배포하려고 도입하는 것이었는데, 찾아보니 이 모든 것이 Github Actions하나에서 처리가 가능했다는 것이다. 그렇게 Github Actions를 도입하기 위해 큰 발걸음을 옮겼다.

이 발걸음이 고난의 행군의 시작인 것은 모른채...

녹록치 않은 첫 CI/CD 도입기

처음은 누구나 어렵다.

CI/CD 구축과 Github Actions를 본격적으로 한 것은 처음이라 우선 템플릿 파일 부터 어떻게 생긴지부터 파악해야 했다.

name: Docker Image CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:

  build:
    runs-on: ubuntu-latest

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

    - name: Setup Node.js environment
      uses: actions/setup-node@v3.8.1
      with:
        node-version: "18.x"
  • on: 해당 workflow가 트리거 되는 조건을 기입하는 필드다. 여기서는 main브랜치에 'Push'하거나 'Pull Request'를 걸었을 때 해당 워크플로우가 Trigger 된다.
  • jobs: Github Actions의 Workflow는 하나 이상의 Job들로 이루어져 있다. 병렬적으로 실행되지만, needs:를 통해 job들간의 의존성을 설정해줄 수 있다.
  • steps: 하나의 job안에서 실행되는 동작들을 의미하며, 순차적으로 진행된다.

일단 내가 머릿속으로 생각한 workflow 순서는 다음과 같았다.

  1. Node.js 환경에서 Unit Test를 실행한다.
  2. 테스트를 통과하면 Docker image를 빌드한다.
  3. 이미지를 빌드하면 이 이미지를 ec2에 배포한다.

그럼 우선 Unit Test를 실행하는 단계부터 구현해야 한다.

왜 Jest가 안되는 거지...?

  • github actions는 처음
  • 테스트 코드의 문제

일단 CI는 Test/Build가 자동화 되는 과정이므로, Github action에서 가장 먼저 해야 할 Task는 다음과 같았다.

  • Node.js Module 설치 (npm install)
  • 대표적인 Test 라이브러리인 Jest 사용
	- name: npm_install
      run: npm install

    - name: Run Jest
      run: jest

    - name: Build Docker image
      run: docker build -t boj-bot .

예전에 잠깐 Github actions를 다뤄본 적이 있었고, .Yaml 파일의 경우에는 Kubernetes를 하면서 익숙해진 터라, 호기롭게 작성하고 한번 돌려봤다. 그러나..

...? 그래서 살펴보니

Jest command가 발견되지 않았다고 나왔다. 어이가 없어서 봤더니, Jest는 개발 환경일 때만 돌아가도록 따로 "devdependencies"로 지정이 되어있다고 한다. 따라서, 이를 쓰기 위해서는 CI 전용 지정 모듈들을 다운받아야 하므로 npm ci를 사용해야 했다.

이제 Unit test는 실행했으니, 도커 이미지를 빌드하여 배포를 하는 과정을 거쳐야 한다.

생각보다 힘들었던 이미지 배포

  • 원격으로 로그인 (도커 로그인)
  • 이미지 대체하기
  • 환경변수 전달 문제점 (.env, Dockerfile ENV)

어느정도 Github actions에 익숙해지고, Marketplace를 통해 Docker login 까지는 괜찮았다. 그러나, 한 가지 문제점이 있었다. "환경변수"였다.

DISCORD_TOKEN=~~~
RDS_ENDPOINT=~@#
RDS_USER=@#$@#
RDS_PASSWORD=%$
RDS_DB=#@$
BASE_URL=$%

내가 그때 선택한 방법은 좀 무식한 방법이었다.

	- name: Generate ENV file
      run: |
        echo "DISCORD_TOKEN=$DISCORD_TOKEN" >> .env
        echo "RDS_ENDPOINT=$RDS_ENDPOINT" >> .env
        echo "RDS_USER=$RDS_USER" >> .env
        echo "RDS_PASSWORD=$RDS_PASSWORD" >> .env
        echo "RDS_DB=$RDS_DB" >> .env
        echo "BASE_URL=$BASE_URL" >> .env
      env:
        DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
        RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }}
        RDS_USER: ${{ secrets.RDS_USER }}
        RDS_PASSWORD: ${{ secrets.RDS_PASSWORD }}
        RDS_DB: ${{ secrets.RDS_DB }}
        BASE_URL: ${{ secrets.BASE_URL }}

그냥 생각없이 ".env" 파일에 환경변수를 다 담고, 그대로 image를 빌드해버렸다!!

심지어, 이걸 한번 Push를 했었는데, 하고 나서 5초만에 등골이 오싹해졌다. "생각해보니...내가 이 .env파일을 그대로 image에 넣으면 저 값이 그대로 노출이 될 텐데...?" 라는 생각이 반사적으로 들었고, 바로 도커허브에서 이를 삭제했다.

자...그래서 결국 환경변수를 "안전"하게 담는 방법이 필요했다.

  1. EC2에 환경변수를 넣자?
    => 만일 환경변수 중 하나가 변경되면 또 EC2에 접속해서 일일이 바꿔줘야 한다. 귀찮다!
  2. ec2에 .env파일을 넣자?
    => 컨테이너는 이미지에서 빌드 되기 때문에 외부 파일과는 상관이 없다.

그래서 열심히 생각해봤다. 그러다 발견한 방법은 바로 임시로 환경변수 리스트를 담은 env.list를 원격으로 EC2에 생성하는 방법이었다.

그 후, 도커 명령어 중 docker run --env-file <환경변수 리스트> <기타 명령어>를 통해 컨테이너에 환경변수를 전달해주면, 이미지에 환경변수가 기록이 되지 않음과 동시에 안전하게 컨테이너 속 애플리케이션이 환경변수를 이용하게 만들 수 있다. 이걸 어떻게 실현하느냐? 역시, Github Actions를 통해!

 	   - name: Deploy to EC2
         uses: appleboy/ssh-action@v1.0.0
         env:
           DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
           RDS_ENDPOINT: ${{ secrets.RDS_ENDPOINT }}
           RDS_USER: ${{ secrets.RDS_USER }}
           RDS_PASSWORD: ${{ secrets.RDS_PASSWORD }}
           RDS_DB: ${{ secrets.RDS_DB }}
           BASE_URL: ${{ secrets.BASE_URL }}
         with:
           host: ${{ secrets.EC2_HOST }}
           username: ${{ secrets.EC2_USERNAME }}
           key: ${{ secrets.EC2_KEY }}

           script: |
             echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
             sudo docker rm $(sudo docker stop $(sudo docker ps -a -q --filter ancestor=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}:latest --format="{{.ID}}"))
             sudo docker rmi ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}:latest
             sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}:latest
             echo "DISCORD_TOKEN=$DISCORD_TOKEN" > env.list
             echo "RDS_ENDPOINT=$RDS_ENDPOINT" >> env.list
             echo "RDS_USER=$RDS_USER" >> env.list
             echo "RDS_PASSWORD=$RDS_PASSWORD" >> env.list
             echo "RDS_DB=$RDS_DB" >> env.list
             echo "BASE_URL=$BASE_URL" >> env.list
             echo "TZ=Asia/Seoul" >> env.list

             sudo docker run -d --env-file env.list --name boj-bot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}:latest
             sudo rm -rf env.list

좀 복잡하긴 하지만, 이렇게 env.list를 만들고 컨테이너를 실행하면, 환경변수가 컨테이너에 순차적으로 전달이 될 것이다라고 생각했다.
캬...됐다...그래서 한번 제대로 되는지 가이드 메시지를 좀 수정해봐서 push를 해봤다.

성공! 그러나...

서버 다운

이제 CI/CD를 완성하고 행복 커밋을 하면 될 거라 생각했지만, 한 가지 문제가 생겼다. 문제가 뭔지 말하기 전에 내 CI/CD 과정을 살펴보면 다음과 같았다.

  • 새로운 이미지를 빌드한 후, 도커허브에 push
  • 기존 이미지, 컨테이너 삭제 후 새로운 이미지 Pull 및 생성

마지막 과정이 문제였다. 이미지 이름이 겹쳐, 기존 이미지와 컨테이너를 모두 삭제 후, 이미지를 pull 해야 했는데, 문제는 이렇게 이미지가 pull하는 과정과, 새롭게 컨테이너가 생성되는 딜레이 타임동안 돌아가는 컨테이너가 없었다.

이 문제는 좀 늦게 발견됐다. CI/CD도 만들었겠다, 신나서 실컷 커밋하고 워크플로우가 돌아가는 동안, 심심해서 백준 문제 명령어를 쳤는데, 봇이 응답을 안했다. 그래서 어라? 하고 곰곰히 생각해보니, 단순 이미지 교체 방식이라는 이유 때문이라는 것을 그제서야 알게 됐다.

로그가 다 삭제된다...

또한, 컨테이너가 교체되고, 내 백준봇은 현재 파일 디렉토리에 로그를 저장하다보니, 컨테이너가 삭제 되고 새 컨테이너가 만들어지면 로그는 모조리 삭제된다.

이것 또한 뒤늦게 발견했는데, 당시 내 서버가 잘 돌아가는 지 확인하기 위해 로그를 살펴봤는데, 모조리 사라진 걸 보고 뒤늦게 아차 싶었다.

아직 끝나지 않았다...

그럼 결국 해결해야 할 과제는 다음과 같았다.

  • CI/CD 과정에서 발생하는 서버 다운 문제
  • 로그 초기화 문제

여담이지만, 이후에 어느 계기로 인해 Github actions에서 Jenkins로 갈아타기 시작했다. 일단, 이 문제를 어떻게 해결했는지는 다음 장에서 다뤄보도록 하겠다.

profile
Engineer, to be a Pioneer.

0개의 댓글