Jenkins Multibranch Pipeline 개선기

공병주(Chris)·2022년 9월 20일
4
post-thumbnail

우아한 테크코스 팀 프로젝트(속닥속닥)에서 Jenkins의 Multibranch Pipeline을 통해 CI/CD를 구축해두었습니다.

https://velog.io/@byeongju/Jenkins의-Mulitbranch-Pipeline을-통한-CICD

현재 CI는 Github Action으로 분리가 된 상태입니다.

당시 CI/CD를 구축해두었을 때, 몇가지 문제가 있었습니다.

1. CI/CD 스크립트(Jenkinsfile)가 Git으로 관리가 된다.

2. dev와 main의 CI/CD를 한 파일로 관리하기가 어렵다.

3. FE와 BE에 대한 분기 처리가 어렵다.

순서대로 위의 문제들을 어떻게 해결한지 알아보겠습니다.

1. Git을 통한 스크립트 관리 해결

위와 같이 Git을 통해서 Jenkinsfile이 관리 되었습니다. 따라서, Infra에 대한 commit log들이 쌓여갔습니다.

물론, 서버의 ip들을 Jenkins의 환경 변수로 지정해두었기 때문에 Github에 스크립트가 노출되어도 큰 문제는 없다고 생각했습니다.

하지만, 변경이 잦지 않기 때문에 Git을 통한 형상 관리에 이점을 얻을 수 없다고 판단했습니다. 따라서, 굳이 Git을 통해서 관리를 할 필요가 없고 서비스 코드에 대한 기록만을 Git으로 관리하는 것이 좋다는 생각이 들어서 스크립트를 Jenkins에 내장하기로 결정했습니다.

**Config File Management를 통한 Script 관리**

DashBoard > **Jenkins 관리 > Managed files > Add a new Config**

위의 경로에서 GroovyFile을 추가하고 ID를 설정해줍니다. ( ID는 임의로 test로 지정했습니다 )

그후 Name과 Comment를 작성하고 Content에 스크립트를 작성해줍니다. 후에 Submit을 하면 Groovy File이 생성됩니다.

Jenkins Item에 적용하기

Pipeline: Multibranch with defaults 플러그인을 설치해줍니다.

기존에 Build Configuration은 Mode가 By Jenkinsfile이고 Script Path가 Git Repo의 경로로 지정되어 있습니다.

위와 같이 Mode를 by default Jenkinsfile로 설정하고, Script ID를 위에서 만들었던 groovy file의 ID로 지정해주면 빌드할 때, 위 스크립트를 실행할 수 있습니다.

2. dev와 main branch 분기 처리

Jenkins의 Multibranch Pipeline을 통해 dev와 main 브랜치에 대해 CI/CD 환경을 구축해두었습니다.

맨 위에 첨부한 포스팅을 보면, Git으로 Jenkinsfile이 관리가 되고 결국 dev branch는 main branch로 merge 되기 때문에, 결국 dev branch와 main branch의 스크립트는 동일해집니다. dev와 main은 배포를 해야하는 서버의 ip가 다르기 때문에, dev가 main으로 머지 될 때마다, 해당 하나의 스크립트를 바꿔줘야 하는 상황이었습니다. 따라서, 아래와 같이 Multibranch Pipeline Item을 2개 생성하고 script path를 따로 지정해주었습니다. 사실상, Multibranch Pipeline의 이점을 누리지 못한 것입니다.

dev 스크립트

