들어가며
안녕하세요. 요즘에 플러피(fluffy)라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 현재 서비스는 초기 단계로, 새로운 기능 추가와 버그 수정을 빈번하게 진행하고 있습니다. 현재 인프라는 하나의 AWS EC2 인스턴스에서 Nginx, Docker, GitHub Actions를 활용하여 지속적으로 배포되고 있습니다.
하지만 새로운 버전을 배포할 때마다 기존 컨테이너를 중지하고, 새로운 컨테이너를 실행하며, 애플리케이션(Spring)을 시작하는 과정을 거치게 됩니다. 이 과정에서 약 20초 정도의 다운타임(downtime)이 발생하여 사용자가 서비스를 이용하지 못하는 상황이 발생하고 있습니다. 이러한 다운타임은 사용자에게 불편을 초래하고, 이탈 가능성을 높이는 문제점을 안고 있습니다.
이러한 문제를 해결하기 위해 무중단 배포, 특히 블루/그린 배포 방식을 도입하기로 결정했습니다. 이번 글에서는 여러 배포 방식 중 블루/그린 배포를 선택한 이유와 함께, Nginx, Docker, GitHub Actions를 활용하여 블루/그린 배포를 구현하는 방법을 자세히 소개하겠습니다.
무중단 배포란?
먼저 무중단 배포에 대해 간단히 알아보겠습니다. 무중단 배포란, 서비스의 가용성(availability)을 유지하면서 새로운 버전의 애플리케이션을 배포하는 방식을 말합니다. 기존에 서비스 중인 애플리케이션을 중지하지 않고, 새로운 버전의 애플리케이션을 배포하여 서비스의 지속적인 이용을 가능하게 합니다.
무중단 배포의 대표적인 방식으로는 롤링 업데이트(rolling update), 블루/그린 배포(blue/green deployment), 카나리아 배포(canary deployment)가 있습니다. 이미 많은 블로그에 작성되어 있어 간단하게 알아보겠습니다.
롤링 업데이트(rolling update)
롤링 업데이트는 기존 서버에서 점진적으로 새로운 버전으로 업데이트하는 방식입니다.
전체 인스턴스를 한 번에 교체하지 않고, 일부 인스턴스를 순차적으로 업데이트하여 서비스의 가용성을 유지합니다.
- 장점: 점진적으로 변경하기 때문에 롤백이 쉽다.
- 단점: 기존 환경과 새로운 환경의 호환성 문제가 발생할 수 있다.
블루/그린 배포(blue/green deployment)
블루/그린 배포 방식은 두 개의 환경(블루, 그린)을 사용하여, 기존 환경(블루)에서 새로운 환경(그린)으로 전환하는 방식입니다.
새로운 환경의 배포가 완료되면, 기존 환경의 트래픽을 새로운 환경으로 일제히 전환하여 서비스의 가용성을 유지합니다.
- 장점: 기존 환경을 새로운 환경으로 일제히 전환하기 때문에 호환성 문제가 없다.
- 단점: 실제 서비스 환경을 두 배로 운영해야 하기 때문에 비용이 더 많이 든다.
카나리 배포(canary deployment)
카나리 배포는 새로운 버전을 소수의 사용자에게만 배포해 보고, 문제가 없는 경우 점진적으로 더 많은 사용자에게 배포하는 방식입니다.
마치 "카나리"가 위험을 감지하는 것처럼, 초기 배포에서의 반응을 살펴보는 것입니다.
새로운 버전이 안정적으로 동작하는 것을 확인한 후, 전체 트래픽을 새로운 버전으로 전환합니다.
- 장점: 초기 배포에서의 문제를 빠르게 발견할 수 있다.
- 단점: 기존 환경과 새로운 환경의 호환성 문제가 발생할 수 있다.
블루/그린 배포를 선택한 이유
플러피 서비스에서 블루/그린 배포를 선택한 이유는 다음과 같습니다.
먼저, 플러피 서비스는 개발 초기 단계이기 때문에 기능이 새롭게 추가되거나 수정, 삭제되는 일이 빈번하게 발생하고 있습니다.
예를 들어, 기존 버전에 없었던 문제 지문이 추가되거나, 시험 출제 일정 등록 기능이 삭제되는 등의 변경이 발생하고 있습니다.
이 경우 롤링 업데이트 방식이나 카나리 배포 방식은 기존 환경과 새로운 환경의 호환성 문제가 발생할 수 있습니다.
다음으로, 플러피 서비스는 하나의 AWS EC2 인스턴스에서 하나의 Docker 컨테이너로 서비스가 운영되고 있습니다.
이미 Nginx와 Docker를 활용하고 있기 때문에, Docker를 통해 블루와 그린 환경을 구성하고, Nginx로 트래픽을 전환하는 방식이 쉽게 구현 가능합니다.
블루/그린 배포 구현
이제 Nginx, Docker, GitHub Actions를 활용하여 블루/그린 배포를 구현하는 방법에 대해 알아보겠습니다.
구체적인 구현 방법은 개발자나 환경에 따라 다룰 수 있으니, 큰 흐름을 따라가며 자신의 환경에 맞게 구현해보시기 바랍니다.
우선, AWS EC2에 Nginx가 설치된 환경에서 Docker를 사용하여 하나의 컨테이너로 서비스를 운영하고 있다고 가정하겠습니다.
또한, GitHub Actions의 Self-hosted Runner를 통해 지속적 배포(Continuous Deployment)가 이루어지고 있는 상황입니다.
구현할 내용 요약
먼저, 블루/그린 배포 작동 방식을 간단하게 설명드리겠습니다.
- 이전 배포에 따라서 블루 환경 또는 그린 환경이 운영 중입니다. 블루 환경이 운영 중이라고 가정하겠습니다. 블루 환경은 8080:8080, 8082:8082 포트로 서비스를 제공하고 있습니다. 참고로, 8080:8080의 의미는 호스트 머신의 8080 포트로 요청이 들어오면 컨테이너의 8080 포트로 요청을 전달한다는 의미입니다. 8080는 스프링 애플리케이션의 포트이고, 8082는 헬스 체크를 위한 액츄에이터 엔드포인트의 포트입니다.
- 새로운 버전의 도커 이미지를 빌드하고, 도커 레지스트리에 배포합니다. 그리고, 그린 환경을 구성합니다. 그린 환경은 8081:8080, 8083:8082 포트로 서비스를 제공하고 있습니다. 각각 스프링 애플리케이션의 포트와 액츄에이터 엔드포인트의 포트입니다.
- 헬스 체크를 통해 그린 환경이 정상적으로 동작하는지 확인합니다. 그린 환경이 정상적으로 동작하면, Nginx 설정을 변경하여 8080 포트로 들어오는 요청을 8081 포트로 전환합니다.
이후 nginx로 들어오는 요청은 그린 환경으로 전환되며, 블루 환경은 중지됩니다.
블루/그린 배포를 구현하기 위해 다음과 같은 내용을 구현해야 합니다.
- docker compose를 사용하여, 블루와 그린 서비스를 구성한다.
- github workflow를 사용하여, 도커 이미지를 빌드 및 레지스트리에 배포하고,
- 블루와 그린 컨테이너를 전환하고, 헬스 체크를 하는 스크립트를 작성한다.
Docker Compose로 블루/그린 서비스 구성하기
먼저, Docker Compose를 사용하여 블루와 그린 서비스를 구성합니다.compose.blue.yml
, compose.green.yml
과 같이 파일을 나눠서 구성할 수도 있지만 여기서는 하나의 파일로 구성하겠습니다. 참고로, 저는 Redis를 사용하고 있기 때문에 Redis 서비스도 함께 구성하였습니다. 필요에 따라 수정하시기 바랍니다.
상황에 따라서 Nginx를 직접 설치해 사용하는 것이 아닌 Docker 이미지를 사용하는 것도 가능합니다. 저는 Nginx를 직접 설치하여 사용하고 있습니다.
# compose.yml
x-app: &app
image: ${DOCKER_APP_IMAGE}
env_file:
- .env
environment:
TZ: Asia/Seoul
SPRING_PROFILES_ACTIVE: prod
restart: always
depends_on:
- redis
services:
redis:
container_name: redis
image: redis:7.4.1
restart: always
ports:
- '6379:6379'
app-blue:
<<: *app
container_name: app-blue
ports:
- '8080:8080'
- '8082:8082'
app-green:
<<: *app
container_name: app-green
ports:
- '8081:8080'
- '8083:8082'
x-app
은 app-blue와 app-green에서 공통으로 사용하는 설정을 정의한 것입니다. services에서 app-blue와 app-green을 정의하고, <<: *app
을 통해 공통 설정을 상속 받습니다. 자세한 부분은 공식문서를 참고하시기 바랍니다.
app-blue와 app-green은 각각 호스트에서 들어오는 8080, 8081 포트를 통해 컨테이너 내부의 8080 포트로 요청을 전달합니다. 8080 포트는 스프링 애플리케이션 포트입니다. 또한, 호스트에서 들어오는 8082, 8083 포트를 통해 컨테이너 내부의 8082 포트로 요청을 전달합니다. 8082 포트는 스프링 애플리케이터 엔드포인트의 포트로 헬스 체크를 위해 사용 중입니다. 8082 값은 management.server.port
로 설정한 값입니다.
GitHub Workflow 작성하기
다음으로, GitHub Workflow를 작성하여 블루/그린 배포를 구현합니다.
GitHub Workflow는 .github/workflows
디렉토리에 작성하며, server_cd.yml
과 같이 작성하겠습니다.
애플리케이션 빌드 후 레지스트리에 이미지를 푸시하는 build 부분은 생략하겠습니다.
# .github/workflows/server_cd.yml
name: Server CD
on:
push:
branches:
- main
paths:
- 'server/**'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/server
jobs:
build: ...생략....
deploy:
name: Deploy
runs-on: [self-hosted]
needs: build
defaults:
run:
working-directory: ./server
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create .env file
env:
SECRET_CONTEXT: ${{ toJson(secrets) }}
run: |
echo "$SECRET_CONTEXT" | tr -d '{}' | tr ',' '\n' | sed -n 's/"\(.*\)":\(.*\)/\1=\2/p' > .env
- name: View
run: cat .env
- name: Compose Docker image and Update Nginx configuration
env:
DOCKER_APP_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
run: |
chmod +x ./scripts/deploy.sh # 실행 권한 부여
sudo -E ./scripts/deploy.sh # 환경 변수 유지 및 실행
- name: Docker remove unused images
run: docker image prune -af
- name: Check running containers
run: docker ps -a
deploy 부분에서 Create .env file
부분은 Github Actions의 환경 변수를 .env 파일로 생성하는 부분입니다.
이 부분은 필요에 따라 수정하시기 바랍니다.
Compose Docker image and Update Nginx configuration
에서는 scripts/deploy.sh 스크립트를 실행합니다. 이 스크립트는 도커 컴포즈를 사용하고, 헬스 체크 이후 Nginx 설정을 변경하는 부분을 담당합니다. 참고로, chmod +x
는 실행 권한을 부여하는 명령어이고, sudo -E
는 환경 변수를 유지하면서 실행하는 명령어입니다.
deploy.sh 스크립트 작성하기
마지막으로, deploy.sh 스크립트를 작성합니다.
# server/scripts/deploy.sh
#!/bin/bash
NGINX_CONFIG_PATH="/etc/nginx/sites-available/api.fluffy.run"
BLUE_PORT=8080
GREEN_PORT=8081
BLUE_HEALTH_CHECK_URL="http://localhost:8082/actuator/health"
GREEN_HEALTH_CHECK_URL="http://localhost:8083/actuator/health"
HEALTH_CHECK_ATTEMPTS=5
HEALTH_CHECK_DELAY=5
BEFORE_HEALTH_CHECK_DELAY=20
health_check() {
local target_url=$1
echo "Performing health check for $target_url (attempts: $HEALTH_CHECK_ATTEMPTS, delay: $HEALTH_CHECK_DELAY)..."
for i in $(seq 1 $HEALTH_CHECK_ATTEMPTS); do
response=$(curl -s -o /dev/null -w "%{http_code}" "$target_url")
if [ "$response" -eq 200 ]; then
echo "Health check attempt ($i/$HEALTH_CHECK_ATTEMPTS) passed"
echo "Health check successful for $target_url"
return 0
else
echo "Health check attempt ($i/$HEALTH_CHECK_ATTEMPTS) failed"
fi
sleep $HEALTH_CHECK_DELAY
done
echo "Health check failed for $target_url"
return 1
}
switch_container() {
local prev_container=$1
local prev_port=$2
local next_container=$3
local next_port=$4
local health_check_url=$5
echo "Starting $next_container (port: $next_port)..."
docker compose -f compose.yml up "$next_container" -d
echo "Waiting for $next_container to start..."
sleep $BEFORE_HEALTH_CHECK_DELAY
if ! health_check "$health_check_url"; then
echo "Health check failed, rolling back..."
docker compose -f compose.yml down "$next_container"
return
fi
echo "Updating Nginx configuration... $prev_container (port: $prev_port) -> $next_container (port: $next_port)"
sed -i "s/server localhost:$prev_port/server localhost:$next_port/" "$NGINX_CONFIG_PATH"
echo "Reloading Nginx configuration..."
if ! sudo nginx -s reload; then
echo "Failed to reload Nginx: $(sudo nginx -t 2>&1)"
return
fi
echo "$next_container is now live, stopping $prev_container..."
docker compose -f compose.yml down "$prev_container"
}
IS_GREEN=$(docker container ps | grep app-green)
if [ -z "$IS_GREEN" ]; then
echo "### BLUE >> GREEN ###"
switch_container "app-blue" "$BLUE_PORT" "app-green" "$GREEN_PORT" "$GREEN_HEALTH_CHECK_URL"
else
echo "### GREEN >> BLUE ###"
switch_container "app-green" "$GREEN_PORT" "app-blue" "$BLUE_PORT" "$BLUE_HEALTH_CHECK_URL"
fi
실행되는 부분을 먼저 살펴보겠습니다. IS_GREEN을 통해서 현재 그린 환경이 실행 중인지 확인합니다. -z
는 문자열이 비어있는지 확인하는 옵션입니다. 그린 환경이 실행 중이지 않다면 블루 환경에서 그린 환경으로 전환합니다. 그린 환경이 실행 중이라면 그린 환경에서 블루 환경으로 전환합니다.
switch_container
함수는 컨테이너를 전환하는 함수입니다. 이 함수는 이전 컨테이너와 포트, 다음 컨테이너와 포트, 헬스 체크 URL을 인자로 받습니다. 먼저, 다음 컨테이너를 실행하고, 해당 환경에 대한 헬스 체크를 수행합니다. 헬스 체크가 성공하면 Nginx 설정을 변경하고, Nginx를 리로드합니다. 마지막으로 이전 컨테이너를 중지합니다.
헬스 체크 전 20초 정도 대기하는 이유는 컨테이너가 실행되고, 애플리케이션이 초기화되는 시간을 고려한 것입니다. 이 시간은 애플리케이션에 따라 다를 수 있으니 적절히 조절하시기 바랍니다. 또한, 헬스 체크 시도 횟수와 딜레이 시간도 5초 간격으로 5번 시도하도록 넉넉하게 설정하였습니다. 이 부분도 상황에 맞게 조절하시기 바랍니다.
헬스 체크에 실패할 경우 새로운 환경을 중지하고, 이전 환경을 계속 사용하도록 설정했습니다.
sed -i "s/server localhost:$prev_port/server localhost:$next_port/" "$NGINX_CONFIG_PATH"
부분은 Nginx 설정 파일을 변경하는 부분입니다. 이 부분은 이해하기 위해 Nginx 설정 파일을 살펴보겠습니다. 저 같은 경우 Nginx 설정 파일은 /etc/nginx/sites-available/api.fluffy.run
에 위치하고 있습니다. 그리고 심볼릭 링크로 /etc/nginx/sites-enabled/api.fluffy.run
에 연결되어 있습니다. 이 설정 파일은 다음과 같이 구성되어 있습니다.
# /etc/nginx/sites-available/api.fluffy.run
upstream app {
server localhost:8080; # 기본 값으로 블루 환경을 가리킴
}
server {
server_name api.fluffy.run;
location / {
proxy_pass http://app; # upstream app으로 요청을 전달
...옵션 생략...
}
...SSL 설정 생략...
}
server {
listen 80;
server_name api.fluffy.run;
return 301 https://$host$request_uri;
}
upstream app
은 Nginx에서 사용하는 업스트림 서버를 정의한 부분입니다. 여기서 server localhost:8080
은 기본 값으로 블루 환경을 가리키고 있습니다. proxy_pass http://app;
은 요청을 upstream app
으로 전달하는 부분입니다.
sed -i "s/server localhost:$prev_port/server localhost:$next_port/" "$NGINX_CONFIG_PATH"
는 이 설정 파일에서 server localhost:$prev_port
를 server localhost:$next_port
로 변경하는 부분입니다. 예를 들어, 블루 환경에서 그린 환경으로 전환할 때 server localhost:8080
을 server localhost:8081
로 변경합니다.
마치며
이번 글에서는 무중단 배포, 특히 블루/그린 배포 방식을 소개하고, Nginx, Docker, GitHub Actions를 활용하여 블루/그린 배포를 구현하는 방법을 자세히 설명했습니다. 구현 부분은 상황에 따라 다를 수 있으니, 큰 흐름을 따라가며 자신의 환경에 맞게 구현해보시기 바랍니다. 또한, 헬스 체크 시도 횟수, 딜레이 시간, 대기 시간 등은 상황에 맞게 조절하시기 바랍니다.
필요하신 분들은 무중단 배포를 구현한 시점의 코드를 참고하시기 바랍니다. 감사합니다.
GitHub - alstn113/fluffy: 온라인 시험 문제 제작 및 관리 서비스 - 플러피
온라인 시험 문제 제작 및 관리 서비스 - 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
'서버' 카테고리의 다른 글
Docker Desktop 오류, "'com.docker.vmnetd'에 악성 코드가 포함되어 있어서 열리지 않았습니다." 해결 방법 (3) | 2025.01.13 |
---|---|
Spring REST Docs로 믿을 수 있는 API 문서 만들기 (1) | 2025.01.12 |
Flyway를 통한 데이터베이스 마이그레이션을 알아보자 (0) | 2025.01.03 |
@JsonTypeInfo와 @JsonSubTypes를 활용하여 요청 데이터에 다형성을 적용해 보자 (0) | 2024.12.25 |
Spring JPA를 사용할 때, OneToMany에서 Fetch Join 사용 시 문제점을 알아보자 (0) | 2024.12.23 |