DevOps/CI|CD

[CI/CD]Jenkins를 이용해서 스프링 프로젝트 배포할 때 중요한 점 (With Docker, GCP) - WABI 회고록

나맘임 2024. 10. 6. 17:10

들어가기 앞서...

pknu-wap/WABI-BE: 와비 : 부경대학교 소속 및 학생 회비 납부자 확인 서비스 BE (github.com)

 

"WABI" 라는 학생회비 납부 서비스에 백엔드로 참가하면서 전적으로 CI/CD를 맡게 되었다.

 

CI는 Git을 통해 어느 정도 경험이 있었지만 CD는 진짜 문외한이었다.

 

이 글은 WABI 를 참가하면서 오랫동안 삽질한 결과물이자 Jenkins를 이용해서 스프링 프로젝트를 배포할 때 놓치기 쉬운 점을 작성해 보았다.

 

즉, Jenkins 및 Docker에 대한 이야기이다.

 

WABI 에서의 나의 첫 번째 목표

1. 스프링 프로젝트 지속적인 통합과 배포까지의 자동화(CI/CD)

2. 웹 서버와 DB 서버 분리

3. https과 DNS 연결

 

크게 3가지였는데 모두 한 번도 안 해본 큰 벽이었다!

 

2번과 3번도 차후 회고록을 써보고자 한다.

프로젝트 구조

그림 1. Google Cloud Platform

먼저, Google Cloud Platform(GCP)를 사용하였다.

 

AWS를 써도 되지만, 무료일 경우 성능 제한이 어느 정도 있는 반면에, GCP는 3개월 동안 한화 약 40만 크레딧을 주기 때문에 성능 좋은 서버를 짧지만 경험해 볼 수 있다는 메리트를 고려하여 GCP를 사용하기로 결정하였다.

(사실 AWS나 GCP나 거기서 거기다..)

 

 

그리고 원래라면 배포 서버를 분리해야 하는데.. 이건 40만 원을 최대한 3개월 동안 뽑아야 하는 현실 때문에 웹 서버 인스턴스에 배포 서버도 같이 두었다.

그림 2. Docker

이런 아찔한 상황을 타개하기 위해 여러 가지를 찾다가 Docker(Docker-Compose)라는 가상화를 이용하여 독립적으로 어플리케이션을 운용할 수 있는 유명한 게 있길래 이걸 사용해 보기로 했었다.

 

그림 3. 전체적인 서버 인스턴스 구조

 

그렇게 만들어진 GCP 인스턴스 구조이다.

그림 4. 구상한 CI/CD 흐름도

전체적인 CI/CD 흐름은 Git Repository develop 브랜치에 push -> webhook으로 Jenkins 서버에 알림 -> Jenkins 파이프라인으로 빌드 -> Docker Image 빌드 -> 빌드된 Docker Image를 DockerHub에 push -> DockerHub로부터 Docker Image pull -> Docker Container 재생성으로 배포 완료

 

여기서 웹 서버랑 Jenkins가 같은 서버 인스턴스에 존재하는데 왜 DockerHub에 빌드된 이미지를 Push 하기로 했냐면

 

이 글을 쓸 당시에, 차후 WABI 기획이 구체화 및 확장을 준비하고 있었기 때문에 배포 서버를 분리할 경우의 수를 대비하여

 

미리 DockerHub에 Push & Pull 하는 방식으로 구현하였다.

 

아무튼 처음 이렇게 구조를 다 짰으나.. 막상 해보니 정말 문제가 많았다

 

1. Jenkins가 계속 엉뚱한 곳에 찾아가서 Gradle Build를 하던 문제

+ ./gradlew clean build --exclude-task test
/var/jenkins_home/workspace/wabi-spring@2/wabi@tmp/durable-0c9c70fd/script.sh.copy: 2: ./gradlew: not found
gradle
        stage('Bulid Gradle') {
          agent any
          steps {
            echo 'Bulid Gradle'
            dir ('./wabi'){
                sh """
                ./gradlew clean build --exclude-task test
                """
            }
          }
          post {
            failure {
              error 'This pipeline stops here...'
            }
          }
        }

 

분명 Jenkins 파이프라인에서 Git Clone을 제대로 하고 진행을 했었는데 계속 wabi-spring@2와 같이 새로운 곳에서

(wabi-spring에서 해야 정상)

 

Build를 진행하는 것이었다.

 

진짜 이 문제 때문에 몇 시간을 썼는지도 모를 정도로 Jenkins 컨테이너를 지웠다가 다시 설치했다가 무한 반복을 했었다..

그림 5. 당시에 사용했던 docker run 코드

 

사실 아직도 뭐 때문에 이런 현상이 발생했는지 의문이다.

 

다만, 추정하기론 Jenkins Docker 컨테이너를 실행할 때, 볼륨을 명확히 하지 않았던 걸로 추측하고 있다.

(차후 두 번째 문제에서 자세히 설명 예정)

 

