[DevOps] Docker Build Pipeline 구성하기

안녕하세요? 정리하는 개발자 워니즈입니다. 이번시간에는 Docker Build Pipeline 구성하기에 대해서 정리를 하려고 합니다. 필자가 회사를 이직한지도 어느덧 5개월(5개월시점에 작성한 글입니다.) 정도 되고 있습니다. 이곳에서 k8s를 구성하면서 Deploy Tool로는 ArgoCd를 사용하고 있습니다.

젠킨스를 통해서 Build Job을 구성하고, ArgoCD에서 자동으로 배포할 수 있도록 구성은 이전의 포스팅에서 했었습니다.

아래의 내용을 참고 부탁드립니다.

  • ArgoCD 설치 방법 : https://blog.wonizz.com/2020/06/08/kubernetes-deploy-tool-argocd/
  • ArgoCD Slack Notification 방법 : https://blog.wonizz.com/2020/06/15/kubernetes-deploy-tool-notification/

이번 포스팅은 좀 더 CI(지속적 통합)에 중점을 두어 작성하려 합니다.

1. Jenkinsfiles 를 통한 pipeline 구축

젠킨스 파이프 라인을 통해서 빌드잡을 구성했습니다. 이전의 젠킨스 파이프라인에 대해서 정리해놓은 포스팅이 있으니 참고 부탁드립니다.

  • Jenkins 파이프라인 사용법 : https://blog.wonizz.com/2019/08/04/jenkins-pipeline/

파이프라인의 흐름은 다음과 같이 구성을 했습니다.

jenkins_1

  • 빌드가 시작되면, 운영자 / 개발자에게 시작 알림 메시지
  • git에서의 소스코드 체크아웃
  • Application Build ( gradle script를 통한 빌드 )
  • Image Build ( Jar file Copy )
  • Harbor push ( Image Repository )
  • 빌드가 완료되면, 운영자/개발자에게 완료 알림 메시지

보시다시피, 파이프라인을 통해서 원하는 Stage별로 나눠서 Task를 실행하도록 해두었습니다. 그렇게 하다보니 어디부분에서 에러가 발생하는지 명확하게 볼 수 있었습니다.

final HARBOR_USERNAME = '{harbor bot 계정}'
final HARBOR_PASSWORD = '{harbor pw 토큰}'

pipeline {
    agent {
        node {
          label '{slave 고유의 이름}'
        }
    }
    parameters {
        string name: 'REVISION'
        booleanParam name: 'ONLY_BUILD'
    }
    environment {
                DOCKER_IMAGE_TAG = """${sh(
                returnStdout: true,
                script: 'echo ${REVISION#refs/tags/}'
                )}"""
                SLACK_CHANNEL = '{슬랙 채널명}'
                SLACK_TOKEN = '{슬랙 채널 토큰}'
    }
    stages {
        stage('Start Notification') {
            steps {
                slackSend(channel: SLACK_CHANNEL, color: '#ECB22E', message: "Build Start! (${env.BUILD_TAG})\n - phase : ${PHASE}\n- revision : ${DOCKER_IMAGE_TAG}", token: SLACK_TOKEN)
            }
        }
        stage('Checkout') {
           steps {
                checkout([$class: 'GitSCM',
                          branches: [[name: "${REVISION}"]],
                          doGenerateSubmoduleConfigurations: false,
                          extensions: [],
                          gitTool: 'Default',
                          submoduleCfg: [],
                          userRemoteConfigs: [[url: '{git 주소}']]
                        ])
            }
        }
        stage('Build Application') {
            tools {
                jdk 'JDK 8u121 + JCE'
            }
            steps {
                sh './gradlew clean assemble'
            }
        }
        stage('Application Test') {
            tools {
                jdk 'JDK 8u121 + JCE'
            }
            steps {
                sh './gradlew check integrationTest'
            }
        }
        stage('Harbor Log-in') {
                steps {
                  sh 'docker logout {harbor 주소}'
                    sh "docker login {harbor 주소} --username ${HARBOR_USERNAME} --password ${HARBOR_PASSWORD}"
                }
        }
        stage('Build Docker Image') {
            parallel {
                stage('Keystore2') {
                    steps {
                      sh 'docker build -t {harbor 주소}/{path}/{application name}:${DOCKER_IMAGE_TAG} --build-arg APPLICATION_PATH=apps/test --build-arg APPLICATION={application name} --build-arg "EXPOSE=29996 20082 20083" .'
                        sh 'docker push {harbor 주소}/{path}/{application name}:${DOCKER_IMAGE_TAG}'
                    }
                }
            }
        }
        stage ('Invoke_Helm Chart commit') {
            steps {
              build job: '{Invoked Job Name}', 
                parameters: [
                    string(name: 'PHASE', value: 'alpha'),
                    string(name: 'TARGET', value: 'link-framework'),
                    string(name: 'REVISION', value: String.valueOf(DOCKER_IMAGE_TAG)),
                    string(name: 'ONLY_BUILD', value: String.valueOf(ONLY_BUILD))
                ]
            }
        }
    }
    post {
      success {
        slackSend(channel: SLACK_CHANNEL, color: '#2EB67D', message: " Build Completed! (${env.BUILD_TAG})\n - phase : ${PHASE}\n- revision : ${DOCKER_IMAGE_TAG}", token: SLACK_TOKEN)
      }
      failure {
        slackSend(channel: SLACK_CHANNEL, color: '#E01E5A', message: " Build Failed: @{slack menthion}\n - phase : ${PHASE}\n - build url : ${env.BUILD_URL}", token: SLACK_TOKEN)
      }
   }
}

