본문 바로가기
Server

스테이징 서버와 운영서버를 나누어 CI/CD 구축하기

by willie_1020 2024. 8. 30.

들어가며

안녕하세요. 서버 파트 정정교입니다. 팀 블로그의 글을 어떤 식으로 작성할 지 많은 고민을 했는데요. 제가 이 프로젝트를 진행하면서 새롭게 배운 내용이나, 트러블 슈팅 등을 정리하면 좋을 것 같다는 생각을 했습니다. 나중에 방향성이 조금은 바뀔 수 있지만, 앞으로 최대한 우리의 경험을 되짚어보고 해결해나가는 과정 속에서의 이야기를 들려드릴 예정이에요!

 

오늘은 스테이징 서버와 운영서버를 분리하면서 팀원들과 함께 학습한 내용들과 트러블 슈팅에 관한 이야기를 들고왔습니다.

 

보통 프로젝트를 진행할 때, 서버파트 내에서 사용한 배포방식은 feat 브랜치에서 api 작업을 진행하면서 develop 브랜치에 PR을 올려 merge하고 어느정도의 api가 모이면 바로 main 브랜치로 push 보내는 방식을 사용했습니다. main 브랜치에 push하는 PR이 올라가면 해당 코드가 문제가 없는지 확인하고, 깃허브 액션을 통해 서버에 배포하는 방식이죠.

 

하지만 이런 방식에 문제점이 있음을 나중에야 깨달았습니다. develop에 머지하는 과정에서 생기는 컴파일 에러, 혹은 배포과정에서 에러가 생길 수 있고, 만약 에러가 발생한다면 프론트 팀원들이 스웨거나 포스트맨으로 테스트 자체를 진행할 수 없게 될 수 있습니다.

 

운영서버는 실제 배포된 서버이기 때문에 보수적으로 접근해야 하지만 이러한 방식으로는 작은 실수에도 서비스 자체가 먹통이 될 수 있는 리스크가 존재하는 것이죠.

 

이러한 문제를 해결하기 위해 이번에 서버파트에서는 스테이징 서버를 추가하여 두 서버의 환경을 완전히 분리하려고 합니다.


스테이징 서버와 운영 서버

서버는 크게 4종류가 있습니다.

로컬 서버 (Local Server)

로컬 서버(Local Server)는 개발자들이 처음으로 실행시키는 서버라고 할 수 있습니다.

 

흔히 말하는 http://local:8080 또는 https://localhost:8080 으로 접속하여 우리가 개발하는 화면들을 볼 수 있습니다.

 

이 로컬 서버에서는 개발자들의 개발 환경에 따라 결과가 달라질 수 있습니다!

 

개발 서버 (Development Server)

개발 서버(Development Server)는 개발자들의 개인 개발환경이 아닌 1개의 통합된 환경으로 테스트를 할 수 있는 서버를 말합니다.

 

대체적으로, 프로젝트에서 개발 서버는 스테이징 서버(Staging Server)와 환경을 비슷하게 구성하여 테스트를 하는 경우도 있습니다.

스테이징 서버 (Staging Server)

스테이징 서버(Staging Server)는 다른 말로 정말 많이 불립니다.

 

예를 들어, 스테이징서버를 테스트서버(Test Server), QA서버(QA Server)등으로 부르는데요.

 

이 스테이징 서버는 운영 서버 환경과 거의 100%로 비슷할 정도로 환경을 맞춘 다음, 운영 서버에서 사용되는 데이터를 가지고 실질적으로 운영 서버에 반영하기 전에 테스트를 거치는 곳이라고 생각하면 좋습니다.

 

즉, 운영 서버(Production Server)에 반영하기 전 최종 확인을 하는 서버라고 할 수 있겠습니다!

 

운영 서버 (Productions Server)

운영 서버(Production Server)는 실질적으로 운영을 하기 위한 서버입니다.

 