당시엔 dir() 파이프라인 함수로 강제로 경로를 고정시켜 build를 넘겼으나 진짜 문제는 바로 여기에 있었다...

 

2. Jenkins가 Docker 를 찾지 못하는 문제

        stage('Docker Build') {
            steps {
                dir("./wabi"){
                    sh """docker build -t ${DOCKER_IMAGE} ."""
                    sh 'docker ps -a'
                }
            }
        }
+ docker build -t seongwonyoon/wabi_public .
/var/jenkins_home/workspace/wabi-spring@tmp/durable-eccbc7b2/script.sh.copy: 1: docker: not found

 

1번도 시간이 오래 걸렸지만, 2번은 진짜였다.

 

Jenkins는 다양한 플러그인이 존재한다.

그림 6. Jenkins의 Docker 플러그인

이 중에선 Jenkins의 파이프라인에서 Docker를 사용할 수 있도록 플러그인들이 존재한다.

 

이 당시에 "이것만 깔면 자동으로 docker를 Jenkins 컨테이너 안에서도 쓸 수 있겠지"라는 생각으로 진행했으나 웬걸? docker가 없다고 나를 반겨준다

 

당시엔 몰랐다.. 이게 이렇게 쓰는 게 아니었다는 걸..

 

어쨌든 이걸 몰랐던 나는 Docker Build를 sh 명령으로 실행하고 싶었다.

 

그래서 여러 가지 찾아본 결과로 

 

Docker in Jenkins in Docker | Blog (tiuweehan.com)

 

Tiu Wee Han

My name is Wee Han and I am an undergraduate student at the National University of Singapore. I started programming in August 2018 and have not looked back since. I enjoy learning and teaching as well as doing DevOps related stuff.

www.tiuweehan.com

 

위 글을 발견했다.

 

결국 Jenkins의 파이프라인에서 커맨드로 Docker Image를 Build 하겠다는 말은 Jenkins 컨테이너 안에도 Docker가 설치가 되어 있어야 함을 의미한다.

 

이를 위해선 Docker가 어떻게 작동되는지 알아야 한다. 

그림 7. Docker 구조

 

Docker는 기본적으로 3가지의 구성 요소로 만들어져 있다.

 

사용자의 명령을 입력받는 Client, 실제로 컨테이너를 실행하고 있는 Daemon, 이 둘 사이를 중재하는 Socket이다.

 

Docker가 정상적으로 작동하려면 이 세 가지 구성요소가 모두 필요하다는 것이다.

 

크게 위 글에선 해결 방법을 3가지로 언급하고 있는데

 

1. 걍 새로 다시 깔기

2. 서버 컴퓨터에 docker 설치한 것을 Jenkins 컨테이너에 마운트

3. Docker는 기본적으로 Unix Socket를 사용하므로 TCP Socket을 사용하는 Socker과 Daemon을 새롭게 Docker 컨테이너를 통해 만들어 분리

 

1번은 이미 엄청 재설치를 많이 한 입장에서 싫었고 3번은 굳이 컨테이너를 하나 더 만들어야 한다는 점에서 싫었기 때문에 2번을 선택하였다.

 

2번은 비교적 하는 방법도 쉬운데 Docker 컨테이너를 만들 때 볼륨 경로만 잘 설정해 주면 된다.

 

version: "3.7"

services:
  jenkins:
    image: jenkins/jenkins:lts
    user: root
    container_name: jenkins
    ports:
      - 8080:8080
      - 50000:50000
    volumes:
      - ./jenkins:/var/jenkins_home
      - /usr/bin/docker:/usr/bin/docker
      - /var/run/docker.sock:/var/run/docker.sock

 

위는 docker-compose 방식이다.

 

위와 같은 과정을 통해서 결국 Docker Image를 빌드하고 DockerHub에 Push 하는 건 성공했다.

 

하지만 여기서 끝이 아니었는데..

3. Jenkins가 Docker-Compose 를 찾지 못하는 문제

        stage('Server Container Setting'){
            steps {
                script{
                    sh """
                    docker-compose --project-name wabi pull spring
                    docker-compose --project-name wabi stop spring
                    docker-compose --project-name wabi rm -f spring
                    docker-compose --project-name wabi up -d spring
                    """
                }
            }
        }

 

Spring 컨테이너를 Docker-Compose를 이용해서 관리하고 있었다.

 

컨테이너를 관리하는데도 편했지만 가장 큰 이유는 따로 있었다.

 

바로 application.properties 값 주입을 docker-compose.yml에서 진행하고 있었기 때문이다.

그림 8. docker-compose.yml 예시
그림 9. application.properties 예시

 

하지만 2번을 겪었던 나는 금방 해결방법을 떠올렸다.

 

바로 Jenkins 컨테이너에 Docker-Compose를 설치하면 되는 것이다.

 

그리고 Jenknins 컨테이너 내부 루트 폴더에 Spring 컨테이너를 관리할 Docker-Compose.yml을 만들어 놓는다.

 

그림 10. 빌드 성공!

 

이로써 계획했던 CI/CD를 완성하게 되었다.