우선 배포과정을 설명하기 전에 전체적인 구조부터 보자.
조금은 추상적일 수도 있는 배포과정을 전체적으로 정리해보려고 한다.
1. GitHub Actions Workflow 트리거: Merge가 완료되면, `.github/workflows/cicd-deploy.yml`에 정의된 GitHub Actions Workflow가 자동으로 트리거 된다. 이 Workflow는 배포 프로세스를 자동으로 실행하기 위한 단계들을 포함하고 있다.
2. 깃 서브모듈 및 시크릿 설정: Workflow가 실행되면서 먼저 깃 서브모듈을 초기화하고 업데이트하여 의존하는 외부 또는 공유 코드 라이브러리를 가져온다. 그다음에 GitHub Secrets에 저장된 비밀 설정 파일(예: 환경 변수, 인증 키 등)을 사용하여 애플리케이션에 필요한 비밀 정보를 설정한다.
3. 빌드: 필요한 설정과 라이브러리가 준비되면, 소스 코드를 컴파일하고 실행 가능한 JAR 파일을 생성한다. 이 단계는 애플리케이션을 배포 준비 상태로 만든다.
4. S3 업로드: 빌드된 JAR 파일과 AWS CodeDeploy 배포에 필요한 `appspec.yml`파일, 그리고 해당 파일이 참조하는 배포 스크립트들을 Amazon S3 버킷에 업로드한다. 이러한 파일들은 배포 과정에서 필요한 자원과 명령어를 포함한다.
5. CodeDeploy 배포: 마지막으로, AWS CodeDeploy에게 S3에서 파일들을 가져와서 지정된 서버 또는 서비스에 배포하도록 지시한다. CodeDeploy는 `appspec.yml`파일에 정의된 지시에 따라 배포를 수행하며, 필요한 스크립트를 실행하여 애플리케이션을 서버에 배포하고 실행한다.
5-1. NGINX를 사용한 무중단 배포: 배포과정에서 NGINX를 활용하여 무중단 배포를 구현한다.
- 서버 준비: 애플리케이션이 배포될 새로운 환경(그린)을 준비한다. 이 환경은 기존 서비스를 제공하고 있는 환경(블루)과 별개로 새로운 포트를 사용한다.
- 트래픽 전환: NGINX 설정을 업데이트하여 트래픽을 새로 준비된 환경(그린)으로 전환한다. 이는 `nginx reload` 명령을 통해 설정 변경을 적용함으로써 이루어진다. 이 과정은 서비스 중단 없이(1초 미만) 이루어져 사용자가 새로운 버전으로의 전환을 인지하지 못하게 한다.
PR 테스트 자동화
우선 들어가기 전에 왜 Test와 Deploy를 나눠서 했을까?
일반적으로 Pull Request (PR)가 제출되었을 때, 즉시 병합(merge)하는 경우는 드물다. PR을 올리는 순간부터 테스트는 이미 실행되고 있고, 병합할 시점에는 이미 테스트가 완료되어 있을 것이다. 따라서 배포 단계에서는 추가적인 테스트 실행이 필요 없게 되며, 이는 배포 시간을 절약하는 데 도움이 될 것이라 생각했다. 이러한 방식은 PR 단계에서의 테스트 자동화를 통해 효율적인 배포 프로세스를 가능하게 한다.
그런데 여러 시도를 해봤지만 스니펫 때문에 test를 제외하고 build를 하게 되면 Rest Docs가 이상하게 되어버린다.. 그래서 test를 돌리면서도 최대한 빠르게 배포되도록 구현했다. 🤣
workflows/cicd-test.yml
name: ✋ Test
on:
pull_request:
types: [ opened, reopened, synchronize ]
branches: [ "main" ]
permissions:
contents: read
checks: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: 🍀 서브모듈 추가
uses: actions/checkout@v3
with:
token: ${{ secrets.GIT_TOKEN }}
submodules: 'recursive'
- name: 🍀 JDK 17 세팅
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: 🍀 Gradle 캐시 설정
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🍀 gradlew 실행 권한 설정
run: chmod +x gradlew
- name: 🍀 비밀 설정 파일 복사
run: ./gradlew copySecret
- name: 🍀 테스트 진행
env:
DEPLOY_CHECK: ok
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
DEV_DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
PROD_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
AES_KEY: ${{ secrets.AES_KEY }}
run: ./gradlew test --parallel
- name: 🍀 테스트 결과 Report
uses: mikepenz/action-junit-report@v3
if: always()
with:
report_paths: 'build/test-results/test/TEST-*.xml'
- name: 🍀 테스트 실패시 슬랙 알림
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: ${{ secrets.SLACK_GIT_ACTIONS_CHANNEL_NAME }}
SLACK_COLOR: ${{ job.status }}
SLACK_MESSAGE: '테스트 실패: ${{ github.repository }}'
SLACK_TITLE: '럭키즈 서버 PR 테스트 실패 알림 🍀'
SLACK_WEBHOOK: ${{ secrets.SLACK_GIT_ACTIONS_WEBHOOK_URL }}
- PR 활성화 시 자동 테스트 시작
- PR이 `main` 브랜치로 `opened`, `reopened` 또는 `synchronize` 될 때 테스트 자동화 프로세스가 시작.
- 접근 권한 설정
- 내용을 읽기 위한 `contents: read` 권한과 체크를 위한 `checks: write` 권한이 설정.
- 작업 설정
- `ubuntu-latest` 환경에서 테스트 작업이 수행.
- 서브모듈 추가
- GitHub Actions의 `actions/checkout@v3`를 사용하여 서브모듈을 추가.
- JDK 17 세팅
- `actions/setup-java@v3`를 사용하여 Java Development Kit 17을 설치. (Temurin 배포판을 사용.)
- Gradle 캐시 설정
- `actions/cache@v3`를 사용하여 Gradle 캐시를 설정. (이는 테스트 시간을 단축시키는 데 도움이 됨.)
- gradlew 실행 권한 설정
- `chmod +x gradlew` 명령어를 사용하여 Gradle Wrapper 실행 권한을 설정.
- 비밀 설정 파일 복사
- Gradle 작업 `copySecret`을 실행하여 비밀 설정 파일을 복사.
- 테스트 진행
- 여러 환경 변수 (메일 비밀번호, JWT 비밀키, DB 비밀번호 등)를 설정한 후 `./gradlew test --parallel` 명령으로 테스트를 병렬로 진행.
- 테스트 결과 Report
- `mikepenz/action-junit-report@v3`를 사용하여 테스트 결과를 JUnit 형식으로 보고.
- 테스트 실패 시 슬랙 알림
- 테스트가 실패하면 `rtCamp/action-slack-notify@v2`를 사용하여 슬랙 채널에 알림을 보냄. 이때 특정 메시지와 함께 테스트 실패 관련 정보가 전달.
가장 최근(12/15) 테스트는 대부분 1분 초중반대가 걸렸다!
배포 자동화
workflows/cicd-deploy.yml
name: 🚀 Deploy
on:
pull_request:
types: [ closed ]
branches: [ "main" ]
jobs:
deploy:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: 🍀 서브모듈 추가 및 업데이트
uses: actions/checkout@v3
with:
token: ${{ secrets.GIT_TOKEN }}
submodules: 'recursive'
- name: 🍀 JDK 17 세팅
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: 🍀 gradlew 실행 권한 설정
run: chmod +x gradlew
- name: 🍀 Gradle 캐시 설정
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🍀 비밀 설정 파일 복사
run: ./gradlew copySecret
- name: 🍀 Gradle 빌드
env:
DEPLOY_CHECK: ok
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
DEV_DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
PROD_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
AES_KEY: ${{ secrets.AES_KEY }}
run: ./gradlew build --parallel
- name: 🌐 AWS 자격 증명 구성
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_PRIVATE_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: 📦 jar, appspec, 배포 스크립트 복사
run: |
mkdir -p before-deploy
cp ./build/libs/*.jar ./before-deploy/
cp ./appspec.yml ./before-deploy/
cp -r ./scripts/ ./before-deploy/scripts/
- name: 🗜️ 배포 패키지 생성
run: zip -r -qq ./deploy.zip ./before-deploy/
- name: 🚛 S3로 업로드
run: aws s3 cp --region ap-northeast-2 ./deploy.zip s3://${{ secrets.CICD_S3_BUCKET }}/
- name: 🚀 CodeDeploy를 이용한 배포
run: |
aws deploy create-deployment \
--application-name ${{ secrets.CODEDEPLOY_APPLICATION }} \
--deployment-config-name CodeDeployDefault.AllAtOnce \
--deployment-group-name ${{ secrets.CODEDEPLOY_APPLICATION_GROUP }} \
--file-exists-behavior OVERWRITE \
--s3-location bucket=${{ secrets.CICD_S3_BUCKET }},bundleType=zip,key=deploy.zip \
--region ap-northeast-2
빌드 프로세스에서 Gradle을 빌드하는 부분 전까지는 동일하게 진행했고, 빌드하는 부분에서 `-x test` 옵션을 통해 테스트를 생략하고 싶었지만, Spring Rest Docs를 활용한 Snippet 생성에 문제가 발생했다. 테스트를 제외한 결과, Snippet들이 제대로 생성되지 않거나 예상과 다른 방식으로 만들어져 빌드 과정이 실패했다. Spring Rest Docs가 테스트 결과를 기반으로 Snippet을 생성하기 때문에 문제가 발생한 것 같다. 그래서 빌드 시간에 아주 큰 차이는 없어서 위와 같이 `./gradlew build --parallel`로 진행했다.
- AWS 자격 증명 구성
- `aws-actions/configure-aws-credentials@v2` 액션을 사용하여 AWS 자격 증명을 설정. 이를 통해 AWS 서비스에 접근할 수 있는 권한을 부여받게 됨. 여기서는 AWS의 액세스 키 ID, 비밀 액세스 키, 그리고 서비스가 운영될 지역(예: ap-northeast-2)을 설정.
- 파일 준비
- 배포에 필요한 파일들을 준비. 먼저, 필요한 디렉터리를 생성한 다음, 빌드된 JAR 파일, `appspec.yml` 파일(배포 사양을 정의) 그리고 배포 스크립트들을 해당 디렉터리로 복사.
- 배포 패키지 생성
- `zip` 명령을 사용하여 준비된 파일들을 하나의 압축 파일(`deploy.zip`)로 묶음. 이렇게 하면 AWS로의 업로드 과정이 효율적으로 진행될 수 있음.
- S3로 업로드
- 생성된 `deploy.zip` 파일을 AWS의 S3 버킷으로 업로드함. 이 과정은 AWS CLI를 사용하여 수행되며, S3 버킷의 이름은 시크릿을 통해 제공.
- CodeDeploy를 이용한 배포
- 마지막으로, AWS CodeDeploy를 사용하여 애플리케이션을 배포. `aws deploy create-deployment` 명령을 사용하여 배포를 생성하며, 여기서는 애플리케이션 이름, 배포 구성 이름, 배포 그룹 이름 등을 설정. 이 단계에서는 파일이 이미 존재하는 경우 덮어쓰도록 설정. S3 버킷에서 배포 패키지의 위치도 명시.
이로써 Github Actions를 통한 배포는 종료되고, AWS CodeDeploy가 실행 단계에 들어간다. 이때, CodeDeploy는 `appspec.yml` 파일에 정의된 절차에 따라 작업을 수행한다. `appspec.yml` 파일은 배포의 각 단계와 관련 스크립트, 실행 순서 등을 포함하여 전체 배포 프로세스를 정의한다.
appspec.yml
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app
overwrite: yes
permissions:
- object: /home/ec2-user
pattern: "**"
owner: ec2-user
group: ec2-user
hooks:
AfterInstall:
- location: scripts/run_new_was.sh
timeout: 60
runas: ec2-user
ApplicationStart:
- location: scripts/health.sh
timeout: 60
runas: ec2-user
ValidateService:
- location: scripts/switch.sh
timeout: 60
runas: ec2-user
- 파일 복사: 소스 디렉터리에서 `/home/ec2-user/app`으로 파일을 복사.
- 권한 설정: `/home/ec2-user` 디렉터리의 파일에 대한 권한을 `ec2-user`로 설정.
- 실행 단계(Hooks): 세 가지 주요 스크립트(`run_new_was.sh`, `health.sh`, `switch.sh`)를 각각
설치 후(AfterInstall), 애플리케이션 시작 시(ApplicationStart), 서비스 유효성 검증 시(ValidateService)에 실행
run_new_was.sh
#!/bin/bash
REPOSITORY=/home/ec2-user/app
CURRENT_PORT=$(cat /etc/nginx/conf.d/service-url.inc | grep -Po '[0-9]+' | tail -1)
TARGET_PORT=0
echo "> Current port of running WAS is ${CURRENT_PORT}."
if [ ${CURRENT_PORT} -eq 8081 ]; then
TARGET_PORT=8082
elif [ ${CURRENT_PORT} -eq 8082 ]; then
TARGET_PORT=8081
else
echo "> No WAS is connected to nginx"
fi
TARGET_PID=$(lsof -Fp -i TCP:${TARGET_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+')
if [ ! -z ${TARGET_PID} ]; then
echo "> Kill WAS running at ${TARGET_PORT}."
sudo kill ${TARGET_PID}
fi
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)
echo "> JAR Name: $JAR_NAME"
echo "> $JAR_NAME에 실행권한 추가"
chmod +x $JAR_NAME
LOG_FILE="$REPOSITORY/nohup.out"
echo "> 로그 파일이 존재하지 않으면 생성하고, 존재하면 내용을 유지 (쓰기 권한 확인)"
touch $LOG_FILE
echo "> $JAR_NAME 실행"
nohup java -jar -Dserver.port=${TARGET_PORT} -Dspring.profiles.active=prod $JAR_NAME > $LOG_FILE 2>&1 &
echo "> Now new WAS runs at ${TARGET_PORT}."
exit 0
health.sh
#!/bin/bash
CURRENT_PORT=$(cat /etc/nginx/conf.d/service-url.inc | grep -Po '[0-9]+' | tail -1)
TARGET_PORT=0
# Toggle port Number
if [ ${CURRENT_PORT} -eq 8081 ]; then
TARGET_PORT=8082
elif [ ${CURRENT_PORT} -eq 8082 ]; then
TARGET_PORT=8081
else
echo "> No WAS is connected to nginx"
exit 1
fi
echo "> Start health check of WAS at 'http://127.0.0.1:${TARGET_PORT}' ..."
for RETRY_COUNT in {1..10}
do
echo "> #${RETRY_COUNT} trying..."
RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${TARGET_PORT}/health-check)
if [ ${RESPONSE_CODE} -eq 200 ]; then
echo "> New WAS successfully running"
exit 0
elif [ ${RETRY_COUNT} -eq 10 ]; then
echo "> Health check failed."
exit 1
fi
sleep 10
done
switch.sh
#!/bin/bash
CURRENT_PORT=$(cat /etc/nginx/conf.d/service-url.inc | grep -Po '[0-9]+' | tail -1)
TARGET_PORT=0
echo "> Nginx currently proxies to ${CURRENT_PORT}."
if [ ${CURRENT_PORT} -eq 8081 ]; then
TARGET_PORT=8082
elif [ ${CURRENT_PORT} -eq 8082 ]; then
TARGET_PORT=8081
else
echo "> No WAS is connected to nginx"
exit 1
fi
# Change proxying port into target port
echo "set \$service_url http://127.0.0.1:${TARGET_PORT};" |sudo tee /etc/nginx/conf.d/service-url.inc
echo "> Now Nginx proxies to ${TARGET_PORT}."
sudo service nginx reload
echo "> Nginx reloaded."
CURRENT_PID=$(lsof -Fp -i TCP:${CURRENT_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+')
sudo kill ${CURRENT_PID}
echo "> kill ${CURRENT_PID}"
이 세 스크립트는 함께 작동하여 AWS 환경에서 무중단 배포를 구현한다:
- run_new_was.sh: 새 웹 애플리케이션 서버(WAS)를 시작한다. 현재 운영 중인 포트를 확인하고, 새 서버를 다른 포트에 시작한다. 필요한 경우 기존 서버를 종료하고, 새로운 JAR 파일을 실행한다.
- health.sh: 새로 시작된 서버의 상태를 체크한다. 지정된 횟수만큼 서버의 응답을 확인하여 정상 작동 여부를 검증한다.
- switch.sh: Nginx의 프록시 설정을 변경하여 트래픽을 새 서버로 전환한다. 설정을 업데이트한 후 Nginx를 리로드 하고, 이전 서버는 종료한다.
여기서 Nginx Reload를 통해 1초 이내에 실행완료가 돼서 유저 입장에선 중단이 되지 않았다고 느끼는 것이다.
도메인과 HTTPS 적용은 무료로 사용하기 위해 내 도메인 한국과 SSL For Free를 사용했습니다.
참고- https://cl8d.tistory.com/97
언제나 잘못된 설명이나 부족한 부분에 대한 피드백은 환영입니다🤍