본문 바로가기
DevOps/CI.CD

Jenkins pipeline 소개 및 Java 애플리케이션 CI/CD pipeline 작성

by 비어원 2024. 2. 4.
728x90

Jenkins pipeline은 CI / CD 파이프라인을 구현하고 Jenkins에 통합하는 것을 지원하는 플러그인 모음이다. Jenkins Pipeline은 Pipeline 도메인별 언어 (DSL)을 통해 간단한 파이프라인부터 복잡한 파이프라인을 코드로 모델링할 수 있는 확장 가능한 도구들을 제공한다. 즉, Jenkins pipeline에서 제공하는 DSL을 활용하여 애플리케이션 CI/CD 파이프라인을 포함한 여러가지 자동화 파이프라인 기능을 구현할 수 있다.

 

Jenkins Pipeline의 정의 부분은 jenkinsfile 이라고 불리는 텍스트 파일에 작성된다. 이 파일은 Git과 같은 소스코드 레포지토리에서 관리할 수도 있다. Jenkinsfile은 pipeline as code의 기초로써, 파이프라인을 다른 코드와 마찬가지로 버전 관리 및 검토해야 하는 애플리케이션의 일부로 취급한다.

 

그리고 Jenkinsfile을 소스코드 레포지토리에 관리하는 방식은 여러 장점이 있다. (Git의 장점과 유사함)

  • 모든 브랜치 및 풀리퀘스트에 대한 파이프라인 빌드 프로세스를 자동으로 생성한다.
  • 파이프라인에서 코드 검토 및 이터레이션
  • 파이프라인 감사 추적
  • 프로젝트의 여러 구성원이 보고 편집할 수 있는 파이프라인에 대한 Single source of truth를 제공

 

Pipeline 구성 요소

Pipeline은 특정 자동화 프로세스의 사용자 지정 모델이다. Pipeline의 코드는 전반적인 자동화 프로세스를 정의하며 일반적으로는 CI/CD로써의 애플리케이션을 빌드, 테스트, 배포하는 스테이지를 포함한다.

Pipeline을 구성하는 요소들은 아래와 같다.

 

Node

Node는 Jenkins 환경의 일부인 머신이며 파이프라인을 실행하는 책임을 가진다. Pipeline을 실행시키기 위해서는 Job이 필요한데, Job을 실행시키면 우선적으로는 Jenkins Controller에서 먼저 실행하며, Pipeline 코드 내에서 특정 블록을 특정 레이블을 가지는 노드에서 실행시킬 수 있도록 구성할 수도 있다. 해당 블록이 실행되면 Controller는 파이프라인 실행을 특정 노드로 위임한다.

Stage

Stage는 전체 파이프라인에서 수행하는 프로세스에서 개념적으로 분리되는 작업이다. (Build / Test / Deploy) 이는 많은 플러그인에서 젠킨스 파이프라인 상태 / 진행 상황을 시각화하거나 표시하는 데 사용된다. 파이프라인을 실행시킬 떄 특정 일부의 Stage만 실행시키도록 구성할 수도 있다.

Step

Step은 하나의 태스크(명령) 를 의미한다. 기본적으로 Step은 특정 시점에 수행해야 할 작업을 Jenkins에 알려준다. 예를 들어, Shell 명령어 make를 실행하기 위해서는 sh step을 사용한다. (sh 'make') 플러그인이 파이프라인 DSL을 확장하면 일반적으로 플러그인이 새로운 단계를 구현했음을 의미한다.

 

SCM (Source Contol Manager) 연동

Jenkinsfile 을 Git 등의 소스코드 레포지토리에서 관리하여 젠킨스와 연동할 수 있다. Jenkins는 소스코드 레포지토리를 체크아웃하여 jenkinsfile에 접근 후 파이프라인을 실행시킬 수 있다. 보통은 Jenkins를 여럿이서 같이 사용하기 때문에 Git과 같은 SCM으로 파이프라인 코드를 관리한다.

 

먼저 파이프라인 코드를 저장할 Github repository를 생성한 뒤에 jenkinsfile 디렉터리를 만들자. 그 다음 해당 디렉터리 하위에 파이프라인 코드를 저장하면 된다.

 

Repository 예시

 

GitHub - beer-one/jenkins: Jenkins pipeline repository

Jenkins pipeline repository. Contribute to beer-one/jenkins development by creating an account on GitHub.

github.com

 

Pipeline 작성하기

파이프라인 코드는 Groovy와 유사한 문법으로 작성된다. 그래서 Groovy 문법에 조금은 익숙해야 한다. (Groovy 특징이 Java + Python + Ruby이고, JVM에서 돌아가기 때문에 라이브러리가 Java와 유사하다.)

 

