인프라/Jenkins

ssh 사용하지 않고 idc 서버 환경에서 백엔드 jenkins cicd 구축

소프 2024. 3. 12.

 

현재 다니고 있는 회사의 전체 개발팀에 CI/CD가 구축되어 있지 않습니다.

진행중인 SI 프로젝트에서는 고객사의 인프라 팀이 Jenkins와 AWS를 활용해 배포 시스템을 구축해주어 배포를 편리하게 진행할 수 있었습니다. 그러나 내부 프로젝트는 FTP를 통해 JAR 파일을 수동으로 업로드하고, 쉘 스크립트로 애플리케이션의 시작, 정지, 재시작 과정으로 배포를 하고 있습니다.

개발 과정에서 빈번한 수정으로 인해 주기적인 배포가 필요했습니다. 이를 위해 아직 커밋하지 않은 코드는 stash에 저장한 뒤, 수동으로 JAR 파일을 빌드하고 배포하는 작업을 최소화하기 위해 CI/CD 시스템 구축을 추진하였습니다.

내부 프로젝트는 IDC 서버를 사용중이었습니다. 제한사항은 다른 고객사에서 임대한 서버를 사용하고 있어, 80번 포트 외에는 인바운드 포트가 오픈되어 있지 않았습니다. 고객사에서 22번 포트를 열어주는 것에 부정적이여서 ssh를 사용할 수 없었습니다. 포트 오픈을 요청할 순 있었지만 새로운 서버마다 포트오픈 요청하는 번거로움을 피하기 위해, 다른 해결 방안을 찾아 나섰습니다.

CI/CD 흐름은 아래와 같습니다.

 

1. 개발자가 코드 변경사항을 GitLab에 푸시합니다.
2. GitLab의 Webhook 설정에 따라, 변경사항이 발생하면 Jenkins가 알림을 받고 GitLab에서 최신 파일을 가져옵니다.
3. Jenkins는 가져온 코드를 빌드하여 생성된 JAR 파일을 zip파일로 묶어 Amazon S3에 업로드합니다.

  - zip파일로 한 이유는 필요한 외부 설정 파일 및 프론트 배포 등 jar만이 아닌 포괄적으로 활용하기 위해서 입니다.
4. ZIP 파일이 S3에 성공적으로 업로드 되면, Jenkins는 배포 모듈에 업로드 완료를 알리는 API를 호출합니다.

5. 배포 모듈은 업로드 완료 API 호출을 받은 후, Amazon S3에서 업로드된 ZIP 파일을 다운로드 및 지정된 경로에 압축 해제합니다.
6. 리눅스 명령어 실행 API를 호출합니다.

7. 리눅스 명령어를 실행하여 API 서버를 재시작합니다.

 

현재, 성공 프로세스 구축을 완료한 상태입니다. 앞으로, 회사 내 모든 개발 팀이 사용할 수 있도록 지속적으로 운영하며, 예외 처리, 알림 발송 등의 기능을 추가하여 보완해 나갈 계획입니다.

더 좋은 방법이 있다면 의견 환영 합니다!!

 

※ 아래는 구축하면서 간단하게 기록한 내용입니다.

 

jenkins 2.448 버전

 

1. 플러그인에서 git, gitlab api 설치

1-1. Dashboard -> Jenkins 관리 -> plugin 관리
1-2. Git, GitLab pluging 설치

 

2. jenkins credentials 설정

2.1. Dashboard -> Jenkins 관리 -> Credentials 
2.2. Stores scoped to Jenkins 에 Domains (global) 클릭
2.3. Add Credentials 클릭
2.4. Gitlab 유저 정보 입력

 

 * Username : gitalb 의 사용자 id (필수)
 * Password : gitlab의 사용자 password (필수)
 * ID : Credentials를 구분하는 ID (필수)
 * Description : 이 Credentials의 대한 부연설명 (선택)

 

3. Item 생성 및 Item 구성 설정

