Infra

Jenkins와 Docker Compose를 이용한 Spring Boot CI/CD 구축기(feat. 홈서버)

뽀루피 2026. 1. 29. 04:04

1. 개요

AWS EC2의 프리티어는 메모리가 1GB 정도로 빌드하기엔 어려운 환경이었다. 마침 집에 사용하고 있던 홈서버용 미니pc가 있었기에 무거운 업무는 미니pc에서, docker 이미지만 AWS에 띄워 안정적으로 서비스하는 배포 전략을 계획했다.

 

  • 인프라 구성: AWS EC2 (Amazon Linux 2023), 홈서버 미니pc(linux ubuntu 24.04.3 LTS)
  • JDK 환경: Java 21.0.9
  • CI/CD 도구: Jenkins (Docker 환경)
  • 배포 방식: Docker Compose를 활용한 멀티 컨테이너 배포 (Spring Boot, MySQL, Jenkins 등)
  • 네트워크 전략: Cloudflare Tunnel을 활용해 포트 포워딩 없이 보안성이 강화된 외부 접속 허용

 

 

파이프라인 시나리오

  1. Code Push: GitHub에 코드를 push한다.
  2. Jenkins Build (미니 PC): 미니 PC에 설치된 젠킨스가 코드를 가져와서 Docker 이미지를 빌드한다.
  3. Image Push: 빌드된 이미지를 Docker Hub에 push한다.
  4. Deploy (AWS EC2): 젠킨스가 AWS EC2에 SSH로 접속하여 최신 이미지를 받아와 실행한다.

 

 

아래와 같은 아키텍처 구성이다.

docker라 되어있지만 docker hub다

 

2. 젠킨스 docker compose 설정 (홈서버 측)

services:
  jenkins:
    image: jenkins/jenkins:lts
    container_name: jenkins
    user: root  # Docker 소켓 권한 및 파일 쓰기 권한을 위해 root 권한 사용
    privileged: true
    restart: always
    ports:
      - "50000:50000" # 에이전트 통신용 포트
    volumes:
      - ./jenkins_home:/var/jenkins_home    # 현재 폴더에 데이터 저장
      - /var/run/docker.sock:/var/run/docker.sock # 미니 PC의 Docker를 빌드에 사용
    environment:
      - TZ=Asia/Seoul # 시간대 설정
      
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: always
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - TUNNEL_TOKEN=${CLOUDFLARED_TOKEN}
    command: tunnel run

① Docker-in-Docker (DinD) 기반 설정

젠킨스 컨테이너 안에서 도커 빌드 명령어를 실행해야 하므로, 미니 PC의 도커 엔진을 젠킨스가 빌려 쓸 수 있게 설정했다.

  • - /var/run/docker.sock:/var/run/docker.sock: 호스트의 도커 소켓을 공유하여 젠킨스가 사용할 수 있도록 함.
  • user: root & privileged: true: 빌드 과정에서 발생하는 파일 권한 문제와 도커 실행 권한을 해결하기 위해 설정함.

② 외부 노출 및 도커 내부 통신 (Cloudflared)

  • 보안: 퍼블릭 인터넷에 포트를 직접 개방하지 않고, Cloudflare의 암호화된 터널을 통해서만 접근할 수 있도록 했다.
  • extra_hosts 설정: 미니pc에서 편집기용으로 vscode를 사용중이었다. cloudflare와 vscode는 각각 다른 컨테이너에서 실행 중이었는데 cloudflare에서 vscode로 요청을 보내기 위해서는 컨테이너 내부에서 host로 접근이 가능한 host.docker.internal:host-gateway 옵션이 필요했다.

 

3. SpringBoot, MySQL docker compose 설정 (EC2 서버측)

services:
  db:
    image: mysql:latest
    container_name: mysql-db
    restart: always
    environment:
      MYSQL_DATABASE: funchat
      MYSQL_ROOT_PASSWORD: ${ROOT_PW}
      MYSQL_USER: ${USER_ID}
      MYSQL_PASSWORD: ${USER_PW}
    volumes:
      - ./mysql_data:/var/lib/mysql

  app:
    image: ${DOCKER_IMAGE}:latest
    container_name: spring-app
    restart: always
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/funchat?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
      SPRING_DATASOURCE_USERNAME: ${USER_ID}
      SPRING_DATASOURCE_PASSWORD: ${USER_PW}
      SPRING_JPA_HIBERNATE_DDL_AUTO: update
    ports:
      - "8080:8080"
    depends_on:
      - db

 

  • MySQL: 데이터 영속성을 위해 ./mysql_data 폴더와 컨테이너의 /var/lib/mysql (MySQL 리눅스 표준 데이터 저장 경로)을 볼륨 마운트했다.
  • Spring Boot: DB 컨테이너 이름(db)을 호스트네임으로 사용하여 내부 네트워크 통신을 설정하고, depends_on으로 실행 순서를 보장했다.
  • 환경 변수 관리: 보안을 위해 .env 파일을 활용하여 DB 비밀번호 등을 분리 관리했다.

 

