개요
그린메이트 배포 자동화를 위해 GithubAction을 도입했다.
이전에는 단순히 매번 배포하는게 귀찮아서 쉘 스크리트로 작성해서 Github Action과 연동해서 배포했다.
근데 AI서버를 도커로 배포하는 상황이었기에 배포 방식을 통일하고 이번 기회에 한번 써볼겸 하고 사용했다.
물론 중간에 도커로 배포하고, 이후에 Blue-Green배포 방식을 적용했지만 블로그에서는 최종 결과만 갖고 이야기 할것이다.
Github Action 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 Action
on:
push:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build with Gradle
run: ./gradlew clean build -x test
- name: Docker build
run: |
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
docker build -t ${{secrets.DOCKER_HUB_USERNAME}}/greenmate_backend:latest .
docker push ${{secrets.DOCKER_HUB_USERNAME}}/greenmate_backend:latest
- name: Deploy
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_SSH_HOST }}
username: ${{ secrets.DEPLOY_SSH_USERNAME }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_SSH_PORT }}
script: |
cd ./greenmate_deploy
sudo sh run_deployment.sh
1. 워크플로우 트리거
main브랜치에 Push되면 이 yml에 의해 워크플로우가 실행된다.
on:
push:
branches: [ "main" ]
2. 권한
Github 컨텐츠에 대해서 읽기 권한만 있음.
permissions:
contents: read
3. 빌드 작업
ubuntu-latest 환경에서 실행함
jobs:
build:
runs-on: ubuntu-latest
4. 저장소 체크아웃
- uses: actions/checkout@v3
5. JDK설정 및 Gradle 빌드
JDK버전 17설정 및 Gradle 빌드진행
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build with Gradle
run: ./gradlew clean build -x test
6. 도커 빌드 및 푸시
- name: Docker build
run: |
docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}
docker build -t ${{secrets.DOCKER_HUB_USERNAME}}/greenmate_backend:latest .
docker push ${{secrets.DOCKER_HUB_USERNAME}}/greenmate_backend:latest
Github Secret에 저장되어 있는 도커 허브 아이디, 패스워드 사용.
7. 배포
- name: Deploy
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_SSH_HOST }}
username: ${{ secrets.DEPLOY_SSH_USERNAME }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_SSH_PORT }}
script: |
cd ./greenmate_deploy
sudo sh run_deployment.sh
위와 같이 CI과정이 진행되며, 스크립트를 통해 CD과정을 거친다.
배포 스크립트
스크립트는 총 5가지로 이루어져 있고 순서대로 진행된다.(스크립트 코드가 많고 길기 때문에 토글로 정리)
1. run_deployment.sh
배포 총괄 스크립트다. 이 스크립트를 통해 아래 2~4번 내용을 순서대로 실행한다.
#!/bin/bash
# Step 1: 현재 실행 중인 포트 확인
echo "Step 1: Checking current active server port..."
if bash check_current_env.sh; then
echo "Current port check completed successfully."
else
echo "Failed to determine current active port."
exit 1
fi
echo ""
# Step 2: 새로운 서버 실행
echo "Step 2: Starting new server on idle port..."
if bash start_new_server.sh; then
echo "New server started successfully."
else
echo "Failed to start new server."
exit 1
fi
echo ""
# Step 3: nginx 재기동
echo "Step 3: Restart Nginx"
if bash reload_nginx.sh; then
echo "Restart Nginx successfully."
else
echo "Failed to restart nginx."
exit 1
fi
echo ""
# Step 4: 기존 서버 중지
echo "Step 4: Stopping old server..."
if bash stop_old_server.sh; then
echo "Old server stopped successfully."
else
echo "Failed to stop old server."
exit 1
fi
echo ""
echo "Deployment process completed successfully."
2. check_current_env.sh
현재 배포되어있는 서버의 환경을 확인한다.
blue-green 배포 방식이기 때문에 blue인지 green인지 확인한다.
스프링 액츄에이터에서 /actuator/health를 통해 상태를 비롯한 여러 정보를 확인할 수 있다.
나는 보안상의 이유로 포트도 서비스 포트와 분리했고, 외부로 공개된 정보고 status정보 뿐이다.
두가지 포트로 모두 Health Check해본 뒤 새로운 포트를 new_env파일에 저장한다.(다음 스크립트에서 사용하기 위함)
#!/bin/bash
# Health Check용 포트 정의
HEALTH_PORT_1=9090
HEALTH_PORT_2=9091
# 실행 중인 포트 확인
CURRENT_PORT=8080
if curl -s http://localhost:$HEALTH_PORT_1/actuator/health | grep -q '"status":"UP"'; then
CURRENT_PORT=8080
elif curl -s http://localhost:$HEALTH_PORT_2/actuator/health | grep -q '"status":"UP"'; then
CURRENT_PORT=8081
fi
echo "Currently active port: $CURRENT_PORT"
# 비활성 포트 계산
if [ $CURRENT_PORT = "8080" ]; then
NEW_ENV="green"
else
NEW_ENV="blue"
fi
echo "Next server will use port: $NEW_ENV"
# 결과 저장 (start_new_server.sh에서 참조할 수 있도록 파일에 저장)
echo $NEW_ENV > ./tmp/new_env
3. start_new_server.sh
2번에서 확인한 환경이 아닌 다른 환경으로 서버를 실행한다.
이과정에서 도커 허브에 있는 내용을 pull 하며, 이전 환경과 다른(blue면 green, green이면 blue) 환경으로 서버를 기동한다.
서버를 기동했을 때 어떤 문제가 생길지 모르기 때문에, 기동한 후 10번 Health Check 엔드포인트를 통해 서버가 정상적으로 기동 됐는지 체크한다.
#!/bin/bash
# 현재 스크립트의 위치를 기준으로 tmp 경로 설정
BASE_DIR=$(dirname "$0")
TMP_DIR="$BASE_DIR/tmp"
# tmp 디렉토리 확인
if [ ! -d "$TMP_DIR" ]; then
echo "Temporary directory $TMP_DIR does not exist."
exit 1
fi
# 비활성 환경 가져오기
NEW_ENV_FILE="$TMP_DIR/new_env"
if [ ! -f "$NEW_ENV_FILE" ]; then
echo "No idle environment information found. Run check_current_env.sh first."
exit 1
fi
NEW_ENV=$(cat "$NEW_ENV_FILE")
# 환경 파일 확인
ENV_FILE="$TMP_DIR/$NEW_ENV"
if [ ! -f "$ENV_FILE" ]; then
echo "Environment file $ENV_FILE not found."
exit 1
fi
# 환경 파일에서 정보 읽기
source "$ENV_FILE"
# APP_NAME, APP_PORT, HEALTH_PORT 확인
if [ -z "$APP_NAME" ] || [ -z "$APP_PORT" ] || [ -z "$HEALTH_PORT" ]; then
echo "Missing APP_NAME, APP_PORT, or HEALTH_PORT in $ENV_FILE."
exit 1
fi
echo "Starting new server for environment: $NEW_ENV"
echo "App Name: $APP_NAME, App Port: $APP_PORT, Health Port: $HEALTH_PORT, Docker Compose File: docker-compose-$NEW_ENV.yml"
# 새로운 서버 실행
sudo docker compose -f docker-compose-$NEW_ENV.yml pull
sudo APP_NAME=${APP_NAME} APP_PORT=${APP_PORT} HEALTH_PORT=${HEALTH_PORT} docker compose -f docker-compose-$NEW_ENV.yml up --build -d
# Health Check 확인
for i in {1..10}; do
if curl -s http://localhost:$HEALTH_PORT/actuator/health | grep -q '"status":"UP"'; then
echo "New server is UP for environment: $NEW_ENV with container name: $APP_NAME on port: $APP_PORT"
exit 0
fi
echo "Waiting for server to be ready on port $APP_PORT..."
sleep 5
done
echo "Failed to start the new server for environment: $NEW_ENV with container name: $APP_NAME on port: $APP_PORT"
exit 1
4. reload_nginx.sh
nginx로 프록시 경로를 바꾼다.
현재 nginx에서 요청을 받아 서버로 요청을 전달하는데, 서버의 환경(blue-green)이 바뀌었으므로 포트도 다르기에 nginx설정을 바꿔 줘야 한다.
blue환경과 green 환경에 따라 nginx파일이 다르기에, 환경에 따라서 nginx.conf를 스위칭 해준다.
#!/bin/bash
NGINX_DIR=./nginx
TMP_DIR=./tmp
NEW_ENV_FILE="$TMP_DIR/new_env"
if [ ! -f "$NEW_ENV_FILE" ]; then
echo "No idle environment information found. Run check_current_env.sh first."
exit 1
fi
NEW_ENV=$(cat "$NEW_ENV_FILE")
echo "Reload Nginx With $NEW_ENV, Docker Compose File: $NGINX_DIR/docker-compose-nginx.yml"
cp $NGINX_DIR/conf/nginx-$NEW_ENV.conf $NGINX_DIR/nginx.conf
sudo docker compose -f $NGINX_DIR/docker-compose-nginx.yml restart nginx
5. stop_old_server.sh
이전 서버를 중지한다.
nginx설정이 바뀌었다면 이젠 이전 서버는 요청을 받지 않는 상태이다. 이는 곧 무의미한 상태이기 때문에 얼른 꺼준다.
이 과정에서 바로 끄기보다는, 이전 요청이 물려있거나 DB작업을 하고 있을 수 있으므로, graceful하게 서버를 종료한다.
스프링 액츄에이터에는 서버를 graceful하게 종료할 수 있도록 기능을 지원한다. 그리고 외부에서 http 통신을 통해 접근할 수도 있다.
#!/bin/bash
# 현재 스크립트의 위치를 기준으로 tmp 경로 설정
BASE_DIR=$(dirname "$0")
TMP_DIR="$BASE_DIR/tmp"
# 새로 활성화된 환경 확인
if [ ! -f $TMP_DIR/new_env ]; then
echo "No environment information found. Ensure new server is started first."
exit 1
fi
NEW_ENV=$(cat $TMP_DIR/new_env)
# 이전 환경 계산
if [ "$NEW_ENV" = "blue" ]; then
OLD_ENV="green"
elif [ "$NEW_ENV" = "green" ]; then
OLD_ENV="blue"
else
echo "Invalid environment: $NEW_ENV"
exit 1
fi
# 이전 환경의 설정 파일 로드
OLD_ENV_FILE="$TMP_DIR/$OLD_ENV"
if [ ! -f "$OLD_ENV_FILE" ]; then
echo "Environment file $OLD_ENV_FILE not found."
exit 1
fi
# 포트 정보 로드
source "$OLD_ENV_FILE"
# APP_PORT 확인
if [ -z "HEALTH_PORT" ]; then
echo "Missing APP_PORT in $OLD_ENV_FILE."
exit 1
fi
echo "Stopping old server for environment: $OLD_ENV on health port: $HEALTH_PORT"
# Graceful Shutdown 요청 (Spring Boot Actuator Shutdown API 사용 가능)
if curl -X POST http://localhost:$HEALTH_PORT/actuator/shutdown -s -o /dev/null; then
echo "Shutdown request sent to server on port $APP_PORT"
else
echo "Failed to send shutdown request to server on port $APP_PORT."
exit 1
fi
# 서버 중단 대기
sleep 15 # application.yml에서 설정한 15초의 graceful timeout
# Docker 컨테이너 중지
CONTAINER_ID=$(sudo docker ps -q -f publish=$HEALTH_PORT)
if [ -n "$CONTAINER_ID" ]; then
sudo docker stop $CONTAINER_ID && sudo docker rm $CONTAINER_ID
echo "Old server container stopped and removed successfully."
else
echo "No running container found for port $APP_PORT."
fi
Docker Compose 구성
나는 도커 컴포즈 파일을 두개를 구성하고있다.
1. 스프링 서버
스프링 서버는 blue-green에 따라서 포트가 다르다.
version: '3'
services:
greenmate_backend_blue:
image: greenmate0701/greenmate_backend:latest # Spring Boot 애플리케이션 이미지
container_name: "greenmate_backend_blue" # 컨테이너 이름 설정
volumes:
- ./logs:/logs
environment:
- SPRING_PROFILES_ACTIVE=prod
env_file:
- .env
ports:
- "${APP_PORT}:8080" # 동적으로 애플리케이션 포트 매핑
- "${HEALTH_PORT}:9090" # 동적으로 Health Check 포트 매핑
networks:
- webnet
networks:
webnet:
driver: bridge
2. Nginx
Nginx는 동일한 도커 컴포즈 파일을 사용하지만, 재기동 될때마다 사용하는 nginx.conf가 다르다.
version: '3'
services:
nginx:
image: nginx:latest # 최신 Nginx 이미지 사용
container_name: "nginx"
ports:
- "80:80" # HTTP 포트
- "443:443" # HTTPS 포트
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf # Nginx 설정 파일을 컨테이너 내로 마운트
- ./ssl:/etc/nginx/ssl # SSL 인증서 파일을 마운트할 경로
environment:
- TZ=Asia/Seoul # timezone 설정 부분
networks:
- greenmate_deploy_webnet
networks:
greenmate_deploy_webnet:
driver: bridge
external: true