[Jenkins] 무중단 배포를 위한 CI/CD 파이프라인 구현 (3) - Gradle 빌드부터 AWS 배포까지

서론

  • 지난 포스트에서는 Jenkins 설치 및 설정, GitHub 토큰 생성 과정을 다루었습니다.
  • 이번 글에서는 Jenkins를 활용한 파이프라인 생성부터 배포까지의 과정을 상세히 살펴보겠습니다.

1. 새로운 파이프라인 생성

  • 파이프라인을 생성하고 이름을 설정합니다. 아래의 이미지를 참고하여 단계별로 설정을 진행하세요.


2. 환경변수 파일 로드

1) 환경변수 파일 생성

  • 환경변수를 저장할 파일을 생성합니다.
  • 파일명은 env.properties로 하고, 아래 예시와 같이 key-value 형태로 작성합니다.
# AWS 설정
AWS_REGION=ap-northeast-2
ASG_NAME=net-novel-backend-ASG

2) 파이프라인에서 환경변수를 읽는 Stage 생성

  • 아래 코드를 사용하여 환경변수를 로드하고 전역변수(env. 형태)로 설정합니다.
//환경변수들을 로드
stage('Load Env Variables') {
    steps {
        script {
            // env.properties 파일 읽기
             def filePath = '/home/ubuntu/properties/env.properties'
             def fileContent = readFile(filePath)

            // 파일 내용을 key-value로 변환
             def props = [:]
             fileContent.split('\n').each { line ->
                line = line.trim()
                if (line && !line.startsWith("#")) { // 빈 줄과 주석 제외
                    def keyValue = line.split('=')
                    if (keyValue.size() == 2) {
                        props[keyValue[0].trim()] = keyValue[1].trim()
                    }
                }
            }
            // 반복문을 돌며환경 변수 설정
            props.each { key, value ->
                env."${key}" = value
            }
             //출력테스트
             echo "SPRING_PROPS_FILE: ${env.SPRING_PROPS_FILE}"
        }
    }
}

3. GitHub 클론 파이프라인 생성

  • 연결된 GitHub Repository의 소스코드를 clone 합니다.
  • Public 레포지토리 기준으로 코드를 작성하였습니다.
//github 소스코드 Clone, jenkins workspace에 저장
  stage('Clone Repository') {
     steps {
            echo 'Cloning GitHub repository...'
            git branch: 'master', url: "${env.REPO_URL}"
         }
    }
  • 클론된 레포지토리는 Jenkins 작업공간(Workspace)에서 확인 가능합니다.
  • 또는 ec2 인스턴스 내에서 jenkins workspace로 이동하여 확인할 수 있씁니다.
cd /var/lib/jenkins/workspace/<pipeline name> && ls

4. Gradle로 Spring Application Build

 

[EC2] Ubuntu Swap Memory 설정으로 메모리 부족 문제 해결(EC2 멈춤 해결)

1. 서론EC2에서 Gradle 빌드 시 인스턴스가 멈추는 현상이 발생했습니다.이는 메모리 부족으로 인해 발생한 문제로, 스왑 메모리(swap)를 설정하여 해결할 수 있었습니다.이번 글에서는 스왑 메모리

myrail.tistory.com

 

 

1) application.properteis 복사

  • GitHub에는 application.properties 파일을 푸시하지 않으므로, Jenkins에서 해당 파일을 전달합니다.
//Spring JAR 파일 만들기위한  application.properties 복사
stage('Copy application.properties') {
    steps {
        echo 'Copy application.properties to target directory'
        sh "cp ${env.SPRING_PROPS_FILE} ${env.TARGET_DIR}"
    }

2) gradlew 실행권한 추가

  • 파이프라인 실행 중, gradlew 권한 문제로 실행이 되지않았습니다.
  • gradlew 에 실행권한 x 를 추가하여 문제를 해결하였습니다.
stage('Ensure gradlew') {
         steps {
             echo 'Ensure gradlew has execute permission'
             sh 'chmod +x ./gradlew'
            }
     }