스테이징 서버에서 정상적으로 작동되는 기능들이 운영 서버에 반영되게 됩니다.

 

1차 스프린트 당시에는 로컬 서버와 운영서버 2개만 구축해 사용하고 있었고, 2차 스프린트에는 스테이징 서버를 추가해, 서버를 분리하여 관리할 수 있는 환경을 구축하는 것이 이번 목표입니다:)

 


서버와 데이터베이스

서버

먼저 서버를 분리하는 것을 고려하기 이전에 프리티어 EC2에서 2개의 스프링을 돌릴 수 있는지를 확인했습니다.

 

현재 운영서버가 도커를 통해 실행되고 있어 docker stats로 확인해 보았는데요.

요청이 아예 없을 때 약 400MB이고, EC2 프리티어는 약 1GB의 메모리 가지게 됩니다.

 

현재 Swap 메모리를 사용하고 있지만, 메인 메모리에 비해 속도가 느리기 때문에 2개의 스프링을 돌리는 것은 무리라고 판단을 했습니다.

 

또한 api응답 속도와 같이 성능 테스트를 할경우 서로 영향을 미칠 수 있기 때문에 분리하는 것이 낫다고 판단했습니다. 그래서 동일한 환경의 EC2 2개를 돌리는 것이 더 나은 선택이라 생각했습니다.

 

하나 걸리는 것은 AWS의 프리티어 정책이 EC2의 경우 750시간인데(EC2 1개를 한달 내내 돌려도 남는 시간이다.) 2개를 돌리게 되면 반드시 과금이 될거라는 것이죠.. (궁금해서 얼마나 나올지도 계산해보았더니 총 월 예상 비용은 약 15540 KRW/월이다.)

 

결국 고민을 거듭한 결과 2개의 EC2로 스테이징 서버와 운영 서버를 분리하기로 결정했습니다!

DB

DB의 경우에도 RDS는 스키마를 통해 분리하는 방법이 있고, 별도의 DB 인스턴스를 사용하는 방법이 있습니다.

 

스키마를 통해 분리하는 것의 장점과 단점을 정리해보았습니다.

  • 스키마를 통해 분리하는 것의 장점 과 단점

 

  • 1) 자원 절약
    • 비용 절감: 두 개의 별도 데이터베이스 인스턴스를 운영하는 것보다 비용이 적게 들 수 있다. 동일한 DB 인스턴스를 사용하므로, 추가적인 인스턴스 비용이나 관리 비용이 발생하지 않는다.
    • 관리 간소화: 하나의 DB 인스턴스에서 여러 스키마를 관리하는 것이 두 개의 별도 인스턴스를 관리하는 것보다 더 간단하다. 백업, 모니터링, 유지보수 등의 관리 작업을 한 번에 수행할 수 있다.

 

  • 2) 유사한 환경에서의 테스트
    • 실제 환경과 유사: 동일한 DB 인스턴스에서 스테이징과 운영 환경이 동작하므로, 테스트 환경이 운영 환경과 매우 유사해질 수 있다. 성능 테스트나 스키마 변경 테스트를 실제 운영 환경과 동일한 DB 리소스를 사용하여 수행할 수 있다.

 

  • 3) 데이터 복제와 동기화 용이
    • 데이터 복제: 운영 환경의 데이터를 스테이징 스키마로 복제하는 것이 비교적 간단하다. 데이터 동기화가 필요할 때도 동일한 DB 인스턴스 내에서 작업을 수행할 수 있으므로 복제 및 동기화가 빠르고 효율적일 수 있다.

 

    1. 스키마를 통해 분리하는 것의 단점 및 리스크
    • 1) 데이터 손상 위험
    • 스키마 간 실수 위험: 동일한 DB 인스턴스 내에서 스테이징과 운영 스키마를 구분하더라도, 실수로 잘못된 스키마에 쿼리를 실행할 위험이 있다. 예를 들어, 스테이징 스키마에 데이터를 삽입하거나 수정하려는 작업이 실수로 운영 스키마에서 실행될 수 있다. 이로 인해 운영 데이터에 영향을 미칠 수 있다.
    • 데이터 접근 통제 어려움: DB 사용자와 권한 관리가 복잡해질 수 있다. 개발자가 스테이징 스키마에 접근할 수 있는 권한을 가졌더라도, 운영 스키마에 대한 접근 권한을 잘못 설정하면 데이터 유출이나 손상이 발생할 수 있다.
    • 2) 성능 영향
    • 성능 저하: 스테이징 스키마에서 실행되는 대량의 테스트나 데이터 처리 작업이 운영 스키마에 영향을 미칠 수 있다. 동일한 DB 인스턴스를 공유하기 때문에 자원을 과도하게 사용하게 되면 운영 환경의 성능이 저하될 위험이 있다.
    • 리소스 경합: 스테이징과 운영 스키마가 동일한 DB 인스턴스의 CPU, 메모리, I/O 리소스를 공유하기 때문에, 리소스 경합이 발생할 수 있다. 트래픽이 많은 운영 환경에서 문제가 될 수 있다.
    • 3) 복구 및 유지보수의 복잡성
    • 복구의 어려움: 스테이징 환경에서 발생한 문제가 운영 환경에도 영향을 줄 수 있다. DB 인스턴스 자체에 문제가 발생하면 두 스키마 모두가 영향을 받을 수 있으므로, 복구 작업이 복잡해질 수 있다.
    • 업그레이드 및 유지보수: DB 인스턴스의 업그레이드나 유지보수 작업 시, 스테이징과 운영 환경 모두에 영향을 미칠 수 있다.

 

