Spring BE CI/CD Pipeline

SangYeon Min·2024년 7월 12일
0

PROJECT-HEARUS

목록 보기
9/12
post-thumbnail

Architecture

CI/CD 파이프라인과 함께 전체 배포될 인프라의 아키텍처는 위와 같이 설계하였다.

React는 현재 FE팀에서 별도로 Firebase를 통해 빌드 후 배포까지 진행될 수 있도록 구현하였고
BE의 경우 프레임워크 배포 이외에도 별도의 FFmpeg, OLLAMA 등 요구되는 사항이 많기 때문에 위와 같이 별도로 Gihub Action, Docker, Jenkins를 통해 CI/CD 파이프라인을 구축하는 것으로 계획하였다.

또한 FE Dev는 개발 도중 Postman Mock Server와 Test Express BE를 통해 더욱 자유롭게 테스트할 수 있도록 하였고 BE Dev는 인공지능 관련 기능 개발 시 Test Vue 앱을 통해 더 원활하게 개발을 진행할 수 있도록 하였다.


Express BE for TEST

이전에 FE 팀원분께서 실시간 음성인식을 위한 Socket과 관련한 스레드르르 남겨주셔서

위와 같이 기존에 개발된 Express BE를 수정하여 테스트할 수 있도록 하였고

BE Dev 로컬에서 Test Vue 앱을 통해 테스팅을 완료하였다.


MariaDB Configuration

AWS RDB

최종적으로는 AWS RDB를 사용하여 서비스 전체의 가용성과 안정성을 높일 예정이지만
현재는 1차 배포 단계이므로 가격이 더 저렴하거나 무료인 클라우드 RDB 서비스를 활용할 예정이다.

또한 이후 AWS RDB를 사용할 때 비용을 절약하기 위해서 아래와 같은 체크 리스트를 점검해야 한다.

  1. Multi-AZ(Multi-Availability Zone, 멀티 가용성 존) deployment -> NO
    특히, Multi-AZ deployment 는 dafault가 Yes로 되어있기때문에 주의해야한다.
  1. Storage type(스토리지 타입) -> General Purpose(SSD)
  1. Backup retention period(백업 보존 기간) -> 0days

litegix

이러한 이유로 litegix라는 서비스에서 위와 같이 hearus-db를 생성하고 이를 호스팅하여준다.

정상적으로 연결할 수 있는지 테스트하기 위하여 로컬 HeidiSQL에서 연결을 진행하였고, DB에 정상적으로 접속하는 것을 확인할 수 있었다.

application-private.properties

# MariaDB Properties
spring.jpa.hibernate.ddl-auto=update
spring.datasource.driverClassName=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://....onlitegix.com:31927/hearus_test
spring.datasource.username=...
spring.datasource.password=...

또한 Spring Boot BE에서도 위와 같이 application-private.properties를 수정하여
litegix에 호스팅된 DB에 정상적으로 연결하는 것을 확인할 수 있었다.


Spring CI Pipeline

Dockerfile

FROM openjdk:17-jdk-slim

# Install FFmpeg
RUN apt-get update && apt-get install -y ffmpeg

WORKDIR /app
COPY build.gradle .
COPY gradlew .
COPY gradlew.bat .
COPY gradle gradle
COPY src ./src
RUN ./gradlew build --no-daemon

