팀 프로젝트가 점점 커지면서 프론트 팀에서 요청이 점점 많아지고 요청 사항 반영을 위해 서버를 내렸다가 올려야 하는 일이 많아졌다. 뿐만 아니라 서버가 어떤 이유에선지 죽어버리면 EC2도 같이 죽어버려서 한번 요청 사항을 반영할 때 아래의 과정을 직접 손으로 해야 했다.
EC2 재부팅 -> git pull -> ./gradlew build -> java -jar *.jar
이걸 자동화하고자 CICD를 도입하기로 결심. 국가권력급 CICD 도입기 시작하겠다.
기존의 배포 플로우는 깃허브에 push한 코드를 ec2에서 pull 하고, 빌드 후 실행시킨다. 빌드 후 jar 파일 실행을 스크립트로 작성하면 이 방식이 직관적이고 가장 쉽다. 그러나 기능 변경 사항, 버그 픽스 등 서버를 내렸다가 다시 올려야 하는 상황에서는 적절하지 않다. 후에 무중단 배포라는 개념을 알게 되긴 하지만 본 포스팅에서는 CICD를 완성하는 데 중점을 둔다.
이 방식의 안 좋은 점은 앞에서 설명했듯 2가지이다.
멈춰버린 EC2..
그렇다면 하나씩 해결해 보자.
Github에 코드를 푸시하면 자동으로 ec2에 코드 내용이 반영되게 하면 어떨까?
여기서 찾은 방법은 AWS의 CodeDeploy 그리고 workflow 자동화 도구였다. 가장 많이 사용되는 것은 Jenkins이지만 프로젝트의 규모를 생각했을 때 배보다 배꼽이 더 커질 것 같아서 GitHub Action을 선택했다.
새롭게 바뀐 배포 플로우는 위와 같다.
결국 CodeDeploy를 사용하지 않았지만, 누군가에게 도움이 되길 바라면서 관련 스크립트를 첨부한다.
# GitHub Action 설정
# /.github/workflows/ci.cd.yml
name: 🚀 Build & Deploy workflow
on:
pull_request:
branches: [main]
types: [closed] # main 브랜치로의 PR이 closed 되었을 때 실행된다.
jobs:
build:
if: github.event.pull_request.merged == true # PR이 Merge 되었을 때 실행한다.
env: # Build job에서 사용할 환경 변수를 지정한다.
ENV_PATH: ./src/main/resources
ENV_DB: application-db.yml
ENV_AUTH: application-auth.yml
runs-on: ubuntu-latest # 실행 환경을 지정한다.
steps: # 실행 순서를 명시한다.
- name: ✅ Checkout branch
uses: actions/checkout@v3 # 코드를 메인 브랜치에서 가져온다.
- name: 📀 Set up JDK 17
uses: actions/setup-java@v3 # 자바 환경을 세팅한다.
with:
java-version: '17'
distribution: 'corretto'
- name: ⚙️ Create application-*.yml files
run: | # 프로젝트에서 사용한 프로퍼티스 파일들을 등록해준다.
touch ${{ env.ENV_PATH }}/${{ env.ENV_DB }}
touch ${{ env.ENV_PATH }}/${{ env.ENV_AUTH }}
echo "${{ secrets.SPRING_DB_CONFIG }}" >> ${{ env.ENV_PATH }}/${{ env.ENV_DB }}
echo "${{ secrets.SPRING_AUTH_CONFIG }}" >> ${{ env.ENV_PATH }}/${{ env.ENV_AUTH }}
- name: ✨ Grant execute permission for gradlew
run: chmod +x gradlew
- name: 🔨 Build with Gradle
uses: gradle/gradle-build-action@v2 # Gradle Build. 다행히 누군가가 만들어서 마켓 플레이스에 올려두었다.
with:
arguments: clean build
gradle-version: 8.3
deploy: # 다음 job
needs: build # build job 이후에 실행된다.
if: github.event.pull_request.merged == true
env:
ENV_PATH: ./src/main/resources
ENV_DB: application-db.yml
ENV_AUTH: application-auth.yml
CODE_DEPLOY_APPLICATION_NAME: server
CODE_DEPLOY_GROUP_NAME: server-deploy
runs-on: ubuntu-latest
steps:
- name: ✅ Checkout branch
uses: actions/checkout@v3
- name: ⚙️ Create application-*.yml files
run: |
touch ${{ env.ENV_PATH }}/${{ env.ENV_DB }}
touch ${{ env.ENV_PATH }}/${{ env.ENV_AUTH }}
echo "${{ secrets.SPRING_DB_CONFIG }}" >> ${{ env.ENV_PATH }}/${{ env.ENV_DB }}
echo "${{ secrets.SPRING_AUTH_CONFIG }}" >> ${{ env.ENV_PATH }}/${{ env.ENV_AUTH }}
- name: 📦 Zip project files
run: zip -r ./$GITHUB_SHA.zip . # 모든 파일을 랜덤한 이름으로 압축한다.
- name: 🌎 Access to AWS
uses: aws-actions/configure-aws-credentials@v4 # aws 로그인
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} # 여기서 사용된 secrets.[]는 github repository -> settings -> Secrets and variables -> Actions -> New repository secret에 가면 설정할 수 있다.
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: 🚛 Upload to S3
run: | # S3에 현재 압축 파일을 업로드한다.
aws deploy push \
--application-name ${{env.CODE_DEPLOY_APPLICATION_NAME}} \
--ignore-hidden-files \
--s3-location s3://${{secrets.S3_BUCKET_NAME}}/$GITHUB_SHA.zip \
--source .
- name: 🚀 Deploy to EC2 with CodeDeploy
run: | # CodeDeploy에 배포 요청을 보낸다. 이후 appspec.yml이 실행된다.
aws deploy create-deployment \
--application-name ${{env.CODE_DEPLOY_APPLICATION_NAME}} \
--deployment-config-name CodeDeployDefault.AllAtOnce \
--deployment-group-name ${{ env.CODE_DEPLOY_GROUP_NAME }} \
--s3-location bucket=${{ secrets.S3_BUCKET_NAME }},bundleType=zip,key=$GITHUB_SHA.zip
# AWS CodeDeploy 설정
# appspec.yml
file_exists_behavior: OVERWRITE
version: 0.0
os: linux
files:
- source: / # CodeDeploy 에서 전달해 준 파일 중 destination 으로 이동시킬 대상 지정 [루트 경로(/) 지정 시 전체 파일]
destination: /home/ec2-user/server/
# - source: /
# destination: /home/api
permissions: # CodeDeploy 에서 EC2 서버로 넘겨준 파일들 권한 설정
- object: /home/api/
pattern: '**'
owner: ec2-user
group: ec2-user
hooks: # CodeDeploy 배포 단계에서 실행할 명령어 지정
ApplicationStart:
- location: scripts/deploy.sh
timeout: 300 # 스크릡트 실행 시간 5분 넘어가면 실패
runas: root
# /scripts/deploy.sh
# shellcheck disable=SC2164
REPOSITORY=/home/ec2-user
PROJECT_NAME=server
BUILD_DIRECTORY=build/libs
echo "> 프로젝트 디렉토리 이동"
cd $REPOSITORY/$PROJECT_NAME
echo "> Gradle Build"
chmod +x gradlew
bash ./gradlew build -x test
echo "> 빌드 디렉토리 이동"
cd $BUILD_DIRECTORY
BUILD_JAR=$(ls *.jar)
JAR_NAME=$(basename "$BUILD_JAR")
echo "> Build 파일명: $JAR_NAME"
echo "> Build 파일 복사"
cp "$JAR_NAME" /$REPOSITORY/$PROJECT_NAME
CURRENT_PID=$(pgrep -f .jar)
echo "> Running Application PID: $CURRENT_PID"
if [ -z "$CURRENT_PID" ]; then
echo "> 현재 실행중인 애플리케이션이 없으므로 종료하지 않습니다."
else
echo "> kill -15 $CURRENT_PID"
kill -15 "$CURRENT_PID"
sleep 5
fi
echo "> log 디렉토리 생성"
mkdir -p log
echo "> 새 애플리케이션 배포"
nohup java -jar $REPOSITORY/$PROJECT_NAME/"$JAR_NAME" 1>log.out 2>error.out &
하지만 국가권력급이 되려면 아직 멀었다. 스프링 서버가 죽으면 EC2도 같이 뻗어버리는 문제를 해결해 보자. 서버 실행 중 갑자기 무한 루프가 발생한다면 서버를 백그라운드에서 실행한다고 해도 HOST OS도 멈춰버리기 때문에 간단한 작업조차 실행할 수 없다.
Docker는 프로스세간 격리를 제공하기 때문에 이런 문제 해결에 아주 적절하다. 바로 이 장점 때문에 Docker를 도입하게 되었다. VM도 가능하긴 하지만 OS실행은 HOST에게 맡기고 나는 프로세스만 독립적으로 실행하고 싶었기 때문에 효율적이지 않다.
자 그럼 내 작고 소중한 서버를 위해 도커 이미지를 만들어보자
# Dockerfile
# jdk 17 alpine 버전을 jdk로 사용한다.
# 런타임 환경만 제공하면 되기 때문에 가벼운 alpine을 사용했다.
FROM openjdk:17-jdk-alpine
# Spring Boot 프로젝트를 gradlew을 사용해 빌드하면
# /build/libs/ 위치에 .jar(자바 아카이브)파일이 생성된다.
# 이를 위한 인자를 정의한다.
ARG JAR_FILE=build/libs/*.jar
# 빌드 결과인 아카이브 파일을 app.jar 파일로 복사한다.
COPY ${JAR_FILE} app.jar
# 나는 도커 이미지를 사용해 스프링 서버를 위한 컨테이너를 만들고 싶은 것이다.
# 그렇다면 컨테이너를 띄울 때 항상 실행해야 하는 명령어가 필요하다.
# 이제 자바 아카이브 실행 명령어를 추가하자.
ENTRYPOINT ["java", "-jar", "app.jar"]
되게 별것 없지 않은가?! CLI로 이미지를 만들 수도 있지만 이렇게 Dockerfile을 활용하는게 가장 편리하다. 이렇게 만들어진 이미지는 현재 로컬에 존재하기 때문에 EC2에서 이 이미지를 사용해 컨테이너를 띄우기 위해선 도커 허브에 이미지를 올려야 한다.
# 윈도우 환경
# 아래의 명령어를 입력하고 도커 허브의 ID와 PW를 입력한다.
# 이때 이메일을 입력해도 로그인이 가능하니 조심할 것. 이메일이 아니라 아이디를 입력해야한다.
> docker login
# 도커 파일이 있는 위치에서 아래의 명령어를 실행한다.
# ex) docker build -t myid/server:test .
# 앞에서 이메일로 로그인했다면 "docker : invalid reference format" 에러를 만날 것이다.
> docker build -t [도커 허브 ID]/[원하는 이미지 이름]:[태그] [경로]
# 도커 허브에 푸시한다.
> docker push myid/server:test
# 잘 올라갔는지 확인해 보자.
# 명령어가 아니라 직접 도커 허브에서 확인해도 된다.
> docker pull teamus/us-server:test
hub.docker.com에서 이렇게 보이면 성공한 것이다. deploy는 무시해도 된다.
이제 CICD 코드를 수정해야 한다. 왜 why. 앞선 Dockerfile에 빌드 결과물이 .jar을 포함했으니 Gradle 빌드 후 Docker 빌드를 수행하는 과정이 필요하다.
name: 🚀 Build & Deploy workflow environment
on:
pull_request:
branches: [main]
types: [closed]
jobs:
build:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
env:
ENV_PATH: ./src/main/resources
ENV_DB: application-db.yml
ENV_AUTH: application-auth.yml
DOCKER_IMAGE_NAME: us-server
DOCKER_IMAGE_TAG: deploy
steps:
- name: ✅ Checkout branch
uses: actions/checkout@v3
- name: 📀 Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'corretto'
- name: 💽 Make application-db.yml
run: |
cd ${{ env.ENV_PATH }}
touch ${{ env.ENV_DB }}
echo "${{ secrets.SPRING_DB_CONFIG }}" >> ${{ env.ENV_DB }}
shell: bash
- name: 🔑 Make application-auth.yml
run: |
cd ${{ env.ENV_PATH }}
touch ${{ env.ENV_AUTH }}
echo "${{ secrets.SPRING_AUTH_CONFIG }}" > ${{ env.ENV_AUTH }}
shell: bash
- name: ✨ Grant execute permission for gradlew
run: chmod +x gradlew
- name: 🔨 Build with Gradle
uses: gradle/gradle-build-action@v2
with:
arguments: clean build
gradle-version: 8.3
- name: 🐳 Build Docker Image
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME}}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.DOCKER_IMAGE_TAG }} .
- name: 🌎 Login DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: 🐋 Push a Docker Image to DockerHub
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.DOCKER_IMAGE_TAG }}
deploy:
needs: build
runs-on: self-hosted
env:
DOCKER_IMAGE_NAME: server
DOCKER_IMAGE_TAG: deploy
steps:
- name: Pull Latest Image
run: |
sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.DOCKER_IMAGE_TAG }}
- name: Stop Current Running Container
run: |
sudo docker stop $(sudo docker ps -q) 2>/dev/null || true
- name: Run Latest Image
run: |
sudo docker run --name api-server -d -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.DOCKER_IMAGE_TAG }}
- name: Delete Old Image
run: |
sudo docker system prune -f
그런데 낯선 스크립트가 있다. deploy.runs_on: self-hosted는 뭘까?
GitHub Action에서 Runner는 깃허브 액션 워크플로우 내에서 job을 실행하는 일종의 서버다. 깃허브 액션에서 호스트되는 가상 머신(VM)이라고 볼 수 있다.
위에서 작성한 job들을 실행하기 위해 github에서 제공하는 러너를 사용해도 되지만 ec2에서 runner를 실행하기 위해서는 self-hosted runner가 필요하다.
self-hosted Runner란 Github Actions에서 사용자가 지정하는 로컬 컴퓨팅 자원으로 빌드를 수행하도록 설정하는 기능인데, 우리의 로컬 컴퓨팅 자원이 EC2 인스턴스가 되겠다. 결국 핵심은 EC2에서 ci.cd.yml의 deploy job을 실행한다는 것이다. 마치 마법같다! 이 좋은 걸 나만 몰랐다니... 이제 이 러너를 등록하러 가보자
프로젝트 레포지토리 -> Settings -> Actions -> Runners로 가서 New self-hosted runner를 클릭하자.
위의 화면이 보이면 된다. 이제 ec2 인스턴스의 콘솔로 이동해서 화면에 표시되는 명령어들을 입력하자.
echo "5691 ...." 명령어를 입력하다가 sha관련 에러를 만난다면, 아래의 명령어를 입력해 설치해 주고
sudo yum install perl-Digest-SHA -y
./config.sh --url ... 명령어를 입력하다가. libicu 관련 에러를 만난다면, 아래의 명령어를 입력해 설치해 주자
sudo yum install libicu -y
./config 명령어까지만 입력하고 아래의 명령어를 입력하면 끝이다.
sudo ./svc.sh install
sudo ./svc.sh start
이렇게 러너가 생성이 된 것을 확인할 수 있다.
이렇게나 간단하다니... 여기까지 완성했다면 다음으로 넘어갈 수 있다!
이제 이렇게 CICD 플로우가 이루어진다. 잘 만들었는지 동작상 오류는 없는지 확인해 보자.
git add .
git commit -m "Some Commit Message"
git push origin develop
이렇게 푸쉬한 후 PR을 만들어 머지하면 이렇게 Merged 라벨이 뜨고
이렇게 action이 실행된다.
여기서 특히 수많은 에러가 발생할 수 있다. 대부분은 runner가 띄워주는 log를 보고 해결할 수 있지만 docker에서 발생하는 오류는 찾기가 어렵다. 그럴 때는 직접 EC2에 들어가서 docker의 로그를 확인하자.
# ex) docker logs 0019da
docker logs [컨테이너 ID 앞 6글자]
마지막으로 docker ps로 현재 실행 중인 컨테이너를 확인하면 아름답게 동작 중인 것을 볼 수 있다.
로그를 찍어보면? 역시 서버가 잘 실행되고 있다.
여기까지 했지만 국가권력급 CICD가 되기엔 부족하다. 더욱 정진하자.
무중단 배포도 올려주세요