Jenkinsfile로 구성한 Docker 기반 CI/CD 파이프라인

오젼·2026년 2월 11일

이번 글에서는 "릴레이 스토리텔링 게임 개소릴레이 백엔드"에 적용한 Jenkinsfile을 기준으로,

멀티브랜치 파이프라인에서 CI와 CD를 어떻게 분리해 구성했는지를 단계별로 정리합니다.

특히,

  • 어떤 조건에서 CI만 실행되는지
  • 어떤 시점에 실제 배포(CD)가 이루어지는지
  • 각 stage가 어떤 역할을 담당하는지
    를 중심으로 설명합니다.

0. Jenkinsfile 전체 구조

CI: Build & Test

  • develop 브랜치에 push된 커밋
  • develop을 대상으로 하는 Merge Request

위 경우에만 실행되며,
빌드와 테스트까지만 수행합니다.

CD: Image & Deploy

  • develop 브랜치에 merge된 커밋

MR 빌드에서는 실행되지 않으며,
실제 배포가 필요한 경우에만 수행됩니다.

이 구조를 통해
검증(CI)과 배포(CD)를 명확하게 분리합니다.

1. 전역 설정 (agent / environment)

Jenkinsfile 상단에서는 파이프라인 전반에 적용되는 실행 방식과 공통 환경 변수를 정의합니다.

agent none은 파이프라인 전체에 공통 실행 노드를 지정하지 않겠다는 의미입니다.
대신 각 stage에서 필요한 실행 환경을 개별적으로 지정합니다.

이 설정을 통해 CI와 CD의 실행 환경을 명확히 분리합니다.

1-1. CI 단계의 실행 환경

CI 단계에서는 node:20-alpine Docker 컨테이너를 사용합니다.

이유는 다음과 같습니다.

  • Jenkins 워커 노드에 Node.js를 직접 설치하면 다른 프로젝트들과 버전 충돌이 발생할 수 있습니다. 이를 방지합니다.
  • 항상 동일한 환경에서 빌드와 테스트를 수행할 수 있습니다.

즉, CI 환경을 컨테이너로 고정합니다.

1-2. CD 단계의 실행 환경

CD 단계에서는 Jenkins 기본 에이전트(agent any)를 사용합니다.

CD 단계에서는 다음 작업이 필요합니다.

  • Docker 이미지 빌드 및 push
  • SSH를 통한 EC2 접속
  • docker compose 실행

이 작업들은 Docker daemon 접근과 SSH 인증이 필요합니다.
따라서 별도의 컨테이너를 사용하기보다,
이미 설정된 Jenkins 노드 환경을 그대로 사용합니다.

2. CI: Build & Test 단계

CI 단계는 코드 변경 사항을 검증하기 위한 단계입니다.
실제 배포는 수행하지 않습니다.

이 단계에서는
“이 커밋이 배포 가능한 상태인지”를 확인하는 데 집중합니다.

2-1. CI 실행 조건 (when)

앞서 말했듯, CI 단계는 다음 조건에서만 실행됩니다.

  • develop 브랜치에 직접 push된 커밋
  • develop을 대상으로 하는 Merge Request

이를 통해 feature 브랜치의 변경 사항이
develop에 병합되기 전에 반드시 빌드와 테스트를 거치도록 합니다.

2-2. CI 단계에서 수행하는 작업 흐름

CI 단계에서는 다음 작업을 순차적으로 수행합니다.

  1. 소스 코드 체크아웃
  2. 커밋 SHA 기반 태그 생성
  3. 의존성 설치
  4. 테스트 실행
  5. 빌드 수행
  6. CD 단계로 전달할 메타데이터 저장

각 단계는 실패 시 즉시 파이프라인이 중단됩니다.

2-3. 소스 코드 체크아웃

멀티브랜치 파이프라인에서 Jenkins가 감지한
현재 브랜치 또는 MR 기준의 소스를 체크아웃합니다.

이 시점의 커밋이
이후 모든 빌드 및 배포의 기준점이 됩니다.

2-4. 커밋 SHA 기반 태그 생성

소스 체크아웃 이후,
현재 커밋을 식별할 수 있는 Docker 이미지 태그를 생성합니다.

  • GIT_COMMIT 앞 7자리를 사용합니다.
  • 생성한 태그는 .tag 파일로 저장합니다.
  • 이후 CD 단계에서 동일한 태그를 재사용합니다.

왜 SHA 태그를 사용했는가

이미지를 latest 태그만으로 관리하면
어떤 커밋이 배포되었는지 즉시 알기 어렵습니다.

반면 SHA 기반 태그를 사용하면,

  • 이미지 이름만 보고 배포 커밋을 알 수 있고
  • 특정 커밋으로 즉시 롤백할 수 있습니다.
BACKEND_TAG=sha-a1b2c3d docker compose up -d backend

배포 이력 관리와 롤백을 단순하게 만들기 위한 선택입니다.

2-5. 의존성 설치 · 테스트 · 빌드

CI 단계의 마지막에서는 실제 코드 검증을 수행합니다.

  • npm ci를 사용해 의존성을 고정합니다.
  • 테스트 실패 시 파이프라인은 즉시 중단됩니다.
  • 빌드 성공 시에만 다음 단계로 넘어갑니다.