COPY build/libs/*.jar app.jar
EXPOSE 8080 9094

ENTRYPOINT ["java", "-jar", "app.jar"]

Spring CI Pipeline를 구축하기 위하여 먼저 위와 같이 Dockerfile을 작성한다.
해당 Dockerfile에서는 실시간 음성인식에 필요한 FFmpeg 라이브러리를 설치한다.

FFmpegConfig.java

package com.hearus.hearusspring.common.ffmpeg;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class FFmpegConfig {

    @Value("${spring.profiles.active:}")
    private String activeProfile;

    public String getFFmpegPath() {
        if ("production".equals(activeProfile)) {
            // Docker 환경에서는 시스템 경로의 FFmpeg 사용
            return "ffmpeg";
        } else {
            // 로컬 개발 환경에서는 프로젝트에 포함된 FFmpeg 사용
            return "src/main/resources/ffmpeg/bin/ffmpeg";
        }
    }
}

이후 FFmpegConfig.java를 정의하고 만약 로컬 개발 환경이라면 현재 진행하는 것처럼 프로젝트에 포함된 FFmpeg를 사용하고 Docker 환경에서는 시스템 경로의 FFmpeg를 사용할 수 있도록 설정하였고

AudioConverter.java

...
	// Use FFmpeg to convert the audio data
        FFmpeg ffmpeg = new FFmpeg(ffmpegConfig.getFFmpegPath());
        FFmpegBuilder builder = new FFmpegBuilder()
                .setInput(tempInputFile.getAbsolutePath())
                .overrideOutputFiles(true)
                .addOutput(tempOutputFile.getAbsolutePath())
                .setAudioCodec("pcm_s16le")
                .setAudioChannels(1)
                .setAudioSampleRate(16000)
                .setFormat("s16le")
                .addExtraArgs("-loglevel", "quiet")
                .done();

AudioConverter.java에서 new FFmpeg(ffmpegConfig.getFFmpegPath())을 통해 FFmpeg의 path를 받아와 Audio Format을 변경할 수 있게 하였다.

docker build --tag judemin/hearus-spring:lastest .
docker run -dit -p 8080:8080 -p 9094:9094 judemin/hearus-spring:lastest

이후 위와 같이 Dockerfile을 빌드하여주고 run을 통해 정상적으로 실행되는지 확인한다.

docker login
docker push judemin/hearus-spring:lastest

또한 이후 위와 같이 Dockerhub에 정상적으로 Push되는지 확인한다.
이후 Github Action이 해당 Repo를 빌드하는 과정에서 application-private.properties를 자동적으로 추가하여줄 수 있도록 Repositoty Secret에 위와 같이 application-private.properties의 내용을 담은 Secret을 생성해준다.

name: Spring Boot CI with Gradle

on:
  push:
    branches: [ "main" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:

      - name: checkout
        uses: actions/checkout@v3

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      ## create application-private.properties
      - name: make application-private.properties
        run: |
          ## create application-private.properties
          cd ./src/main/resources
          
          # application-private.properties 파일 생성
          touch ./application-private.properties
          
          # GitHub-Actions 에서 설정한 값을 application-private.properties 파일에 쓰기
          echo "${{ secrets.SPRING_BE_APPLICATION_PRIVATE }}" >> ./application-private.properties
        shell: bash

      # gradle build
      - name: Build with Gradle
        run: |
          chmod +x ./gradlew
          ./gradlew bootJar
        shell: bash


      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.ORG_JUDEMIN_DOCKERHUB_USERNAME }}
          password: ${{ secrets.ORG_JUDEMIN_DOCKERHUB_PASSWORD }}

      - name: Install FFmpeg and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          push: true
          tags: judemin/hearus-spring:lastest
          # GitHub Action Cache
          cache-from: type=gha
          cache-to: type=gha,mode=max

이후 Github Action의 Workflow를 위와 같이 작성하여 make application-private.properties 단계에서는 Secret 값을 프로젝트 내에 추가해줄 수 있도록 하였고 Build with Gradle 단계에서는 Gradle 빌드를 실행해준 뒤 Set up Docker Buildx 단계 이후로는 빌드된 jar 파일을 통해 Docker 빌드를 수행하고 DockerHub에 PUSH할 수 있도록 하였다.

Trouble Shooting

./gradlew: Permission denied

/home/runner/work/_temp/fa30f693-5dd6-4bc0-92e0-8a6437feb104.sh: line 2: ./gradlew: Permission denied

아지만 위와 같이 gradlew를 실행하는 도중 Permission 문제가 발생하였고

....
      ## gradle build
      - name: Build with Gradle
        run: |
          chmod +x ./gradlew
          ./gradlew bootJar
        shell: bash

Workflow 내에서 chmod를 통해 gradlew의 권한을 변경해주었다.

Cannot perform an interactive login from a non TTY device

Run docker login -u  -p ...
Error: Cannot perform an interactive login from a non TTY device

또한 Dockerhub에 로그인하는 과정에서 위와 같은 오류가 발생하여

...
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.ORG_JUDEMIN_DOCKERHUB_USERNAME }}
          password: ${{ secrets.ORG_JUDEMIN_DOCKERHUB_PASSWORD }}

      - name: Build and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          push: true
          tags: judemin/hearus-spring:lastest
          # GitHub Action Cache
          cache-from: type=gha
          cache-to: type=gha,mode=max

Workflow를 docker/login-action@v1를 통해 로그인하는 것으로 변경하였다.

/build/libs: no such file or directory

buildx failed with: ERROR: failed to solve: lstat /tmp/buildkit-mount3977780021/build/libs: no such file or directory

이후 정상적으로 Gradle 빌드까지 수행하는 것을 확인하였지만 위와 같이 /build/libs 경로 문제가 발생하였고

Dockerfile

FROM openjdk:17-jdk-slim

# Install FFmpeg
RUN apt-get update && apt-get install -y ffmpeg

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

EXPOSE 8080 9094
ENTRYPOINT ["java", "-jar", "app.jar"]

Github Action과 Docker에서 모두 Gradle 빌드를 하는 것이 비효율적이라고 판단하여 Docker에서는 Github Action에서 빌드한 jar 파일을 COPY할 수 있도록 변경하였으며

spring-ci.yaml

...
      - name: Install FFmpeg and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          # Dockerfile이 위치한 디렉토리를 지정
          context: .
          push: true
          tags: judemin/hearus-spring:lastest
          # GitHub Action Cache
          cache-from: type=gha
          cache-to: type=gha,mode=max

Dockerfile이 위치한 디렉토리를 지정하여 해당 문제를 해결하였다.
Github Action에서 Gradle 빌드가 정상적으로 수행되는 것을 확인하였고
최종적으로 Dockerhub에도 이미지가 잘 Push된 것을 확인하였다.


Infrastructure Configuration

HEARUS-SPRING EC2 Instance

EC2 인스턴스를 생성하기 이전 위와 같이 VPC를 생성해주었다.

이후 HEARUS-SPRING을 위한 보안 그룹을 생성하고

HEARUS-SPRING의 인스턴스를 위와 같이 생성해주었다.
또한 퍼블릭 IP를 자동으로 할당해주도록 하여, 최종 배포 이전 테스트를 가능하게 하였다.

인바운드 규칙의 경우 기본 API 포트인 8080, 추후 Socket 연결을 수행할 9094 포트에 대한 요청을 모두 허용하였고, SSH 연결의 경우 현재 내 IP만 허용해주었다.

이후 미리 생성한 pem 키를 통해 SSH 연결을 시도하였고

정상적으로 SSH 연결이 완료된 것을 볼 수 있었다.

1. Set up Docker's apt repository.

# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update

EC2 인스턴스의 OS를 ubuntu로 설정하였기 때문에 Docker 공식 Document의 설명에 따라 위와 같이 apt repository를 설정하였다.

2. Install the Docker packages.

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

이후 apt-get install을 통해 해당 인스턴스에 Docker를 설치하였으며

~$ sudo systemctl | grep docker
  sys-devices-virtual-net-docker0.device                                       loaded active plugged   /sys/devices/virtual/net/docker0
  sys-subsystem-net-devices-docker0.device                                     loaded active plugged   /sys/subsystem/net/devices/docker0
  docker.service                                                               loaded active running   Docker Application Container Engine
  docker.socket                                                                loaded active running   Docker Socket for the API

systemctl을 통해 Docker 엔진이 정상적으로 구동되고 있는지 확인하였다.

3. Verify that the Docker Engine installation is successful by running the hello-world image.

docker login

최종적으로 docker에 login한 이후

sudo docker run hello-world

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

위와 같이 hello-world를 실행해 Docker를 HEARUS-SPRING EC2 인스턴스에 설치하였다.

https://docs.docker.com/engine/install/ubuntu/

Jenkins EC2 Instance

이후 CD 파이프라인을 위한 Jenkins EC2 인스턴스를 위와 같이 생성한다.

1. Long Term Support release

sudo wget -O /usr/share/keyrings/jenkins-keyring.asc \
  https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key

echo "deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc]" \
  https://pkg.jenkins.io/debian-stable binary/ | sudo tee \
  /etc/apt/sources.list.d/jenkins.list > /dev/null

sudo apt-get update
sudo apt-get install jenkins

LTS 버전을 설치하기 위해 Jenkins 공식 Document를 참고하여 위와 같이 apt-get install jenkins를 통해 Jenkins를 설치한다.

2. Installation of Java

sudo apt update
sudo apt install fontconfig openjdk-17-jre
java -version

또한 Jenkins는 Java가 필요하기 때문에 openjdk-17-jre를 위위 같이 설피하고.

sudo -i

$ systemctl | grep jenkins
● jenkins.service                                                              loaded failed failed    Jenkins Continuous Integration Server

systemctl를 통해 jenkins가 구동되는 것을 확인한다.

$ systemctl status jenkins.service
× jenkins.service - Jenkins Continuous Integration Server
     Loaded: loaded (/usr/lib/systemd/system/jenkins.service; enabled; preset: enabled)
     Active: failed (Result: exit-code) since Mon 2024-07-15 07:06:50 UTC; 4min 5s ago
   Main PID: 1942 (code=exited, status=1/FAILURE)
        CPU: 10ms

하지만 위와 같이 jenkins의 실행 상태가 FAILURE라면

systemctl edit jenkins

systemctl edit jenkins를 통해 jenkins conf 파일로 접근하여
별도로 Jenkins가 실행될 포트 번호를 환경변수로 설정한다.

systemctl restart jenkins

이후 jenkins 서비스를 재시작하면

# systemctl status jenkins
● jenkins.service - Jenkins Continuous Integration Server
     Loaded: loaded (/usr/lib/systemd/system/jenkins.service; enabled; preset: enabled)
    Drop-In: /etc/systemd/system/jenkins.service.d
             └─override.conf
     Active: active (running) since Mon 2024-07-15 07:17:48 UTC; 22s ago

위와 같이 active함을 확인할 수 있다.

https://www.jenkins.io/doc/book/installing/linux/

이후 Web UI 환경의 Jenkins에 접근하기 위하여 Jenkins conf에서 설정한 8081 포트를 EC2 인스턴스의 보안그룹의 인바운드 규칙 수정을 통해 허용해주고
위와 같이 정상적으로 접속이 되는 것을 확인할 수 있었다.

Jenkins 초기 비밀번호 확인

sudo cat /var/lib/jenkins/secrets/initialAdminPassword

이후 초기 비밀번호를 위와 같이 확인하여 Jenkins를 Unlock하고
suggested plugin들을 설치하면

위와 같이 EC2 환경에서 Jenkins를 정상적으로 실행할 수 있다.


Spring CD Pipeline

EC2 SSH

SPRING-BE Instance

$ ssh-keygen
$ ls -al
total 36
drwx------  5 root root 4096 Jul 15 06:44 .
drwxr-xr-x 22 root root 4096 Jul 15 06:27 ..
-rw-------  1 root root  884 Jul 15 06:44 .bash_history
-rw-r--r--  1 root root 3106 Apr 22 13:04 .bashrc
drwx------  2 root root 4096 Jul 15 06:37 .docker
-rw-------  1 root root   20 Jul 15 06:36 .lesshst
-rw-r--r--  1 root root  161 Apr 22 13:04 .profile
drwx------  2 root root 4096 Jul 15 08:11 .ssh
drwx------  3 root root 4096 Jul 15 06:27 snap

이후 Integration 이후 Deployment는 Jenkins에서 Spring-BE EC2 인스턴스로 Docker command 요청을 보내 Github Action을 통해 빌드된 Docker 이미지를 pull 한 이후 run 할 수 있도록 하기 위하여 위와 같이 ssh-keygen 명령을 통해 key를 생성한다.

$ cat id_ed25519pub
ssh-... ... root@...

Jenkins Instance

vi hearus-spring-be-key-pair.pem
chmod 600 hearus-spring-be-key-pair.pem
ssh -i hearus-spring-be-key-pair.pem ...@...

하지만 ssh-keygen를 사용하는 방식에 문제가 있다고 판단되어 위와 같이 EC2 인스턴스 생성시 생성된 hearus-spring-be-key-pair.pem를 통해 접속을 시도하였다.

Jenkins Project Configuration

하지만 이러한 방식에 문제가 있다고 판단하여 Jenkins의 Publish Over SSH 플러그인을 통해 해당 기능을 구현할 수 있도록 하였다.

cp hearus-spring-be-key-pair.pem /var/lib/jenkins

먼저 기존에 생성한 pem 키를 jenkins의 home dir로 복사하고
위와 같이 Jenkins 관리 -> System에서 새로운 SSH Server를 추가해주었다.
이때 Hostname은 Remote Host IPv4 Address이다.

sudo docker pull judemin/hearus-spring:lastest
sudo docker run -p 8080:8080 -p 9094:9094 --name hearus-spring judemin/hearus-spring:lastest

이후 위와 같은 docker 명령어들을
Jenkins의 Item의 Transfer Set에 추가하였고

위와 같이 성공적으로 docker 명령어들이 이미지를 받아와서 실행하는 것을 볼 수 있었다.

SPRING-BE Instance

$ docker ps
CONTAINER ID   IMAGE                           COMMAND               CREATED              STATUS              PORTS                                                                                  NAMES
1d5328075184   judemin/hearus-spring:lastest   "java -jar app.jar"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:9094->9094/tcp, :::9094->9094/tcp   hearus-spring

또한 SPRING-BE 인스턴스에서도 docker ps를 통해 Jenkins에서 구동한 docker 컨테이너를 확인할 수 있었다.
하지만 첫번째 배포 이후에서는 이미 동일한 이름의 컨테이너가 구동되고 있기 때문에

sudo docker stop hearus-spring
sudo docker rm hearus-spring

sudo docker pull judemin/hearus-spring:lastest
sudo docker run -p 8080:8080 -p 9094:9094 --name hearus-spring -d judemin/hearus-spring:lastest

위와 같이 이전에 구동한 hearus-spring 컨테이너를 중지시키고 삭제한 뒤 다시 새로운 이미지를 통해 배포하는 형태로 명령어를 재구성하였고 -d 옵션을 통해 명령어 실행 후 detach하였다.
최종적으로 Jenkins에서 위와 같이 정상적으로 배포가 수행되는 것을 확인할 수 있었다.

Github Webhook

이후 Webhook을 통해 Github Action에서 Jenkins의 Job을 Trigger할 수 있도록 위와 같이 Repo를 명시해주었다.

또한 EC2 인스턴스의 HTTP에 대한 모든 인바운드 규칙을 활성화하고

Jenkins의 프로필(admin)->설정에서 API Token을 발급한 뒤

Github Orgatization에 http://[Jenkins 서버주소]:[포트번호] 값을 가지는 Secret을 추가해 주었다.

spring-boot-ci.yaml

...
	  # Dockerhub로 PUSH된 이후 Jenkins의 Job Trigger
      - name: Trigger Jenkins Job
        uses: appleboy/jenkins-action@master
        with:
          url: ${{ secrets.ORG_JENKINS_WEBHOOK_ENDPOINT }}
          user: "admin"
          token: ${{ secrets.ORG_JENKINS_API_TOKEN }}
          job: "SPRING-BE-SSH-CD"

또한 현재 Jenkins의 Deploy가 CI 이후에 이루어진다는 보장이 없기 때문에 Dockerhub로 PUSH된 이후 Jenkins의 Job을 Trigger할 수 있도록 Github Action Workflow에서 appleboy/jenkins-action@master를 사용하여
해당 Repo의 main branch에 push되기만 하면 Github Action의 Workflow가 완료되어 Dockerhub로 push된 이후

해당 이미지가 Jenkins를 통해 EC2 인스턴스에 자동적으로 배포되는 CI/CD 파이프라인을 구축하였고

배포, FE 활용, 덤프 데이터 등의 내용을 팀원분들께 전달하였다.


References
https://gist.github.com/bmaupin/0ce79806467804fdbbf8761970511b8c
https://velog.io/@arara90/AWS-Free-tier%EB%A1%9C-RDS-%EC%82%AC%EC%9A%A9-%EC%A4%91-%EC%9A%94%EA%B8%88%EC%9D%84-%EC%A7%80%EB%B6%88%ED%96%88%EC%96%B4%EC%9A%94
https://gong-story.tistory.com/m/40

2개의 댓글

comment-user-thumbnail
2024년 7월 26일

응원합니다~~!!
소규모 프로젝트인데 소통 채널로 slack 선택한 이유가 궁금합니다

1개의 답글