Jenkins 기반 CI/CD 파이프라인 구축
Jenkins와 Docker로 이미지 빌드와 Docker Hub push를 자동화하고, Docker Compose 배포 단계까지 파이프라인 스크립트로 구현한 프로젝트
LG CNS 블로그 서비스를 MSA로 전환하는 팀 프로젝트에서 ai-fortune-service의 Jenkins CI/CD 파이프라인을 맡았습니다. 구축 중 Jenkins가 반복해서 끊기는 장애가 있었는데, 멘토와 함께 docker logs를 확인해 볼륨 마운트 권한 불일치가 원인임을 찾아 해결했습니다.

- 기간
- 2025.10.23 ~ 10.29 (1주)
- 인원
- 7명
- 내 역할
- CI/CD 파이프라인 구축 담당
기술 스택
- AWS EC2
- Jenkins (lts-jdk17)
- Docker
- Docker Hub
- Docker Compose
- Bash
- GitHub
아키텍처
내가 한 일
- EC2에 Jenkins 컨테이너를 구성하고, docker.sock을 마운트해 호스트의 Docker로 이미지를 빌드하도록 설정했습니다.
- Docker Hub Access Token을 Jenkins Credentials에 등록하고, 빌드할 때 환경변수로 주입되도록 했습니다.
- 이미지 빌드, Docker Hub push, Docker Compose 배포까지 이어지는 최종 파이프라인 스크립트를 작성했습니다.
- 실패했을 때 원인을 빨리 찾을 수 있도록 필수값 검증, 클린 clone, 단계별 로그 같은 방어 장치를 넣었습니다.
- 배포 구조를 SSH 원격 배포에서 단일 EC2 로컬 compose 배포로 다시 설계했습니다.
팀 맥락
7명이 세 팀(nGrinder 부하 테스트, Jenkins CI/CD, GitHub Actions CI)으로 나눠 진행했고, nGrinder와 GitHub Actions는 다른 팀원이 맡았습니다. Jenkins 초기 셋업과 docker.sock 권한 해결, SSH 원격 배포 스크립트 초안은 팀원이 만들었고, 저는 이를 바탕으로 단일 EC2 로컬 배포 방식의 최종 파이프라인 스크립트를 직접 작성했습니다.
트러블슈팅
01첫 배포 실패: 인증 환경변수가 주입되지 않음
- 증상
- Docker Hub 계정 환경변수(
DOCKERHUB_USR,DOCKERHUB_PSW)가 비어 있어unbound variable오류로 스크립트가 멈췄습니다. - 원인
- 세 가지가 겹쳤습니다. Jenkins Credentials가 빌드 환경변수로 바인딩되지 않았고, 스크립트가 참조한 변수명(
DOCKERHUB_USR)과 바인딩한 변수명도 달랐습니다. 게다가 Docker Hub를 구글 계정으로 가입해서 비밀번호 자체가 없었습니다. - 해결
- Docker Hub Access Token을 발급해 Jenkins Credentials에 등록하고, Credentials Binding 플러그인으로 환경변수에 주입했습니다. 변수명을
DOCKERHUB_USER/DOCKERHUB_PSW로 맞추고, 값이 비어 있으면 바로 멈추는 필수 변수 검증(${VAR:?})도 넣었습니다.

02Jenkins 접속 불가와 플러그인 설치 실패
- 증상
- 8080 포트로 접속하면 무한 로딩이 걸렸고, 재구축한 뒤에는 플러그인 설치가 매우 느리고 대부분 실패했습니다.
- 대응
- 포트를 8085로 바꾸고 보안그룹을 다시 설정해 처음부터 재구축하면서 원인 가설을 정리했습니다.
- 원인 가설
- 업데이트 서버 타임아웃
- 컨테이너 DNS 불안정
- 설치 lock 파일 잔존
- 디스크 부족
jenkins_home권한 꼬임- 플러그인 의존성 충돌