위의 장단점을 생각해 보았을 때, DB 환경을 아예 격리하여 각각 EC2가 하나씩 연결되도록 하는 것이 좋겠지만, 비용적인 측면에서 생각해보았을때 스키마로 분리하는 방향을 적용해보고 추후에 변경을 논의하는 것이 좋다고 판단했습니다.

 

RDS도 2개를 돌리게 되면 약 16,150 KRW/월의 비용이 발생하기 때문에 좀 더 신중하게 고려해야 한다고 생각했습니다.

 

탄력적 IP

고정 IP 없이도 스테이징 서버를 구축할 수 있지만 몇가지 고려해야할 사항들이 존재합니다.

 

고정 IP가 필요한 경우

 

  • 외부 서비스와의 통합 테스트가 필요한 경우: 스테이징 서버가 외부 API나 서비스와 통합되어 있고, 이 서비스에서 IP 화이트리스트를 사용한다면, IP가 고정되어 있어야 외부 서비스와 안정적으로 통신할 수 있다.

 

  • 도메인 네임 연동하려 할 경우: 만약 스테이징 서버에 도메인 네임을 연결하여 접근하고자 할 경우, 고정 IP를 사용하면 DNS 설정이 간편해진다. IP가 변하지 않으므로 DNS 레코드를 자주 변경할 필요가 없다.

 

  • 접근 제어의 경우: 개발자들이 특정 IP를 통해서만 스테이징 서버에 접근하도록 네트워크 레벨에서 제한하고자 할 때도 고정 IP가 유리하다.

 

만약 스테이징 서버의 EC2 인스턴스에도 탄력적 IP를 할당하면, 두 번째 탄력적 IP에 대해 약 4,850 KRW/월의 가격이 발생하게 됩니다.

 

(

EC2, RDS, ElasticIP를 모두 2개씩 운용할 경우 한달의 약 4만원의 비용이 발생한다는 사실...

)


배포

깃허브 엑션을 통한 도커 배포를 자동화해놓은 상태인데, EC2 인스턴스를 두개로 분리하여 운영 서버와 스테이징 서버를 각각 독립적으로 운영하려는 경우, CI/CD 파이프라인과 배포 스크립트(deploy.sh) 를 어떻게 관리해야 할지에 대한 전략 또한 고려해보아야 합니다.

 

