이번 CI/CD 파이프라인은 Pipeline script from SCM으로 사용할 것이다.

Pipeline script는 이전 포스팅에서 진행했듯이 Jenkins 서버에 스크립트 구성을 하고 직접 빌드 버튼을 눌러서 진행했지만

Pipeline script from SCM는 gogs 웹훅을 통해 git에 변경점을 감지해서 자동으로 Jenkins 파이프라인이 빌드되는 구조이다. 이 때 웹훅은 주로 HTTP POST 요청을 보내는 방식이다.

소스 변경이 이루어질 때를 감지해서 바로 반영되는 흐름이 구성되는 것이야 말로 이번 주차 최종 자동화구성이 되겠다.

image

image 1

흐름도를 보자면 개발자가 gogs에 push → Jenkins에서 gogs 웹훅으로 인한 트리거 발생 → 도커허브에 빌드된 이미지 push → 최신이미지로 컨테이너 재기동 하는 구성으로 이루어 진다.

이 때 Jenkins 서버는 DooD 방식으로 호스트와 도커 소켓을 공유하고 있기에 컨테이너를 재기동했을 때 최신이미지가 바로 반영될 수 있다.

먼저 웹훅에 사용할 ip를 지정해야 하는데 내 pc ip를 설정해 줘야 한다. 근데 jenkins와 gogs가 같은 ip를 사용하기 때문에 처음 설정 시에는 차단될 것이다.

image 2

그래서 gogs 내부에서 /data/gogs/conf/app.ini 파일 마지막 줄에 LOCAL_NETWORK_ALLOWLIST = 192.168.219.100을 추가해 주어야 한다

image 3

docker compose restart gogs로 컨테이너 재기동을 해 주면 바로 반영된다.

image 4

gogs 레포지토리 > Settings > Webhooks 에서 추가해 주면 된다

Payload URL의 형식은 http://192.168.219.100/:8080/gogs-webhook/?job=SCM-Pipeline/ 로 되어 있는데 gogs-webhook은 webhook 플러그인에서 사용하는 엔드포인트이고 SCM-Pipeline이라는 job id를 가진 아이템이 트리거되어 빌드된다는 뜻이다

여기서 Secret은 패스워드가 아닌 jenkins와 gogs가 상호간에 체크를 하게 되는데 이 때 사용하는 해시값이다.

웹훅 트리거는 push 이벤트가 발생할 때만으로 하겠다!

image 5

이제 Jenkins로 넘어가서 SCM-Pipeline라는 신규 Item을 파이프라인으로 구성해 보자

image 6

저번 포스팅에선 소스코드관리 git을 사용하였지만 이번엔 Github project로 세팅을 하게 되는데 둘의 차이점이 있다.

Github project는 웹훅연동을 위한 설정이고 git 소스는 git에서 소스를 가져오기 위한 설정으로 쓰인다. 이번엔 웹훅연동을 위한 것이기에 Github project로 설정하면 된다

내 git 주소와 아까 웹훅에 설정한 secret값을 넣어 준다

image 7

빌드 트리거는 gogs에 변경점이 push됐을 때로 지정한다

Pipeline은 Pipeline script from SCM으로 선택하고 gogs 레포지토리 정보를 입력한다

image 8

마지막 Jenkinsfile 위치를 지정해 주면 된다

웹훅 트리거가 발생하면 Jenkins 컨테이너 내부에 위치한 Jenkinsfile 파일이 실행되면서 자동화 구성이 완료된다. 이 경로는 gogs 레포지토리 경로에 맞춰주면 된다.

image 9

이제 로컬에서 docker compose exec jenkins touch /var/jenkins_home/dev-app/Jenkinsfile 명령어로 Jekinsfile을 생성해 주겠다

Jenkinsfile 내용은 이전 포스팅에서의 스크립트와 동일하다

Git Checkout → Read VERSION → Docker Build and Push 순서로 이루어 진다

