Logic in Code,
Freedom in Travel.

인생 뭐 있나 사람 사는거 다 똑같지

DevOps/모니터링

[DevOps] - Jenkins와 Spring Boot로 구축하는 CI/CD 파이프라인

귀찮은 개발자 2024. 2. 16. 02:10 계산 중...
목차 (Table of Contents)

과거에는 어찌되었는지 모르지만 github, gitlab, bitbucket(잘 모르겠음) 에서 CI 를 지원해주고 오픈소스가 많은 현대 소프트웨어 개발에서 CI/CD는 선택이 아니라 필수인 것 같다. 보안 문제가 아닌 이상 이 간편한걸 사용하지 않으면 바보라고 생각이 든다. 이번에는 Jenkins 를 이용해서 Spring Boot 프로젝트를 자동화된 빌드, 테스트, 배포 파이프라인을 구축을 시도해보았다. 

CI/CD가 필요한 이유

  • 빠른 피드백: 코드 변경 후 즉시 빌드/테스트 결과 확인
  • 품질 보증: 자동화된 테스트로 버그 조기 발견
  • 배포 자동화: 수동 배포의 실수 방지
  • 개발 생산성: 반복 작업 자동화로 개발에 집중

Jenkins 설치 및 설정

Docker를 활용한 Jenkins 설치

docker run -d \
  --name jenkins \
  -p 8080:8080 \
  -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  jenkins/jenkins:lts

초기 관리자 비밀번호는 다음 명령으로 확인 가능하다. 

docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

필수 플러그인 설치

Jenkins 대시보드에서 다음 플러그인을 설치합니다:

  • Git Plugin: Git 저장소 연동
  • Maven Integration: Maven 빌드 지원
  • Pipeline: Jenkinsfile 기반 파이프라인
  • Docker Pipeline: Docker 이미지 빌드/배포
  • Blue Ocean: 시각적 파이프라인 UI

Spring Boot 프로젝트 준비

기본 프로젝트 구조

spring-boot-app/
├── src/
│   ├── main/java/
│   └── test/java/
├── pom.xml
├── Dockerfile
└── Jenkinsfile

Dockerfile 작성

FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

application.yml 프로파일 설정

spring:
  profiles:
    active: ${SPRING_PROFILE:dev}

---
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    url: jdbc:h2:mem:testdb

---
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

Jenkinsfile 작성

선언형 파이프라인 예제

pipeline {
    agent any

    tools {
        maven 'Maven-3.9'
        jdk 'JDK-17'
    }

    environment {
        DOCKER_IMAGE = 'myapp/spring-boot-app'
        DOCKER_TAG = "${BUILD_NUMBER}"
        DOCKER_REGISTRY = 'docker.io'
    }

    stages {
        stage('Checkout') {
            steps {
                git branch: 'main',
                    url: 'https://github.com/username/spring-boot-app.git'
            }
        }

        stage('Build') {
            steps {
                sh 'mvn clean compile'
            }
        }

        stage('Test') {
            steps {
                sh 'mvn test'
            }
            post {
                always {
                    junit '**/target/surefire-reports/*.xml'
                    jacoco(
                        execPattern: '**/target/jacoco.exec',
                        classPattern: '**/target/classes',
                        sourcePattern: '**/src/main/java'
                    )
                }
            }
        }

        stage('Code Analysis') {
            steps {
                script {
                    withSonarQubeEnv('SonarQube') {
                        sh 'mvn sonar:sonar'
                    }
                }
            }
        }

        stage('Package') {
            steps {
                sh 'mvn package -DskipTests'
            }
        }

        stage('Docker Build') {
            steps {
                script {
                    docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                    docker.build("${DOCKER_IMAGE}:latest")
                }
            }
        }

        stage('Docker Push') {
            steps {
                script {
                    docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-credentials') {
                        docker.image("${DOCKER_IMAGE}:${DOCKER_TAG}").push()
                        docker.image("${DOCKER_IMAGE}:latest").push()
                    }
                }
            }
        }

        stage('Deploy to Dev') {
            steps {
                sh '''
                    docker stop spring-boot-app-dev || true
                    docker rm spring-boot-app-dev || true
                    docker run -d \
                        --name spring-boot-app-dev \
                        -p 8081:8080 \
                        -e SPRING_PROFILE=dev \
                        ${DOCKER_IMAGE}:${DOCKER_TAG}
                '''
            }
        }

        stage('Deploy to Production') {
            when {
                branch 'main'
            }
            input {
                message "배포를 진행하시겠습니까?"
                ok "배포"
            }
            steps {
                sh '''
                    docker stop spring-boot-app-prod || true
                    docker rm spring-boot-app-prod || true
                    docker run -d \
                        --name spring-boot-app-prod \
                        -p 8080:8080 \
                        -e SPRING_PROFILE=prod \
                        -e DB_URL=${PROD_DB_URL} \
                        -e DB_USERNAME=${PROD_DB_USERNAME} \
                        -e DB_PASSWORD=${PROD_DB_PASSWORD} \
                        ${DOCKER_IMAGE}:${DOCKER_TAG}
                '''
            }
        }
    }

    post {
        success {
            echo '파이프라인 성공!'
            // Slack 알림 등 추가 가능
        }
        failure {
            echo '파이프라인 실패!'
            // 이메일 알림 등 추가 가능
        }
        always {
            cleanWs()
        }
    }
}

