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을 활용해 포트 포워딩 없이 보안성이 강화된 외부 접속 허용
파이프라인 시나리오
- Code Push: GitHub에 코드를 push한다.
- Jenkins Build (미니 PC): 미니 PC에 설치된 젠킨스가 코드를 가져와서 Docker 이미지를 빌드한다.
- Image Push: 빌드된 이미지를 Docker Hub에 push한다.
- Deploy (AWS EC2): 젠킨스가 AWS EC2에 SSH로 접속하여 최신 이미지를 받아와 실행한다.
아래와 같은 아키텍처 구성이다.

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단계 파이프라인을 자동화했다.
- Checkout: GitHub 소스 코드 내려받기
- test: 테스트 코드 테스트 실행
- Build: Docker 내부에서 Java 21 기반 Spring Boot 빌드 및 이미지 생성
- Push: 빌드된 이미지를 Docker Hub에 업로드
- 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 클릭

- Name에 알아보기 쉬운 이름 적어준다.
- Install automatically 체크. 젠킨스가 사용할 JDK를 자동 설치
- jdk 공식 사이트에서 .zip이나 .tar.gz 파일 링크를 Download URL for binary archive에 넣는다.
- Subdirectory of extracted에 bin/java가 있는 폴더명을 작성. 검색해서 작성하거나 직접 받아서 확인
마치며
단순히 docker run으로 띄우는 것보다 docker-compose로 멀티 컨테이너 간의 의존성을 정의하고 Jenkins Pipeline에 통합하며 배포 자동화의 효율성을 체감할 수 있었다. 특히 트러블슈팅 과정에서 로그 분석을 통해 도커 브릿지 네트워크와 호스트 간의 격리 구조를 깊이 이해할 수 있었다. 여기서 멈추지 않고, 테스트 자동화와 TDD를 워크플로우에 녹여내어 비즈니스 로직의 안정성을 높여보고자 한다.
'Infra' 카테고리의 다른 글
| N8N 홈서버 구축 회고(2) - 도커 설정 및 트러블 슈팅 (0) | 2026.01.27 |
|---|