npm ci

  • package-lock.json을 기준으로 의존성을 설치합니다.
  • package.json보다 엄격하게 버전을 일치시키므로, 어떤 환경에서 빌드해도 항상 동일한 의존성 구성을 보장합니다.

2-6. CI 결과를 CD 단계로 전달 (stash)

CI와 CD 단계는 동일한 워크스페이스를 공유하지 않습니다.
따라서 CI 단계에서 생성한 태그 정보를 별도로 전달해야 합니다.

이를 위해 Jenkins의 stash / unstash를 사용합니다.

CD 단계에서는 이 파일을 다시 불러와
CI에서 검증한 커밋 기준 태그를 그대로 사용합니다.


여기까지가 CI 파트입니다.
이후 CD 파트(이미지 빌드 → 배포)에 대해 진행하겠습니다.

3. CD: Image & Deploy 단계

CD 단계는 CI에서 검증된 결과물을 기반으로
Docker 이미지를 빌드하고 실제 서버에 배포하는 단계입니다.

이 단계는 실제 인프라에 영향을 주기 때문에,
실행 조건을 엄격하게 제한합니다.

3-1. CD 실행 조건 (when)

CD 단계는 다음 조건을 모두 만족할 때만 실행됩니다.

  • develop 브랜치에서 실행된 빌드일 것
  • Merge Request 빌드가 아닐 것

즉,

  • MR 단계에서는 CI까지만 수행하고
  • develop에 merge된 커밋에 대해서만 CD를 수행합니다.

이를 통해
검증되지 않은 코드가 배포되는 상황을 방지합니다.

3-2. CD 단계의 전체 흐름

CD 단계는 다음 순서로 진행됩니다.

  1. 배포용 워크스페이스 준비
  2. Docker 이미지 빌드 및 Docker Hub push
  3. EC2 서버에서 이미지 pull 및 컨테이너 재기동

각 단계는 이전 단계가 성공했을 때만 진행됩니다.

3-3. Prepare Workspace

CD 단계의 첫 번째 작업은
배포를 위한 작업 환경을 정리하고, CI 결과를 복구하는 것입니다.

작업 내용 설명

먼저 deleteDir()를 통해
이전 빌드의 잔여 파일을 모두 제거합니다.

이후 checkout scm으로
배포에 필요한 최신 소스 코드와 설정 파일을 다시 체크아웃합니다.

마지막으로 unstash 'meta'를 통해
CI 단계에서 생성한 .tag 파일을 복구합니다.

커밋 기준 태그 복구

env.TAG = readFile('.tag').trim()

이 단계에서 읽어온 태그 값은
이후 Docker 이미지 빌드와 배포 과정 전반에서 사용됩니다.

이를 통해 CI와 CD가
동일한 커밋 기준을 공유하게 됩니다.

3-4. Docker 이미지 빌드 및 Push

Prepare Workspace 단계에서 복구한 태그를 사용해
백엔드 Docker 이미지를 빌드하고 Docker Hub로 푸시합니다.

Docker 이미지 빌드

  • deploy/backend/Dockerfile을 기준으로 이미지를 빌드합니다.
  • CI 단계에서 생성한 SHA 기반 태그를 이미지 태그로 사용합니다.
  • 이미지 이름만으로도 어떤 커밋 기준인지 확인할 수 있습니다.

Docker Hub 인증

Docker Hub 인증 정보는
Jenkins Credentials로 관리합니다.

Jenkinsfile에는 실제 인증 정보가 노출되지 않으며,
필요한 시점에만 안전하게 주입됩니다.

이미지 Push 전략

두 가지 태그를 함께 push합니다.

  • sha-xxxxxxx
    → 배포 이력 관리 및 롤백용
  • latest
    → 최신 이미지 참조용

이 구조를 통해
가시성과 운영 편의성을 동시에 확보합니다.

3-5. EC2 배포 (pull & run)

Docker Hub에 이미지가 준비되면,
배포 서버(EC2)에서 해당 이미지를 pull 받아 컨테이너를 재기동합니다.

이 과정은 SSH를 통해 원격으로 수행됩니다.

SSH 인증 및 민감 정보 주입

  • 이 단계에서는 다음 정보를 안전하게 주입합니다.
  • EC2 접속용 SSH 키
  • Docker Hub 인증 정보
  • 배포 서버 IP
  • 런타임 환경 변수 (GMS_API_KEY)

모든 민감 정보는
Jenkins Credentials로 관리합니다.

3-6. 배포 서버에 파일 전달

배포 서버에는
이미지에 포함되지 않은 설정 파일들이 필요합니다.

전달하는 파일은 다음과 같습니다.

  • docker-compose.yml
  • Swagger OpenAPI 문서

Docker 이미지는
애플리케이션 실행에 필요한 파일만 포함하도록 구성되어 있기 때문에,
환경별 설정 파일은 서버에 별도로 전달합니다.

3-7. EC2에서 컨테이너 재기동