4. Dockerfile 설정

# 1단계: 빌드 환경
FROM gradle:jdk21-ubi AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY src ./src
RUN gradle clean bootJar -x test

# 2단계: 실행 환경
FROM eclipse-temurin:21.0.9_10-jre-alpine-3.23
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar

ENTRYPOINT ["java", "-jar", "app.jar"]

 

빌드환경

gradle 공식 이미지는 Gradle 실행 환경이 이미지 레이어에 포함되어 있어서 매번 Gradle을 다운로드하는 시간을 아낄 수 있다.

  • WORKDIR: /app으로 설정하여 실행 파일이 놓일 작업 폴더를 지정한다.
  • COPY...: gradle 이미지는 실행환경이 포함되어 있으므로 추가된 코드들만 처리한다. 이후 소스코드를 복사
  • RUN
    • gradle: 설치된 Gradle 실행
    • clean bootJar: 기존 빌드물을 지우고, 실행 가능한 Jar 파일을 생성한다.
    • -x test: 빌드 속도를 개선하고 별도의 테스트 스테이지를 실행하기 위해 테스트 실행을 제외한다.

실행환경

JRE는 jdk보다 가벼운 패키지로 컴파일러 등 보안에 위험한 부분을 제외한 실행에 필요한 부분만을 담아 빠르고 안정적이다.

  • COPY --from=build /app/build/libs/*.jar app.jar: 빌드환경에서 만든 jar파일을 복사한다.
  • ENTRYPOINT ["java", "-jar", "app.jar"]: 컨테이너가 시작될때 자동으로 실행할 명령어 지정.

 

5. Jenkins Pipeline 구축

pipeline {
    agent any
    
    environment {
        DOCKER_IMAGE = "changbill/funchat"
        AWS_IP = "15.164.233.8"
        DOCKER_HUB_CREDS = 'docker-hub-credentials'   // 젠킨스에 등록한 Docker ID
        AWS_CREDS = 'funchat-ec2-credentials'         // 젠킨스에 등록한 AWS .pem 키 ID
    }

    stages {
        stage('1. 준비 (Checkout)') {
            steps {
                git branch: 'main', url: 'https://github.com/changbill/FunChat'
            }
        }

        stage('2. 테스트 (Unit Test)') {
            steps {
                dir('backend') {
                    sh "./gradlew test" 
                }
            }
        }
        
        stage('3. 빌드 (Spring Boot Build)') {
            steps {
                dir('backend') {
                    script {
                        sh "docker build -t ${DOCKER_IMAGE}:latest ."
                    }
                }
            }
        }
        
        stage('4. 이미지 생성 및 업로드 (Docker Push)') {
            steps {
                script {
                    withCredentials([usernamePassword(credentialsId: "${DOCKER_HUB_CREDS}", passwordVariable: 'DOCKER_PASS', usernameVariable: 'DOCKER_USER')]) {
                        sh 'echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin'
                        sh 'docker push ${DOCKER_IMAGE}:latest'
                    }
                }
            }
        }

        stage('5. AWS 원격 배포 (EC2 Deploy)') {
            steps {
                sshagent(credentials: ["${AWS_CREDS}"]) {
                    script {
                        sh "scp -o StrictHostKeyChecking=no backend/docker-compose.yml ec2-user@${AWS_IP}:~/funchat/docker-compose.yml"
                        
                        withCredentials([usernamePassword(credentialsId: "${DOCKER_HUB_CREDS}", passwordVariable: 'DOCKER_PASS', usernameVariable: 'DOCKER_USER')]) {
                            sh '''
                                ssh -o StrictHostKeyChecking=no ec2-user@$AWS_IP "
                                    echo '$DOCKER_PASS' | sudo docker login -u '$DOCKER_USER' --password-stdin

                                    cd ~/funchat
                                    sudo docker compose pull
                                    sudo docker compose up -d
                                    sudo docker logout
                                    sudo docker image prune -f
                                "
                            '''
                        }
                    }
                }
            }
        }
    }
}

 

Jenkinsfile을 작성하여 5단계 파이프라인을 자동화했다.

  1. Checkout: GitHub 소스 코드 내려받기
  2. test: 테스트 코드 테스트 실행
  3. Build: Docker 내부에서 Java 21 기반 Spring Boot 빌드 및 이미지 생성
  4. Push: 빌드된 이미지를 Docker Hub에 업로드
  5. Deploy: SSH Agent를 통해 EC2에 접속 후 docker-compose pull 및 up -d 실행
  • withCredentials: 실행 로그에 비밀번호를 '마스킹 처리'하는 명령어. {} 중괄호가 닫히게 되면 메모리에 담긴 아이디와 비밀번호는 삭제된다.
  • usernamePassword(...): 젠킨스 관리페이지에 등록해둔 Username with password 타입의 자격증명을 쓴다는 내용. credentialsId로 credentials를 찾고, passwordVariable과 usernameVariable을 변수에 담는다는 내용.
  • echo $DOCKER_PASS: 도커 비밀번호를 화면에 출력하라는 내용. withCredentials 설정으로 ***이 로그에 찍힌다.
  • | (파이프라인): 앞의 출력값(비밀번호)을 뒤에 오는 명령어의 입력값으로 바로 넘겨주라는 기호
  • --password-stdin: 비밀번호를 직접 타이핑하지 않고 앞의 echo 명령어로 넘어온 값을 입력값으로 받는다는 내용
  • sshagent: 젠킨스에 저장된 .pem키를 사용하여 ssh로 접속
  • sh "scp -o StrictHostKeyChecking=no backend/docker-compose.yml ec2-user@${AWS_IP}:~/funchat/docker-compose.yml"
    scp: 전송 명령어로 동일한 이름이 있다면 덮어씀
    -o StrictHostKeyChecking=no: 믿을 수 있는 서버인지 묻는 메시지를 무시하는 옵션.
    backend~: backend/docker-compose.yml를 ec2 서버의 해당 위치에 보낸다는 의미.
  • ssh -o StrictHostKeyChecking=no ec2-user@$AWS_IP: ssh로 ec2에 접속하는 명령어.
    그 뒤엔 ec2-user라는 이름의 사용자로 $AWS_IP 서버에 접속한다는 내용
  • 테스트 시 gradlew에 실행 권한 주기: 윈도우에서 작업한 파일을 리눅스 기반인 젠킨스 컨테이너로 가져오면 '실행 불가한 일반 텍스트'로 처리한다. 이를 해결하기 위해 로컬 프로젝트 폴더(git bash)에서 다음 명령어를 입력하자.
git update-index --chmod=+x backend/gradlew
git commit -m "fix: add execution permission to gradlew"
git push origin main

 

테스트 스테이지를 별도로 만든 이유는 빌드 시 테스트가 한꺼번에 일어날 경우 오류가 발생할 때 어디서 문제가 발생했는지 로그를 봐야지만 알 수 있기 때문에 책임을 별도로 가질 수 있도록 분리해줬다.

 

 

6. 트러블 슈팅 (Troubleshooting)

Case 1. Docker 컨테이너 무한 Restarting

  • 문제: docker ps 확인 시 앱 컨테이너가 Up이 아닌 Restarting 상태 반복.
  • 원인 파악: docker logs -f spring-app으로 로그 분석 결과 ClassNotFoundException: com.mysql.cj.jdbc.Driver 발견.
  • 해결: build.gradle에 runtimeOnly 'com.mysql:mysql-connector-j' 의존성이 누락된 것을 확인하고 추가 후 재빌드.

Case 2. GitHub Push - Permission Denied

  • 문제: Git Bash에서 push 시 이전 계정명으로 인증을 시도하며 권한 오류 발생.
  • 해결: 윈도우 자격 증명 관리자에서 기존 git:https://github.com 항목을 삭제하여 인증 정보를 초기화하고 현재 계정으로 재인증.

Case 3. Spring Security 로그인 화면

  • 문제: 성공적으로 배포 후 접속했으나 뜻밖의 로그인 창이 나타남.
  • 원인: 스프링 부트 프로젝트 생성 시 포함된 Spring Security가 모든 경로를 기본적으로 차단함.
  • 해결: 개발 초기 단계임을 감안하여 모든 요청을 허용하는 SecurityConfig 클래스를 작성하여 적용.

Case 4. Jenkins java: command not found

  • 문제: 젠킨스 파이프라인을 실행했는데, sh "./gradlew test" 단계에서 'java: command not found' 에러가 발생함.
  • 원인: 젠킨스를 구동하기 위한 최소한의 자바 환경만 갖춰져 있고 빌드를 위한 JDK 환경은 안갖춰져 있었다.
  • 해결: 젠킨스 내부적으로 JDK를 관리하도록 설정한다.

 

Jenkins 페이지에서 설정 -> Tools 클릭

 

 

  1. Name에 알아보기 쉬운 이름 적어준다.
  2. Install automatically 체크. 젠킨스가 사용할 JDK를 자동 설치
  3. jdk 공식 사이트에서 .zip이나 .tar.gz 파일 링크를 Download URL for binary archive에 넣는다.
  4. Subdirectory of extracted에 bin/java가 있는 폴더명을 작성. 검색해서 작성하거나 직접 받아서 확인

 

 

 

마치며

단순히 docker run으로 띄우는 것보다 docker-compose로 멀티 컨테이너 간의 의존성을 정의하고 Jenkins Pipeline에 통합하며 배포 자동화의 효율성을 체감할 수 있었다. 특히 트러블슈팅 과정에서 로그 분석을 통해 도커 브릿지 네트워크와 호스트 간의 격리 구조를 깊이 이해할 수 있었다. 여기서 멈추지 않고, 테스트 자동화와 TDD를 워크플로우에 녹여내어 비즈니스 로직의 안정성을 높여보고자 한다.

'Infra' 카테고리의 다른 글

N8N 홈서버 구축 회고(2) - 도커 설정 및 트러블 슈팅  (0) 2026.01.27