[CI/CD] - Maven Spring 자동 배포 파이프 라인 구축(Github Actions)
개요
MiniMoment 프로젝트를 진행하면서 얕은 범위로 서버까지 개발을 진행하고 있습니다.
서버 개발을 진행하니, CI/CD에 관심이 자연스레 관심이 생겨서 Docker를 이용해 자동 배포 파이프라인을 구축해보고 싶었습니다.
저는 Jenkins가 아닌 Github Actions를 이용해서 파이프라인을 구축했고, 느낀 점을 적어볼까 합니다.
도커 파일 생성
도커 파일은 여러 블로그에서 사용되는 코드를 참고해서 작성했습니다.
target 폴더 아래에 mimo-*.jar 이라는 jar 파일을 app.jar이라는 이름의 파일로 복사해 줍니다.
FROM openjdk:17-jdk
COPY target/mimo-*.jar app.jar
ENTRYPOINT [ "java", "-jar","app.jar" ]
자료의 부족
자동 배포 파이프라인을 적용하기 위해 많은 글을 찾아봤는데, 대부분의 글이 Gradle 관련 프로젝트였습니다.
하지만 저희 프로젝트는 Maven..이었기 때문에 전체적인 흐름을 파악하는 것으로 만족해야 했습니다.
Gradle과 Maven의 차이는 여러 가지가 있겠지만, 현재 상황에서 느껴지는 차이점은 jar 파일을 만들어내는 방식입니다.
Gradle은./gradlew ~ 를 이용해서 build를 하고 jar 파일을 만들어냅니다.
Maven은 mvn install이란 명령어를 통해 build를 하고 jar 파일을 만들어냅니다.
yaml 파일에서 step을 정하는 과정에서 두 부분의 차이가 있었고, 이를 반영했습니다.
과정
Docker를 이용한 Github Actions를 사용하기 위해선 여러 가지 변수들이 필요하고, 이를 yaml 파일에 노출하는 것은 위험하기에
secrets 변수로 관리해 줍니다.
변수 관리
- APPLICATION_PROPERTIES : 서버 포트나, 디비 경로와 같은 민감한 정보들
- DOCKER_USER_NAME : 도커에 로그인하기 위한 유저 네임
- DOCKER_USER_PASSWORD : 도커에 로그인 하기 위한 비밀번호
- DOCKER_REPO_NAME : 도커 레포지토리 이름
- DOCKER_CONTAINER_NAME : 도커 컨테이너 이름
- EC2_HOST : EC2 Host 주소
- EC2_SSH_PRIVATE_KEY : pem 확장자인 EC2 키입니다.
Yaml 파일 작성
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
액션을 실행할 트리거입니다.
저는 develop에 푸시되거나, pr이 생긴 경우 실행하도록 트리거를 설정해 줬습니다.
jobs:
build:
runs-on: ubuntu-latest
jobs는 해당 작업에 대한 이름이고, runs-on은 작업이 실행될 환경이고, 우분투 최신버전으로 정의했습니다.
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up JDK 19
uses: actions/setup-java@v3
with:
java-version: '19'
distribution: 'temurin'
Checkout Repository는 깃헙 레조피토리 폴더 구조를 그대로 파일 시스템으로 들고 오는 작업입니다.
파이프라인에서는 반드시 필요한 작업입니다.
Set up JDK 19에서는 말 그대로 JDK 버전을 설정합니다.
- name: Set up application.properites
run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.properties
application.properties나 yaml 파일은 민감한 정보가 많아서 github에 푸시하지 않는 ignore에 추가하지만, 실제 배포 소스 코드에는 포함이 되어야 하기 때문에 secrets 변수로 관리해서 필요한 경로에 위치시켜 줍니다.
- name: Set up Maven
uses: actions/setup-java@v3
with:
java-version: '19'
distribution: 'temurin'
cache: 'maven'
- name: Clean with Maven
run: mvn clean
- name: Install with Maven
run: mvn install
저는 Gradle 환경이 아니고, Maven 기반 프로젝트이기에, Maven을 세팅하고, jar 파일을 만들기 위해 mvn을 clean -> install 하는 과정을 진행했습니다.
- name: Build the Docker image
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{secrets.DOCKER_PASSWORD}}
docker build -t ${{secrets.DOCKER_REPO }}
docker push ${{ secrets.DOCKER_REPO }}
도커 이미지를 build 하는 과정입니다.
도커 허브에 로그인을 하고, 도커 파일에 정의된 경로에 jar 파일을 통해 이미지를 빌드하고, 도커 레포지토리에 푸시합니다.
- name: Deploy to EC2 Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST}}
username : ec2-user
key : ${{secrets.EC2_SSH_PRIVATE_KEY}}
script: |
CONTAINER_ID=$(sudo docker ps -q --filter "publish=80-8080")
if [ ! -z "$CONTAINER_ID" ]; then
sudo docker stop $CONTAINER_ID
sudo docker rm $CONTAINER_ID
fi
sudo docker pull ${{secrets.DOCKER_REPO }}
sudo docker run -- name ${{ secrets.DOCKER_CONTAINER_NAME }} -d -p 8080:8080
sudo docker logs ${{secrets.DOCKER_CONTAINER_NAME}}
EC2 서버에 배포하는 단계입니다.
host와 username을 지정하고, key를 입력해 줍니다.
실행할 명령어는 다음과 같습니다.
- 실행 중인 컨테이너의 ID를 가져온 빈다.
- 만약 컨테이너 ID가 존재한다면(실행 중이라면) 해당 컨테이너를 멈추고, 컨테이너를 삭제합니다.
- 도커 레포지토리에 pull을 하고 Image를 받아옵니다.
- 도커 이미지를 바탕으로 컨테이너를 실행합니다.
- 로그를 찍어줍니다
생각해 보니 d 옵션을 줬는데 로그 찍는 명령어를 적어놨고, 포트 번호를 하드 코딩하는 등 미흡한 점이 많네요.
코드가 아닌 흐름을 참고해 주세요. ㅎㅎ
최종 yaml 파일
name: Docker Image CI
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up JDK 19
uses: actions/setup-java@v3
with:
java-version: '19'
distribution: 'temurin'
- name: Set up application.properites
run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.properties
- name: Set up Maven
uses: actions/setup-java@v3
with:
java-version: '19'
distribution: 'temurin'
cache: 'maven'
- name: Clean with Maven
run: mvn clean
- name: Install with Maven
run: mvn install
- name: Build the Docker image
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{secrets.DOCKER_PASSWORD}}
docker build -t ${{secrets.DOCKER_REPO }}
docker push ${{ secrets.DOCKER_REPO }}
- name: Deploy to EC2 Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST}}
username : ec2-user
key : ${{secrets.EC2_SSH_PRIVATE_KEY}}
script: |
CONTAINER_ID=$(sudo docker ps -q --filter "publish=80-8080")
if [ ! -z "$CONTAINER_ID" ]; then
sudo docker stop $CONTAINER_ID
sudo docker rm $CONTAINER_ID
fi
sudo docker pull ${{secrets.DOCKER_REPO }}
sudo docker run -- name ${{ secrets.DOCKER_CONTAINER_NAME }} -d -p 8080:8080
sudo docker logs ${{secrets.DOCKER_CONTAINER_NAME}}
나름 잘 작성했다고 생각했고, 액션이 돌아가는 과정을 기다리는 과정에서 오류가 발생했습니다.
구글링을 한 결과 쉘에 기록이나 로그 파일에 기록이 남을 수 있어 이에 대한 경고를 하는 것으로 확인했습니다.
해당 블로그의 방법을 참고해서, echo로 패스워드를 출력하고, 해당 정보를 파이프로 로그인 시 전달해 줬습니다.
수정 결과 액션이 정상적으로 동작했습니다.
성공했지만 아쉬운 점은 build라는 job 하나로 모든 단계를 진행하고 있습니다.
사실 build의 과정은 Jdk와 Maven을 세팅하는 과정까지라고 생각하고, 더 개선해 볼 여지가 있다 생각했습니다.
다음과 같이 job을 나누고, 코드를 분배했는데, 액션이 실패한 것을 확인했습니다.
오류 메시지로 추정한 결과 checkout repository라는 작업이 없어서 코드를 가져오지 못했다?라고 이해했습니다.
나눠진 작업에서 필요한 의존관계를 살펴보고 코드를 다시 작성해 봤습니다.
그래서 크게 job을 두 개로 분리해서 작성해 봤습니다.
job 사이의 의존관계는 needs라는 명령어를 통해서 지정할 수 있습니다.
name: MiniMoment Deploy
on:
push:
branches: [ "develop" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up JDK 19
uses: actions/setup-java@v3
with:
java-version: '19'
distribution: 'temurin'
- name: Set up application.properties
run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.properties
- name: Set up Maven
uses: actions/setup-java@v3
with:
java-version: '19'
distribution: 'temurin'
cache: 'maven'
- name: Clean with Maven
run: mvn clean
- name: Install with Maven
run: mvn install
- name: Log in to Docker Hub
run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
- name: Build the Docker image
run: docker build -t ${{ secrets.DOCKER_REPO }} .
- name: Push the Docker image
run: docker push ${{ secrets.DOCKER_REPO }}
deploy-ec2-server:
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to EC2 Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ec2-user
key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
script: |
CONTAINER_ID=$(sudo docker ps -q --filter "publish=80-8080")
if [ ! -z "$CONTAINER_ID" ]; then
sudo docker stop $CONTAINER_ID
sudo docker rm $CONTAINER_ID
fi
IMAGE_ID=$(sudo docker images -q ${{ secrets.DOCKER_REPO}})
if [! -z "$IMAGE_ID" ]; then
sudo docker rmi $IMAGE_ID
fi
sudo docker pull ${{ secrets.DOCKER_REPO }}
sudo docker run --name ${{ secrets.DOCKER_CONTAINER_NAME }} -d -p 8080:8080 ${{ secrets.DOCKER_REPO }}
sudo docker logs ${{ secrets.DOCKER_CONTAINER_NAME }}
하지만 아직까지도 build에서 도커 관련 작업을 하는 것이 맘에 안 들었습니다. 개선할 필요성도 느꼈고요.
제가 분리한 단계에 대한 작업은 다음과 같이 구성할 예정이었습니다.
- build
- JDK 세팅
- application.properties 작성
- Maven 세팅 및 빌드
- docker-login
- 도커 로그인
- build-and-push-docker-image
- 도커 이미지 빌드
- 도커 이미지 푸시
- deploy-ec2-server
- 실행 중인 컨테이너 지우기
- 컨테이너에 사용된 도커 이미지 지우기
- 도커 레포지토리 pull
- 도커 빌드
작업을 분리한 결과 build-and-push-docker-image에서 오류가 발생했고, 오류 원인은 도커 인증이 되지 않았다 이런 의미라고 이해했습니다.
제가 생각한 플로우로는 도커 인증을 한 후 적용이 되는 줄 알았지만, 각 작업이 독립적으로, 병렬적으로 진행된다는 것을 알 수 있었습니다.
그래서 각 job마다 checkout Repository라는 코드가 붙어있나?라는 생각을 해봤습니다.
따라서 docker-login과 build-and-push-docker-image 작업을 같이 진행했습니다.
그 결과!!!
성공했습니다...
번외로 만난 오류가 build와 docker 작업 사이에서 jar 파일을 찾을 수 없어서 생긴 오류도 있었습니다.
위에서 언급했던 것과 같이 각 작업별로 디렉터리나 파일 구조가 연결되지 않기에 jar 파일을 빌드해도 찾을 수 없다는 오류가 생겼습니다.
왼쪽이 build 파일 디렉토리고, 오른쪽이 docker관련 작업 단계에서의 디렉터리인데, 디렉터리가 비어있는 것을 확인했고,
이를 해결하기 위해 build에서 jar 파일을 업로드해서, 다른 job에서 다운로드하는 형태로 jar 파일을 보관했습니다.
Docker Volume
스프링 프로젝트 내부 경로에서 동영상의 썸네일과 동영상 mp4 파일을 관리하고 있었습니다.
자동 배포가 진행될 때마다 Container를 삭제하고, 빌드하는 작업이 진행되었고, 해당 파일들이 소실되는 것을 확인했습니다.
대응으로는 S3 서버를 도입할까 했지만, 앱 배포가 급선무라 S3가 아닌 방식으로 해결할 수 있는 수단이 필요했고, 그 수단으로 Docker Volume을 적용했고, 이를 CI/CD에 추가적으로 작성했습니다.
사실 원래 Docker Volume과 같은 내용들은 Docker-Compose로 관리하는 것이 맞습니다. ㅎ
aws에 docker volume을 생성하고, 경로를 설정하고, CI/CD를 작성했습니다.
관련 코드는 아래에 첨부하겠습니다.
sudo docker run --name ${{ secrets.DOCKER_CONTAINER_NAME }} -d -p 8080:8080 -v ${{ secrets.MIMO_VIDEO_VOLUME }} -v ${{ secrets.MIMO_THUMBNAIL_VOLUME }} ${{ secrets.DOCKER_REPO }}