캡스톤 디자인 프로젝트를 진행하며, 백엔드 서버를 기능별로 나누어 개발하기로 했습니다.
이 경우, 각 서비스를 독립적으로 개발하고 배포할 수 있다는 장점이 있지만, 수동 배포로는 관리와 유지보수가 어렵다는 한계를 가지고 있었습니다.
이에 따라 배포에 드는 반복적이고 소모적인 작업을 줄이고, 개발에 더욱 집중할 수 있는 환경을 만들고자 CI/CD 파이프라인 구축을 제안 및 구성하였습니다.
이 글에서는 당시의 구조와 구현 방식, 그리고 아쉬웠던 부분까지 정리해보려 합니다.
프로젝트 환경 및 조건
서버: Ubuntu 22.04
저장소 플랫폼: GitLab (캡스톤 규정)
백엔드 폴더 구조:
backend/
├── ai/
│ └── Dockerfile
├── grading/
│ └── Dockerfile
├── member/
│ └── Dockerfile
├── review/
│ └── Dockerfile
└── workbook/
└── Dockerfile
Jenkinsfile
docker-compose.yml
아키텍쳐 구조:
도커 파일과 도커 컴포즈 구성
CI/CD 구성을 위해서는 우선 통합된 환경에서 안정적으로 서비스를 실행할 수 있는 컨테이너 기술이 필요하였고, 이를 위해 도커를 사용하기로 하였습니다.
초기 구성 단계에서 도커 파일과 도커 컴포즈 파일을 어떻게 구성해야 할 지 고민됐습니다. 특히, 환경변수, 마운트, 네트워크 설정을 어디에서 해야 적절한 지 헷갈렸습니다.
여러 레포지터리를 찾아보고, 고민해본 결과, 도커 자체에 대한 개념이 부족하여 고민하게 되었다는 점을 알았습니다.
도커 파일은 이미지 빌드를 담당하고, 도커 컴포즈는 이미지 실행을 담당하여 각각 담당하는 역할이 달랐고, 이 구분에 대한 이해가 부족하여 헤매었습니다.
마운트, 네트워크 설정은 실행 시점에 필요한 설정으로, 도커 컴포즈에서 설정하는 것이 적절했습니다.
환경변수의 경우, 컨테이너와 밀접하게 연관된 경우에는 도커파일에서 관리하는 것이 적절했고, (민감한 정보가 아닌 경우)
API Key, DB 정보, 서버 URL 등의 민감한 정보는 도커 컴포즈의 환경변수와 env 파일로 관리하는 것이 적절했습니다.
따라서, 도커 파일은 서버를 실행할 수 있는 환경(빌드, 실행 Entry Point)을 최소한으로 구성하되, 연관된 환경 변수 일부를 포함시키고,
도커 컴포즈에서 나머지 환경 변수, 마운트, 네트워크 등을 관리하는 것으로 결정했습니다.
결과적으로 구성된 도커 컴포즈 파일은 다음과 같습니다.
services:
frontend:
image: frontend
networks:
- lami-backend
workbook:
image: workbook
environment:
- API_SERVER_URL=http://ai:8000
- DATABASE_HOST=${MYSQL_HOST}
- DATABASE_PORT=${MYSQL_PORT}
- DATABASE_DATABASE=${MYSQL_DATABASE}
- DATABASE_USERNAME=${MYSQL_USERNAME}
- DATABASE_PASSWORD=${MYSQL_PASSWORD}
- FILE_PATH=/app/uploads
volumes:
- ./uploads:/app/uploads
networks:
- lami-backend
depends_on:
- db
ai:
image: ai
environment:
- GPT_API_KEY=${GPT_API_KEY}
- DIR_PATH=/tmp/
volumes:
- ./uploads:/tmp
networks:
- lami-backend
member:
image: member
environment:
- DATABASE_HOST=${MYSQL_HOST}
- DATABASE_PORT=${MYSQL_PORT}
- DATABASE_DATABASE=${MYSQL_DATABASE}
- DATABASE_USERNAME=${MYSQL_USERNAME}
- DATABASE_PASSWORD=${MYSQL_PASSWORD}
- REDIS_PORT=6379
- REDIS_HOST=redis
- REDIS_DATABASE=0
- JWT_SECRET=${JWT_SECRET}
- JWT_ACCESS-TOKEN-EXPIRY=3600000
- JWT_REFRESH-TOKEN-EXPIRY=1209600
- MAIL_FROM=${MAIL_FROM}
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_PORT=${MAIL_PORT}
networks:
- lami-backend
depends_on:
- db
review:
image: review
environment:
- WORKBOOK_SERVER_URL=http://workbook:8080/api
- GRADING_SERVER_URL=http://grading:3000/api
- DATABASE_HOST=${MYSQL_HOST}
- DATABASE_PORT=${MYSQL_PORT}
- DATABASE_DATABASE=${MYSQL_DATABASE}
- DATABASE_USERNAME=${MYSQL_USERNAME}
- DATABASE_PASSWORD=${MYSQL_PASSWORD}
networks:
- lami-backend
depends_on:
- db
grading:
image: grading
environment:
- WORKBOOK_SERVER_URL=http://workbook:8080/api
- MEMBER_SERVER_URL=http://member:8080/api
- AI_SERVER_URL=http://ai:8000/api
- DATABASE_HOST=${MYSQL_HOST}
- DATABASE_PORT=${MYSQL_PORT}
- DATABASE_DATABASE=${MYSQL_DATABASE}
- DATABASE_USERNAME=${MYSQL_USERNAME}
- DATABASE_PASSWORD=${MYSQL_PASSWORD}
networks:
- lami-backend
depends_on:
- db
redis:
image: redis:latest
networks:
- lami-backend
db:
image: mariadb:latest
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USERNAME}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
volumes:
- ./db_data:/var/lib/mysql
- ./exec/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- lami-backend
networks:
lami-backend:
external: true
volumes:
db_data:
CI/CD 전략 비교
[1] GitLab CI/CD+ Docker Hub
1. main 브랜치 병합 감지
2. GitLab CI/CD를 통해 도커 이미지 빌드 후 도커 허브에 Push
3. SSH 연결을 통해 배포
[2] GitLab Webhook + Jenkins (선택)
1. main 브랜치 병합 감지
2. GitLab-> Jenkins로 Webhook 전송
3. Jenkins에서 전체 빌드 및 배포 수행
[1]번은 서버 자원이 부족한 경우, 외부에서 이미지를 빌드한 후 서버에서는 실행만 하면 되기 때문에 자원 부담이 적습니다.
[2]번은 Jenkins 사용으로 서버 자원이 많이 요구되어 자원 부담이 상대적으로 큽니다.
저희 서버 성능은 i7-7700, RAM 16GB로 널널한 편이었고, 캡스톤 마감 이후에도 프로젝트를 지속적으로 관리해보고자 했습니다.
따라서 우선 GitLab(학교 서버)으로 관리하되, 마감 이후 GitHub로 레포지터리를 옮기자는 의견이 있었습니다.
그래서 저희는 플랫폼 영향이 적은 [2]번을 선택하게 되었습니다.
왜 하필 Jenkins?
Jenkins는 커뮤니티가 거대하면서, 다양한 플러그인을 지원하여 널리 사용되는 CI/CD 툴입니다.
초기 세팅 및 러닝커브가 발생할 수 있지만, 가장 널리 사용되는 대표적인 도구로서 경험해보고, 학습해두면
나중에 다른 프로젝트에서 CI/CD 도구를 선정할 때 기준 삼아 비교할 수 있는 안목을 기를 수 있을 것으로 기대하여 Jenkins를 선택했습니다.
Jenkins Stage 구성
Jenkins 파일 작성은 해당 레포지터리를 참고하여 작성하였습니다.
참고 레포지토리에서는 다음과 같은 단계로 구성되어 있었습니다.
1. Git clone
2. Git branch 확인
3. 각 서비스 도커 파일 유무 확인
4. 도커 컴포즈 파일 유무 확인
5. 각 서비스 빌드 및 도커 허브 push
6. 도커 컴포즈 실행
- 도커 컴포즈는 도커 허브의 이미지를 내려받아 실행합니다.
7. 미사용 이미지 삭제
저희는 단일 호스트 서버에서 Jenkins와 서비스를 직접 빌드 및 실행하는 구조였습니다.
따라서, 도커 허브를 이용하지 않아도 되었고, 다음과 같은 단계로 구성하였습니다.
1. Git clone
2. Git branch 확인
3. 각 서비스 도커 파일 유무 확인
4. 도커 컴포즈 파일 유무 확인
5. 각 서비스 빌드
6. 도커 컴포즈 실행
- 도커 컴포즈는 빌드된 로컬 이미지를 그대로 사용합니다.
7. 미사용 이미지 삭제
환경변수 등은 Jenkins의 Credential로 설정했습니다.
결과적으로 작성된 Jenkinsfile은 다음과 같습니다:
pipeline {
agent any
environment {
DEPLOY_DIR = "/opt/capstone/deploy"
GIT_BRANCH = "main"
DOCKER_TAG = "latest"
}
stages {
stage('Git Clone') {
steps {
script {
git branch: "${GIT_BRANCH}", credentialsId: 'gitlab', url: 'https://git.chosun.ac.kr/iap1-2025/class-06/team-08.git'
}
}
}
stage('Show Git Branch') {
steps {
script {
def branch = sh(script: 'git rev-parse --abbrev-ref HEAD', returnStdout: true).trim()
echo "Current Git Branch: ${branch}"
}
}
}
stage('Show Directory Structure') {
steps {
script {
sh 'find .'
}
}
}
stage('Show Frontend Dockerfile') {
steps {
script {
def dockerfilePath = "frontend/Dockerfile"
def dockerfileExists = fileExists(dockerfilePath)
if (dockerfileExists) {
echo "Dockerfile for frontend exists, displaying content."
sh "cat ${dockerfilePath}"
} else {
echo "Dockerfile for frontend does not exist, skipping."
}
}
}
}
stage('Show Backend Dockerfiles') {
steps {
script {
def services = ['ai', 'grading', 'member', 'review', 'workbook']
for (service in services) {
def dockerfilePath = "backend/${service}/Dockerfile"
def dockerfileExists = fileExists(dockerfilePath)
if (dockerfileExists) {
echo "Dockerfile for ${service} exists, displaying content."
sh "cat ${dockerfilePath}"
} else {
echo "Dockerfile for ${service} does not exist, skipping."
}
}
}
}
}
stage('Show Docker Compose File') {
steps {
script {
def dockerComposeFilePath = "${WORKSPACE}/docker-compose.yml"
def dockerComposeFileExists = fileExists(dockerComposeFilePath)
if (dockerComposeFileExists) {
echo "docker-compose.yml exists, displaying content."
sh "cat ${dockerComposeFilePath}"
} else {
echo "docker-compose.yml does not exist."
}
}
}
}
stage('Build Frontend Docker Image') {
steps {
script {
def image = "frontend:${DOCKER_TAG}"
def dockerfilePath = "frontend/Dockerfile"
if (fileExists(dockerfilePath)) {
echo "Dockerfile for frontend exists, proceeding with build."
sh """
docker build -t ${image} -f ${dockerfilePath} frontend
"""
} else {
echo "Dockerfile for frontend does not exist, skipping."
}
}
}
}
stage('Build Backend Docker Images') {
steps {
script {
def services = ['ai', 'grading', 'member', 'review', 'workbook']
for (service in services) {
def image = "${service}:${DOCKER_TAG}"
def dockerfilePath = "backend/${service}/Dockerfile"
if (fileExists(dockerfilePath)) {
echo "Dockerfile for ${service} exists, proceeding with build."
sh """
docker build -t ${image} -f ${dockerfilePath} backend/${service}
"""
} else {
echo "Dockerfile for ${service} does not exist, skipping."
}
}
}
}
}
stage('Deploy') {
steps {
script {
sh """
echo "Current User: \$(whoami)"
if [ ! -d "${DEPLOY_DIR}" ]; then
mkdir -p ${DEPLOY_DIR}
fi
cd ${DEPLOY_DIR}
cp ${WORKSPACE}/docker-compose.yml ${DEPLOY_DIR}/
echo "Directory Contents:"
ls -al
if [ -f "docker-compose.yml" ]; then
echo "docker-compose.yml exists."
cat docker-compose.yml
else
echo "docker-compose.yml does not exist."
exit 1
fi
if [ ! -d "${DEPLOY_DIR}/exec/sql" ]; then
mkdir -p "${DEPLOY_DIR}/exec/sql"
fi
cp ${WORKSPACE}/exec/sql/init.sql ${DEPLOY_DIR}/exec/sql/init.sql
docker compose down
docker compose up --build -d
"""
}
}
}
stage('Docker Cleanup') {
steps {
script {
sh """
echo "Cleaning up old Docker images..."
docker image prune -a
"""
}
}
}
}
post {
always {
echo "Cleaning up.."
// 빌드 결과물, 로그, 캐시 파일 등을 삭제(빌드 꼬임 및 오류 방지)
cleanWs()
}
success {
echo 'Pipeline succeeded!'
}
failure {
echo 'Pipeline failed!'
}
}
}
구성 완료된 파이프라인은 정상적으로 동작하였고(많은 시행착오가 있었습니다...), 프로젝트가 끝날 때 까지 쉬지 않고 잘 동작했습니다.
마무리
백엔드와 프론트엔드 간 통합 과정에서 API 연결, 환경 변수 설정 등 예상보다 많은 이슈가 발생하여
인프라 구축에 더 많은 작업(배포 환경 분리, 테스트 자동화)을 못한 점이 아쉽습니다.
만족스러운 점은, 초기 단계에 구축한 CI/CD 파이프라인이 안정적으로 동작하면서,
변경 사항이 빠르고 간편하게 배포되어 개발 효율성을 높이는 데 도움이 되었던 점입니다.
이번 작업을 통해 컨테이너 환경 구성 방법에 대해 고민해보고, 간단한 CI/CD를 구축해보면서, 인프라 전반적인 이해도를 기를 수 있었던 거 같습니다.
'개발 일지' 카테고리의 다른 글
오픈스택 설치 방법과 종류 (0) | 2025.03.24 |
---|