최종적인 Jenkinsfiles의 구성은 위와 같습니다. 중요한 부분은 source를 체크아웃 받기 위해서 commit hash값을 인자로 받고 있습니다. 개발(커밋해시), 운영(태그)와 같이 2가지 방식으로 커밋 해시 값이 전달 됩니다.

    parameters {
        string name: 'REVISION'
        booleanParam name: 'ONLY_BUILD'
    }
    environment {
                DOCKER_IMAGE_TAG = """${sh(
                returnStdout: true,
                script: 'echo ${REVISION#refs/tags/}'
                )}"""
                SLACK_CHANNEL = '{슬랙 채널명}'
                SLACK_TOKEN = '{슬랙 채널 토큰}'
    }

위와 같이 파라미터로 REVISION값을 받게 되어있는데, 개발같은 경우는 commit hash 값이 그대로 와서 사용이 가능하지만, tag는 (ref/tags/v2.1.4)와 같은 형태로 오기 때문에 environment에서 한번 replace를 해주는 과정이 있습니다.

2. 젠킨스 Job 구성

위에서 간단하게 설명하긴 했지만, 파이프라인 이전 스텝에서 2가지 방식으로 파라미터를 전달 받습니다.

  • develop / release branch 푸시 : commit hash 값

따라서, 앞에서는 2가지를 받을 수 있는 Job이 필요합니다. 그리고 각 Job에서는 다음과 같이 구성이 필요합니다.

jenkins_2

Branch specifier부분에서 어떤 부분에 대해서 해당 Job이 감지를 할지 기입해 두면 됩니다.
Trigger parameterized build on the project의 사전 정의 파라미터에서 아래와 같이 입력을 하게 됩니다.

jenkins_3

GIT_COMMIT은 해당 브랜치에서 최신의 커밋 해시 버전을 가져옵니다.

같은 방식으로 Tag 생성시에는 아래와 같이 입력을 합니다.

  • tag 생성시 : tag 버전

jenkins_4

jenkins_5

GIT_BRANCH는 해당 태그버전의 브랜치명을 가져옵니다.

3. Dockfile 최적화 작업

빌드를 수행하면서 Dockerfile에 대해서도 최적화 작업을 진행했습니다. 기존에는 Multi-stage 방식으로 아래와 같이 굉장히 긴 포맷으로 빌드를 수행했습니다.

# Build
FROM {harbor 주소}/{path}/{application name}/gradle:6.7.1-jdk8 AS build-env
ARG PROJECT

WORKDIR /{application name}
COPY . ./
RUN gradle clean :${PROJECT}:assemble

# Production
FROM {harbor 주소}/{path}/{application name}/openjdk:8-jre-alpine AS production-env

ARG PROJECT_PATH
ARG PROJECT
ARG EXPOSE
ENV JAR=${PROJECT}.jar
EXPOSE ${EXPOSE}

