조각조각 프로젝트를 진행하면서 블루/그린 무중단 배포를 진행했다.
이번 글에선
- 어떻게 아키텍쳐가 변화해 왔는지
- 왜 무중단 배포로 변경했는지
- 어떻게 블루/그린 무중단 배포를 구현했는지
의 순서로 적어보겠다!
(무중단 배포가 궁금한 사람들은 해당 부분만 찾아서 보면 된다.)
아키텍처가 변화해온 과정
1. 초기 아키텍쳐
초기 상태는 위와 같았다.
Github Actions를 사용해 CI/CD 파이프라인을 구성했다.
Nginx를 통해 프록시를 설정했고, Docker를 사용해 스프링부트 애플리케이션을 띄웠다.
비용을 절감하기 위해 DB는 AWS를 사용했다.
CI/CD는 다음과 같은 형식으로 이뤄졌다.
- 테스트 및 빌드
- 컨테이너 레지스트리에 이미지를 push
- NCP에 docker image pull
- Docker를 통해 띄움.
초기 배포에 관한 방법은
아래 글의 3. 배포를 진행해보자!
파트를 보면 된다.
[NCP x KUSITMS] 밋업 프로젝트 중간회고! (feat. Docker , GithubActions를 활용한 CI/CD)
큐시즘에 들어오기 위해 지원서를 작성하고 면접을 봤던 순간이 어제같은데 벌써 큐시즘의 막바지에 다다르고 있다.더웠던 날씨가 쌀쌀해지고 가을을 넘어 겨울이 다가오는 지금, 좋은 팀원들
securityinit.tistory.com
2. Redis 추가 및 docker-compose 사용
우리 서비스는 JWT 토큰을 사용해서 인증/인가를 구현한다.
여기서 리프레시 토큰을 Redis에 담아서 사용하기로 했다.
리프레시 토큰을 서버에 저장해서 사용하는 것은 공통된 의견이었다.
여기서 저장소로 DB를 사용할 것인가(여기서 DB는 RDBMS), Redis를 사용할 것인가 고민이 되었다.
Redis를 사용한 이유는 아래와 같다.
- 리프레시 토큰은 기간이 지나면 사라지게 둘 것이다. MySQL을 사용하면, 삭제 쿼리를 날리게 되는데 이 부분이 싫었다. Redis의 경우 TTL을 걸어두면 알아서 사라지기에 Redis를 사용하도록 했다.
Redis를 따로 서버에 설치하기보단, 컨테이너로 띄우는 것이 좋다고 생각되었다.
서버는 최대한 깔끔하게 두고 싶은 개인적인 취향이 있기에 그런 결정을 내렸고 아키텍처를 아래처럼 변경했다.
이젠 스프링부트 앱과 레디스를 함께 컨테이너로 사용하게 되었다.
여러 컨테이너를 관리하기 위해 docker-compose를 사용했다.
바뀐 구조에 따른 배포 파이프라인은 아래와 같다.
- 테스트 및 빌드
- 컨테이너 레지스트리에 이미지를 push
- NCP에 docker image pull
- Docker-compose를 통해 배포
위 배포 파이프라인을 구성하며 에러도 여러번 터졌다...
Redis 주입 , docker-compose 파일 전송 , deploy.sh 수정 등등
관련 에러 및 해결에 관한 내용은 아래 글을 보면 된다!
https://securityinit.tistory.com/241
CI 중 Redis 주입하기 , docker-compose를 다른 서버로 전송하기 그리고 docker-compose의 container_name을 잘
에러들...1. CI 도중 Redis 연결이 안됨2. CI 서버에서 배포 서버로 docker-compose.yml 파일 전송이 안됨.3. 배포서버의 deploy.sh 에서 에러가 발생함.문제를 해결하고 난 뒤 보니까 간단해보이지만... 새벽
securityinit.tistory.com
무중단 배포로 바꾼 이유
현재 dev에 머지가 되면 ci 과정에서 프라이빗 레지스트리로 docker 이미지를 업로드한다.
그 후 NCP 서버에서 이미지를 받아와 현재 돌아가는 컨테이너를 종료하고 새로운 컨테이너를 다시 실행하는 구성이다.
이럴 경우 다음과 같은 문제가 발생한다.
1. 다운타임 발생
- 배포를 하기 위해 서버를 종료하는 그 순간 서비스가 일시적으로 중단된다. 이 시간 동안 사용자가 서비스를 사용하지 못하게 된다.
2. 유저의 불편도 증가
- 아무래도 작업을 하다보면 배포가 많아진다. 배포를 많이 하면 서비스 중단이 자주 일어나게 되고 이로 인해 사용자의 불편함을 초래하게 된다.
크게 위 두가지 이유로 무중단 배포로 변경하게 되었다.
무중단 배포 전략과 내가 블루그린을 선택한 이유
무중단 배포 전략은 3가지가 있다.
이 글은 무중단 배포의 개념과 전략을 소개하는 글이 아닌, 실제로 어떻게 구현했는지가 중점인 글이다.
그러므로 무중단 배포 전략에 대해 간단하게만 정리해보겠다.
롤링 업데이트
- 롤링 업데이트는 기존 버전의 인스턴스를 하나씩 새 버전으로 교체해나가는 방식
- 장점 : 점진적인 업데이트가 가능하다.
- 단점 : 배포 중 구버전과 새로운 버전이 공존해 호환성 문제가 발생할 수 있다.
블루 / 그린 배포
- 블루 / 그린 배포는 기존 버전(블루)과 동일한 새 버전(그린) 환경을 구성한 후 , 트래픽을 한 번에 전환하는 방식
- 장점 : 빠른 롤백
- 단점 : 시스템 자원이 많이 필요할 수 있음
카나리 배포
- 새로운 버전을 소수의 사용자나 서버에 먼저 배포하고 점진적인 확대를 해나가는 방법
- 장점 : 실제 사용자 대상으로 테스트 가능
- 단점 : 복잡한 모니터링과 트리팩 제어 필요
그래서 왜 블루 / 그린 배포 방식을 선택했을까 ?
일단 우리는 하나의 서버를 사용한다.
그러다 보니 리소스가 충분하지 않았다.
여유 리소스가 필요한 롤링 업데이트나 일부 사용자에게 새 버전을 제공하는 카나리 배포 모두 단일 서버에선 꽤 어려운 일이었다.
블루 / 그린 배포는 새로운 버전(그린)을 기존 버전(블루)과 동일한 환경에 구성한 뒤 트래픽을 빠르게 전환시키는 방식이다.
현재 우린 Docker와 Ngnix를 사용하고 있기에 블루/그린 배포 방식이 적합하다 느껴졌다.
이러한 이유들로 블루/그린 배포 방식을 사용하게 되었다!
무중단 배포를 위한 nginx 설정 , 쉘 스크립트 그리고 최종 아키텍쳐
먼저 docker-compose.yml 파일을 변경해야 했다.
블루 / 그린 두 서비스를 띄워야 하니까!
원래는 다음과 같은 상태였다.
services:
cnergy-backend:
image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}"
container_name: cnergy-backend
ports:
- '8080:8080'
depends_on:
- redis
networks:
- cnergy-backend-network
redis:
image: redis:6.0.9
container_name: redis
ports:
- '6379:6379'
volumes:
- redis-data:/data
networks:
- cnergy-backend-network
...
블루 / 그린을 적용하기 위해 다음과 같이 두 개의 컨테이너를 설정했다.
services:
blue:
image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}"
container_name: cnergy-backend-blue
env_file:
- .env
environment:
TZ: Asia/Seoul
ports:
- '8080:8080'
depends_on:
- redis
networks:
- cnergy-backend-network
green:
image: "${NCP_CONTAINER_REGISTRY}/cnergy-backend:${GITHUB_SHA}"
container_name: cnergy-backend-green
env_file:
- .env
environment:
TZ: Asia/Seoul
ports:
- '8081:8080'
depends_on:
- redis
networks:
- cnergy-backend-network
redis:
image: redis:6.0.9
container_name: redis
ports:
- '6379:6379'
volumes:
- redis-data:/data
networks:
- cnergy-backend-network
...
배포 과정을 그림으로 보자.
1. 초기엔 blue 버전 하나가 떠있다.
여기서 새로운 feature를 추가하고 dev에 머지했다고 생각해보자.
2. 새로운 컨테이너를 띄운다.(green)
그러면 새로운 컨테이너를 띄운다. (green 컨테이너)
이제 green 버전이 정상적으로 동작하는지 헬스체크를 진행한다.
그 후 green 버전이 정상적으로 동작한다면, 서버로 들어오는 요청을 green으로 변경하고 blue 버전을 종료한다.
과정은 대략적으로 위와 같다.
어렵지 않은 과정이다.
과정을 보면 느껴지지만 핵심은 nginx이다.
이를 위해 서버 내 nginx 설정과 배포를 위해 작성한 deploy.sh 파일을 수정해야 한다.
내가 배포 시 사용한 nginx 설정 파일이다.
(/etc/nginx/sites-available/default)
upstream backend {
server localhost:8080;
server localhost:8081 backup;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
}
server {
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name cnergy.p-e.kr; # managed by Certbot
location /rabbit-mq/ {
proxy_pass http://localhost:15672/;
}
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/cnergy.p-e.kr/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/cnergy.p-e.kr/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
if ($host = cnergy.p-e.kr) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80 ;
listen [::]:80 ;
server_name cnergy.p-e.kr;
return 404; # managed by Certbot
}
위 스크립트에서 중요한 부분 몇가지를 살펴보자.
1. upstream 설정
upstream backend {
server localhost:8080;
server localhost:8081 backup;
}
트래픽 전환을 위해 두개의 서버를 지정했다.
2. location 설정
server {
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name cnergy.p-e.kr; # managed by Certbot
location /rabbit-mq/ {
proxy_pass http://localhost:15672/;
}
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/cnergy.p-e.kr/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/cnergy.p-e.kr/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
위 코드에서
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
이 부분이 중요하다.
proxy_pass http://backend;
이 부분은 클라이언트의 요청을 'backend' 라는 이름의 upstream 서버 그룹으로 전달한다. (처음에 upstream backend를 설정했다.)
nginx가 이 설정을 통해 8080 또는 8081로 라우팅을 진행한다.
블루/그린 배포를 진행하면 포트 번호가 변경된다.
위 설정을 통해 변경된 포트 번호로 클라이언트의 요청을 전달할 수 있다.
위까지가 블루/그린 배포를 위한 nginx 설정의 중요 부분이다.
nginx 설정을 끝냈고, 이젠 배포를 위한 shell 파일(deploy.sh)를 설정해 보자.
(모든 내용을 적기엔 많아서 주요 부분만 발췌했다.)
# 현재 활성 환경 확인
ACTIVE_ENV=$(sudo docker ps --format '{{.Names}}' | grep -E 'cnergy-backend-(blue|green)' | cut -d'-' -f3)
if [ -z "$ACTIVE_ENV" ]; then
NEW_ENV="blue"
NEW_PORT="8080"
else
if [ "$ACTIVE_ENV" == "blue" ]; then
NEW_ENV="green"
OLD_ENV="blue"
NEW_PORT="8081"
OLD_PORT="8080"
else
NEW_ENV="blue"
OLD_ENV="green"
NEW_PORT="8080"
OLD_PORT="8081"
fi
fi
echo "현재 활성 환경: $ACTIVE_ENV"
echo "새 환경: $NEW_ENV"
# 환경 변수 업데이트
echo "환경변수 업데이트 시작..."
...
echo "환경변수 업데이트 완료."
# 새 환경 배포
echo "새 환경($NEW_ENV) 배포 중..."
sudo -E docker-compose -f $DEPLOY_PATH/docker-compose.yml up -d $NEW_ENV
# 새 환경 헬스 체크
echo "새 환경 헬스 체크 중..."
for i in {1..30}; do
HEALTH_CHECK_URL="http://localhost:$NEW_PORT/health"
echo "헬스 체크 요청: $HEALTH_CHECK_URL"
if curl -s "$HEALTH_CHECK_URL" | grep -q "OK"; then
echo "새 환경 준비 완료"
break
fi
if [ $i -eq 30 ]; then
RESPONSE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_CHECK_URL")
echo "새 환경 준비 실패. HTTP 상태 코드: $RESPONSE"
exit 1
fi
sleep 5
done
# Nginx 설정 업데이트
echo "Nginx 설정 업데이트 중..."
sudo cp /etc/nginx/sites-available/default /etc/nginx/sites-available/default.bak
sudo sed -i "s/proxy_pass http:\/\/localhost:[0-9]\+\//proxy_pass http:\/\/localhost:$NEW_PORT\//" /etc/nginx/sites-available/default
sudo nginx -t || {
echo "Nginx 설정 테스트 실패. 복원합니다.";
sudo cp /etc/nginx/sites-available/default.bak /etc/nginx/sites-available/default;
exit 1;
}
sudo nginx -s reload
# 이전 환경 종료
if sudo docker ps --format '{{.Names}}' | grep -q "cnergy-backend-$OLD_ENV"; then
echo "이전 환경($OLD_ENV) 종료 중..."
sudo docker-compose -f $DEPLOY_PATH/docker-compose.yml stop $OLD_ENV
else
echo "이전 환경($OLD_ENV)은 이미 종료 상태입니다."
fi
echo "배포 완료"
1. 현재 활성 환경 확인
# 현재 활성 환경 확인
ACTIVE_ENV=$(sudo docker ps --format '{{.Names}}' | grep -E 'cnergy-backend-(blue|green)' | cut -d'-' -f3)
if [ -z "$ACTIVE_ENV" ]; then
NEW_ENV="blue"
NEW_PORT="8080"
else
if [ "$ACTIVE_ENV" == "blue" ]; then
NEW_ENV="green"
OLD_ENV="blue"
NEW_PORT="8081"
OLD_PORT="8080"
else
NEW_ENV="blue"
OLD_ENV="green"
NEW_PORT="8080"
OLD_PORT="8081"
fi
fi
먼저 현재 활성 환경을 체크한다.
blue 라면 green을 사용할 예정이고, green이라면 blue를 새 환경으로 사용할 준비를 한다.
2. 새 환경 배포
# 새 환경 배포
echo "새 환경($NEW_ENV) 배포 중..."
sudo -E docker-compose -f $DEPLOY_PATH/docker-compose.yml up -d $NEW_ENV
환경 변수 업데이트를 끝낸 뒤 docker-compose에 새로운 환경을 배포한다.
3. 새로운 환경에 대한 헬스체크
# 새 환경 헬스 체크
echo "새 환경 헬스 체크 중..."
for i in {1..30}; do
HEALTH_CHECK_URL="http://localhost:$NEW_PORT/health"
echo "헬스 체크 요청: $HEALTH_CHECK_URL"
if curl -s "$HEALTH_CHECK_URL" | grep -q "OK"; then
echo "새 환경 준비 완료"
break
fi
if [ $i -eq 30 ]; then
RESPONSE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_CHECK_URL")
echo "새 환경 준비 실패. HTTP 상태 코드: $RESPONSE"
exit 1
fi
sleep 5
done
새로운 환경이 동작할 준비가 되어있는지 확인한다.
헬스체크를 진행하는 것인데, 아직 blue 버전을 종료하지 않았기에 green의 헬스체크가 실패하더라도 서버가 중단되지 않는다.
4. nginx 설정 업데이트
# Nginx 설정 업데이트
echo "Nginx 설정 업데이트 중..."
sudo cp /etc/nginx/sites-available/default /etc/nginx/sites-available/default.bak
sudo sed -i "s/proxy_pass http:\/\/localhost:[0-9]\+\//proxy_pass http:\/\/localhost:$NEW_PORT\//" /etc/nginx/sites-available/default
sudo nginx -t || {
echo "Nginx 설정 테스트 실패. 복원합니다.";
sudo cp /etc/nginx/sites-available/default.bak /etc/nginx/sites-available/default;
exit 1;
}
sudo nginx -s reload
위 명령어는 현재 nginx 설정 파일을 백업 파일로 지정하고 (새로운 설정이 동작하지 않을 경우 롤백),
nginx 설정 파일 내부의 proxy_pass 설정을 찾은 뒤 , 새로운 포트 번호로 proxy_pass를 변경하는 명령이다.
위에 설명했던 nginx 설정 파일의 location 부분에 'proxy_pass http://backend;' 가 있었다.
'backend'는 upstream 블록을 참조하는 부분이다.
즉! proxy_pass의 포트번호를 업데이트하면 upstream 블록 내부의 서버 포트가 변경된다.
upstream backend {
server localhost:8080;
server localhost:8081 backup;
}
여기서 포트 번호가 `$NEW_PORT` 변수의 값으로 변경된다.
이 과정을 통해 nginx가 새로운 포트로 트래픽을 전달할 수 있게 되고, 새로 배포된 애플리케이션으로 라우팅이 가능해진다.
정상적으로 설정파일이 동작하면 reload를 통해 nginx를 재시작해준다.
5. 이전 환경 종료
# 이전 환경 종료
if sudo docker ps --format '{{.Names}}' | grep -q "cnergy-backend-$OLD_ENV"; then
echo "이전 환경($OLD_ENV) 종료 중..."
sudo docker-compose -f $DEPLOY_PATH/docker-compose.yml stop $OLD_ENV
else
echo "이전 환경($OLD_ENV)은 이미 종료 상태입니다."
fi
새로운 nginx 설정도 동작하고, 새로운 어플리케이션도 배포가 되었으니 이전에 돌아가던 애플리케이션을 종료하면서 무중단 배포가 완료된다.
그렇게 나온 우리 서버의 최종 아키텍쳐이다!
느낀점
아쉬운 부분은 k8s를 잘 몰라서 docker-compose로 무중단 배포를 구현했다.
docker-compose를 통해 편하게 컨테이너를 띄울 순 있었다.
그러나 서버에서 문제가 발생해 컨테이너가 죽은 경우 자동으로 컨테이너를 다시 띄워주거나(`restart` 정책이 있지만 제한적이다.),
트래픽이 몰리는 경우 자동으로 오토스케일링을 하는 등의 기능은 docker-compose에 없기에 아쉬운 부분들이 있다.
추후엔 k8s를 공부해 k8s를 통한 무중단 배포를 구현해보고 싶다는 생각이 든다.