티스토리 뷰

반응형

✏️ 1. 배포 방식에 대한 고민

사람들과 팀 플젝을 하면서 느꼈던 문제점 중 하나는 서버를 배포할 때마다 서비스가 잠깐씩 멈춘다는 점이었다.
기존에는 단일 EC2 서버에서 애플리케이션을 교체하는 방식으로 배포를 진행하였는데, 이 방식은 단순하지만 다음과 같은 문제가 있었다.

  • 배포 중 서버 재시작으로 인한 다운타임 발생
  • 배포 실패 시 즉각적인 롤백 어려움
  • 사용자가 배포 타이밍에 따라 오류를 경험할 수 있음

사용자가 있는 서비스라면 배포 때문에 서비스가 끊기는 경험은 반드시 피해야 한다고 생각하여서 무중단 배포를 직접 구축해보기로 하였다.

 

✏️ 2. Blue-Green 배포를 선택한 이유

무중단 배포를 구현하는 방법에는 여러 가지가 있다. 대표적으로 Rolling 배포와 Blue-Green 배포와 카나리 배포 방식이 있다. 이 3개의 배포에 관한 설명은 추후 게시글을 작성할 예정이다. 이 얘기는 뒤로하고 Blue-Green 배포를 선택하기로 하였다. Blue-Green 배포 방식을 선택한 이유는 다음과 같다.

  • 배포 전에 충분히 검증할 수 있는 구조
  • 문제 발생 시 즉시 롤백 가능
  • 트래픽 전환 방식이기 때문에 구조가 명확함

즉, Blue-Green 배포는 간단히 말하면 두 개의 동일환 환경을 운영하고, 트래픽만 전환하는 방식이다.
Blue는 현재 서비스 중인 서버, Green은 새 버전을 배포한 서버를 의미한다. 그래서 새로운 버전을 바로 사용자에게 노출하는 것이 아니라 검증된 이후에만 트래픽을 넘긴다는 점이 핵심이다.

 

✏️ 3. 전체 아키텍쳐

이번 배포 구조는 다음과 같이 구성하였다.
GitHub Actions → Docker 이미지 생성 → DockerHub Push → S3 업로드 → CodeDeploy → EC2 배포 → Nginx 트래픽 전환


주요 구성 요소는 다음과 같고 이 구조에서 중요한 역할을 Nginx가 어떤 컨테이너로 트래픽을 보낼지 결정하는 것이다.

  • EC2: 애플리케이션 실행 서버
  • Docker: 컨테이너 기반 배포
  • Nginx: 트래픽 라우팅(포트 스위칭)
  • CodeDeploy: 배포 자동화
  • S3: 배포 파일 저장
  • GitHub Actions: CI/CD 파이프라인

 

✏️ 4. 배포 전략 설계

배포 흐름은 아래와 같이 설계하였고 간단하게 요약하면 검증->전환->정리이다.

  1. 현재 서비스 중인 포트 확인
  2. 반대 포트에 새로운 컨테이너 실행
  3. 헬스 체크 수행
  4. 성공 시 Nginx 트래픽 전환
  5. 기존 컨테이너 종료

 

✏️ 5. CodeDeploy를 선택한 이유

우선 배포 자동화를 위해 AWS CodeDeploy를 선택하였고 이유는 다음과 같다.
특히 CodeDeploy의 장점은 서버에 직접 SSH로 접속하지 않아도 되고, 배포 과정에서 실핼할 스크립트를 정의할 수 있기 때문에 Blue-Green 배포 로직을 직접 구현할 수 있다.

  • EC2, S3와 자연스럽게 연동
  • 배포를 자동으로 실행할 수 있음
  • appspec.yml을 통해 배포 단계 제어 가능

 

✏️ 6. AWS 세팅

6-1. IAM 생성

 

6-2. 역할 생성

 

 

6-3. S3 생성

6-4. EC2 설정

6-5. CodeDeploy 생성

 

✏️ 7. EC2 설정

7-1. Docker 설치

sudo apt update
sudo apt install -y docker.io
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker ubuntu

7-2. Docker 설치

sudo apt install -y nginx
sudo systemctl start nginx
sudo systemctl enable nginx

7-3. CodeDeploy Agent 설치

sudo apt install -y ruby wget
wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
chmod +x ./install
sudo ./install auto
sudo systemctl start codedeploy-agent
sudo systemctl enable codedeploy-agent

7-4. 설치 확인

docker --version
nginx -v
sudo systemctl status codedeploy-agent

7-5. 가비아 설정

