CI/CD 의존성 분리 필요성
Jenkins 로 프로젝트를 CI/CD 하게되면 git 에 Jenkinsfile 을 포함한 CI/CD 에 필요한 각종 문서들이 필요하다.
프로젝트의 코드실행과는 관계 없는 해당 파일들이 의존성으로 느껴지는 순간이 오게된다.
- Dockerfile 빌드시 추가 환경변수가 필요로할 때
- 빌드 명령에 파라미터가 일부 변경될 때
- 배포 위치가 변경될 때
프로젝트 코드와는 크게 관련 없는 내용이지만 git 에 추가적인 변경 이력을 생성시킨다.
Dockerfile 기반으로 빌드하는 경우도 프로젝트의 구성, 언어 상관 없이 CI 스크립트는 어느정도 통일시킬 수 있는데,
프로젝트별로 동일한 CI 코드(Jenkinsfile) 을 생성하고 관리하는 것이 지루하게 느껴질 때가 있다.
만약 일부 CI 코드에 변경이 일어나면 모든 프로젝트의 Jenkinsfile 을 변경시켜야 함으로 의존관계로 인한 부하가 심해질 수 도 있다.
아래와 같이 단계별로 스크립트를 구성하면 프로젝트에 CI/CD 파일 의존성 없이도 CI/CD 를 진행할 수 있다.
- 트리거 job
- pollSCM 기능을 사용해 git 의 commit 발생시 트리거 역할
- 빌드 job
- 트리거 job 으로부터 실행
- 프로젝트를 git 으로부터 clone
- 지정된 스크립트를 수행하여 build
- 배포 job
- 빌드 job 으로부터 실행
- 빌드된 결과물을 실행
Jenkins 의 장점은 다양한 플러그인으로 인해 자유로은 설정으로 CI/CD 를 구성할 수 있다는 것.
위와같이 구성하면 언어별, 프레임워크별로 빌드 job 을 미리 생성해두고 기존에 존재하는 프로젝트, 앞으로 생성할 프로젝트에 재사용할 수 있다.
배포 job 또한 마찬가지로 dev, stg, prd 환경별로 배포 job 을 만들어두고 상황에 맞춰 재사용 가능하다.
트리거 job 구성
단순 git과 연동하여 poolSCM 을 통해 트리거 역할만 수행해줄 Freestyle Project
H/2 * * * * 으로 2분마다 커밋기록을 확인하고 트리거시킬 수 있다.
트리거 되면 parameterized-trigger 플러그인을 사용해서 빌드 job 과 배포 job 을 순차적으로 실행시킨다.
https://plugins.jenkins.io/parameterized-trigger/
Freestyle Project 생성시 build step 에서 Trigger/call builds on other projects 을 사용하면 생성할 수 있다.
Predefined parameters 을 사용하면 아래와 같이 config 설정으로 다름 job 에 파라미터를 넘길 수 있다.
1 2 3 4
| # for build job SERVICE_NAME=my_demo_service REPO_URL=https://gitlab.mydomain.com/my_demo_service BRANCH=dev
|
1 2 3 4
| # for deploy job SERVICE_NAME=my_demo_service DEPLOY_SERVER=192.168.0.10 DEPLOY_PATH=/home/kouzie/service-docker-struct
|
빌드 job 구성
build 는 Pipeline 으로 생성, 환경은 아래와 같다.
- kaniko 사용, k8s jenkins 사용중
- nexus Private Docker Registry 사용중
- Pipeline script from SCM 사용
git 으로부터 pipeline 코드 가져오기
빌드에 필요한 Dockerfile_service_jvm 사전 정의
- 사전에 git, nexus 를 위한 Credential 은 미리 저장해준다.
실행시키는 Jenkinsfile 의 코드는 아래와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
| pipeline { agent { kubernetes { yaml ''' apiVersion: v1 kind: Pod metadata: name: kaniko namespace: jenkins spec: containers: - name: kaniko image: gcr.io/kaniko-project/executor:debug tty: true command: # 컨테이너가 종료되지 않도록 tty 설정과 명령어 실행 - /busybox/cat volumeMounts: - name: docker-config mountPath: /kaniko/.docker volumes: - name: docker-config emptyDir: {} ''' } } parameters { string(name: 'SERVICE_NAME', description: 'Service name for Docker image', defaultValue: '') string(name: 'REPO_URL', description: 'Git repository URL', defaultValue: '') string(name: 'BRANCH', description: 'Branch to build', defaultValue: 'dev') } environment { REGISTRY = 'nexus.mydomain.co.kr' IMAGE_NAME = "${params.SERVICE_NAME}" NEXUS_CREDENTIALS_ID = 'nexus-registry' GIT_CREDENTIALS_ID = 'kouzie_accesstoken' } stages { stage('Check Parameters') { steps { script { echo "SERVICE_NAME: ${params.SERVICE_NAME}" echo "REPO_URL: ${params.REPO_URL}" echo "BRANCH: ${params.BRANCH}" if (!params.SERVICE_NAME?.trim()) { error "SERVICE_NAME 파라미터가 비어있습니다." } if (!params.REPO_URL?.trim()) { error "REPO_URL 파라미터가 비어있습니다." } if (!params.BRANCH?.trim()) { error "BRANCH 파라미터가 비어있습니다." } } } } stage('Create Docker Config') { steps { container('kaniko') { withCredentials([usernamePassword(credentialsId: env.NEXUS_CREDENTIALS_ID, usernameVariable: 'DOCKER_CREDENTIALS_USR', passwordVariable: 'DOCKER_CREDENTIALS_PSW')]) { script { sh ''' set +x printf '{"auths": {"%s": {"auth": "%s"}}}' "$REGISTRY" "$(echo -n "$DOCKER_CREDENTIALS_USR:$DOCKER_CREDENTIALS_PSW" | base64)" > /kaniko/.docker/config.json cat /kaniko/.docker/config.json set -x ''' } } } } } stage('Checkout Service Source Code') { steps { checkout([ $class: 'GitSCM', branches: [[name: params.BRANCH]], userRemoteConfigs: [[ url: params.REPO_URL, credentialsId: env.GIT_CREDENTIALS_ID ]], extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'app/source']] ]) script { echo "서비스 소스코드가 app/source 디렉토리에 정상적으로 체크아웃되었습니다." } } } stage('Copy Dockerfile') { steps { container('kaniko') { script { checkout scm def dockerfileName = "Dockerfile_service_jvm" sh "cp ${dockerfileName} app/source/Dockerfile" echo "${dockerfileName}을 app/source/Dockerfile로 복사 완료" } } } } stage('Build and Push with Kaniko') { steps { container('kaniko') { withCredentials([usernamePassword(credentialsId: env.NEXUS_CREDENTIALS_ID, usernameVariable: 'NEXUS_USERNAME', passwordVariable: 'NEXUS_PASSWORD')]) { script { def tag = params.BRANCH ?: 'latest' try { sh ''' /kaniko/executor \ --dockerfile app/source/Dockerfile \ --context app/source \ --destination $REGISTRY/$IMAGE_NAME:''' + tag + ''' \ --cache=true \ --snapshotMode=redo \ --cache-repo=$REGISTRY/$IMAGE_NAME/cache \ --build-arg NEXUS_USERNAME=$NEXUS_USERNAME \ --build-arg NEXUS_PASSWORD=$NEXUS_PASSWORD \ --cleanup ''' } catch (Exception e) { error "Kaniko build failed: ${e}" } } } } } } } post { always { dir('.') { sh 'rm -rf app/source' } } } }
|
Dockerfile_service_jvm 파일은 SpringBot + Kotlin 빌드를 위한 Dockerfile 로 아래와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| # 빌드 단계 FROM gradle:8.8-jdk21 AS build WORKDIR /app
# Nexus 인증 정보 ARG NEXUS_USERNAME ARG NEXUS_PASSWORD ENV NEXUS_USERNAME=$NEXUS_USERNAME ENV NEXUS_PASSWORD=$NEXUS_PASSWORD
# 먼저 Gradle 설정 파일만 복사, 의존성 미리 로딩 COPY build.gradle.kts settings.gradle.kts gradle.properties ./ # wrapper 복사(gradle 이미지에선 생략해도 됨) COPY gradle ./gradle RUN gradle dependencies --no-daemon || true
# 프로젝트 소스 복사 COPY src ./src
# Gradle 빌드 실행 RUN gradle build --no-daemon -x test
# 실행 단계 FROM openjdk:21-jdk-slim WORKDIR /app
# 빌드된 JAR 파일 복사 COPY --from=build /app/build/libs/*.jar app.jar
# 애플리케이션 실행 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
배포 job 구성
개발단계의 서비스의 경우 개인서버에서 배포하여 테스트 진행중이다.
단순 ssh 로 접속 후 nexus 에 업로드된 이미지를 Pull & Up 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| pipeline { agent { kubernetes { yaml ''' apiVersion: v1 kind: Pod metadata: name: ssh-deploy-agent namespace: jenkins spec: containers: - name: ssh image: lscr.io/linuxserver/openssh-server:latest command: - cat tty: true ''' } } options { disableConcurrentBuilds() } stages { stage('Validate Parameters') { steps { container('ssh') { script { if (!params.SERVICE_NAME) { error "SERVICE_NAME must be provided" } if (!params.DEPLOY_SERVER) { error "DEPLOY_SERVER must be provided" } if (!params.DEPLOY_PATH) { error "DEPLOY_PATH must be provided" } } } } } stage('Prepare SSH') { steps { container('ssh') { withCredentials([sshUserPrivateKey( credentialsId: 'dev-server-ssh', keyFileVariable: 'SSH_KEY', usernameVariable: 'SSH_USER' )]) { sh """ mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keyscan -H ${params.DEPLOY_SERVER} >> ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts chmod 600 \$SSH_KEY ssh -i \$SSH_KEY \$SSH_USER@${params.DEPLOY_SERVER} whoami """ } } } } stage('Deploy via SSH') { steps { container('ssh') { withCredentials([sshUserPrivateKey( credentialsId: 'dev-server-ssh', keyFileVariable: 'SSH_KEY', usernameVariable: 'SSH_USER' )]) { sh """ ssh -i \$SSH_KEY \$SSH_USER@${params.DEPLOY_SERVER} \ "cd ${params.DEPLOY_PATH} && \ docker-compose -f docker-compose.yml pull ${params.SERVICE_NAME} && \ docker-compose -f docker-compose.yml up -d ${params.SERVICE_NAME}" """ } } } } } post { always { container('ssh') { sh 'rm -rf ~/.ssh' } } } }
|
결론
- 기존 서비스 프로젝트의 CI/CD 관련 코드 의존성을 제거하기 위해 별도의 Jenkinsfile 리포지토리를 운영
- Jenkins 관련 코드도 유지하면서 재사용 가능한 job 을 생성할 수 있다.
- 기존의 방식보단 GUI 콘솔 환경에서 손이 더 많이 가는것은 단점이다.