pipeline{
  agent any

  tools {
      gradle 'gradle'
  }

  stages{
    stage('Ready'){
      steps{
        sh "echo 'Ready'"
        git branch: 'dev',
          credentialsId: 'sokdak_hook',
          url: 'https://github.com/woowacourse-teams/2022-sokdak'
      }
    }

    stage('FE-Install'){
      steps{
        dir('frontend'){
          sh 'npm i'
        }
      }
    }

    stage('FE-Build'){
      steps{
        dir('frontend'){
          sh 'npm run build-dev'
        }
      }
    }

    stage('BE-Build'){
      steps{
        dir('backend/sokdak') {
          sh './gradlew bootJar'
        }
      }
    }

    stage('FE-Deploy'){
      steps{
        script{
          withCredentials([sshUserPrivateKey(credentialsId: "front-key", keyFileVariable: 'front_key_file')]) {
            dir('frontend'){
                sh "scp -o StrictHostKeyChecking=no -i ${front_key_file} -r dist ubuntu@${env.DEV_FRONT_IP}:/home/ubuntu"
            }
            sh "ssh -o StrictHostKeyChecking=no -i ${front_key_file} ubuntu@${env.DEV_FRONT_IP} 'sudo service nginx restart'"
          }
        }
      }
    }

    stage('BE-Deploy'){
      steps{
        script{
          withCredentials([sshUserPrivateKey(credentialsId: "back-key", keyFileVariable: 'my_private_key_file')]) {
            dir('backend/sokdak/build/libs'){
                sh "scp -o StrictHostKeyChecking=no -i ${my_private_key_file} *.jar ubuntu@${env.DEV_BACK_IP}:/home/ubuntu/sokdak"
            }
            sh "ssh -o StrictHostKeyChecking=no -i ${my_private_key_file} ubuntu@${env.DEV_BACK_IP} 'cd sokdak && ./deploy.sh'"
          }
        }
      }
    }
  }

  post {
    success {
        slackSend (channel: 'jenkins', color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
    }
    failure {
        slackSend (channel: 'jenkins', color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
    }
  }
}

main 스크립트

pipeline{
  agent any

  tools {
      gradle 'gradle'
  }

  stages{
    stage('Ready'){
      steps{
        sh "echo 'Ready'"
        git branch: 'main',
          credentialsId: 'sokdak_hook',
          url: 'https://github.com/woowacourse-teams/2022-sokdak'
      }
    }

    stage('FE-Install'){
      steps{
        dir('frontend'){
          sh 'npm i'
        }
        sh "echo '########### FE MODULE INSTALL SUCCESS ###########'"
      }
    }

    stage('FE-Build'){
      steps{
        dir('frontend'){
          sh 'npm run build-prod'
        }
      }
    }

    stage('BE-Build'){
      steps{
        dir('backend/sokdak') {
          sh './gradlew bootJar'
        }
      }
    }

    stage('FE-Deploy'){
      steps{
        script{
          withCredentials([sshUserPrivateKey(credentialsId: "front-key", keyFileVariable: 'front_key_file')]) {
            dir('frontend'){
                sh "scp -o StrictHostKeyChecking=no -i ${front_key_file} -r dist ubuntu@${env.PROD_FRONT_IP}:/home/ubuntu"
            }
            sh "ssh -o StrictHostKeyChecking=no -i ${front_key_file} ubuntu@${env.PROD_FRONT_IP} 'sudo service nginx restart'"
          }
        }
      }
    }

    stage('BE-Deploy'){
      steps{
        script{
          withCredentials([sshUserPrivateKey(credentialsId: "back-key", keyFileVariable: 'my_private_key_file')]) {
            dir('backend/sokdak/build/libs'){
                sh "scp -o StrictHostKeyChecking=no -i ${my_private_key_file} *.jar ubuntu@${env.PROD_BACK_IP}:/home/ubuntu/sokdak"
            }
            sh "ssh -o StrictHostKeyChecking=no -i ${my_private_key_file} ubuntu@${env.PROD_BACK_IP} 'cd sokdak && ./deploy.sh'"
          }
        }
      }
    }
  }

  post {
    success {
        slackSend (channel: 'jenkins', color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
    }
    failure {
        slackSend (channel: 'jenkins', color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
    }
  }
}

위에서 보시는 것처럼 두 스크립트의 차이점은 아래와 같습니다.

  • Ready Stage의 ‘main’, ‘dev’
  • FE-Build의 npm build-prod와 npm build-dev
  • BD-Deploy, FE-Deploy Stage의 ec2 주소

위와 같이 branch 분기를 처리하지 못해서 두 개의 item과 두 개의 script로 처리 하는 환경이었습니다.

env.GIT_BRANCH를 통한 branch 분기 처리

http://{젠킨스ip}:8080/env-vars.html/ 에 접속하면 아래와 같이 작업에 대한 정보를 추출할 수 있습니다.

여기서 env.GIT_BRANCH 변수로 branch에 따른 분기를 처리 할 수 있었습니다.

pipeline {
    agent any

    tools {
        gradle 'gradle'
    }

    stages {
        stage('CHECK-OUT') {
            steps {
                git branch: env.GIT_BRANCH,
                        credentialsId: 'sokdak_hook',
                        url: 'https://github.com/woowacourse-teams/2022-sokdak'
            }
        }

        stage('FE-Install') {
            steps {
                dir('frontend') {
                    sh 'npm i'
                }
            }
        }

        stage('FE-PROD-Build') {
            when {
                expression { env.GIT_BRANCH == 'main' }
            }
            steps {
                sh "echo '########### FE-PROD BUILD START ###########'"
                dir('frontend') {
                    sh 'npm run build-prod'
                }
                sh "echo '########### FE-PROD BUILD SUCCESS ###########'"
            }
        }

        stage('FE-PROD-Deploy') {
            when {
                expression { env.GIT_BRANCH == 'main' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "front-key", keyFileVariable: 'front_key_file')]) {
                        dir('frontend') {
                            sh "scp -o StrictHostKeyChecking=no -i ${front_key_file} -r dist ubuntu@${env.PROD_FRONT_IP}:/home/ubuntu"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${front_key_file} ubuntu@${env.PROD_FRONT_IP} 'sudo service nginx restart'"
                    }
                }
            }
        }

        stage('BE-BUILD') {
            steps {
                dir('backend/sokdak') {
                    sh './gradlew bootJar'
                }
            }
        }

        stage('BE-DEV-DEPLOY') {
            when {
                expression { env.GIT_BRANCH == 'dev' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "back-key", keyFileVariable: 'my_private_key_file')]) {
                        dir('backend/sokdak/build/libs') {
                            sh "scp -o StrictHostKeyChecking=no -i ${my_private_key_file} *.jar ubuntu@${env.DEV_BACK_IP}:/home/ubuntu/sokdak"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${my_private_key_file} ubuntu@${env.DEV_BACK_IP} 'cd sokdak && ./deploy.sh'"
                    }
                }
            }
        }

        stage('BE-PROD-DEPLOY') {
            when {
                expression { env.GIT_BRANCH == 'main' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "back-key", keyFileVariable: 'my_private_key_file')]) {
                        dir('backend/sokdak/build/libs') {
                            sh "scp -o StrictHostKeyChecking=no -i ${my_private_key_file} *.jar ubuntu@${env.PROD_BACK_IP}:/home/ubuntu/sokdak"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${my_private_key_file} ubuntu@${env.PROD_BACK_IP} 'cd sokdak && ./deploy.sh'"
                    }
                }
            }
        }

        stage('FE-DEV-Build') {
            when {
                expression { env.GIT_BRANCH == 'dev' }
            }
            steps {
                dir('frontend') {
                    sh 'npm run build-dev'
                }
            }
        }

        stage('FE-DEV-Deploy') {
            when {
                expression { env.GIT_BRANCH == 'dev' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "front-key", keyFileVariable: 'front_key_file')]) {
                        dir('frontend') {
                            sh "scp -o StrictHostKeyChecking=no -i ${front_key_file} -r dist ubuntu@${env.DEV_FRONT_IP}:/home/ubuntu"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${front_key_file} ubuntu@${env.DEV_FRONT_IP} 'sudo service nginx restart'"
                    }
                }
            }
        }
    }

    post {
        success {
            slackSend(channel: 'jenkins', color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
        }
        failure {
            slackSend(channel: 'jenkins', color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
        }
    }
}

변경 사항

1. CHECK-OUT STAGE의 branch
branch에 env.GIT_BRANCH를 할당하여, checkout을 하도록 설정해두었습니다.

2. branch에 따른 FE build 명령어 분기

3. DEPLOY STAGE의 branch에 따른 분기
when~expression을 통해 branch에 따라, CD를 진행할 서버의 ip를 다르게 할당해주었습니다.

하나의 Script와 하나의 item으로 main과 dev 브랜치에 대한 처리를 해줄 수 있게 되었습니다.

3. FE와 BE merge에 따른 분기

하지만, 문제는 여전히 남아있습니다. BE 변경에 따른 merge와 FE 변경에 따른 merge 두 경우 모두, BE와 FE의 스크립트가 동작한다는 것입니다. 우아한테크코스 팀 프로젝트의 특성상 FE와 BE가 하나의 Repo를 사용하고, 따라서 WebHook을 따로 설정해줄 수 없기 때문입니다.

물론 CI가 Github Action으로 분리되어서 Test에 소요되는 시간이 줄어들었지만, FE가 merge 되었을 때 BE 스크립트가 함께 동작하는 것과 그 반대 경우가 비효율적이라고 생각했습니다.

build strategies를 통한 FE와 BE 분기

먼저 위와 같이 진영에 따라 item을 하나씩 생성해줍니다.

1. Multibranch build strategy extensionVersion 플러그인 설치

먼저 위 이름의 Plugin을 설치해줍니다.

2. Item에 Build Strategy 설정

Item의 Configuration 페이지를 내리다보면 위의 플러그인 설치에 따른 Build strategies 설정 항목이 추가되어 있습니다.

sokdak-frontend

sokdak-backend

repository가 제일 상단에서 backend와 frontend로 나뉘기 때문에, 각 item의 Build strategies 설정을 위와 같이 해주었습니다.

위의 설정을 하면, frontend 패키지 하위에서 변경이 일어났을 때만 sokdak-frontend item이 동작하고
backend 패키지 하위에서 변경이 일어났을 때만 sokdak-backend item이 동작합니다.

따라서, 아래와 같이 frontend와 backend의 script도 분리해줍니다.

sokdak-backend의 script

pipeline {
    agent any

    tools {
        gradle 'gradle'
    }

    stages {
        stage('CHECK-OUT') {
            steps {
                git branch: env.GIT_BRANCH,
                        credentialsId: 'sokdak_hook',
                        url: 'https://github.com/woowacourse-teams/2022-sokdak'
            }
        }

        stage('BE-BUILD') {
            steps {
                dir('backend/sokdak') {
                    sh './gradlew bootJar'
                }
            }
        }

        stage('DEV-DEPLOY') {
            when {
                expression { env.GIT_BRANCH == 'dev' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "back-key", keyFileVariable: 'my_private_key_file')]) {
                        dir('backend/sokdak/build/libs') {
                            sh "scp -o StrictHostKeyChecking=no -i ${my_private_key_file} *.jar ubuntu@${env.DEV_BACK_IP}:/home/ubuntu/sokdak"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${my_private_key_file} ubuntu@${env.DEV_BACK_IP} 'cd sokdak && ./deploy.sh'"
                    }
                }
            }
        }

        stage('PROD-DEPLOY') {
            when {
                expression { env.GIT_BRANCH == 'main' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "back-key", keyFileVariable: 'my_private_key_file')]) {
                        dir('backend/sokdak/build/libs') {
                            sh "scp -o StrictHostKeyChecking=no -i ${my_private_key_file} *.jar ubuntu@${env.PROD_BACK_IP}:/home/ubuntu/sokdak"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${my_private_key_file} ubuntu@${env.PROD_BACK_IP} 'cd sokdak && ./deploy.sh'"
                    }
                }
            }
        }
    }

    post {
        success {
            slackSend(channel: 'jenkins', color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
        }
        failure {
            slackSend(channel: 'jenkins', color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
        }
    }
}

sokdak-frontend

pipeline {
    agent any

    tools {
        gradle 'gradle'
    }

    stages {
        stage('CHECK-OUT') {
            steps {
                git branch: env.GIT_BRANCH,
                        credentialsId: 'sokdak_hook',
                        url: 'https://github.com/woowacourse-teams/2022-sokdak'
            }
        }

        stage('FE-Install') {
            steps {
                dir('frontend') {
                    sh 'npm i'
                }
            }
        }

        stage('FE-PROD-Build') {
            when {
                expression { env.GIT_BRANCH == 'main' }
            }
            steps {
                dir('frontend') {
                    sh 'npm run build-prod'
                }
            }
        }

        stage('FE-PROD-Deploy') {
            when {
                expression { env.GIT_BRANCH == 'main' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "front-key", keyFileVariable: 'front_key_file')]) {
                        dir('frontend') {
                            sh "scp -o StrictHostKeyChecking=no -i ${front_key_file} -r dist ubuntu@${env.PROD_FRONT_IP}:/home/ubuntu"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${front_key_file} ubuntu@${env.PROD_FRONT_IP} 'sudo service nginx restart'"
                    }
                }
            }
        }

        stage('FE-DEV-Build') {
            when {
                expression { env.GIT_BRANCH == 'dev' }
            }
            steps {
                dir('frontend') {
                    sh 'npm run build-dev'
                }
            }
        }

        stage('FE-DEV-Deploy') {
            when {
                expression { env.GIT_BRANCH == 'dev' }
            }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: "front-key", keyFileVariable: 'front_key_file')]) {
                        dir('frontend') {
                            sh "scp -o StrictHostKeyChecking=no -i ${front_key_file} -r dist ubuntu@${env.DEV_FRONT_IP}:/home/ubuntu"
                        }
                        sh "ssh -o StrictHostKeyChecking=no -i ${front_key_file} ubuntu@${env.DEV_FRONT_IP} 'sudo service nginx restart'"
                    }
                }
            }
        }
    }

    post {
        success {
            slackSend(channel: 'jenkins', color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
        }
        failure {
            slackSend(channel: 'jenkins', color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
        }
    }
}

1. CI/CD 스크립트(Jenkinsfile)가 Git으로 관리가 된다.

2. dev와 main의 CI/CD를 한 파일로 관리하기가 어렵다.

3. FE와 BE에 대한 분기 처리가 어렵다.

이렇게 위의 3가지 문제를 모두 해결할 수 있었습니다!!

참고자료

https://github.com/jenkinsci/pipeline-multibranch-defaults-plugin/blob/master/README.md

도움주신분

우아한테크코스 4기 리차드 ❤️

profile
self-motivation

1개의 댓글

comment-user-thumbnail
2022년 9월 21일

❤️

답글 달기