고급 기능 구현

1. 멀티 브랜치 파이프라인

pipeline {
    agent any

    stages {
        stage('Branch Strategy') {
            steps {
                script {
                    if (env.BRANCH_NAME == 'main') {
                        echo 'Production 배포 준비'
                    } else if (env.BRANCH_NAME.startsWith('develop')) {
                        echo 'Development 환경 배포'
                    } else if (env.BRANCH_NAME.startsWith('feature/')) {
                        echo 'Feature 브랜치 테스트만 수행'
                    }
                }
            }
        }
    }
}

2. 병렬 실행

stage('Parallel Tests') {
    parallel {
        stage('Unit Tests') {
            steps {
                sh 'mvn test -Dtest=*UnitTest'
            }
        }
        stage('Integration Tests') {
            steps {
                sh 'mvn test -Dtest=*IntegrationTest'
            }
        }
        stage('Security Scan') {
            steps {
                sh 'dependency-check.sh --scan .'
            }
        }
    }
}

3. Blue-Green 배포

stage('Blue-Green Deploy') {
    steps {
        script {
            def currentColor = sh(
                script: "docker ps --filter 'name=app-blue' --format '{{.Names}}'",
                returnStdout: true
            ).trim() ? 'blue' : 'green'

            def newColor = currentColor == 'blue' ? 'green' : 'blue'

            sh """
                docker run -d \
                    --name app-${newColor} \
                    -p 808${newColor == 'blue' ? '1' : '2'}:8080 \
                    ${DOCKER_IMAGE}:${DOCKER_TAG}

                # Health check
                sleep 10
                curl -f http://localhost:808${newColor == 'blue' ? '1' : '2'}/actuator/health

                # Switch traffic
                nginx -s reload

                # Stop old container
                docker stop app-${currentColor}
                docker rm app-${currentColor}
            """
        }
    }
}

4. 롤백 메커니즘

stage('Rollback') {
    when {
        expression { currentBuild.result == 'FAILURE' }
    }
    steps {
        script {
            def previousTag = sh(
                script: "git describe --abbrev=0 --tags",
                returnStdout: true
            ).trim()

            sh """
                docker stop spring-boot-app-prod
                docker rm spring-boot-app-prod
                docker run -d \
                    --name spring-boot-app-prod \
                    -p 8080:8080 \
                    ${DOCKER_IMAGE}:${previousTag}
            """
        }
    }
}

보안 및 자격증명 관리

Jenkins Credentials 사용

stage('Secure Deploy') {
    steps {
        withCredentials([
            usernamePassword(
                credentialsId: 'database-credentials',
                usernameVariable: 'DB_USER',
                passwordVariable: 'DB_PASS'
            ),
            string(
                credentialsId: 'api-key',
                variable: 'API_KEY'
            )
        ]) {
            sh '''
                docker run -d \
                    -e DB_USERNAME=${DB_USER} \
                    -e DB_PASSWORD=${DB_PASS} \
                    -e API_KEY=${API_KEY} \
                    ${DOCKER_IMAGE}:${DOCKER_TAG}
            '''
        }
    }
}

모니터링 및 알림

Slack 통합

