![[Jenkins] 스프링부트 프로젝트 CICD 테스트 +삽질 로그](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGxO2f%2FbtsMhlbAgMT%2FVEK6Lt51JUemefQK0BoEg1%2Fimg.png)
✔️ CICD 테스트를 진행하기 앞서 테스트 환경과 관련 포스팅을 참고해주세요.
-- 테스트 환경 --
AWS EC2 : Ubuntu
WAS : Java 17, Springboot 3.x
SCM : Github
CICD : Jenkins
🧷 [Ubuntu] Java 및 Jenkins 설치 + 스왑 메모리
🧷 [Jenkins] Github 자격 증명 추가 + 웹훅 설정
🧷 [Jenkins] Item 추가 및 Pipeline 작성 + 테스트
✅ 들어가기에 앞서
지난 시간에는 Jenkins 아이템을 추가하고, 간단한 파이프라인을 통해 Github Push이벤트를 Jenkins에서 수신하는지 테스트를 해보았습니다.
이번에는 다음과 같은 플로우에 대한 CICD 파이프라인을 작성해보고, 테스트해보려 합니다.
Github Push Event -> Webhook -> Jenkins 수신 -> Item Build
Item Build : git clone -> gradle build -> jar 실행
⚒️ Jenkins Tools 추가
기본적으로 Ubuntu환경에서 Jenkins는 Shell Script를 통해 모든 명령어를 수행합니다.
그렇기에 Gradle Build를 위해서는 특정 버전의 JDK가 필요하게 됩니다.
이전 🧷 [Ubuntu] Java 및 Jenkins 설치 + 스왑 메모리 포스팅에서 저희는 JDK 17을 서버에 설치해주었습니다.
서버에 설치된 JDK 17은 시스템 전체에서 기본값으로 사용됩니다.
그러나 각각의 Jenkins 아이템이 실행할 프로젝트는 JDK 버전이 다를 수 있습니다.
이는 곧 특정 JDK가 필요하게 될수도 있다는 의미입니다.
Jenkins에서는 이러한 상황을 대비하여 Tools라는 기능을 통해 JDK와 같은 필요한 툴들을 아이템별로 자동으로 설치하여 사용할 수 있게 지원해주는데요.
이번에는 이런 상황이 아니긴 하지만 Tools 등록하는 법에 대해 가볍게 알아보고 가려합니다.
우선 Dashboard -> Jenkins 관리 -> Tools 탭으로 들어가줍니다.
그러면 아래와 같은 화면이 보이게 됩니다.
항목 중 'JDK installations'가 바로 이러한 JDK 자동 설치를 지원해주는 설정 부분입니다.
Name 부분은 Pipeline Script에서 사용될 변수명입니다.
처음에는 JAVA_HOME을 입력하는 칸이 뜰텐데요. 이는 서버에 설치된 JDK를 활용하겠다는 의미로 실제 JDK가 설치된 경로를 입력해주면 됩니다. (예 : /usr/lib/jvm/java-17-openjdk-amd64)
이번에는 자동 설치가 목적이기에 아래 Install automatically 부분을 체크하고 JDK를 추가해주겠습니다.
Save 버튼을 통해 변경사항에 대해 저장하게 되면, 이제 저희는 서버에서 JDK 설치를 하지 않고서도 Jenkins CI를 통해 JDK를 사용할 수 있게 됩니다.
📄 Pipeline Script 작성
이제 파이프라인을 작성할 차례인데요. 🧷 [Jenkins] Item 추가 및 Pipeline 작성 + 테스트 포스팅에서 작성해두었던 기초 스크립트에서 변경하여 사용하도록 하겠습니다.
우선 저희 CICD의 과정은 git clone -> build -> deploy 순입니다. 이 과정들을 각각 stage로 구성하면 좋을 듯 합니다.
(실제로 Jenkins 공식 홈페이지에서도 이러한 과정에 대한 Stage 구축을 기본으로 권장하고 있습니다.
pipeline {
agent any // 파이프라인이 실행될 에이전트 지정
environment {
APP_NAME = 'sample-app' // 실행할 앱 이름 (원하는 이름으로 변경)
}
stages {
stage('Clone Repository') {
steps {
git branch: 'main', credentialsId: 'Github-Auth', url: 'https://github.com/Be-HinD/InfraStructure_Sample.git'
}
}
stage('Gradle Build') {
steps {
dir('./cicd') { // 스프링부트 루트 디렉토리
sh 'chmod +x ./gradlew' // Gradle Wrapper 실행 권한 부여
sh './gradlew clean build' // 빌드 수행
}
}
}
stage('Stop Existing Application') {
steps {
script {
def pid = sh(script: "pgrep -f sample-app || echo 'no process found'", returnStdout: true).trim()
if (pid != 'no process found') {
sh "kill -9 ${pid}"
}
}
}
}
stage('Run Application') {
steps {
dir('/var/lib/jenkins/workspace/BUILD_TEST/cicd') {
sh 'nohup ./gradlew bootrun --no-daemon > app.log 2>&1 &' // 백그라운드 실행
//sh 'sleep 30' // 프로세스가 안정적으로 실행되도록 대기
}
}
}
}
post {
failure {
echo 'Build or Deployment Failed!'
}
success {
echo 'Application Deployed Successfully!'
}
}
}
위 스크립트는 Declarative Pipeline" (선언형 파이프라인) 방식으로 작성하였습니다.
선언형 파이프라인 ?
✅ pipeline { ... } 블록을 사용하여 구조적으로 파이프라인을 정의하는 방식입니다.
✅ Jenkins가 제공하는 DSL(Domain-Specific Language)을 사용하여 간결하게 작성 가능.
✅ stages {} 블록을 통해 각 단계(stage)를 명확히 정의.
✅ post {} 블록을 사용하여 빌드 성공/실패 후 수행할 작업 지정 가능.
✅ 문법이 엄격하여 읽기 쉽고 유지보수가 용이함.
이제 블록 위주로 전체적으로 한번 훑어보겠습니다.
우선 enviroment 블록입니다.
해당 블록은 전체 스크립트에서 활용될 전역 환경 변수를 설정하는 블록입니다. 주로 AWS Secret Key와 같은 중요 정보들을 Credentials에 등록하고, 이를 스크립트에서 활용하기 위해 사용되는 블록입니다.
다음은 stages 블록입니다.
여러 stage 블록으로 구성될 수 있고, 전체 stage들에 대한 포괄적인 블록입니다.
이제 개별 stage 블록들을 살펴보겠습니다.
1. 'Clone Repository' 블록
제일 먼저 git clone을 진행하여 변경된 소스를 Jenkins 서버로 가져오기위한 스테이지입니다.
Github 브랜치의 소스 코드를 Jenkins로 가져오기 위한 방법으로는 두 가지가 있습니다.
✔️ git {} 스텝 사용
위 스크립트와 동일하게 git 스텝을 사용하여 branch, 인증 정보, url을 명시하는 것으로 clone을 진행합니다.
✔️ 명시적 sh 사용
stage('Clone Repository') {
steps {
sh 'git clone -b main https://github.com/Be-HinD/InfraStructure_Sample.git'
}
}
말 그대로 명시적으로 sh를 사용하여 git clone 명령어를 수행하는 것으로 해당 소스 코드를 가져올 수 있습니다.
2. 'Gradle Build' 블록
Build 이전에 Github에서 가져왔던 스프링부트 프로젝트의 루트 디렉토리로 dir구문을 통해 이동합니다.
그 후 Gradle Wrapper에 실행 권한을 부여합니다. 이유로는 Jenkins 사용자는 실제 서버의 권한이 없을 수 있습니다. 그렇기에 직접적인 권한을 부여하는 명령어를 사용해줍니다.
권한이 부여됐다면, 명령어를 통해 빌드를 진행합니다.
3. 'Stop Existing Application' 블록
해당 블록이 필요한 이유는 CICD는 지속적인 배포 과정이 이루어지게 됩니다. 그러나 새로운 빌드가 수행될 때 이전에 실행중이던 스프링부트 프로그램이 있다면 다양한 이유(포트 중복, 메모리 부족 등) 로 현재 빌드가 실패할 수 있습니다.
그렇기에 해당 블록을 통해 이전에 실행중이던 동일한 프로그램이 있다면 종료 후 배포를 진행합니다.
4. 'Run Application' 블록
Build를 통해 실행 가능한 상태의 Jar 결과물을 얻었다면 직접적으로 배포하는 CD 과정을 위한 블록입니다.
+ 위 스크립트에서는 Jar실행 옵션으로 bootrun을 사용했는데요. 조금 더 메모리 절약을 위해 java -jar 실행 옵션을 주는게 좋아보입니다.
해당 부분에서 삽질을 되게 많이 했었는데요...
삽질 로그는 다음과 같습니다.
1️⃣ 포그라운드 실행
처음 sh 명령어로는 단순히 ./gradlew bootrun이였습니다. 하지만 이러한 방식으로 진행하게 될 경우 포그라운드 실행이 되기 때문에 해당 스프링부트 프로그램이 실행되는 동안에는 Jenkins Item 실행이 멈추지 않게 됩니다.
이렇게되면 당연히 다음 빌드에서 문제가 발생하게 됩니다.
그러나 여기서 궁금했던 부분이 "Jenkins 자체가 프로세스라고 한다면, 아이템은 쓰레드가 되고, 아이템에서 스프링부트 프로세스가 실행된다면 어떤 연관관계가 있는건가?" 였습니다.
이에 대한 찾아본 결과 다음과 같았습니다.
Jenkins 자체는 하나의 JVM 프로세스다. 그렇기에 젠킨스는 OS에서 하나의 프로세스로 관리된다.
Item 또는 Job은 Jenkins가 실행하는 하나의 쓰레드 단위이다. Item(Job)이 실행될 때, Jenkins 내부에서는 새로운 쓰레드(Thread)로 실행됩니다.
Item(Job)에서 실행된 Spring Boot는 새로운 독립 프로세스로 실행된다. 즉 Spring Boot 또한 OS에서 하나의 프로세스로 관리된다.
위 결과에서 두가지 사실을 알 수 있었습니다.
1. Item(Job)에서 실행되는 프로세스들은 독립적이다. -> Spring Boot의 결과가 Item에 영향을 끼치지 않는다.
2. Item 실행이 멈추지 않는 이유는 단순히 ./gradlew bootrun 명령어가 동기적 방식이기 때문이다.
해결책으로 nohup을 통해 프로세스를 데몬 형태로 실행하고, &를 명령어 맨 끝에 붙여주면서 해당 프로그램이 백그라운드로 실행되게 변경했습니다.
2️⃣ 1번의 해결책으로 인한 이슈 (무반응)
nohup과 &을 통해 Spring Boot를 백그라운드에서 실행시키고, 정상적으로 Item을 종료하는 방식으로 스크립트를 변경했습니다. 그러나 빌드 시 Item은 정상적으로 종료가 되지만, Spring Boot 프로젝트는 실행이 안되고 log조차 남지 않는 문제가 생겼습니다.
이를 해결하기위해 서버에서 직접 해당 명령어가 정상적으로 돌아가는지 테스트해보고, 실행 권한을 다시 줘보기도 하고, htop 명령어를 통해 메모리 오버헤드가 발생해서 그런건지 계속해서 찾아보기도 했습니다.
이번 문제를 찾기 힘들었던 이유가 3가지 있었는데요.
- Item은 정상적으로 종료된다.
- 서버에서는 정상적으로 실행된다.
- GPT의 해결책들이 적용되지 않는다.
사실 3번 때문에 많이 헤멨습니다...
현재도 정확한 원인은 발견하지 못했습니다. 아마도 프리티어 환경 특성 상 메모리 관련 이슈로 문제가 발생한게 아닐까 싶었는데 AWS 대시보드 상에는 문제가 없는 걸 확인하고 점점 더 미궁으로...
임시 해결을 위한 방법으로는 Spring Boot실행과 동시에 Item 쓰레드 종료를 방지하기 위한 sleep 옵션이였습니다.
해당 옵션 적용 후에는 정상적으로 동작되는 것을 확인할 수 있었습니다.
그러나 위 해결 방법이 독립된 프로세스랑 아이템 쓰레드와 연관이 없다는 점에서 이해가 되지 않아 이후에 sleep 옵션을 주석처리하고 실행했더니 정상적으로 실행 log가 찍히는 것을 확인할 수 있었습니다. (왜 이전에는 프로세스도 log도 안찍혔었던거지?....)
하지만 프로젝트 초기화 도중 아래와 같은 에러를 볼 수 있었습니다.
3️⃣ --no-daemon?
위 로그는 Gradle Daemon이 비정상적으로 종료되었거나 충돌했다는 것을 말하고 있었습니다.
Gradle의 데몬 모드는 빌드 성능을 개선하기 위해 백그라운드에서 Gradle 프로세스를 계속 유지하고, 이를 통해 빌드 캐시 및 기타 설정을 재사용하는 방식으로 동작한다고 합니다.
--no-daemon은 이러한 Gradle 데몬 프로세스를 사용하지 않겠다는 옵션입니다.
이후에 레퍼런스를 찾던 도중 위 구문과 관련하여 StackOverFlow 문서를 찾을 수 있었습니다.
🧷 StackOverFlow About build daemon
내용 확인 결과 CI 환경에서는 daemon 옵션을 비활성화 하는게 일반적 이라는걸 확인할 수 있었습니다.
이 부분과 관련하여 제 추측으로는 CI 환경에서 Jenkins Item은 쓰레드 형태로 동작합니다. 이러한 쓰레드에서 daemon 프로세스가 수행이되면서 쓰레드 종료시에 해당 프로세스까지 같이 종료되어 제대로 된 프로젝트 초기화가 되지 않기 때문이지 않을까 입니다. (각 빌드는 독립적이라는 것이라 재사용에 문제가 있다는 말이죠)
적용 이후에는 sleep 옵션을 주지 않고도 제대로 프로젝트 초기화 및 실행이 잘 되는 점을 확인할 수 있었습니다.
🚩테스트 결론
AWS 프리티어 환경에서 Jenkins를 통한 CICD 구축에 대해 스프링부트 프로젝트를 기반으로 테스트를 진행해보았습니다.
프리티어 환경이기 때문에 발생할 수 있는 여러 문제가 있었는데요. 그 중 제일 큰 문제는 메모리 부족 문제였습니다.
임시방편으로 가상 메모리인 스왑 메모리를 활성화 했어도 Java기반의 프로그램을 2개 돌리는 것은 큰 용량이 필요한 작업이였는데요.
이러한 메모리 부족 문제는 정확한 원인 진단도 하기 힘들기 때문에 헤매기 딱 좋은 이슈인 것 같습니다.
또한 이번 테스트를 통해 전체적으로 Github -> Webhook -> Jenkins -> CI/CD 과정이 어떻게 이루어지고, 어떻게 구성해야 하는지 알아보았습니다. 각각에 필요한 구성 요소 및 설정 부분까지 말이죠.
이미 많은 레퍼런스가 있기에 보고 따라하셔도 문제가 없을 수 있지만, 어떻게 돌아가는지 이해가 부족하다면 원인 모를 이슈를 발견했을 때 대처 능력이 부족해지고, 이는 곧 시간 지연으로 이어질 수 있습니다.
그렇기에 지난 포스팅부터 이번 포스팅까지 가볍게라도 왜? 라는 사고를 통해 이해를 하며 정리하려 노력했습니다.
아직도 이해가 잘 되지 않는 부분, 모르는 부분이 많아도 전반적인 흐름이 보여진다면 성공적인 정리라고 생각합니다.
다음에는 EC2 호스트 배포 환경이 아닌 Docker와 같은 가상 환경에서의 Jenkins를 통한 CI/CD 구축, SSL 환경에서의 CI/CD, SSH를 통한 Webhook 보안 강화 등 고도화할 수 있는 방향들에 대해 탐구해볼 예정입니다.
개발 기술 블로그, Dev
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!