안녕하세요, codedbyjst입니다.
이번에는 무중단 배포에 대해 이야기해보려 합니다.
다들 무중단 배포를 들어보신 적은 있으리라 생각이 되지만, 이게 왜 필요한 걸까요? 이를 저희 팀이 겪은 상황을 통해 알려드리려 합니다.
뭐가 문제였나요?
저희 팀은 Github Action을 통해 CI/CD를 적용해놓은 상태입니다.
따라서 PR이 승낙되면, 자동으로 빌드, 테스트 및 배포가 진행되도록 구성되어 있습니다.
따라서 배포를 할 때에는 늘 Github Action을 보면서 마음의 평온을 얻고 있었습니다.
직접 손으로 옮길 필요도 없고, 볼 때마다 뿌듯해지는 게 이 맛에 CI/CD 파이프라인 구축하는구나 싶습니다.
그런데 분명 문제없이 배포는 매번 성공하는데, 프론트엔드측에서 가끔 문의가 들려옵니다.
바로 자주 일어나는 배포때문에, 프론트엔드가 작업중인데도 자꾸 다운되는 일이 발생하는 겁니다.
물론 저희 프론트분들은 친절하시기 때문에 조금 불편하더라도 크게 신경쓰지 않으셨지만, 백엔드 개발자 입장에서는 계속 신경이 쓰이게 됩니다.
뭔가 이상합니다.
분명 CI/CD 파이프라인을 구축해놓고, 자동화로 인해 배포에 대한 걱정이 줄어든 것은 사실이지만, 여전히 배포에 대한 문제가 남아있습니다.
분명 CI/CD는 자주 배포해야 의미가 있는건데, 지금까지 CI/CD 파이프라인을 구축해놨다는 사실만으로 안주해있던 것은 아닐까요? 심지어 더 열심히 개발할수록, 더 자주 배포할수록 계속 문제가 될텐데, 이를 내버려두는 것으로 괜찮은 걸까요?
바로 그 때 어디서 들어본 '무중단 배포'라는 단어가 떠오릅니다.
무중단 배포를 이용하면 다운타임을 거의 0에 가깝게 구성할 수 있다는 말을 어디서 들었는데, 이를 적용하면 문제가 해결되지 않을까요?
바로 이 결심에서부터 무중단 배포의 여정이 시작됩니다.
상황파악부터!
우선, 시작하기에 앞서 왜 이렇게 오랫동안(프론트엔드가 알아차릴 정도로) 서버가 다운되었는지 원인 파악이 필요합니다.
저희가 기존에 갖고 있던 파이프라인은 대략 아래와 같습니다.
1. Github Action으로 빌드/테스트 작업을 진행합니다. 2. 도커 컴포즈로 동작중인 컨테이너를 'docker compose down'으로 내립니다. 3. 새로운 빌드 결과물을 SCP로 전달받습니다. 4. 다시 도커 컴포즈로 'docker compose up'하여 컨테이너를 올립니다. |
이 중, 2~4의 과정이 약 25초 이상의 다운타임을 유발합니다.
3과 2를 뒤집으면 시간을 줄일 수 있지 않느냐 생각할 수도 있지만, 도커 컴포즈 상 언제나 'app.jar'라는 파일이 컨테이너에 바인딩되도록 되어 있어, 순서를 뒤집을 순 없습니다. 반드시 해당 파일을 이용중인 컨테이너가 종료되어야 새로운 파일로
교체할 수 있습니다.
따라서 엄청나게 긴 다운타임이 유발될 수밖에 없는 구조입니다.
이를 어떻게 하면 개선시킬 수 있을까요?
Nginx를 이용한 무중단 배포
다행히 저는 이미 Nginx를 사용하고 있기 때문에, Nginx의 기능을 이용하면 비교적 쉽게 무중단 배포 구성이 가능합니다.
이번에 사용할 배포 전략은 '블루-그린' 전략입니다.
'블루-그린' 전략이란 기본적으로 2개의 포트에 각각 돌아가는 컨테이너를 두고, 배포가 새로 이루어질 때 아래와 같이 동작시키는 것을 말합니다.
1. 기존에 '블루' 컨테이너가 최신 버전으로 동작하는 경우, NGINX는 해당 포트(8000번)에만 트래픽을 몰아줍니다. '그린' 컨테이너는 동작중이지만, 실제로는 아무 트래픽이 라우팅되지 않습니다. 2. 새로운 배포가 생기는 경우, 구버전으로 동작중이던 '그린' 컨테이너를 잠시 내리고 해당 컨테이너의 구성파일을 최신 파일로 바꿔줍니다. 이제, 최신 배포는 '그린' 컨테이너에 있습니다. 3. '그린' 컨테이너를 다시 동작시키고(올리고), NGINX의 설정을 수정 후 reload하여 트래픽을 '그린' 컨테이너 측(8001번)으로 보냅니다. 4. 추후 새로운 배포가 발생하면, 2~3을 해당 시점 기준 구버전을 가진 컨테이너에서 반복합니다. |
이와 같이 동작시키면, 기본적으로 다운타임은 언제나 0에 가깝기 때문에 사용자는 계속 서버가 동작중인 것으로 생각합니다. 저희가 처음에 목적했던 바를 이룰 수 있죠!
하지만 이와 같은 배포는 늘 컨테이너 두 개를 켜놔야 하기 때문에, 서버 성능상 손해가 발생합니다. 물론 롤백이 간단하다는 장점도 존재하지만, 현재 저희는 성능이 더 중요한 요소라고 판단하고 있습니다.
따라서, 아래와 같이 동작시켜 보려 합니다.
1. 기존에 '블루' 컨테이너만이 동작중이고, 8000번 포트에 모든 트래픽이 들어옵니다. 2. 새로운 배포가 생기면, 'Green' 컨테이너 측에 새로운 배포를 적용합니다. 3. 새롭게 업데이트된 'Green' 컨테이너를 동작시킵니다. 4. 구동이 완료되면, NGINX의 설정을 바꿔 'Green' 컨테이너로(8001번포트)로 모든 트래픽을 넘깁니다. 5. 이제 구버전이 된 'Blue' 컨테이너를 동작 종료시킵니다. 6. 추후 새로운 버전이 배포되면, 2~5를 해당 시점 기준 구버전을 가진 컨테이너에서 반복합니다. |
이와 같이 동작시키면, 잠시 2대가 모두 동시에 켜져있는 때(3~5사이)가 존재하긴 하지만 거의 대부분의 시간에 하나의 컨테이너만 동작시키며 무중단 배포가 가능합니다.
그렇다면 계획은 세워졌으니, 직접 한 번 실행에 옮겨보도록 하죠!
라이브 서버를 끌 순 없으니, 복제합시다...
하지만 무중단 배포를 해보겠다고 기존 라이브 서버를 막 건들이면 당연히 안 되겠죠?
지금까지 별 말 없으셨던 프론트 분들도 뭐라 하시게 될 지도 모릅니다...
따라서, 우선 EC2에서 작업중이었다 가정하고, 기존의 서버를 중단 없이 복제해오는 것부터 시작하도록 하겠습니다.
우선, 현재 작동중인 EC2 인스턴스의 상세 설정에 들어가 작업>이미지 및 템플릿>이미지 생성을 눌러 이미지 생성 창으로 이동합니다.
이후 나오는 창에서 이미지 이름 등을 설정해주시면 되는데, '재부팅 안 함' 옵션을 반드시 활성화해주세요.
그렇지 않으면 원본 인스턴스가 재부팅됩니다! 설정을 마치셨다면 이미지 생성을 눌러주세요.
그 후 좌측 메뉴의 이미지>AMI로 가시면 생성된 이미지를 보실 수 있습니다.
처음엔 '대기 중..'이라고 표시될텐데, 조금 기다리시면 위처럼 '사용 가능'으로 변하게 됩니다.
이후, 해당 이미지의 세부 설정으로 들어가시면 'AMI로 인스턴스 시작'이라는 버튼이 있을 겁니다. 해당 버튼을 눌러주세요.
그 후에는 평소 인스턴스를 생성하듯이 진행하시면 되는데, '애플리케이션 및 OS 이미지'에 보시면 저희가 설정한 AMI가 들어가 있는 것을 볼 수 있습니다.
설정을 확인하시고, '인스턴스 시작'을 눌러 인스턴스를 실행시켜주세요.
그 후 접속해보면, 생성된 인스턴스에 접속하면 기존과 동일하게 동작중인 인스턴스를 볼 수 있습니다!
이 모든게 재부팅도 없이 클릭 몇 번에 완료됐네요.(역시 이래서 클라우드가 좋아요)
스크립트 작성
이제 갖고 놀(테스트 해볼) 자리도 마련됬으니 본격적으로 테스트해보겠습니다.
우선 위에 단계들을 참조하여 실제로는 어떻게 구현되야 하는지 다시 정리해 보겠습니다.
1. 기존에 어떤 컨테이너가 동작중이었는지 판별하여, 동작중이지 않던 컨테이너에 새로운 버전의 빌드 파일을 주입합니다. 2. 위 새로 업데이트된 컨테이너를 docker compose로 구동시키고, 정상 처리 상태가 될 때까지 대기합니다. 3. nginx의 설정을 수정하여 모든 트래픽이 업데이트된 컨테이너를 향하도록 합니다. 4. 기존 사용중이던 구버전의 컨테이너를 종료합니다. 추후 새로운 버전이 배포되면 지금 종료된 컨테이너를 이용하게 됩니다. |
이를 실제로 스크립트로 나타내면 다음과 같습니다.
#!/bin/bash
###########
## 1단계 ##
###########
# 컨테이너가 동작중인지 파악하는 함수입니다.
is_container_running() {
container_name=$1
if [ "$(docker ps -q -f name=$container_name)" ]; then
return 0
else
return 1
fi
}
# 도커 컴포즈 파일이 존재하는 경로들을 지정합니다.
BLUE_COMPOSE_FILE="/home/ubuntu/spring/blue/blue-docker-compose.yml"
GREEN_COMPOSE_FILE="/home/ubuntu/spring/green/green-docker-compose.yml"
# 어떤 컨테이너가 동작중인지 판별하여, app.jar 이동 목표 경로를 결정합니다.
if is_container_running "green_app"; then
TARGET_COMPOSE_FILE=$BLUE_COMPOSE_FILE
TARGET_DIR="/home/ubuntu/spring/blue"
else
TARGET_COMPOSE_FILE=$GREEN_COMPOSE_FILE
TARGET_DIR="/home/ubuntu/spring/green"
fi
# 새 빌드 파일을 다운로드할 위치를 지정합니다.
NEW_APP_JAR="/home/ubuntu/spring/app.jar"
# 해당 목표 경로에 app.jar 파일을 이동시킵니다.
cp $NEW_APP_JAR $TARGET_DIR/app.jar
###########
## 2단계 ##
###########
# docker compose를 이용해 새로운 버전의 컨테이너를 동작시킵니다.
docker compose -f $TARGET_COMPOSE_FILE up -d
echo "새로운 버전의 컨테이너가 배포되었습니다."
# 각 컨테이너들이 사용하는 주소를 지정합니다.
BLUE_CONTAINER_URL="localhost:8000"
GREEN_CONTAINER_URL="localhost:8001"
# 헬스체크를 위한 함수입니다.
check_health() {
# 헬스 체크를 실행할 경로를 지정합니다.
if [ "$TARGET_COMPOSE_FILE" == "$BLUE_COMPOSE_FILE" ]; then
health_check_url="http://$BLUE_CONTAINER_URL/trim" # 여기를 실제 요청 가능한 주소로 변경해주세요.
else
health_check_url="http://$GREEN_CONTAINER_URL/trim" # 여기를 실제 요청 가능한 주소로 변경해주세요.
fi
response=$(curl -s -o /dev/null -w "%{http_code}" $health_check_url)
if [ "$response" == "200" ]; then # 200번 응답이 돌아오면 정상이라고 판단합니다.
echo "헬스 체크에 통과하였습니다."
return 0
else
echo "헬스 체크에 실패하였습니다."
return 1
fi
}
# 정상적인 응답이 돌아올 때까지 헬스체크를 반복합니다.
while ! check_health; do
echo "헬스 체크가 통과할 때까지 대기 중..."
sleep 5
done
###########
## 3단계 ##
###########
# nginx 설정파일 경로를 지정합니다.
NGINX_CONF_FILE="/etc/nginx/sites-enabled/api.ohmycarset.com"
# nginx 설정을 수정하고 reload 하여 트래픽을 새 컨테이너 측으로 넘겨줍니다.
if [ "$TARGET_COMPOSE_FILE" == "$BLUE_COMPOSE_FILE" ]; then # 새로 버전업된 컨테이너가 'Blue'인 경우
sed -i "s/$GREEN_CONTAINER_URL/$BLUE_CONTAINER_URL/" $NGINX_CONF_FILE
else
sed -i "s/$BLUE_CONTAINER_URL/$GREEN_CONTAINER_URL/" $NGINX_CONF_FILE
fi
nginx -s reload
echo "nginx 설정이 변경되었습니다."
###########
## 4단계 ##
###########
# 기존 사용중이던 구버전 컨테이너를 종료합니다.
if [ "$TARGET_COMPOSE_FILE" == "$BLUE_COMPOSE_FILE" ]; then
docker compose -f $GREEN_COMPOSE_FILE down
else
docker compose -f $BLUE_COMPOSE_FILE down
fi
echo "기존 구버전의 컨테이너가 종료되었습니다."
위 스크립트가 조금 복잡해보이나, 각 단계를 천천히 살펴보면 그렇게 복잡하지 않습니다.
적혀져 있는 주석에 따라 필요한 부분을 수정하여 이용하시면 됩니다.
또, 본 스크립트는 docker가 공식 설치법을 통해 설치되어 'docker-compose'가 아닌 'docker compose'로 동작한다고 가정하고 작성되었습니다.
그런데 위 스크립트는 정해진 규칙대로 폴더 구조와 파일 내용물이 작성되어있는 것이 가정되어 있습니다. 따라서 구조와 각 파일 예시가 필요한데, 이는 아래와 같습니다.
디렉토리 구조
- /home/ubuntu/spring/
새로운 버전의 app.jar, 배포를 위한 update.sh 파일이 존재합니다.
(물론, update.sh에는 실행 권한이 부여되어 있어야 합니다. 안 되어 있다면 chmod +x /home/ubutnu/spring/update.sh로 실행 권한을 부여해주세요.)
- /home/ubuntu/spring/blue/
'Blue' 컨테이너를 위한 파일들이 있는 곳입니다. redis 폴더는 본 프로젝트에서 필요하여 사용중인 것이므로 신경쓰지 않으셔도 됩니다.
- /home/ubuntu/spring/green/
'Green' 컨테이너를 위한 파일들이 있는 곳입니다. redis 폴더는 본 프로젝트에서 필요하여 사용중인 것이므로 신경쓰지 않으셔도 됩니다.
각 docker compose 설정파일
- /home/ubuntu/spring/blue/blue-docker-compose.yml
version: "3"
services:
redis:
image: redis:alpine
command: redis-server /usr/local/conf/redis.conf
ports:
- 6500:6379 # 동시에 'Green'측 Redis 컨테이너가 켜져도 충돌을 피하기 위해 6500번 포트 이용
volumes:
- ./redis/data:/data
- ./redis/conf/redis.conf:/usr/local/conf/redis.conf
restart: unless-stopped
blue_app: # 컨테이너를 찾을 수 있게 이름을 스크립트에 적힌대로 'blue_app'으로 합니다.
image: openjdk:11
volumes:
- ./:/spring # blue-docker-compose.yml 파일이 존재하는 경로의 파일들을 바인딩
entrypoint: java -jar /spring/app.jar
network_mode: host # 브릿지 모드가 아니여서 application port가 그대로 localhost에서 이용됨
environment:
- APPLICATION_PORT=8000 # 'Blue' 컨테이너는 8000번 포트를 이용
- DB_HOST=localhost
- DB_PORT=3306
- DB_USERNAME=username
- DB_PASSWORD=password
- REDIS_HOST=localhost
- REDIS_PORT=6500 # 위 Redis 포트와 맞춤
restart: unless-stopped
- /home/ubuntu/spring/green/green-docker-compose.yml
version: "3"
services:
redis:
image: redis:alpine
command: redis-server /usr/local/conf/redis.conf
ports:
- 6501:6379 # 동시에 'Blue'측 Redis 컨테이너가 켜져도 충돌을 피하기 위해 6501번 포트 이용
volumes:
- ./redis/data:/data
- ./redis/conf/redis.conf:/usr/local/conf/redis.conf
restart: unless-stopped
green_app: # 컨테이너를 찾을 수 있게 이름을 스크립트에 적힌대로 'green_app'으로 합니다.
image: openjdk:11
volumes:
- ./:/spring# green-docker-compose.yml 파일이 존재하는 경로의 파일들을 바인딩
entrypoint: java -jar /spring/app.jar
network_mode: host # 브릿지 모드가 아니여서 application port가 그대로 localhost에서 이용됨
environment:
- APPLICATION_PORT=8001 # 'Green' 컨테이너는 8001번 포트 이용
- DB_HOST=localhost
- DB_PORT=3306
- DB_USERNAME=username
- DB_PASSWORD=password
- REDIS_HOST=localhost
- REDIS_PORT=6501 # 위 Redis 포트와 맞춤
restart: unless-stopped
nginx 설정파일
- /etc/nginx/sites-enabled/api.ohmycarset.com
server { # server 블록
listen 80;
server_name api.ohmycarset.com;
access_log /var/log/nginx/proxy/access.log;
error_log /var/log/nginx/proxy/error.log;
location / { # location 블록
include /etc/nginx/proxy_params;
proxy_pass http://localhost:8001; # 컨테이너 변경시 여기 수정됨
}
# 인증서 발급을 위한 추가 설정
location ^~ /.well-known/acme-challenge {
allow all;
root /var/www/letsencrypt;
}
}
# HTTPS
server {
listen 443 ssl;
server_name api.ohmycarset.com;
ssl_certificate /etc/letsencrypt/live/api.ohmycarset.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.ohmycarset.com/privkey.pem;
location / {
proxy_pass http://localhost:8001; # 컨테이너 변경시 여기 수정됨
}
}
사용방법
사용방법은 아래처럼 'update.sh' 파일을 sudo 권한으로 실행하면 됩니다.
(nginx 설정 파일을 수정하기 위해서 sudo 권한이 필요합니다.)
github action 연동
이제 다시 github action으로 돌아와 설정파일을 수정해줄 차례입니다.
...
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: 빌드 결과물 artifact를 다운로드합니다.
uses: actions/download-artifact@v3
with:
name: build-backend
- name: SCP로 빌드 파일을 전송합니다.
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_KEY }}
port: ${{ secrets.SERVER_PORT }}
source: "app.jar"
target: "/home/ubuntu/spring"
- name: 배포 스크립트를 작동시킵니다.
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_KEY }}
port: ${{ secrets.SERVER_PORT }}
timeout: 3m
script: |
sudo /home/ubuntu/spring/update.sh
위처럼, 빌드 완료된 파일을 SCP로 전송하고, 배포 스크립트를 작동시키면 끝입니다!
(생략된 부분도 보고 싶은 분들은 https://github.com/softeerbootcamp-2nd/A3-OhMyCarSet/blob/dev/.github/workflows/ci_backend.yml 여기를 참조해주세요. 2023.08.20 현재 기준 아직 적용은 되지 않았습니다.)
그런데 이게 진짜 무중단인가?
그런데, 여기서 중요한 질문이 하나 있습니다.
이는 정말로 '무중단' 배포일까요?
확인해 볼 방법은 하나뿐이죠. 테스트 프로그램으로 직접 확인해보면 됩니다.
위 테스트는 Jmeter를 이용해서 서버에 배포가 진행 중일때 매우 빠르게 요청을 날린 결과입니다.
대부분의 시간에 정상 처리가 이루어졌지만, 일부 요청은 오류가 발생한 것을 볼 수 있습니다.
어, 분명 순서대로 컨테이너들을 올바르게 껐다 켜서 무중단이어야 할 것 같은데, 왜 이러는 걸까요?
사실 이는 nginx의 reload가 가진 특성때문에 그렇습니다.
nginx에서 reload(설정 갱신)가 진행되면, 아래의 처리 루틴을 통해 처리됩니다.
1. nginx 데몬이 reload 시그널을 받습니다. 2. 워커 프로세스들은 현재 처리중이었던 요청들까지만 처리하고, tcp 커넥션을 종료합니다. |
그래서 nginx는 본인들의 방식이 graceful하다 주장하지만, 사실 2.의 단계와 http 1.1 규약 사이에 문제가 존재합니다.
http 1.1은 기본적으로 connection : keep-alive 헤더를 통해 한 번 수립된 tcp 소켓 연결을 유지하려고 노력합니다.
그런데 nginx의 설정이 reload되면 강제로 워커 프로세스들이 수립된 tcp 커넥션을 종료해 버립니다.
따라서, 클라이언트는 해당 사실을 모르고 닫힌 소켓에 요청을 보내 오류가 발생하는 것입니다.
물론 대부분의 클라이언트(브라우저 등)은 문제가 발생하면 자동으로 다시 요청을 보내기도 하고, nginx의 설정을 바꿔 모든 connection이 keep-alive가 되지 않고 close가 되게 하면 해결되는 문제이기는 합니다.
다만 해당 사실을 인지하고, 어떻게 할 지 계획을 수립하는 것이 중요합니다.
저 같은 경우 이정도 짧은 요청 오류는 큰 문제가 되지 않아 놔두기로 했습니다.
Conclusion
이것으로 nginx / docker compose를 이용해 무중단 배포를 진행하는 법에 대해 알아봤습니다.
위에서 볼 수 있듯, docker compose로 쉽게 설정을 나눠 관리하고, nginx의 reload 기능을 통해 쉽게 '거의 무중단' 배포에 성공했습니다. 새로운 기술을 적용하는 것이 즐거운 이유가 바로 이런 것 때문인 것 같습니다.
여러분들 개발에도 도움이 돼셨으면 좋겠습니다. 감사합니다.
'개발팁' 카테고리의 다른 글
Certbot HTTPS용 SSL 인증서 발급 / Nginx로 적용하기 (2) | 2023.08.15 |
---|---|
스프링 부트 CORS 설정법 정리 (0) | 2023.08.12 |
Spring Boot + Redis Cache 사용법 정리 (0) | 2023.08.11 |
[Fastapi]파라미터 올바르게 다루기 (0) | 2023.03.03 |
[Fastapi]sqlalchemy말고, pymysql로 데이터베이스(Mysql)와 통신하기 (4) | 2023.02.23 |