2024.06.28 - [Nginx] - Docker와 nginx를 이용하여 HTTPS 적용, 무중단 배포까지[1] - HTTPS 적용
오늘은 이전 포스팅에 이어서, 무중단 배포를 구현해보도록 하겠습니다.
지금 하는 프로젝트가 아직은 실제 서비스 중인 단계는 아니지만, 새로 merge할때마다 배포한 서버가 멈추는게 아쉬워, 말로만 듣던 무중단 배포를 한번 경험해보고 싶어서 HTTPS을 적용하며 ngnix를 만진 김에, 같이 한번 해보기로 마음먹었습니다.
무중단 배포란?
무중단 배포란, 기존에 운영중인 서비스를 중단하지 않고 새로운 버전의 서비스를 배포하는 것을 말합니다. 로드 밸런서를 통해 두개 이상의 인스턴스를 제어하며 배포하는 것입니다.
무중단 배포에는 인스턴스를 여러 개 만들어 현재 위치 배포 방식 으로 할 수도 있고, 블루 그린 배포 방식, 롤링, 카나리 등으로 진행할 수도 있습니다.
무중단 배포의 종류
- 롤링 : 롤링 배포는 서비스 중인 인스턴스 중 하나를 로드밸런서에서 라우팅 하지 않도록 한 후 새 버전을 적용하여 라우팅 합니다. 이를 반복하여 모든 인스턴스에 새 버전을 배포하게 됩니다. 따라서 이 방식을 위해서는 서버가 2개 이상이여야 하며, 구버전과 신버전이 공존하기 때문에 호환성 문제가 발생할 수 있습니다.
- 블루/그린 : 블루/그린 배포에서는, 운영중인 구버전과 동일하게 신 버전의 인스턴스를 만들고 완성되면 로드밸런서를 통해 모든 트래픽을 한번에 신 버전으로 전환합니다. 이 버전은 구버전이 그대로 있어 롤백이 쉽다는 장점이 있지만, 그만큼 시스템 자원은 두배로 필요합니다.
- 카나비 : 소수의 유저들에게만 신 버전을 배포하여 테스트한 후 전체 배포합니다. 인스타그램이 이런 방식이 아닐까? 라는 생각이 듭니다. (친구와 내 인스타의 UI가 다른경우가 몇번 있었음) 또한 A/B테스트가 가능합니다.
앞서 설명에서는 두개 이상의 인스턴스가 있어야 한다고 되어있는데, 두개의 서버 인스턴스를 만들어 유지하는 것이 금액적으로도 부담이 되는 저같은 경우에는 서버 하나로 어떻게 무중단 배포를 구현할 수 있을까요?
방법은 도커를 이용하여 한 서버에 여러개의 인스턴스를 구현하는 것 입니다.
롤링 전략을 사용하기엔 WAS 서버가 두대 이상 필요한데, 저는 이 이상 서버 증설은 어려운 상황입니다. 카나비 테스트의 강점인 A/B는 현재 저에겐 불필요합니다. 따라서 저는 신속하고 빠른 롤백이 가능한 블루/그린 전략을 사용하여 도커에 서로 다른 인스턴스 두개를 구성하여 진행하겠습니다.
먼저 8080포트에 연결해뒀던 있던 기존 스프링 컨테이너가 동작중입니다. 이 버전을 그린이라고 가정합니다. 깃허브에서 merge를 통해 이벤트가 감지되면 github action이 작동하는데, 이때 새로운 버전의 스프링 앱은 새로운 컨테이너를 생성하여 구동하며 이것을 블루라고 가정합니다. 성공적으로 구동이 되었다고 판단하였을 때 트래픽을 8081포트에 연결한 새로운 서비스(블루)로 모두 보내도록 합니다.
정리하자면 아래와 같습니다.
- 도커를 통해 새로운 버전의 서비스 실행
- 실행이 확인되면, nginx의 설정파일을 신규 서비스를 바라보도록 수정
- nginx를 재실행
이제 직접 구현해보겠습니다.
Dockerfile, docker-compose.yml
저는 도커 파일을 아래와 같이 준비하였습니다.
FROM openjdk:17
WORKDIR /app
# 빌드된 Spring Boot JAR 파일을 복사
COPY build/libs/server-0.0.1-SNAPSHOT.jar trippyj.jar
# JAR 파일 실행
CMD ["java", "-jar", "trippyj.jar", "--spring.profiles.active=prod-profile"]
이어서 도커 컴포즈 파일에 블루/그린 두가지의 컨테이너를 추가하였습니다. docker-compose.yml 파일은 서버에 docker-compose up -d 명령어를 실행할 디렉토리에 위치시킵니다. 저의 경우에는 gcp를 사용하고 있으며, /home/dh1010a 경로에 놓았습니다.
version: "3"
services:
green:
image: devyoung00/trippy-docker-repo
container_name: trippy-green
ports:
- "8080:8080"
networks:
- trippy-network
volumes:
- trippy-app-vol:/root
blue:
image: devyoung00/trippy-docker-repo
container_name: trippy-blue
ports:
- "8081:8080"
networks:
- trippy-network
volumes:
- trippy-app-vol:/root
nginx:
image: nginx:latest
container_name: trippy-nginx
networks:
- trippy-network
restart: unless-stopped
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf
- ./conf/nginx1.conf:/etc/nginx/nginx1.conf
- ./conf/nginx2.conf:/etc/nginx/nginx2.conf
- /etc/letsencrypt:/etc/letsencrypt
- /var/www/certbot:/var/www/certbot
ports:
- 80:80
- 443:443
command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''
certbot:
image: certbot/certbot
container_name: trippy-certbot
networks:
- trippy-network
restart: unless-stopped
volumes:
- /etc/letsencrypt:/etc/letsencrypt
- /var/www/certbot:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
trippy-network:
driver: bridge
volumes:
trippy-app-vol:
trippy-nginx-vol:
trippy-certbot-vol:
이제 블루와 그린 두가지 컨테이너에 nginx 컨테이너까지 모두 구성할 준비를 마쳤습니다. 이제 무중단 배포를 가능하게하는 쉘 스크립트를 작성해보겠습니다.
nginx.conf 설정파일과 deploy.sh 스크립트
nginx가 새로운 스프링 컨테이너를 바라보게 하려면, nginx.conf 설정파일에서 설정을 바꾸어 주어야 합니다. nginx1.conf - 그린용 , nginx2.conf - 블루용 두가지 설정파일을 nginx에 덮어쓰기 하는 방식으로 구현하겠습니다.
nginx1.conf
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
upstream trippy-spring {
server trippy-blue:8080;
}
server {
listen 80;
server_name trippy-api.store;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name trippy-api.store;
ssl_certificate /etc/letsencrypt/live/trippy-api.store/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/trippy-api.store/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://trippy-spring/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
# SSE settings
proxy_buffering off;
proxy_read_timeout 3600s; # 1시간으로 설정
proxy_send_timeout 3600s; # 1시간으로 설정
keepalive_timeout 3600s; # 1시간으로 설정
add_header Cache-Control no-cache; # Disable caching
add_header Connection keep-alive; # Keep connection alive
}
}
}
nginx.conf는 위 파일과 내용이 동일하게 구성해도 됩니다.(어차피 덮어쓰기 할 예정). nginx2.conf 파일은 위 파일에서 아래 부분만 바꾸면 됩니다.
upstream trippy-spring {
server trippy-green:8080;
}
green 컨테이너의 내부 8080 포트로 진입하도록 합니다.
이제 세가지 파일을 모두 이전 포스팅에서 위치해놨던 /home/dh1010a/conf/ 디렉토리에 위치시키면 됩니다.
추가한 내용
배포 환경에서 SSE 연결을 길게 유지하기 위해서는, nginx 설정을 변경해야 합니다. SSE 스트림이 장시간 열려 있어야 하므로, proxy_read_timeout을 충분히 길게 설정해야 합니다. 따라서 아래와 같이 코드를 추가해주었습니다.
location / {
proxy_pass http://trippy-spring/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
# 추가한 SSE settings
proxy_buffering off;
proxy_read_timeout 3600s; # 1시간으로 설정
proxy_send_timeout 3600s; # 1시간으로 설정
keepalive_timeout 3600s; # 1시간으로 설정
add_header Cache-Control no-cache; # Disable caching
add_header Connection keep-alive; # Keep connection alive
}
deploy.sh
이 스크립트는 docker-compose.yml 파일이 있는 경로와 동일한 위치에 놓으면 됩니다.
#!/bin/bash
IS_ORIGIN=$(sudo docker ps | grep green) # 현재 실행 중인 App이 Green인지 확인합니다.
NGINX_ID=$(sudo docker ps --filter "name=nginx" --quiet)
NGINX_CONFIG="/etc/nginx/nginx.conf"
if [ -z "$IS_ORIGIN" ]; then # green이 실행중이 아니라면
echo "### BLUE => GREEN ###"
echo "1. green container up"
sudo docker-compose up -d green # green 컨테이너 실행
while true; do
echo "2. green health check..."
sleep 3
REQUEST=$(sudo curl -s http://127.0.0.1:8080) # green으로 request
if [ -n "$REQUEST" ]; then # 서비스 가능하면 health check 중지
echo "health check success"
break
fi
done
echo "3. reload nginx"
sudo docker exec $NGINX_ID /bin/bash -c "cp /etc/nginx/nginx1.conf $NGINX_CONFIG"
sudo docker exec $NGINX_ID /bin/bash -c "nginx -s reload" || { ERR_MSG='Failed to reload nginx'; exit 1; }
echo "4. blue container down"
sudo docker-compose stop blue
else
echo "### GREEN => BLUE ###"
echo "1. blue container up"
sudo docker-compose up -d blue
while true; do
echo "2. blue health check..."
sleep 3
REQUEST=$(sudo curl -s http://127.0.0.1:8081) # blue로 request
if [ -n "$REQUEST" ]; then # 서비스 가능하면 health check 중지
echo "health check success"
break
fi
done
echo "3. reload nginx"
sudo docker exec $NGINX_ID /bin/bash -c "cp /etc/nginx/nginx2.conf $NGINX_CONFIG"
sudo docker exec $NGINX_ID /bin/bash -c "nginx -s reload" || { ERR_MSG='Failed to reload nginx'; exit 1; }
echo "4. green container down"
sudo docker-compose stop green
fi
자세한 설명은 생략하도록 하겠습니다. 대략 새로운 컨테이너 생성 -> 새로운 컨테이너가 정상 구동될때까지 대기 -> nginx 컨테이너에 접속하여 설정 변경 및 재시작 -> 기존 컨테이너 작동 정지 의 과정을 거칩니다.
기존 컨테이너는 롤백을 위해 그냥 실행시켜두는 경우도 많습니다만, 저는 아직 개발 단계이고 리소스를 아끼기 위해 기존 컨테이너를 정지시켰습니다.
Github action
gradle.yml
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle
name: CI/CD
on:
push:
branches: [ "main" ]
# pull_request:
# branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle Wrapper
run: |
chmod +x gradlew
./gradlew build
## 도커허브 로그인
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
## 웹 이미지 빌드 및 도커허브에 push
- name: web docker build and push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}:latest
## docker compose up
- name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{secrets.USERNAME}}
key: ${{ secrets.KEY }}
script: |
docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}
cd ~
sudo bash deploy.sh
sudo docker image prune -f
자세한 사용법은 다루지 않고 넘어가겠습니다. 여기서 중점적으로 봐야할 부분은 마지막입니다.
Mysql, Redis 등 필요한 컨테이너들은 새로운 버전 배포때마다 내렸다가 올릴 필요가 없기때문에, 서버에서 초기에 직접 실행시켜 주었습니다. 이후 sudo bash deploy.sh 명령어를 통해 무중단 배포를 실행하도록 하였습니다.
출처 및 참고
https://diary-blockchain.tistory.com/313
'Nginx' 카테고리의 다른 글
Docker와 nginx를 이용하여 HTTPS 적용, 무중단 배포까지[1] - HTTPS 적용 (0) | 2024.06.28 |
---|