3) JAR 파일 빌드

  • 로컬에서 테스트를 진행했다고 가정하고, 불필요한 test를 스킵했습니다.
//JAR 파일 build
 stage('Build Spring JAR') {
     steps {
         echo 'Build Spring JAR'
         sh './gradlew clean build -x test'
     }
 }

5. Docker Image Build 및 Docker Hub Push

  • 먼저 인스턴스에 Docker 설치가 필요합니다. 설치방법은 도커 공식문서 를 참고해주세요

1) Jenkins Docker 권한부여

  • Jenkins 사용자를 Docker 그룹에 추가합니다.
  • 권한부여후 적용이 되지않는경우, Jenkins를 재부팅해줍니다.
# docker group에 젠킨스 넣기
sudo usermod -aG docker jenkins

# Jenkins 재부팅
sudo systemctl restart jenkins

2) Docker image build

  • 앞서 만든 Spring jar 파일 기준으로 Docker Image을 만듭니다.
  • GitHub에서 받은 소스코드 안에 Dockerfile이 작성되어 있어야 합니다
 stage("Build Docker Image") {
            steps {
                echo 'Build Docker Image'
                sh "docker build -t ${env.DOCKER_IMAGE_NAME}:${env.DOCKER_IMAGE_TAG} ."
            }
        }
  • 빌드가 완료되면 아래 명령어로 생성된 이미지를 확인할 수 있습니다.
docker images
REPOSITORY       TAG       IMAGE ID       CREATED         SIZE
image/backend   latest    df4cf5d8614e   21 hours ago    417MB

3) Dockr-Compose Up 테스트

  • Docker Hub에 이미지를 푸시하기 전에 정상적으로 실행되는지 확인합니다.
  • 실행 후 5분 뒤 API를 호출하여 Health Check를 수행합니다.
  • 헬스체크 실패시 다음과같이 에러를 출력합니다.

//Docker Compose 배포테스트
//헬스체크 API 호출, 실패시 에러로처리
stage("Docker Compose Deployment and Health Check") {
    steps {
        echo "Docker Compose Deployment and Health Check..."
        //Docker compose 실행
        sh """
                    cd ${env.DOCKER_COMPOSE_YML}
                    docker-compose up -d
                """

        sleep(300) // 컨테이너 실행 대기시간, 초단위

        //컨테이너의 헬스체크 API 호출, 성공시 200 반환
        //200이 아닐경우 error로 파이프라인 종료
        script {
            def httpResponse = sh(
                    script: """
                            curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8081/api/health || true
                            """,
                    returnStdout: true
            ).trim()

            //응답받은 HTTP 코드 로그 출력
            echo "HTTP Response: ${httpResponse}"

            //HTTP 200코드가 아닐경우 로그 출력후 에러처리
            if (httpResponse != "200") {
                echo "Backend Docker-Container Health check failed. Please check."
                error("Health check failed: Received HTTP $httpResponse instead of 200.")
            }
            //HTTP 200코드인경우 성공 로그 출력
            echo "Backend Docker-Container Health check passed successfully!!"
        }

        //Docker 컨테이너 종료
        echo "Shutting down Docker container..."
        sh """
                 cd ${env.DOCKER_COMPOSE_YML}
                 docker-compose down
                 """
    }
}

4) Docker hub push

  • 생성한 Docker Image 파일을 EC2 인스턴스에서 사용할 수 있게 Docker Hub에 업로드합니다.
  • Docker Hub에 이미지를 푸시하기 위해 Jenkins에 Docker 관련 Credentials가 등록되어 있어야 합니다.
//Docker Hub에 push
stage("Push Docker Image") {
    steps {
        echo "Push Docker Image to Docker Hub"
        withCredentials([usernamePassword(credentialsId:
                'docker-hub-credentials',//젠킨스에 저장된 credentials 이름
                usernameVariable: 'DOCKER_USER',
                passwordVariable: 'DOCKER_PASS')]) {
            //도커 로그인후 이미지 push
            // 도커 로그인
            sh "docker login -u ${DOCKER_USER} -p ${DOCKER_PASS}"
            // 이미지 Push
            sh "docker push ${env.DOCKER_IMAGE_NAME}:${env.DOCKER_IMAGE_TAG}"
        }
    }
}
  • Jenkins 로그에서 이미지가 정상적으로 푸시된 것을 확인할 수 있습니다.