Declarative VS Scripted

Jenkins pipeline은 선언형과 스크립트형인 두 가지 형태로 작성할 수 있다. 두개의 작성방식이 서로 다르며 선언형 방식이 비교적 최근에 만들어졌다. 이번 시간에는 Script형 파이프라인에 대해 알아보자.

Script형 파이프라인 기초

스크립트형 파이프라인 문법에서는 하나 이상의 node 블록이 전체 파이프라인에서 핵심 작업을 수행한다. 이것이 스크립트형 파이프라인 문법에서 필수 조건은 아니지만 파이프라인 작업의 노드 블록안에 한정하면 두 가지 효과가 있다.

  1. Jenkins queue에 항목을 추가하여 블록 안에 포함된 스텝이 실행되도록 스케줄링한다.
  2. 소스코드 레포지토리에서 체크아웃한 파일에 대한 작업을 수행할 수 있는 워크스페이스를 생성한다.
node("label1") {
    stage('Build') {

    }

    stage('Test') {

    }

    stage('Deploy') {

    }
}
  • node 블록으로 감싸서 블록 안의 코드를 특정 노드에서 실행하도록 구성할 수 있다.
  • node 내에 있는 문자열은 노드 레이블 셀렉터로, 위의 예시에서는 블록에 감싸진 부분은 label1이 포함된 노드에서 실행한다.
  • 레이블 셀렉터는 여러 연산자를 포함시킬 수 있다.
    • "label1 && label2": label1과 label2가 레이블을 모두 포함된 노드
    • `"!label1" : label1 레이블을 가지지 않는 노드
  • node 블록으로 감싸져있지 않은 부분은 Jenkins Controller에서 처리한다.

Jenkins node를 추가하는 방법은 추후에 설명하겠다.

 

Java 애플리케이션을 배포하는 파이프라인 구현하기

Gradle을 사용하여 빌드 및 테스트 후 Jib을 사용하여 이미지를 빌드한 뒤 도커 레지스트리에 푸시한 후 Git에 저장된 매니페스트를 변경하여 ArgoCD를 통해 쿠버네티스로 애플리케이션을 배포하는 파이프라인을 작성해보자.

파이프라인을 작성하기 앞서, 전체 배포 프로세스에 대한 Stage를 나누어보자.

  • 파이프라인 시작 시: 코드 레포지토리 클론
  • Build: gradle build
  • Test: gradle test
  • Package: gradle jib
  • Deploy: update manifest

 

파이프라인 시작

파이프라인을 시작하면 공통적으로 코드 레포지토리를 클론하도록 파이프라인을 작성해보자.

node {

    def buildNumber = currentBuild.number

    def appRepo = "https://github.com/beer-one/TodoList.git"
    def appBranch = "main"
    def appDirectory = "TodoList-server-kotlin"

    checkout([
        $class: 'GitSCM', 
        branches: [[name: "*/${appBranch}"]], 
        doGenerateSubmoduleConfigurations: false,
        extensions: [[$class: 'WipeWorkspace'], [$class: 'LocalBranch', LocalBranch: '**']],
        userRemoteConfigs: [[url: appRepo]]
    ])

    // 워킹 디렉터리 변경
    dir(appDirectory) {
        stage('Build') {
            // Build
        }

        stage('Test') {
            // Test
        }

        stage('Package) {
            // Package
        }

        stage('Deploy) {
            // Deploy
        }
    }
}

 

일단 배포할 애플리케이션의 코드 레포지토리를 체크아웃한다. Jenkins에서 제공하는 git 플러그인을 사용하여 checkout을 하면 워킹 디렉토리가 클론한 레포지토리의 루트 디렉터리가 되기 때문에 별도로 워킹디렉터리를 변경할 필요가 없다.

 

그런데 지금 배포하려는 애플리케이션의 레포지토리 구조를 보면 TodoList-server-kotlin 하위 디렉터리에 Java(Kotlin) 애플리케이션 프로젝트가 있다. 그래서 배포 파이프라인을 실행하기 앞서 워킹 디렉터리를 변경해야 한다. 워킹 디렉터리 변경은 dir() 으로 감싸면 된다.

 

Build

...

stage('Build') {
    sh 'gradle build -x test -x jib'
}

Build 단계는 별거 없다. gradle build만 하면 되기 때문이다. 대신, 빌드 단계에서는 무조건 build만 할 수 있도록 test, jib 등은 하지않도록 강제로 옵션을 주자.

 

Test

...

stage('Test') {
    sh 'gradle test'
}

Test 단계 또한 별거 없다. gradle test만 하면 된다.

 

Package

Package 단계에서는 Gradle Jib 플러그인을 사용하여 컨테이너 이미지를 빌드 후 레지스트리로 푸쉬한다.
BaseImage는 openjdk:17-alpine을 사용하고 이미지 푸쉬는 도커허브에 할 것이다. 푸쉬 대상 레지스트리는 jib.to.image 에서 결정된다.

 

BaseImage와 푸시할 이미지 이름 및 태그는 상단에 변수로 선언한다. 특히 푸시할 이미지 이름과 태그는 Deploy 단계에서 바로 사용할 것이기 때문에 일관성을 유지하기 위해 반드시 변수로 선언하자.

 

이미지를 푸시할 때 권한 인증이 필요하다면 계정 정보도 넣어줘야 한다. 이러한 경우에는 withCredentials 플러그인을 사용하여 해결할 수 있다.

 

먼저 Jenkins에 credentials 플러그인을 설치해야 한다. Helm chart로 Jenkins를 설치했다면 values.yaml 파일에서 controller.installPlugins 필드에 credentials 플러그인을 추가하면 된다. 그리고 Jenkins에서 시크릿을 조회 권한을 부여하기 위해 rbac.readSecrets을 true로 설정해야 한다.

 

controller:
  installPlugins:
    ...
    - credentials:1319.v7eb_51b_3a_c97b_
    - credentials-binding:657.v2b_19db_7d6e6d
    - kubernetes-credentials-provider:1.258.v95949f923a_a_e

...

rbac:
  create: true
  readSecrets: true
serviceAccount:
  create: true

 

그 다음 Credential 데이터를 Jenkis에 추가해야 한다. Jenkins on K8S에서는 Secret을 생성하여 Jenkins에 Credential을 추가할 수 있다.

apiVersion: v1
kind: Secret
metadata:
  name: docker-hub-credential
  namespace: jenkins
  labels:
    jenkins.io/credentials-type: usernamePassword
  annotations:
    jenkins.io/credentials-description : credentials for image push
type: Opaque
stringData:
  username: username
  password: password
  • metadata.labels.jenkins.io/credentials-type 레이블을 선언하여야만 Jenkins에 Credential을 추가할 수 있다. type의 종류는 다음 문서를 참고하자.
  • stringData.username에서는 도커 레지스트리 유저이름, stringData.password에는 도커 레지스트리 유저 비밀번호를 입력하면 된다.
  • namespace는 반드시 Jenkins가 배포된 네임스페이스와 동일해야 한다.

 

시크릿을 생성한 후에는 withCredentials 플러그인을 사용하여 파이프라인 내에 Credential 데이터를 불러올 수 있다.

 

withCredentials 플러그인을 사용하여 민감한 데이터를 불러오는 이유는 파이프라인 코드에 민감한 데이터를 적어둔다면 파이프라인 코드가 git에 저장되어 노출이 되기 때문에 보안에 좋지 않기 때문이다.

def baseImage = "openjdk:17-jdk-slim"
def appImage = "beer1/todo-server-kotlin"
def tag = "test"

stage('Package') {

    withCredentials([usernamePassword(credentialsId: "docker-hub-credential", passwordVariable: "password", usernameVariable: "username")]) {
        def jib = """
        |gradle jib -Djib.from.image=${baseImage} \\
        |    -Djib.to.image=${appImage} \\
        |    -Djib.to.tags=${tag} \\
        |    -Djib.to.auth.username=${username} \\
        |    -Djib.to.auth.password=${password} 
        """.stripMargin()

        sh(script: jib)
    }
}}
  • usernamePassword 타입의 시크릿을 사용하기 위해서는 withCredentials에서 usernamePassword 타입의 Credential을 불러오도록 해야한다.
  • credentialsId는 생성한 시크릿의 이름을 사용해야 한다.
  • 위 예시에서는 baseImage는 공식 이미지를 사용하기 때문에 별도의 인증이 필요없으며, 만든 이미지에 대한 push 권한이 필요하기 때문에 to.auth 정보만 추가하였다.
  • 위 예시는 BaseImage와 푸시할 이미지 둘 다 같은 레지스트리에 있는 경우에 대한 예시이다. 서로 다른 레지스트리인 경우에는 withCredentials 블록을 한번 더 묶어줘야 한다.

 

Deploy

이미지까지 푸시가 되었으면 Git에 저장된 매니페스트를 변경하여 ArgoCD가 변경된 이미지로 애플리케이션을 배포할 수 있도록 유도하자. 전제조건은 클러스터에 ArgoCD가 연동되어 있어야 한다.

def baseImage = "openjdk:17-alpine"
def appImage = "beer1/todo-server-kotlin"
def tag = "test"

def manifestRepo = "github.com/beer-one/k8s-manifests"
def valueFile = "todo-list/todo-api.yaml"

stage('Deploy') {

    withCredentials([usernamePassword(credentialsId: "github-credential", passwordVariable: "password", usernameVariable: "username")]) {

        checkout([
            $class: 'GitSCM', 
            branches: [[name: '*/main']], 
            doGenerateSubmoduleConfigurations: false,
            extensions: [[$class: 'WipeWorkspace'], [$class: 'LocalBranch', LocalBranch: '**']],
            userRemoteConfigs: [[url: manifestRepo]]
        ])

        def deployScripts = """
        |sed -i "s/buildNumber: \$(yq .podAnnotations.buildNumber ${valueFile})/buildNumber: build-${buildNumber}/g" ${valueFile}
        |sed -i "s@image: \$(yq .app.image ${valueFile})@image: ${appImage}:${tag}@g" ${valueFile}
        |git add .
        |git commit -m "Deploy ${appName} (${appImage}:${tag})"
        |git remote set-url origin https://${username}:${password}@${manifestRepo}
        |git push -u origin main
        """.stripMargin()

        sh(script: deployScripts)
    }
}

Deploy 스테이지에서는 매니페스트 레포지토리를 가져온 후 타겟 매니페스트 파일 또는 chart values.yaml 파일의 내용을 수정한 후 git에 반영하는 단계를 거친다. 여기서는 yq(YamlQuery) 도구를 통해 타겟 values.yaml 파일에 있는 app.image 내용과 podAnnotations.buildNumber 내용을 얻은 후, sed 명령어를 사용하여 기존의 app.image를 푸시한 이미지로 변경하고, podAnnotations.buildNumber을 젠킨스 빌드넘버로 변경한다.

 

컨테이너 이미지의 태그를 관리하지 않는다면 태그 값이 변경되지 않기 때문에 매니페스트가 변경되지 않아서 배포가 진행되지 않기 때문이다. 그래서 values.yaml의 app.image 뿐 아니라 podAnnotations.buildNumber도 함께 변경시켜 파드 정보가 변경되도록 유도한다.

 

git에 저장된 파일 내용을 변경한 후 git push를 해야하는데, 여기서도 마찬가지로 Jenkins에서 git push를 할 수 있도록 권한을 줘야 한다. 그래서 withCredentials을 통해 인증정보를 전달해야 한다. github-credential 이라는 이름의 시크릿을 추가로 생성하자.

apiVersion: v1
stringData:
  password: token
  username: username
kind: Secret
metadata:
  annotations:
    jenkins.io/credentials-description: credentials for github push
  labels:
    jenkins.io/credentials-type: usernamePassword
  name: github-credential
  namespace: jenkins
type: Opaque

 

전체 코드

전체 코드는 개인 레포지토리에 있으니 참고하자.
https://github.com/beer-one/jenkins/blob/main/jenkinsfile/todo-api.jenkinsfile

Pipeline Job 생성하기

Pipeline 코드를 작성 완료하였다면

이제 Jenkins UI로 들어가서 New Items > Pipeline을 선택하여 새로운 Job을 만들어보자. 그 후 세부 구성을 조작해보자.

 

Pipeline SCM

 

Pipeline 구성의 DefinitionPipeline script from SCM으로 변경하면 SCM에 올려둔 파이프라인 코드를 사용할 수 있다.

 

우리는 Git에 파이프라인 코드를 올렸기 때문에 SCMGit으로 설정하고, 파이프라인 코드를 올려둔 깃 레포지토리 URL을 등록하자. 세부적으로 브랜치도 설정할 수 있다. 마지막으로 Script Path에 실행할 파이프라인 코드의 경로를 넣으면 된다.

 

Pipeline 실행 (1)

모든 구성을 다 했으면 Save 버튼을 누르고 잡을 실행시켜보자. 하지만 얼마 못가서 실패가 날 것이다. 로그를 확인해보면 gradle: not found가 뜬다. 이유는 Jenkins node에 gradle이 설치되어 있지 않기 때문에 명령어를 사용할 수 없다.

 

이 문제를 해결하려면 젠킨스 노드를 커스터마이징해야 한다.

 

Jenkins node 커스터마이징

쿠버네티스로 젠킨스를 설치했다면 Jenkins node는 파드로 뜰 것이다. 그리고 Helm chart에서는 additionalAgents 를 통해 커스터마이징한 젠킨스 노드 이미지를 추가로 등록할 수 있다.

 

그래서 Gradle이 설치된 Jenkins node 이미지를 생성한 뒤 해당 노드를 위에서 만든 파이프라인을 실행시키는 노드로 구성하면 된다. 간단히 Dockerfile을 만들면 다음과 같다.

 

Dockerfile

FROM jenkins/inbound-agent:alpine-jdk17

USER root

RUN apk --no-cache add bash curl wget jq yq git tar unzip bash-completion ca-certificates && \
    curl -LO "https://services.gradle.org/distributions/gradle-7.4.2-all.zip" && \
    unzip gradle-7.4.2-all.zip -d /opt/gradle && \
    rm gradle-7.4.2-all.zip


ENV GRADLE_HOME="/opt/gradle/gradle-7.4.2" 
ENV PATH="$PATH:${GRADLE_HOME}/bin"

USER jenkins

 

여기서는 Gradle 뿐 아니라 git, yq 등 여러 도구들도 함께 설치하였다. 해당 Dockerfile을 통해 이미지를 레지스트리에 올려두자.

$ docker build -t beer1/jenkins-agent:gradle-7.4.2-jdk17 .
$ docker push beer1/jenkins-agent:gradle-7.4.2-jdk17

 

Chart values.yaml에 additionalAgent 등록하기

커스텀 젠킨스 노드를 생성한 뒤에 values.yaml에 additionalAgent를 등록해보자.

...

additionalAgents: 
 gradle-7.4.2-jdk17:
   podName: gradle-7-4-2-jdk17
   customJenkinsLabels: gradle-7-4-2-jdk17
   image: beer1/jenkins-agent
   tag: gradle-7.4.2-jdk17
   idleMinutes: 10

...
  • customJenkinsLabels에 등록된 레이블이 노드 셀렉터에서 사용할 수 있는 레이블이다.
  • idleMinutes는 한 번 에이전트가 뜬 후 지속적으로 살아있는 시간 (분) 이다. 10분 후에 잡을 실행시키고 있지 않는다면 파드가 죽어버린다. idleMinutes를 등록하지 않는다면 잡 실행 완료 시 바로 제거된다. 대신 파드를 띄우는 데 실패하는 등 실패가 발생하면 트러블슈팅이 어렵기 때문에 idleMinutes를 일단 등록해두자.

 

해당 필드를 추가하고 helm upgrade 명령어로 반영시키자.

label selector 추가

위에서 만들어둔 파이프라인 코드 중 node에 레이블 셀렉팅 기능을 추가하여 커스터마이징한 노드에서 실행할 수 있도록 구성해보자.

node("gradle-7-4-2-jdk17") {
    // Pipeline code
}
  • gradle-7-4-2-jdk17 레이블을 가진 노드에서 실행할 수 있도록 구성하였다.

Pipeline 실행 (2)

모든 과정을 완료한 후 한번 더 파이프라인을 실행시켜보자. 잡이 실행되면 먼저 Custom Jenkins agent가 파드로 뜬다.

$ kubectl get po -n jenkins
NAME                       READY   STATUS    RESTARTS   AGE
gradle-7-4-2-jdk17-9x1cn   1/1     Running   0          10m
jenkins-0                  2/2     Running   2 (36m ago)   45d

 

그리고 Jenkins Job 로그를 확인해보면 파이프라인 코드에 등록된 gradle-7-4-2-jdk17 에이전트가 선택되고 관련 파드가 생성되는 것을 알 수 있다. 로그에는 Jenkins agent에 대한 Pod 매니페스트가 찍혀있다.

 

 

그리고 Build, Test, Package, Deploy 스테이지를 각각 거치면서 앱 빌드, 테스트, 도커 이미지 빌드 & 푸시, 매니페스트 파일 변경 작업이 이루어진다. 최종적으로 변경된 매니페스트 파일이 Git에 반영되면 ArgoCD가 감지하여 새로운 버전의 애플리케이션이 배포된다.

Build

Test

Package

Deploy

Kubernetes

약간의 시간이 지난 후 Pod를 조회해보면 실제로 파드가 뜨는 것을 확인할 수 있다.

$ kubectl get po -n todo
NAME                        READY   STATUS    RESTARTS   AGE
todo-api-7b47fc765f-ws7t5   1/1     Running   0          9h
todo-api-ccbf6fb8f-ghk9s    0/1     Running   0          24s
728x90

댓글