우리는 CI/CD 파이프라인(DOCKER-CD.yml)을 분리하는 방법을 사용해보고자 합니다. 동일한 DOCKER-CD.yml 파일에서 조건부로 분기하는 방법도 있겠지만, 이 방법을 선택한 이유는 운영과 스테이징 환경에 각각 맞는 설정을 안전하게 독립적으로 관리할 수 있다는 점에 있습니다.

 

각 환경에 맞는 deploy.sh 스크립트를 사용하고, 필요한 환경 변수나 설정을 파일에 명시적으로 구분할 수 있는 장점도 있습니다.

환경 분리

(1) Docker Repository 분리

  • 태그를 활용해서 구분해줄 수 있지만, 확실하게 환경을 분리해보고 싶어서 레포지토리를 분리하는 방식을 선택했습니다.

 

  • 각 환경이 별도의 Docker 이미지를 사용하기 때문에, 이미지가 서로 다른 기능이나 설정을 포함하고 있어도 문제가 없습니다.

(2) 포트 분리

  • 우선 Docker Repository를 새롭게 생성하여, 포트만 구분해 주었는데요. 이렇게 하면 운영 서버와 스테이징 서버에서 서로 다른 호스트 포트를 사용하기 때문에, 동일한 물리적 서버에서 실행되더라도 충돌이 발생하지 않게 됩니다. Nginx와 Docker가 각기 다른 포트에서 트래픽을 처리할 수 있도록 설정하였기 때문이죠.
    • 운영서버 : 8080, 8081 포트 사용
    • 스테이징 서버 : 8082, 8083 포트 사용

별도의 DOCKER-CD-staging.yml 파일 생성하기

  • 운영용 DOCKER-CD.yml: 운영 서버에 대한 배포 파이프라인을 정의한 DOCKER-CD.yml 파일을 유지합니다.

 

  • 스테이징용 DOCKER-CD-staging.yml: 스테이징 서버에 대한 배포 파이프라인을 정의한 새로운 DOCKER-CD-staging.yml 파일을 생성했습니다.

 

  • 전체적인 파일의 구조는 운영 서버와 비슷하게 구성하지만, 스테이징 서버의 IP, 사용자 정보, 포트 등은 다르게 설정해 주어야 했습니다.
name: DOCKER-CD-STAGING

on:
  push:
    branches: [ "staging" ]

jobs:
  ci:
    # Using Environment - Staging 환경 사용
    # environment: staging
    runs-on: ubuntu-24.04
    env:
      working-directory: .

    # Checkout - 가상 머신에 체크아웃
    steps:
      - name: 체크아웃
        uses: actions/checkout@v3

      # JDK setting - JDK 21 설정
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '21'

      # Gradle caching - 빌드 시간 향상
      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      # create .yml - yml 파일 생성
      - name: application.yml 생성
        run: |
          mkdir -p ./src/main/resources && cd $_
          touch ./application.yml
          echo "${{ secrets.YML }}" > ./application.yml
          cat ./application.yml
        working-directory: ${{ env.working-directory }}

      - name: application-staging.yml 생성
        run: |
          cd ./src/main/resources
          touch ./application-staging.yml
          echo "${{ secrets.YML_STAGING }}" > ./application-staging.yml
        working-directory: ${{ env.working-directory }}

      # Gradle build - 테스트 없이 gradle 빌드
      - name: 빌드
        run: |
          chmod +x gradlew
          ./gradlew build -x test
        working-directory: ${{ env.working-directory }}
        shell: bash

      - name: docker 로그인
        uses: docker/setup-buildx-action@v2.9.1

      - name: login docker hub
        uses: docker/login-action@v2.2.0
        with:
          username: ${{ secrets.DOCKER_LOGIN_USERNAME }}
          password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN }}

      - name: docker image 빌드 및 푸시
        run: |
          docker build -f Dockerfile-staging --platform linux/amd64 -t terningpoint/terning-staging .
          docker push terningpoint/terning-staging
        working-directory: ${{ env.working-directory }}

  cd:
    needs: ci
    runs-on: ubuntu-24.04

    steps:
      - name: docker 컨테이너 실행
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.STAGING_SERVER_IP }}
          username: ${{ secrets.STAGING_SERVER_USER }}
          key: ${{ secrets.STAGING_SERVER_KEY }}
          script: |
            cd ~
            ./deploy-staging.sh

 