Jenkins Log

  • Docker Hub에 접속하여 이미지가 업로드되었는지 확인합니다.

Docker Hub 정상 업로드된 모습

}


6. EC2 AutoScaling Group 인스턴스 재시작

  • ASG(Auto Scaling Group)의 모든 인스턴스를 차례로 종료하고, 새 인스턴스를 생성합니다
  • 새인스턴스는 부팅시 Docker 이미지를 Pulling 하여 최신 이미지를 적용합니다.

1) ASG의 인스턴스 목록 가져오기

  • 실행 중인 ASG의 인스턴스 ID를 가져옵니다.
// Auto Scaling Group의 인스턴스 정보를 가져오는 스테이지
stage("Fetch Auto Scaling Group Instances") {
    steps {
        script {
            //Auto Scaling Group에서 실행중인 인스턴스 ID를 받아옴
            def asgInstances = sh(script: """
                    aws autoscaling describe-auto-scaling-groups \
                    --region ${env.AWS_REGION} \
                    --auto-scaling-group-names ${env.ASG_NAME} \
                    --query "AutoScalingGroups[0].Instances[?LifecycleState=='InService'].InstanceId" \\
                    --output text
                    """, returnStdout: true).trim()

            // 줄바꿈을 기준으로 인스턴스 ID를 나눈 후 배열에 저장
            env.ASG_INSTANCE_IDS = asgInstances
            // Instance ID 로그에 출력
            echo "ASG Instances: ${env.ASG_INSTANCE_IDS}"
        }
    }

}

2) ASG 인스턴스 종료 및 갱신

  • 앞 가정에서 가져온 인스턴스 목록을 하나씩 종료합니다.
  • 새 인스턴스가 생성될 때까지 대기하고, 생성이 완료되면 다음 인스턴스를 종료합니다.
  • 목록의 모든 인스턴스가 종료될때까지 반복합니다.
// Auto Scaling Group의 인스턴스를 차례로 종료하는 스테이지
stage("Terminate and Refresh ASG Instances") {
    steps {
        script {
            // 인스턴스 ID 목록 배열로 변환
            def instanceList = env.ASG_INSTANCE_IDS.split("\\s+")
            //ASG 인스턴스 ID 목록을 출력(디버깅용)
            echo "Instance List: ${instanceList}"


            // ASG의 초기 인스턴스 수를 저장
            def initialInstanceCount = instanceList.size()
            echo "Initial Instance Count: ${initialInstanceCount}"

            // 각 인스턴스를 종료
            instanceList.each { instanceId ->
                //터미네이팅 시킬 인스턴스 이름 출력
                echo "Terminating instance: ${instanceId}"

                // 종료 명령 실행
                sh """
                            aws autoscaling terminate-instance-in-auto-scaling-group \
                            --region ${env.AWS_REGION} \
                            --instance-id ${instanceId} \
                            --no-should-decrement-desired-capacity
                        """


                // 새 인스턴스가 준비될 때까지 대기
                echo "Waiting for ASG to refresh all instances to InService state..."

                // 최대 대기 시간(초) 설정
                def maxRetries = 30 // 최대 반복 횟수 (30회 반복, 총 10분 대기)
                def retryInterval = 20 // 각 반복 간 대기 시간 (초)
                def retries = 0//반복횟수 카운트용 객체

                //ASG의 인스턴스 수를 확인
                //새로운 인스턴스가 생성되어 In Service 인스턴스 수가, Stage 시작전과 동일해질때까지 확인 반복
                //반복 횟수가 초과될경우 에러 출력
                while (true) {
                    echo " Retries = ${retries}"
                    def inServiceCount = sh(script: """
                                    aws autoscaling describe-auto-scaling-groups \
                                    --region ${env.AWS_REGION} \
                                    --auto-scaling-group-names ${env.ASG_NAME} \
                                    --query "AutoScalingGroups[0].Instances[?LifecycleState=='InService'] | length(@)" \
                                    --output text
                                    """,
                            returnStdout: true
                    ).trim().toInteger()

                    echo "Current InService instance count: ${inServiceCount}"
                    if (inServiceCount == initialInstanceCount) {
                        echo "All instances are now in service!"
                        break
                    }
                    // 반복 횟수 증가 및 제한 확인
                    //최대 반복횟수에 도달하면 에러 출력
                    retries++
                    if (retries >= maxRetries) {
                        error("Max retries reached. Not all instances reached InService state within the expected time.")
                    }

                    echo "Waiting for instances to reach InService state..."
                    sleep(retryInterval)//다음 반복까지 잠시대기
                }

            }
        }
        // 모든 인스턴스 종료 완료 로그
        echo "All specified instances have been terminated and refreshed successfully."

    }
}
  • ASG의 인스턴스 관리 또는, 활동->작업기록 에서 결과를 확인할 수 있습니다.