03진짜 원인: 볼륨 마운트 권한 불일치
- 증상
- 빌드 직전에 Jenkins 연결이 다시 끊겼습니다.
- 원인
- 멘토와 함께 docker logs를 확인해 보니, 마운트한 디렉토리의 권한이 Jenkins 실행 사용자와 맞지 않아 read/write가 안 된다는 로그가 남아 있었습니다.
- 해결
- 마운트할 디렉토리를 미리 만들고 Jenkins 실행 사용자(uid 1000)에게 소유권을 준 뒤 재실행하니 정상적으로 동작했습니다.
- 배운 점
- 정리했던 가설 중 권한 꼬임이 실제 원인이었고, 플러그인 설치 실패와 접속 끊김도 같은 원인이었을 가능성이 컸습니다. 이후로는 장애가 나면 docker logs부터 확인합니다.
설계 결정
배포 구조 결정
- 결정
- 1주라는 짧은 일정 안에 배포를 성공시키는 것을 우선해 단일 EC2 로컬 배포를 골랐습니다.
- 근거와 한계
- 단일 EC2에서는 Jenkins와 서비스가 리소스를 나눠 써서 실제 운영에는 맞지 않습니다. 실제로 메모리 부족도 의심됐기 때문에, 운영 환경이라면 서버를 분리하는 게 맞다고 판단했습니다.
최종 스크립트의 방어 장치
펼쳐 보기
| 겪은 문제 | 반영한 장치 |
|---|---|
| 환경변수 미주입 | ${VAR:?} 필수값 검증 |
| 로그인 캐시 꼬임 | docker logout 후 --password-stdin으로 재로그인 |
| 이전 작업물 잔존 | rm -rf 후 --depth 1 클린 clone |
| 원인 파악이 어려운 실패 | 버전 출력, Dockerfile 존재 체크, 단계별 로그 |
| 롤백 불가 | :BUILD_NUMBER + :latest 이중 태깅 |
| 배포 환경 차이 | compose 파일이 있을 때만 배포하도록 옵션화 |
최종 파이프라인 스크립트
펼쳐 보기
bash
APP="ai-fortune"
REPO_URL="https://github.com/lgcns2-mini/inspire3_fortune.git"
BRANCH="main"
: "${DOCKERHUB_USER:?DOCKERHUB_USER is empty}" # Jenkins가 주입
: "${DOCKERHUB_PSW:?DOCKERHUB_PSW is empty}" # Jenkins가 주입
IMAGE="${DOCKERHUB_USER}/${APP}"
TAG="${BUILD_NUMBER:-latest}"
# (배포 옵션) EC2 로컬에 준비해 둔 compose 파일 경로
COMPOSE_FILE="/opt/compose/fortune.yml"
echo "==> APP=${APP}"
echo "==> REPO=${REPO_URL} (${BRANCH})"
echo "==> IMAGE=${IMAGE}:${TAG}"
### ── 1) 디버그용 버전 확인 ─────────────────────────────────────────────────
docker version || true
docker compose version || true
# ⭐(추가) 캐시 로그인 비우고 새 토큰으로 로그인, 베이스 이미지 미리 당김
docker logout || true
echo "${DOCKERHUB_PSW}" | docker login -u "${DOCKERHUB_USER}" --password-stdin
docker pull openjdk:17-jdk-slim
### ── 2) 소스 체크아웃(깨끗하게) ───────────────────────────────────────────
rm -rf repo
git clone -b "${BRANCH}" --depth 1 "${REPO_URL}" repo
cd repo
# 필요 시 Gradle 빌드가 선행되어야 하는 프로젝트면 주석 해제
# ./gradlew -x test build
# Dockerfile 존재 확인(있으면 진행, 없으면 실패)
test -f Dockerfile || { echo "❌ Dockerfile not found in $(pwd)"; exit 1; }
### ── 3) 이미지 빌드 ────────────────────────────────────────────────────────
echo "==> docker build ${IMAGE}:${TAG}"
docker build --pull -t "${IMAGE}:${TAG}" .
### ── 4) DockerHub 푸시 ─────────────────────────────────────────────────────
echo "==> docker push ${IMAGE}:${TAG}"
docker push "${IMAGE}:${TAG}"
echo "==> tag & push :latest"
docker tag "${IMAGE}:${TAG}" "${IMAGE}:latest"
docker push "${IMAGE}:latest"
docker logout || true
### ── 5) (옵션) 배포: compose pull/up ──────────────────────────────────────
if [ -f "${COMPOSE_FILE}" ]; then
echo "==> Deploy with compose: ${COMPOSE_FILE}"
docker compose -f "${COMPOSE_FILE}" pull
docker compose -f "${COMPOSE_FILE}" up -d
else
echo "==> Skip deploy (compose file not found: ${COMPOSE_FILE})"
fi
### ── 6) 완료 ───────────────────────────────────────────────────────────────
echo "==> DONE. Pushed ${IMAGE}:${TAG} and ${IMAGE}:latest"결과
- Docker Hub 이미지 태그
- 7 / latest
- 두 태그의 digest
- 0d69a56e1d90 (동일)
- push 날짜
- 2025-10-28
한계
- Docker Compose 배포 단계는 스크립트로 구현했지만, 실제로 실행한 기록은 남아 있지 않습니다.
- 단일 EC2 구성이라 Jenkins와 서비스가 리소스를 나눠 씁니다.
다음 개선 과제
- CI 서버와 서비스 서버 분리
- docker.sock 마운트 대신 권한을 최소화한 빌드 방식 검토
- deprecated 상태인 openjdk 베이스 이미지를 eclipse-temurin으로 교체