별도의 deploy-staging.sh 파일 생성하기

# 스테이징용 deploy-staging.sh
nginx_config_path="/etc/nginx"
all_port=("8082" "8083") #운영 서버와 포트를 분리
available_port=()
user_name=terningpoint
server_name=terning-staging

docker_ps_output=$(docker ps | grep $server_name)
running_container_name=$(echo "$docker_ps_output" | awk '{print $NF}')
blue_port=$(echo "$running_container_name" | awk -F'-' '{print $NF}')
web_health_check_url=/actuator/health

if [ -z "$blue_port" ]; then
    echo "> 실행 중인 서버의 포트: 없음"
else
    echo "> 실행 중인 서버의 포트: $blue_port"
fi

# 실행 가능한 포트 확인 ( all_port 중 blue_port를 제외한 port )
for item in "${all_port[@]}"; do
    if [ "$item" != "$blue_port" ]; then
        available_port+=("$item")
    fi
done

if [ ${#available_port[@]} -eq 0 ]; then
    echo "> 실행 가능한 포트가 없습니다."
    exit 1
fi

green_port=${available_port[0]}

echo "----------------------------------------------------------------------"
# docker image pull
echo "> 도커 이미지 pull 받기"
docker pull ${user_name}/${server_name}

echo "> ${green_port} 포트로 서버 실행"
echo "> docker run -d --name ${server_name} -p ${green_port}:8080 -e TZ=Asia/Seoul ${user_name}/${server_name}"
docker run -d --name ${server_name}-${green_port} -v /app -p ${green_port}:8080 -e TZ=Asia/Seoul ${user_name}/${server_name}
echo "----------------------------------------------------------------------"

sleep 10
for retry_count in {1..10}
do
    echo "> 서버 상태 체크"
    echo "> curl -s http://localhost:${green_port}${web_health_check_url}"
    response=$(curl -s http://localhost:${green_port}${web_health_check_url})
    up_count=$(echo $response | grep 'UP' | wc -l)

    if [ $up_count -ge 1 ]
    then
        echo "> 서버 실행 성공"
        break
    else
        echo "> 아직 서버 실행 안됨"
        echo "> 응답 결과: ${response}"
    fi
    if [ $retry_count -eq 10 ]
        then
        echo "> 서버 실행 실패"
        docker rm -f ${server_name}-${green_port}

        exit 1
    fi
    sleep 5
done
echo "----------------------------------------------------------------------"
# nginx switching
echo "> nginx 포트 스위칭"
echo "set \$service_url http://127.0.0.1:${green_port};" | sudo tee ${nginx_config_path}/conf.d/service-url-staging.inc
sudo nginx -s reload

sleep 1

echo "----------------------------------------------------------------------"

response=$(curl -s http://localhost${web_health_check_url})
up_count=$(echo $response | grep 'UP' | wc -l)
if [ $up_count -ge 1 ]
then
    echo "> 서버 변경 성공"
else
    echo "> 서버 변경 실패"
    echo "> 서버 응답 결과: ${response}"
    exit 1
fi

if [ -n "$blue_port" ]; then
    echo "> 기존 ${blue_port}포트 서버 중단"
    echo "> docker rm -f ${server_name}-${blue_port}"
    sudo docker rm -f ${server_name}-${blue_port}
        docker rmi $(docker images -f "dangling=true" -q)
fi

/etc/nginx/conf.d/service-url.inc

set $service_url http://127.0.0.1:8083;

 

추가적으로 develop 브랜치에 PR이 올라가기 때문에, merge 하기 전 코드에 결함이 없는지 빌드 및 테스트하는 작업이 필요하다고 판단했고 CI 로직 (DEV-CI)을 추가해 주었습니다.

 

name: DEV-CI

on:
  pull_request:
    branches: [ "develop" ]

jobs:
  build:
    runs-on: ubuntu-24.04
    env:
      working-directory: .

    # Checkout - 가상 머신에 체크아웃
    steps:
      - name: 체크아웃
        uses: actions/checkout@v3

      # JDK setting - JDK 21 설정
      - name: Set up JDK 21
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '21'

      # Gradle caching - 빌드 시간 향상
      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      # Gradle build - 테스트 없이 gradle 빌드
      - name: 빌드
        run: |
          chmod +x gradlew
          ./gradlew build -x test
        working-directory: ${{ env.working-directory }}
        shell: bash

트러블 슈팅

(1) 모든 파일을 제대로 생성한 이후에도 CD 플로우 진행과정에서 deploy-staging.sh 읽어오면서 계속 서버 변경 실패가 뜨는 상황이 발생했다.

  • /actuator/health 접속하니까 502 에러 502 Bad - Gateway 오류가 발생했습니다.
  • 다행히도 이전 CI/CD를 진행할 때, 이미 경험해본(?) 상황이라 docker image가 삭제되지 않고 남아있어 생기는 문제였다는 사실을 알게 되었고, 다음과 같이 해결했습니다.
  • docker ps # 지금 실행중인 도커 컨테이너 docker ps -a # 실행중 아닌것도 다 뜨는 컨테이너 docker rm id값 # 3글자만 입력해도 컨테이너를 자동으로 인식한다 → 컨테이너 지우는 명령어 docker rmi 이미지이름 # id로 못지운다고 한다. → 컨테이너 이미지 지우는 명령어 ./deploy-staging.sh # 도커 이미지 받아오고 도커 컨테이너 실행되는 지 확인. 이후 다시 블루그린 배포 진행

(2) 하나의 RDS로 스키마를 분리하여 스테이징 서버용 스키마에 접근하려 할 때, 서버를 읽어오지 못하고, 서버 통신 실패가 계속해서 발생했다.

저는 스키마 자체에 접근을 못하는 문제라고 판단했고, 분리된 스키마에 어떻게 접근해야 하는지에 대해 고민해보았습니다.

 

처음에는 application.yml 파일에서 다음과 같이 접근했었는데요.

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://{RDS주소}:5432/{DB명}?currentSchema={접근할 스키마 이름}
    ...

하지만 계속 실패해서 DB의 스키마를 자세히 살펴보니 postgres 기본 DB에서는 currentSchema 설정을 해도 public으로 접근이 되고 있었음을 발견했습니다..

 

그래서 스테이징 서버에 접근하는 yml파일을 분리했으니 내부의 default schema를 설정하는 방식으로 코드를 수정했습니다.

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://{RDS주소}:5432/{DB명}?currentSchema={접근할 스키마 이름}
    username: {사용자 이름}
    password: {사용자 비밀번호}

  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        default_schema: develop //default schema를 develop으로 지정하는 코드
        format_sql: true
        show_sql: true

이렇게 코드를 수정하니 문제 해결! 생각보다 스테이징 서버 하나 구축하는데도 손이 많이 가네요.

 

이번 이슈를 통해 CI/CD 플로우에 대한 전반적인 로직과 DB를 스키마로 분리하여 관리하는 방식에 대해 좀 더 깊게 이해할 수 있었던 것 같습니다 :)


출처