3.1. Dashboard -> 새로운 Item
3.2. 소스코드관리 -> Git 선택
3.3. Repository URL -> gitlab url
3.4. Credentials 설정 [2번에 작성한 Credentials]
3.5. Jenkins build가 돌아갈 gitlab branch 지정
3.6. 빌드 유발 지정 (Build when a change is pushged to Gitlab. Gitlab.webhook.URL;[jenkins url]) 선택
3.7. 활성화된 GitLab 트리거 지정
  - Accepted Merge Request Events 와 Closed Request Events 를 체크
3.8. '고급' 버튼 클릭 후 Secret token 목록에서 Generate 로 토큰 생성

 

4.1. 프로젝트 진입
4.2. 좌측 상단 Settings -> Integrations
4.3. 아래 정보 기입
 * URL : Jenkins의 URL로 Jenkins 설정 중 빌드 유발 부분에 나오는 Jenkins url 기입
   - 빌드 유발에 존재
 * Secret token : 빌드 유발 부분에서 Generate로 생성한 Secret token 기입
 * Trigger : 이벤트를 발생시키는 조건

 

4. gradle 세팅

jenkins 관리 > tools > gradle 세팅

 

5. item에 gradle 세팅

build steps -> invoke grade -> 4번에서 세팅한 gradle 선택

tasks 작성
clean
build

 

6. publish over ssh 

플러그인에서 설치

Dashboard -> System 

최하단의 Publish over SSH에서 ssh servers 추가 

ssh server 정보 입력

 

name: 이름

hostname: ip 주소

username: 서버 계정

고급 클릭

User password ~ 선택 후 비밀번호 입력

ssh 포트번호 지정

※ 비밀번호 없이 전송하려면 ssh key 생성 후 원격 서버에 등록 필요

 

7. item에서 publish over ssh 세팅

빌드 후 조치 -> send build artifacts over ssh 선택 -> ssh server의 name select box에서 선택