ENTRYPOINT /sbin/tini java \
${JAVA_OPTS} \
-jar ${JAR}

RUN addgroup -S -g 2001 www && adduser -S -u 1001 -G www www
RUN apk add --no-cache tini bash wget curl bind-tools && rm -rf /var/cache/apk/*
RUN sed -i s/.*networkaddress.cache.ttl.*/networkaddress.cache.ttl=0/g /usr/lib/jvm/java-1.8-openjdk/jre/lib/security/java.security
RUN sed -i s/.*networkaddress.cache.negative.ttl.*/networkaddress.cache.negative.ttl=0/g /usr/lib/jvm/java-1.8-openjdk/jre/lib/security/java.security

WORKDIR /home/www
USER www

# Copy test fils
COPY docker/test-custom.conf \
docker/test-env.conf \
docker/install_test_agent.sh \
./

# Install test agent
RUN sh install_test_agent.sh

# Copy kafka .jks files
COPY kafka ./kafka

# Copy jar
COPY --from=build-env --chown=www /{application 이름 }/${PROJECT_PATH}/${PROJECT}/build/libs/${PROJECT}.jar ./

파일의 원본은 밝힐수 없어, 내용을 가렸습니다. 결론적으로 위와 같이 Multi Stage에서의 가장큰 문제는 매번 빌드를 수행할때마다, Build-env안에서는 gradle스크립트를 통해서 모든 빌드를 수행한다는 것입니다.

아래의 과정은 jenkinsfiles에서 1번만 수행하도록 변경했습니다.

RUN gradle clean :${PROJECT}:assemble

추가적으로 아래와 같은 작업들을 더 진행했습니다.

  • Build 영역 삭제
  • COPY . ./ : COPY는 좀더 구체적으로 작성
  • RUN : layer를 출이기 위해 하나의 작업 단위로 구성
  • wget, curl : 불필요 의존성 제거 필요

최종적인 Dockerfile은 아래와 같은 모습으로 변경되었습니다.

# Production
FROM {harbor 주소}/{path}/{application name}/openjdk:8-jre-alpine-base

# Arguments & Environments
ARG PROJECT_PATH
ARG PROJECT
ARG EXPOSE

ENV JAR=${PROJECT}.jar
EXPOSE ${EXPOSE}

# Entrypoint for running docker 
ENTRYPOINT /sbin/tini java  \
${JAVA_OPTS} \
-jar ${JAR}

# Copy jar
COPY --chown=www /${PROJECT_PATH}/${PROJECT}/build/libs/${PROJECT}.jar ./

엄청 간결해진것도 있고, 빌드 시간도 약 10m이 소요된것에서 2m정도로 많이 단축이 되었습니다.

4. 빌드 / 배포 아키텍처

전체적인 빌드 / 배포에 대한 아키텍처는 다음과 같습니다.

jenkins_6

  • develop branch : 개발/릴리즈 브랜치로부터 커밋 해시 추출
  • master branch : 태그 생성시 태그값 추출
  • pipeline : 젠킨스 파일을 통한 빌드 파이프라인
  • helm chart update : 최종적으로 빌드가 완료되면, values.yaml 파일의 docker image 버전 업데이트

위와 같은 과정을 거치게 되고, ArgoCD에서는 helm chart가 변경되게 되면 싱크를 체크하여 변경내역을 확인합니다. 그리고 ArgoCD를 통해 sync를 수행하면 배포가 진행 되는 구조입니다.

5. 마치며…

빠른 개발과 안정적인 서비스를 위해서는 무엇보다도 빌드와 배포는 안정적으로 이루어져야 합니다. Jenkinsfiles와 Dockerfile의 최적화로 안정적인 CI를 구축했고, ArgoCD와 연계하기 위해 Helmchart를 중간에 업데이트 하도록 해두었습니다. 이로써, CI-CD간에 지속적인 통합과 배포가 이루어지도록 했습니다.
개발자들도 새로운 시스템에서 빠른 이해로 안정적으로 개발을 진행하고 있습니다. 다음시간에는 ArgoCD에 대해서 좀더 심화학습 하여 정리하도록 하겠습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다