배포 서버에서는 다음 순서로 작업을 수행합니다.

  1. Docker Hub 로그인
  2. 지정한 커밋 태그의 이미지 pull
  3. backend 컨테이너만 재기동
  4. 사용하지 않는 이미지 정리

backend 컨테이너만 재기동하는 이유

--no-deps 옵션을 사용해
다른 서비스에는 영향을 주지 않고
backend 컨테이너만 교체합니다.

이를 통해
서비스 중단 범위를 최소화합니다.

4. 최종 정리

이번에는 GitHub Actions가 아닌 Jenkins를 사용해
CI/CD 파이프라인을 직접 구성해보았습니다.

도구의 차이만 있을 뿐이지

  • 코드 변경이 발생하면 빌드와 테스트로 검증하고,
  • 검증이 끝난 결과만 배포하며,
  • 배포 버전은 명확하게 관리해야 한다는 원칙은 동일했기에

처음 접하는 것보다는 훨씬 수월하게 CI/CD 파이프라인을 구축할 수 있었던 것 같습니다.

앞으로 이 흐름에 맞게 파이프라인을 구축하는 연습을 꾸준히 해야할 것 같습니다.


Jenkinsfile

pipeline {
  agent none

  environment {
    IMAGE_NAME = "zhy2on/gaesorelay-backend-api"
    REGISTRY_CREDENTIAL_ID   = "dockerhub-auth"        // Username/Password (Docker Hub ID + Access Token)
    DEPLOY_SSH_CREDENTIAL_ID = "deploy-server-ssh"     // SSH Username with private key

    DEPLOY_USER = "ubuntu"
    DEPLOY_DIR  = "/home/${DEPLOY_USER}/gaesorelay/backend/deploy"
  }

  stages {

    stage('CI: Build & Test') {
      when {
        anyOf {
          branch 'develop'
          expression { return env.CHANGE_ID && env.CHANGE_TARGET == 'develop' }
        }
      }
      agent {
        docker {
          image 'node:20-alpine'
          args '-u root'
        }
      }
      steps {
        checkout scm

        script {
          env.TAG = "sha-${env.GIT_COMMIT.take(7)}"
          writeFile file: '.tag', text: env.TAG
          echo "TAG=${env.TAG}"
          echo "CHANGE_ID=${env.CHANGE_ID}, CHANGE_TARGET=${env.CHANGE_TARGET}, BRANCH_NAME=${env.BRANCH_NAME}"
        }

        sh 'npm ci'
        sh 'npm test'
        sh 'npm run build'

        stash name: 'meta', includes: '.tag'
      }
    }

    stage('CD: Image & Deploy') {
      when {
        allOf {
          branch 'develop'
          expression { return !env.CHANGE_ID }
        }
      }
      agent any

      stages {

        stage('Prepare Workspace') {
          steps {
            deleteDir()
            checkout scm
            unstash 'meta'
            script {
              env.TAG = readFile('.tag').trim()
              echo "Deploy TAG=${env.TAG}"
            }
          }
        }

        stage('Build & Push Docker Image') {
          steps {
            script {
              docker.withRegistry("", "${REGISTRY_CREDENTIAL_ID}") {
                def img = docker.build("${IMAGE_NAME}:${env.TAG}", "-f deploy/backend/Dockerfile .")
                img.push()
                img.push("latest")
              }
            }
          }
        }

        stage('Deploy (pull & run on EC2)') {
          steps {
            sshagent(credentials: ["${DEPLOY_SSH_CREDENTIAL_ID}"]) {
              withCredentials([
                usernamePassword(
                  credentialsId: "${REGISTRY_CREDENTIAL_ID}",
                  usernameVariable: 'REG_USER',
                  passwordVariable: 'REG_PASS'
                ),
                string(
                  credentialsId: 'deploy-server-ip',
                  variable: 'DEPLOY_SERVER_IP'
                ),
                string(
                  credentialsId: 'gms-api-key',
                  variable: 'GMS_API_KEY'
                )
              ]) {
                sh """
                  set -e

                  ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_SERVER_IP} 'mkdir -p ${DEPLOY_DIR}'

                  scp -o StrictHostKeyChecking=no deploy/docker-compose.yml ${DEPLOY_USER}@${DEPLOY_SERVER_IP}:${DEPLOY_DIR}/docker-compose.yml

                  scp -o StrictHostKeyChecking=no deploy/swagger/public/openapi.yaml ${DEPLOY_USER}@${DEPLOY_SERVER_IP}:${DEPLOY_DIR}/swagger/public/openapi.yaml

                  ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_SERVER_IP} '
                    set -e
                    cd ${DEPLOY_DIR}

                    echo "${REG_PASS}" | docker login docker.io -u "${REG_USER}" --password-stdin

                    BACKEND_IMAGE="${IMAGE_NAME}" BACKEND_TAG="${env.TAG}" docker compose pull backend
                    BACKEND_IMAGE="${IMAGE_NAME}" BACKEND_TAG="${env.TAG}" GMS_API_KEY="${GMS_API_KEY}" docker compose up -d --no-deps backend

                    docker image prune -f
                  '
                """
              }
            }
          }
        }
      }
    }
  }
}

0개의 댓글