7-6. Nginx 설정

### Cerbot 설치
sudo apt install -y certbot python3-certbot-nginx

### SSL 인증서 발급
sudo certbot --nginx -d lgenius.site -d www.lgenius.site

### 자동 갱신 확인
sudo certbot renew --dry-run 

server {
    listen 80;
    server_name lgenius.site www.lgenius.site;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name lgenius.site www.lgenius.site;

    ssl_certificate /etc/letsencrypt/live/lgenius.site/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/lgenius.site/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    set $service_port 8080;

    location / {
        proxy_pass http://127.0.0.1:$service_port;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

 

✏️ 8. 프로젝트 설정

8-1. Dockerfile

FROM openjdk:17-jdk-jammy

WORKDIR /app

COPY build/libs/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]

8-2. appspec.yml

version: 0.0
os: linux
hooks:
  ApplicationStart:
    - location: scripts/deploy.sh
      timeout: 300
      runas: ubuntu

8-3. scripts/deploy.sh

#!/bin/bash
set -e

BLUE_PORT=8080
GREEN_PORT=8081
NGINX_CONF="/etc/nginx/sites-available/default"
IMAGE="pooreumjung/lgenius-backend:latest"
ENV_FILE="/home/ubuntu/app/.env.prod"

echo "=== Blue-Green Deploy Start ==="

# 0. Docker 네트워크 생성 (없으면)
docker network create lgenius-net 2>/dev/null || true

# 1. Redis 실행 여부 확인
if ! docker ps | grep -q lgenius-redis; then
  echo "Redis 컨테이너가 실행 중이 아닙니다. Redis를 먼저 실행합니다."
  docker run -d \
    --name lgenius-redis \
    --network lgenius-net \
    --restart unless-stopped \
    redis:7-alpine
fi

# 2. 현재 nginx가 바라보는 포트 확인
CURRENT_PORT=$(grep -oP 'set \$service_port \K[0-9]+' $NGINX_CONF | head -n 1)
if [ -z "$CURRENT_PORT" ]; then
  CURRENT_PORT=$BLUE_PORT
fi

if [ "$CURRENT_PORT" = "$BLUE_PORT" ]; then
  NEW_PORT=$GREEN_PORT
else
  NEW_PORT=$BLUE_PORT
fi

echo "현재 포트: $CURRENT_PORT"
echo "새 포트: $NEW_PORT"

# 3. 최신 이미지 pull
echo "이미지 pull 중..."
docker pull $IMAGE

# 4. 기존 NEW_PORT 컨테이너 제거
docker rm -f lgenius-backend-${NEW_PORT} 2>/dev/null || true

# 5. 새 컨테이너 실행
echo "새 컨테이너 실행 (${NEW_PORT})"
docker run -d \
  --name lgenius-backend-${NEW_PORT} \
  --network lgenius-net \
  -p ${NEW_PORT}:8080 \
  --env-file ${ENV_FILE} \
  --restart unless-stopped \
  $IMAGE

# 6. 헬스체크 (최대 120초)
echo "헬스체크 확인 중..."
SUCCESS=false
for i in {1..24}
do
  if curl -fsS --connect-timeout 3 --max-time 5 http://localhost:${NEW_PORT}/actuator/health > /dev/null 2>&1; then
    SUCCESS=true
    echo "헬스체크 성공"
    break
  fi
  echo "헬스체크 재시도 ($i/24)"
  sleep 5
done

if [ "$SUCCESS" != "true" ]; then
  echo "헬스체크 최종 실패 → 롤백"
  docker logs --tail 50 lgenius-backend-${NEW_PORT}
  docker stop lgenius-backend-${NEW_PORT}
  docker rm lgenius-backend-${NEW_PORT}
  exit 1
fi

# 7. nginx 포트 전환
echo "nginx 전환 중..."
sudo sed -i "s/set \$service_port .*/set \$service_port $NEW_PORT;/" $NGINX_CONF
sudo nginx -t
sudo systemctl reload nginx
echo "nginx 전환 완료"

# 8. 기존 컨테이너 종료
echo "기존 컨테이너 종료 (${CURRENT_PORT})"
docker stop lgenius-backend-${CURRENT_PORT} 2>/dev/null || true
docker rm lgenius-backend-${CURRENT_PORT} 2>/dev/null || true

# 9. 사용하지 않는 이미지 정리
docker image prune -f

echo "=== 배포 완료 (포트: $NEW_PORT) ==="

8-4. application-prod.yml

spring:
  datasource:
    url: jdbc:mysql://${DB_URL}:3306/${DB_NAME}?serverTimezone=Asia/Seoul
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
  data:
    redis:
      host: lgenius-redis
      port: 6379

oauth:
  kakao:
    client-id: ${KAKAO_CLIENT_ID}
    client-secret: ${KAKAO_CLIENT_SECRET}
    redirect-uri: ${KAKAO_REDIRECT_URI}
    auth-base-url: https://kauth.kakao.com
    api-base-url: https://kapi.kakao.com

jwt:
  secret: ${JWT_SECRET}
  access-token-expiration: 3600000
  refresh-token-expiration: 604800000

server:
  forward-headers-strategy: framework

8-5. ci.yml

name: CI

on:
  pull_request:
    branches: [ main, develop, deploy/#7/cicd ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: 코드 체크아웃
        uses: actions/checkout@v4

      - name: JDK 17 설정
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: application-prod.yml 생성
        run: |
          mkdir -p src/main/resources
          echo "${{ secrets.APPLICATION_PROD_YML }}" > src/main/resources/application-prod.yml

      - name: Gradle 권한 부여
        run: chmod +x gradlew

      - name: Gradle 빌드 (테스트 제외)
        run: ./gradlew build -x test

8-6. cd.yml

name: CD

on:
  push:
    branches: [ main, deploy/#7/cicd ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: 코드 체크아웃
        uses: actions/checkout@v4

      - name: JDK 17 설정
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: application-prod.yml 생성
        run: |
          mkdir -p src/main/resources
          cat > src/main/resources/application-prod.yml << 'EOF'
          ${{ secrets.APPLICATION_PROD_YML }}
          EOF

      - name: Gradle 권한 부여
        run: chmod +x gradlew

      - name: Gradle 빌드 (테스트 제외)
        run: ./gradlew clean build -x test

      - name: DockerHub 로그인
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Docker 이미지 빌드 및 푸시
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/lgenius-backend:latest

      - name: 배포 파일 zip 압축
        run: |
          zip -r deploy.zip appspec.yml scripts/

      - name: S3 업로드
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: S3에 업로드
        run: |
          aws s3 cp deploy.zip s3://${{ secrets.S3_BUCKET }}/deploy.zip

      - name: CodeDeploy 배포 트리거
        run: |
          aws deploy create-deployment \
            --application-name ${{ secrets.CODEDEPLOY_APP }} \
            --deployment-group-name ${{ secrets.CODEDEPLOY_GROUP }} \
            --s3-location bucket=${{ secrets.S3_BUCKET }},key=deploy.zip,bundleType=zip

 

✏️ 9. 배포 상태 확인

 

✏️ 10. 한계 및 단점

이번 구조는 안정적인 무중단 배포를 가능하게 했지만, 분명 몇 가지 한계점도 존재한다.
우선 CodeDeploy의 경우, AWS 서비스와의 통합은 편리하지만 디버깅이 쉽지 않다는 단점이 있다. 배포가 실패했을 때 로그가 여러 위치에 분산되어 있어 원인을 파악하는 데 시간이 걸릴 수 있었고, 배포 과정이 내부적으로 추상화되어 있어 세밀한 제어에는 한계가 있었다.

또한 Blue-Green 배포 자체의 단점으로는 두 개의 환경을 동시에 유지해야 하기 때문에 리소스 비용이 증가한다는 점이 있다. 특히 EC2 기반 환경에서는 동일한 애플리케이션을 이중으로 운영해야 하므로 비용 부담이 발생할 수 있다.

그리고 구조가 단순한 단일 서버 배포에 비해 구성 복잡도가 증가한다는 점도 고려해야 한다. Nginx, Docker, CodeDeploy, CI/CD 파이프라인까지 여러 요소가 결합되면서 초기 구축과 운영 난이도가 높아졌다.

마지막으로 상태를 가지는 서비스의 경우 세션이나 캐시를 외부 시스템등으로 분리하지 않으면 Blue-Green 전환 시 문제가 발생할 수 있어, 아키텍쳐 설계에 추가적인 고려가 필요했다.

비용과 복잡성이라는 trade-off가 존재했지만, 무중단 배포와 빠른 롤백을 통한 서비스 안전성 확보가 중요하다고 판단하였기에 이 방식을 택하여 배포를 진행하였고 배포도 성공적으로 마무리 하였다. 플젝을 하면서 느끼는 부분이지만 인프라에 관한 지식이 너무 부족한 것 같다. 앞으로는 인프라와 관련된 글들을 공부하고 게시글로 작성해야겠다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함