post {
    success {
        slackSend(
            color: 'good',
            message: "빌드 성공: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
        )
    }
    failure {
        slackSend(
            color: 'danger',
            message: "빌드 실패: ${env.JOB_NAME} #${env.BUILD_NUMBER}\n${env.BUILD_URL}"
        )
    }
}

이메일 알림

post {
    failure {
        emailext(
            subject: "빌드 실패: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
            body: """
                빌드가 실패했습니다.

                Job: ${env.JOB_NAME}
                Build Number: ${env.BUILD_NUMBER}
                Build URL: ${env.BUILD_URL}
            """,
            to: 'team@example.com'
        )
    }
}

성능 최적화 팁

1. Maven 캐싱

stage('Build with Cache') {
    steps {
        sh '''
            mvn -Dmaven.repo.local=.m2/repository \
                clean package
        '''
    }
}

2. Docker 레이어 캐싱

# 의존성 레이어 분리
FROM openjdk:17-jdk-slim as dependencies
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline

# 애플리케이션 레이어
FROM dependencies as builder
COPY src ./src
RUN mvn package -DskipTests

FROM openjdk:17-jdk-slim
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

3. 병렬 스테이지 활용

stage('Parallel Analysis') {
    parallel {
        stage('SonarQube') {
            steps { sh 'mvn sonar:sonar' }
        }
        stage('Dependency Check') {
            steps { sh 'mvn dependency-check:check' }
        }
        stage('OWASP Scan') {
            steps { sh 'zap-cli quick-scan http://localhost:8080' }
        }
    }
}

실전 예제: 완전한 파이프라인

@Library('shared-library') _

pipeline {
    agent {
        kubernetes {
            yaml """
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: maven
    image: maven:3.9-openjdk-17
    command: ['cat']
    tty: true
  - name: docker
    image: docker:latest
    command: ['cat']
    tty: true
    volumeMounts:
    - name: docker-sock
      mountPath: /var/run/docker.sock
  volumes:
  - name: docker-sock
    hostPath:
      path: /var/run/docker.sock
"""
        }
    }

    options {
        buildDiscarder(logRotator(numToKeepStr: '10'))
        timeout(time: 1, unit: 'HOURS')
        timestamps()
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Build & Test') {
            steps {
                container('maven') {
                    sh 'mvn clean verify'
                }
            }
        }

        stage('Quality Gate') {
            steps {
                container('maven') {
                    script {
                        def qualityGate = waitForQualityGate()
                        if (qualityGate.status != 'OK') {
                            error "품질 게이트 실패: ${qualityGate.status}"
                        }
                    }
                }
            }
        }

        stage('Build & Push Image') {
            steps {
                container('docker') {
                    script {
                        def app = docker.build("${DOCKER_IMAGE}:${BUILD_NUMBER}")
                        docker.withRegistry('https://registry.example.com', 'docker-creds') {
                            app.push()
                            app.push('latest')
                        }
                    }
                }
            }
        }

        stage('Deploy') {
            steps {
                kubernetesDeploy(
                    configs: 'k8s/*.yaml',
                    kubeconfigId: 'kubeconfig'
                )
            }
        }
    }
}

트러블슈팅

일반적인 문제와 해결방법

문제: Maven 의존성 다운로드 실패

// settings.xml에 미러 설정
sh 'mvn -s settings.xml clean package'

문제: Docker 빌드 시 권한 오류

# Jenkins 사용자를 docker 그룹에 추가
sudo usermod -aG docker jenkins
sudo systemctl restart jenkins

문제: 메모리 부족

environment {
    MAVEN_OPTS = '-Xmx2048m -XX:MaxPermSize=512m'
}

결론

Jenkins와 Spring Boot를 활용한 CI/CD 파이프라인 구축함으로서 이러한 장점이 있다. 

  • 개발 속도 향상: 자동화된 빌드/테스트/배포
  • 품질 보증: 지속적인 코드 품질 검증
  • 안정적 배포: Blue-Green, 롤백 전략
  • 팀 협업: 표준화된 배포 프로세스

Jenkins 가 간단한 프로젝트에서는 무거워서 사용하기 쉽지 않은 부분이 있지만 연습은 해볼만 한 것 같다. 처음 시작할때는 빌드-테스트-배포 파이프라인으로 시작해서 필요에 따라 조금씩 개선해 나가는 것이 좋을 것 같다.