pipeline {
    agent any
    environment {
        DOCKER_IMAGE = '6ain/dev-app' // Docker 이미지 이름
    }
    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://192.168.219.100:3000/devops/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-dev-app'  // Credentials ID
            }
        }
        stage('Read VERSION') {
            steps {
                script {
                    // VERSION 파일 읽기
                    def version = readFile('VERSION').trim()
                    echo "Version found: ${version}"
                    // 환경 변수 설정
                    env.DOCKER_TAG = version
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-credentials') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                    }
                }
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}

소스 코드 업데이트를 버전으로 구분하기 위해 docker compose exec jenkins sh -c 'echo "0.0.2" > /var/jenkins_home/dev-app/VERSION'으로 소스코드 버전도 0.0.2로 변경해 준다

image 10

근데 Jenkins에서 자동으로 빌드가 실행되지 않는다

gogs 웹훅에서 아래 Recent Deliveries에서 실패한 응답 로그를 확인할 수 있는데 왤까 헤매다가 결국엔 Jenkins URL에 대한 오탈자 때문이었다ㅠ

image 11

두 번째 난관은 자동 빌드는 실행됐지만 GitException 에러가 발생하였다. stderr: fatal: invalid refspec '+refs/heads/?main:refs/remotes/origin/?main' 로 표준에러를 출력해 주었는데..

image 12

아무래도 오탈자는 없는데 세팅도 동일하고.. 근데 자꾸 실패 뜨길래 새로운 아이템을 생성해 주었고

젠킨스 서버에서 코드변경점 push해 주니 자동으로 빌드되었다..! 뭐지ㅠ 어딘가 꼬였었나보다..

image 13

도커허브에도 새로운 이미지가 잘업로드되었다

image 14

gogs 웹훅 기록에도 성공적으로 실행되어 있다

image 15

image 16

이제 진짜 마지막으로 새로운 이미지로 컨테이너 재시작되는 실습으로 마무리한다

스크립트 진행 단계는 Checkout → Read VERSION → Docker Build and Push → Check, Stop and Run Docker Container 순서로 이루어 진다.

pipeline {
    agent any
    environment {
        DOCKER_IMAGE = '6ain/dev-app' // Docker 이미지 이름
        CONTAINER_NAME = 'dev-app' // 컨테이너 이름
    }
    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://192.168.219.100:3000/devops/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-dev-app'  // Credentials ID
            }
        }
        stage('Read VERSION') {
            steps {
                script {
                    // VERSION 파일 읽기
                    def version = readFile('VERSION').trim()
                    echo "Version found: ${version}"
                    // 환경 변수 설정
                    env.DOCKER_TAG = version
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-credentials') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                        appImage.push("latest")  // 빌드 이미지 push 할 때, 2개의 버전(현재 버전, latest 버전)을 업로드
                    }
                }
            }
        }
        stage('Check, Stop and Run Docker Container') {
            steps {
                script {
                    // 실행 중인 컨테이너 확인
                    def isRunning = sh(
                        script: "docker ps -q -f name=${CONTAINER_NAME}",
                        returnStdout: true
                    ).trim()
                    
                    if (isRunning) {
                        echo "Container '${CONTAINER_NAME}' is already running. Stopping it..."
                        // 실행 중인 컨테이너 중지
                        sh "docker stop ${CONTAINER_NAME}"
                        // 컨테이너 제거
                        sh "docker rm ${CONTAINER_NAME}"
                        echo "Container '${CONTAINER_NAME}' stopped and removed."
                    } else {
                        echo "Container '${CONTAINER_NAME}' is not running."
                    }
                    
                    // 5초 대기
                    echo "Waiting for 5 seconds before starting the new container..."
                    sleep(5)
                    
                    // 신규 컨테이너 실행
                    echo "Starting a new container '${CONTAINER_NAME}'..."
                    sh """
                    docker run -d --name ${CONTAINER_NAME} -p "$ContainerPort":80 ${DOCKER_IMAGE}:${DOCKER_TAG}
                    """
                }
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}

나머지 설정은 그대로인데 컨테이너 이미지 버전 관리를 위해 appImage.push("latest")로 lastest 태그를 추가해 주었고 컨테이너를 재배포해 주는 Check, Stop and Run Docker Container 스크립트가 추가되었다.

실행중인 컨테이너가 있는지 확인하고 있다면 컨테이너 중지 후 제거해 주는 과정이 있고 없다면 메세지만 출력한다.

5초의 슬립타임을 준 후에 신규 컨테이너를 실행한다. 이 때 파이프라인 구성에는 매개변수를 추가해 주고 스크립트는 docker run -d --name ${CONTAINER_NAME} -p "$ContainerPort":80 ${DOCKER_IMAGE}:${DOCKER_TAG} 에서 "$ContainerPort"로 변경하여 매개변수로 컨테이너포트를 유동적으로 변경할 수 있게 설정하였다.

image 17

실행중인 컨테이너를 확인하고 노출된 포트로 확인해 보면 server.py 가 잘 실행된다.

image 18

마지막으로 컨테이너를 다시 실행할 때의 과정을 살펴 보자

server.py에서 소스코드 수정하고 VERSION 파일도 변경한 후 push한다

이전버전 컨테이너가 죽고 신규 컨테이너가 생성되는 순간의 서비스 순단 시간을 확인해 보면 약 10초정도 소요되었다

image 19

신규버전으로 재배포 시에 서비스 순단을 줄이려면 배포 전략도 고려해야 한다. 블루그린, 카나리 배포 전략 등을 사용할 수 있는데 리소스가 두 배로 생성된다.

보통 쿠버네티스 환경에서 Deployment 리소스를 사용하면 POD의 롤링업데이트 전략이 잘구성되어 있어 좀 더 안정적이게 배포가 가능해 진다.

컨테이너 뿐 아니라 일반 EC2 서버에서도 배포 전략을 생각해 보면 인플레이스, 블루그린, 카나리 배포 등이 있다. 지금 진행중인 프로젝트에서는 ASG를 구성하고 있는데 ALB - TG - ASG 순서대로 구성되어 있을 때, 구버전 ASG에서 신규버전 ASG로 서비스 트래픽을 넘기면서 배포전략을 세울 수 있을거같다. 일반적인 레퍼를 찾아보면 주로 CodeDeploy를 많이 사용하는 거 같다. 아직 테스트해 보지 않았지만 테스트 진행 후에 포스팅 진행해 봐야겠다!

Categories:

Updated: