귀찮은 것을 하지 않기 위해 귀찮은 짓을 한 썰
틈틈이 백준봇을 개발하면서, 느낀 것이 있다. 배포가 진짜 너무 귀찮다. 지금 현재 내 디코봇은 의존성 문제 때문에 컨테이너에서 돌아가도록 했는데, 그러다보니까 정말 사소한 변경사항 하나 때문에 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였다. 현재 서버 규모 해봤자 겨우 디스코드 봇 하나이고, 모니터링까지 필요한 수준은 아닌 아주 가벼운 서버인 것을 고려하면, 굳이 저 위처럼 복잡한 CI/CD 파이프라인을 구축해야 할 이유가 있나라는 생각이 들었다.
원래 내가 CI/CD를 도입하려 했던 이유는, 커밋하고 푸시하자 마자 유닛 테스트를 진행하고, 자동으로 빌드한 후에 ec2에 배포하려고 도입하는 것이었는데, 찾아보니 이 모든 것이 Github Actions하나에서 처리가 가능했다는 것이다. 그렇게 Github Actions를 도입하기 위해 큰 발걸음을 옮겼다.
이 발걸음이 고난의 행군의 시작인 것은 모른채...
처음은 누구나 어렵다.
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"
needs:
를 통해 job들간의 의존성을 설정해줄 수 있다.일단 내가 머릿속으로 생각한 workflow 순서는 다음과 같았다.
그럼 우선 Unit Test를 실행하는 단계부터 구현해야 한다.
일단 CI는 Test/Build가 자동화 되는 과정이므로, Github action에서 가장 먼저 해야 할 Task는 다음과 같았다.
- 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는 실행했으니, 도커 이미지를 빌드하여 배포를 하는 과정을 거쳐야 한다.
어느정도 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에 넣으면 저 값이 그대로 노출이 될 텐데...?" 라는 생각이 반사적으로 들었고, 바로 도커허브에서 이를 삭제했다.
자...그래서 결국 환경변수를 "안전"하게 담는 방법이 필요했다.
그래서 열심히 생각해봤다. 그러다 발견한 방법은 바로 임시로 환경변수 리스트를 담은 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 과정을 살펴보면 다음과 같았다.
마지막 과정이 문제였다. 이미지 이름이 겹쳐, 기존 이미지와 컨테이너를 모두 삭제 후, 이미지를 pull 해야 했는데, 문제는 이렇게 이미지가 pull하는 과정과, 새롭게 컨테이너가 생성되는 딜레이 타임동안 돌아가는 컨테이너가 없었다.
이 문제는 좀 늦게 발견됐다. CI/CD도 만들었겠다, 신나서 실컷 커밋하고 워크플로우가 돌아가는 동안, 심심해서 백준 문제 명령어를 쳤는데, 봇이 응답을 안했다. 그래서 어라? 하고 곰곰히 생각해보니, 단순 이미지 교체 방식이라는 이유 때문이라는 것을 그제서야 알게 됐다.
또한, 컨테이너가 교체되고, 내 백준봇은 현재 파일 디렉토리에 로그를 저장하다보니, 컨테이너가 삭제 되고 새 컨테이너가 만들어지면 로그는 모조리 삭제된다.
이것 또한 뒤늦게 발견했는데, 당시 내 서버가 잘 돌아가는 지 확인하기 위해 로그를 살펴봤는데, 모조리 사라진 걸 보고 뒤늦게 아차 싶었다.
그럼 결국 해결해야 할 과제는 다음과 같았다.
여담이지만, 이후에 어느 계기로 인해 Github actions에서 Jenkins로 갈아타기 시작했다. 일단, 이 문제를 어떻게 해결했는지는 다음 장에서 다뤄보도록 하겠다.