transfers 입력
source files: build/libs/*.jar
remove prefix: build/libs
remote directory: .
exec command: {ssh 성공 후 실행할 명령어}
build/libs 디렉토리 하위의 .jar확장자를 가진 파일을 SSH를 통해 서버에 복사

 

8. 플러그인에서 s3 publisher 설치

Dashboard -> System -> Amazon s3 profiles

추가
profile name: 식별 내용
access key: access key
secret key: secret key

HTTP ERROR 403 No valid crumb was included in the request 에러 발생시

방법1
"Security" -> CSRF Protected -> "Enable proxy compatibility" 를 체크

★ NCP Object Storage 키를 사용하면 연결 불가능
- 아마 실제 s3에 존재하지 않는 키라서 그런듯
- NCP Object storage 키로 AWS S3에서 제공하는 CLI를 사용할 수 있는거지 실제 S3 버킷정보가 아니기 때문인것 같음
Can't connect to S3 service: The AWS Access Key Id you provided does not exist in our records.
[S3 서비스에 연결할 수 없습니다. 제공한 AWS 액세스 키 ID가 당사 기록에 존재하지 않습니다.]
(Service: Amazon S3; Status Code: 403; Error Code: InvalidAccessKeyId; Request ID: P3DM3Q1HKK9D4NZS; S3 Extended Request ID: PyuYZO8zYtzFsKf8Npz3aLrIpCKv4Ona7ldIGEfokHhSCUfWqJvvrwFkVaJr1ZBk78J0+8N93jQ=; Proxy: null)
[(서비스: Amazon S3, 상태 코드: 403, 오류 코드: InvalidAccessKeyId, 요청 ID: P3DM3Q1HKK9D4NZS, S3 확장 요청 ID: PyuYZO8zYtzFsKf8Npz3aLrIpCKv4Ona7ldIGEfokHhSCUfWqJvvrwFkVaJr1ZBk78J0+8N93jQ=; 프록시: null)]

 

9. 빌드후 조치 -> publish artifacts to s3 bucket 

source: build/libs/*.jar

exclude: 
destination bucket: {bucket 명}/{경로}
storage class:STANDARD
bucket region: ap-northeast-2

 

99. 젠킨스 서버에 aws cli 설치

우분투

# Next, update the packages list:
sudo apt-get update

# Download AWS CLI zip file from AWS.
sudo curl "https://awscli.amazonaws.com/awscli-exe-linux-$(arch).zip" \ -o "awscliv2.zip"

# Unzip the package
sudo unzip awscliv2.zip

# Install the AWS CLI by running the script
sudo ./aws/install

# Check if the AWS CLI has been installed properly.
sudo aws --version

 

10. item -> pipeline 선택

pipeline -> syntax 클릭

 

11. git 세팅

steps에 git: Git클릭
정보 입력 후 generate 클릭하면 스니펫이 생성됨

 

12. gitlab clone

pipeline {
    agent any

    stages {
        stage('gitlab clone') {
            steps {
                git branch: 'develop', credentialsId: 'gitlab id', url: 'gitlab 저장소 url'
            }
        }
    }
}

13. 빌드 추가

        stage('build') {
            steps {
                sh ''' 
                    chmod 755 gradlew
                    ./gradlew clean build
                '''
            }
        }

 

빌드 실패 발생했는데 jdk 버전 문제면
젠킨스에서 세팅 필요
Jenkins 관리 > Tool > JDK 항목 > Add JDK
install automatically 클릭 후 extract *.zip/*.tar.gz 클릭
아래 내용 채우기
Name: pipeline에서 사용할 이름
Label: 생략
Download URL for binary archive : JDK 다운로드 URL
                                    ex) https://adoptium.net/?variant=openjdk11 페이지에서 다운로드 버튼 링크 URL   
Subdirectory of extracted archive : jdk tar.gz 파일을 압축 해제 했을때 폴더 이름
                                    아래 예제의 경우 jdk-11.0.14+9 이름의 폴더가 생성되며 압축이 헤제된다.
                                    ex ) tar -xvf OpenJDK11U-jdk_x64_linux_hotspot_11.0.14_9.tar.gz

 

다운로드 url 찾기가 힘들었음(네트워크 탭에 있음)

download url for binary archive:

 

https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.10%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.10_7.tar.gz

linux, x64, 로 받아야 함

네트워크 탭에서 URL 확인 가능

 

suybdirectory of extracted archive

jdk-17.0.10+7

 

pipeline {
    agent any
    tools {
        jdk 'jdk-17.0.10+7'
    }

 

 

14. 최종 파이프 라인

import groovy.json.JsonOutput

pipeline {
    agent any
    tools {
        jdk 'jdk-17.0.10+7' // Tool > JDK 항목 > Add JDK에서 설정한 Name
    }
    
    environment {
        GIT_URL = "GIT 저장소"
        GIT_BRANCH = "develop"
        S3_BUCKET = "S3 버킷명"
        S3_PATH = "S3 알집 파일 경로"
        
        S3_DOWNLOAD_KEY = "S3 알집 파일 경로 + "/" + 알집 파일명"
        ZIP_FILE_NAME = "알집파일명(확장자 제외)"
        
        APPLICATION_NAME = "application name"
        API_SERVER_IP = "IP"
        
        DOWNLOAD_API_URL = "다운로드 API URL"
        EXECUTE_API_URL = "명령어 실행 API URL"
        SERVER_PATH = "알집을 해제할 경로"
        SERVER_JAR_NAME = "jar이름(확장자 포함)"
    }

    stages {
        stage('gitlab clone') {
            steps {
                cleanWs()
                git branch: "${env.GIT_BRANCH}", credentialsId: 'credentialsId', url: "${env.GIT_URL}"
            }
            post {
                failure {
                    echo 'gitlab clone fail.'  
                }
            }
        }

        
        stage('build') {
            steps {
                    sh ''' 
                        chmod 755 gradlew
                        ./gradlew clean build
                    '''
                
            }
            post {
                failure {
                    echo 'build fail.'  
                }
            }
        }
        
        
        stage('zip') {
            steps {
                script {
                    sh '''
                        cd build/libs
                        # build/libs 디렉토리에 .jar 파일이 하나라도 있는지 확인
                        if ls *.jar 1> /dev/null 2>&1; then
                            # 모든 .jar 파일을 ZIP_FILE_NAME 환경 변수로 지정된 이름의 zip 파일로 압축
                            zip -r ${ZIP_FILE_NAME}.zip *.jar
                            echo "Jar files have been zipped successfully."
                        else
                            echo "No jar files found in build/libs."
                            exit 1
                        fi
                    '''
                }
            }
            post {
                failure {
                    echo 'zip fail.'  
                }
            }
        }
       
        stage('upload s3') {
            steps {
                script {
                    // 환경 변수 값을 Groovy 변수에 할당
                    def zipFilePath = "build/libs/${env.ZIP_FILE_NAME}.zip"
        
                    // s3Upload 스텝에 Groovy 변수를 사용
                    s3Upload consoleLogLevel: 'INFO', 
                        dontSetBuildResultOnFailure: false, 
                        dontWaitForConcurrentBuildCompletion: false, 
                        entries: [[
                            bucket: "${env.S3_BUCKET}/${env.S3_PATH}", 
                            excludedFile: '', 
                            flatten: false, 
                            gzipFiles: false, 
                            keepForever: false, 
                            managedArtifacts: false, 
                            noUploadOnFailure: true, 
                            selectedRegion: 'ap-northeast-2', 
                            showDirectlyInBrowser: false, 
                            sourceFile: zipFilePath, // Groovy 변수 사용
                            storageClass: 'STANDARD', 
                            uploadFromSlave: false, 
                            useServerSideEncryption: true
                        ]], 
                        pluginFailureResultConstraint: 'FAILURE', 
                        profileName: 's3-ostream', 
                        userMetadata: []
                }
            }
            post {
                failure {
                    echo 'upload s3 fail.'  
                }
            }
        }
        
        stage('file down') {
            steps {
                script {
                    def requestBody = [
                        s3ZipPath: "${env.S3_DOWNLOAD_KEY}",
                        serverPath: "${env.SERVER_PATH}",
                        zipFileName: "${env.ZIP_FILE_NAME}"
                    ]

                    def response = httpRequest (
                        acceptType: 'APPLICATION_JSON',
                        contentType: 'APPLICATION_JSON',
                        httpMode: "POST",
                        url: "${env.DOWNLOAD_API_URL}",
                        requestBody: JsonOutput.toJson(requestBody),
                        consoleLogResponseBody: true,
                        timeout: 5000
                    )

                    if (response.status != 200) {
                        error "Notification failed with status: ${response.status}. Halting pipeline."
                    }
                }
            }
        }
        
        stage('execute') {
            steps {
                script {
                    def requestBody = [
                        command: "cd ${env.SERVER_PATH} && chmod 755 ${env.SERVER_JAR_NAME} && sh action.sh stop && sh action.sh start"
                        branch: "${env.GIT_BRANCH}",
                        application: "${env.APPLICATION_NAME}",
                        ip: "${env.API_SERVER_IP}"
                    ]
                            
                    def response = httpRequest (
                        acceptType: 'APPLICATION_JSON',
                        contentType: 'APPLICATION_JSON',
                        httpMode: "POST",
                        url: "${env.EXECUTE_API_URL}",
                        requestBody: JsonOutput.toJson(requestBody),
                        consoleLogResponseBody: true,
                        timeout: 5000
                    )

                    if (response.status != 200) {
                        error "Notification failed with status: ${response.status}. Halting pipeline."
                    }
                }
            }
        }
       
    }
}

 

 

 

 

댓글