인스턴스 관리 탭
작업기록 탭


7. Slack에 알람보내기

  • Jenkins 파이프라인 단계의 결과를 Slack으로 알림 보냅니다.
  • Slack과 Jenkins 연동은 아래글을 참고해주세요!

2024.12.19 - [Jenkins] - [Jenkins] Jenkins 빌드 상태를 Slack으로 자동 알림 보내기

    post {
        always {
            echo 'Pipeline compoleted!'
            slackSend channel: '#netnovel', message: """
            📢 *Pipeline 완료*
            - *Job*: ${env.JOB_NAME}
            - *Build Number*: ${env.BUILD_NUMBER}
            - *결과*: ${currentBuild.currentResult}
            - *빌드 링크*: ${env.BUILD_URL}
            - *Duration*: ${currentBuild.durationString}
            - *Commit*: ${env.GIT_COMMIT}
            """
        }
        success {
            slackSend channel: '#netnovel', message: """
        ✅ *빌드 및 배포 성공!*
        - *Job*: ${env.JOB_NAME}
        - *Build Number*: ${env.BUILD_NUMBER}
        - *빌드 링크*: ${env.BUILD_URL}
        """

        }
        failure {
            slackSend channel: '#netnovel', message: """
        ❌ *빌드 실패!*
        - *Job*: ${env.JOB_NAME}
        - *Build Number*: ${env.BUILD_NUMBER}
        - *빌드 링크*: ${env.BUILD_URL}
        - *확인 후 문제 해결 필요!*
        """
        }
    }
  • Slack에 다음과 같이 메시지가 전달됩니다.

8.파이프라인 전체코드

pipeline {
    agent any

    stages {

        //환경변수들을 로드
        stage('Load Env Variables') {
            steps {
                script {
                    // env.properties 파일 읽기
                    def filePath = '/home/ubuntu/properties/env.properties'
                    def fileContent = readFile(filePath)

                    // 파일 내용을 key-value로 변환
                    def props = [:]
                    fileContent.split('\n').each { line ->
                        line = line.trim()
                        if (line && !line.startsWith("#")) { // 빈 줄과 주석 제외
                            def keyValue = line.split('=')
                            if (keyValue.size() == 2) {
                                props[keyValue[0].trim()] = keyValue[1].trim()
                            }
                        }
                    }
                    // 반복문을 돌며환경 변수 설정
                    props.each { key, value ->
                        env."${key}" = value
                    }
                    //출력테스트
                    echo "SPRING_PROPS_FILE: ${env.SPRING_PROPS_FILE}"
                }
            }
        }


        //github 소스코드 Clone, jenkins workspace에 저장
        stage('Clone Repository') {
            steps {
                echo 'Cloning GitHub repository...'
                git branch: 'master', url: "${env.REPO_URL}"
            }
        }
        //Spring JAR 파일 만들기위한  application.properties 복사
        stage('Copy application.properties') {
            steps {
                echo 'Copy application.properties to target directory'
                sh "cp ${env.SPRING_PROPS_FILE} ${env.TARGET_DIR}"
            }
        }
        //gradlew 쓰기 권한 추가
        stage('Ensure gradlew') {
            steps {
                echo 'Ensure gradlew has execute permission'
                sh 'chmod +x ./gradlew'
            }
        }
        //JAR 파일 build
        stage('Build Spring JAR') {
            steps {
                echo 'Build Spring JAR'
                sh './gradlew clean build -x test'
            }
        }

        //Docker Image Build
        stage("Build Docker Image") {
            steps {
                echo 'Build Docker Image'
                sh "docker build -t ${env.DOCKER_IMAGE_NAME}:${env.DOCKER_IMAGE_TAG} ."
            }
        }

        //Docker Compose 배포테스트
        //헬스체크 API 호출, 실패시 에러로처리
        stage("Docker Compose Deployment and Health Check") {
            steps {
                echo "Docker Compose Deployment and Health Check..."
                //Docker compose 실행
                sh """
                    cd ${env.DOCKER_COMPOSE_YML}
                    docker-compose up -d
                """

                sleep(300) // 컨테이너 실행 대기시간, 초단위

                //컨테이너의 헬스체크 API 호출, 성공시 200 반환
                //200이 아닐경우 error로 파이프라인 종료
                script {
                    def httpResponse = sh(
                            script: """
                            curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8081/api/health || true
                            """,
                            returnStdout: true
                    ).trim()

                    //응답받은 HTTP 코드 로그 출력
                    echo "HTTP Response: ${httpResponse}"

                    //HTTP 200코드가 아닐경우 로그 출력후 에러처리
                    if (httpResponse != "200") {
                        echo "Backend Docker-Container Health check failed. Please check."
                        error("Health check failed: Received HTTP $httpResponse instead of 200.")
                    }
                    //HTTP 200코드인경우 성공 로그 출력
                    echo "Backend Docker-Container Health check passed successfully!!"
                }

                //Docker 컨테이너 종료
                echo "Shutting down Docker container..."
                sh """
                 cd ${env.DOCKER_COMPOSE_YML}
                 docker-compose down
                 """
            }
        }

        //Dockr Hub에 push
        stage("Push Docker Image") {
            steps {
                echo "Push Docker Image to Docker Hub"
                withCredentials([usernamePassword(credentialsId:
                        'docker-hub-credentials',//젠킨스에 저장된 credentials 이름
                        usernameVariable: 'DOCKER_USER',
                        passwordVariable: 'DOCKER_PASS')]) {
                    //도커 로그인후 이미지 push
                    // 도커 로그인
                    sh "docker login -u ${DOCKER_USER} -p ${DOCKER_PASS}"
                    // 이미지 Push
                    sh "docker push ${env.DOCKER_IMAGE_NAME}:${env.DOCKER_IMAGE_TAG}"
                }
            }
        }

        // Auto Scaling Group의 인스턴스 정보를 가져오는 스테이지
        stage("Fetch Auto Scaling Group Instances") {
            steps {
                script {
                    //Auto Scaling Group에서 실행중인 인스턴스 ID를 받아옴
                    def asgInstances = sh(script: """
                    aws autoscaling describe-auto-scaling-groups \
                    --region ${env.AWS_REGION} \
                    --auto-scaling-group-names ${env.ASG_NAME} \
                    --query "AutoScalingGroups[0].Instances[?LifecycleState=='InService'].InstanceId" \\
                    --output text
                    """, returnStdout: true).trim()

                    // 줄바꿈을 기준으로 인스턴스 ID를 나눈 후 배열에 저장
                    env.ASG_INSTANCE_IDS = asgInstances
                    // Instance ID 로그에 출력
                    echo "ASG Instances: ${env.ASG_INSTANCE_IDS}"
                }
            }

        }

        // Auto Scaling Group의 인스턴스를 차례로 종료하는 스테이지
        stage("Terminate and Refresh ASG Instances") {
            steps {
                script {
                    // 인스턴스 ID 목록 배열로 변환
                    def instanceList = env.ASG_INSTANCE_IDS.split("\\s+")
                    //ASG 인스턴스 ID 목록을 출력(디버깅용)
                    echo "Instance List: ${instanceList}"


                    // ASG의 초기 인스턴스 수를 저장
                    def initialInstanceCount = instanceList.size()
                    echo "Initial Instance Count: ${initialInstanceCount}"

                    // 각 인스턴스를 종료
                    instanceList.each { instanceId ->
                        //터미네이팅 시킬 인스턴스 이름 출력
                        echo "Terminating instance: ${instanceId}"

                        // 종료 명령 실행
                        sh """
                            aws autoscaling terminate-instance-in-auto-scaling-group \
                            --region ${env.AWS_REGION} \
                            --instance-id ${instanceId} \
                            --no-should-decrement-desired-capacity
                        """


                        // 새 인스턴스가 준비될 때까지 대기
                        echo "Waiting for ASG to refresh all instances to InService state..."

                        // 최대 대기 시간(초) 설정
                        def maxRetries = 30 // 최대 반복 횟수 (30회 반복, 총 10분 대기)
                        def retryInterval = 20 // 각 반복 간 대기 시간 (초)
                        def retries = 0//반복횟수 카운트용 객체

                        //ASG의 인스턴스 수를 확인
                        //새로운 인스턴스가 생성되어 In Service 인스턴스 수가, Stage 시작전과 동일해질때까지 확인 반복
                        //반복 횟수가 초과될경우 에러 출력
                        while (true) {
                            echo " Retries = ${retries}"
                            def inServiceCount = sh(script: """
                                    aws autoscaling describe-auto-scaling-groups \
                                    --region ${env.AWS_REGION} \
                                    --auto-scaling-group-names ${env.ASG_NAME} \
                                    --query "AutoScalingGroups[0].Instances[?LifecycleState=='InService'] | length(@)" \
                                    --output text
                                    """,
                                    returnStdout: true
                            ).trim().toInteger()

                            echo "Current InService instance count: ${inServiceCount}"
                            if (inServiceCount == initialInstanceCount) {
                                echo "All instances are now in service!"
                                break
                            }
                            // 반복 횟수 증가 및 제한 확인
                            //최대 반복횟수에 도달하면 에러 출력
                            retries++
                            if (retries >= maxRetries) {
                                error("Max retries reached. Not all instances reached InService state within the expected time.")
                            }

                            echo "Waiting for instances to reach InService state..."
                            sleep(retryInterval)//다음 반복까지 잠시대기
                        }

                    }
                }
                // 모든 인스턴스 종료 완료 로그
                echo "All specified instances have been terminated and refreshed successfully."

            }
        }
    }

    post {
        always {
            echo 'Pipeline compoleted!'
            slackSend channel: '#netnovel', message: """
            📢 *Pipeline 완료*
            - *Job*: ${env.JOB_NAME}
            - *Build Number*: ${env.BUILD_NUMBER}
            - *결과*: ${currentBuild.currentResult}
            - *빌드 링크*: ${env.BUILD_URL}
            - *Duration*: ${currentBuild.durationString}
            """
        }
        success {
            slackSend channel: '#netnovel', message: """
        ✅ *빌드 및 배포 성공!*
        - *Job*: ${env.JOB_NAME}
        - *Build Number*: ${env.BUILD_NUMBER}
        - *빌드 링크*: ${env.BUILD_URL}
        """

        }
        failure {
            slackSend channel: '#netnovel', message: """
        ❌ *빌드 실패!*
        - *Job*: ${env.JOB_NAME}
        - *Build Number*: ${env.BUILD_NUMBER}
        - *빌드 링크*: ${env.BUILD_URL}
        - *확인 후 문제 해결 필요!*
        """
        }
    }


}

9. 결론

  • 이번 포스트에서는 Jenkins를 활용한 CI/CD 파이프라인을 구축하며 GitHub 클론, Gradle 빌드, Docker 이미지 생성 및 배포 테스트, ASG를 통한 무중단 배포, Slack 알림 설정까지 진행했습니다.
  • 이로써 배포 프로세스를 자동화하고, 안정성효율성을 높이는 데 성공했습니다.