name: Spring Boot CI/CD with Gradle
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
# Gradle Build를 위한 권한 부여
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# Gradle Build (test 제외)
- name: Build with Gradle
run: ./gradlew clean build --exclude-task test
# DockerHub 로그인
- name: DockerHub Login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
# Docker 이미지 빌드
- name: Docker Image Build
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }} .
# DockerHub Push
- name: DockerHub Push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}
# EC2 인스턴스 접속 및 애플리케이션 실행
- name: Application Run
uses: appleboy/ssh-action@v0.1.6
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_KEY }}
script: |
sudo docker kill ${{ secrets.PROJECT_NAME }}
sudo docker rm -f ${{ secrets.PROJECT_NAME }}
sudo docker rmi ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}
sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}
sudo docker run -p 8088:8088 \
--name ${{ secrets.PROJECT_NAME }} \
-e SPRING_DATASOURCE_URL=${{ secrets.POSTGRESQL_URL }} \
-e SPRING_DATASOURCE_USERNAME=${{ secrets.POSTGRESQL_USERNAME }} \
-e SPRING_DATASOURCE_PASSWORD=${{ secrets.POSTGRESQL_PASSWORD }} \
-e SPRING_REDIS_HOST=${{ secrets.REDIS_HOST }} \
-e SPRING_REDIS_PORT=${{ secrets.REDIS_PORT }} \
-e JWT_ACCESS_KEY=${{ secrets.JWT_ACCESS_KEY }} \
-e JWT_REFRESH_KEY=${{ secrets.JWT_REFRESH_KEY }} \
-e KAKAO_TOKEN_URL=${{ secrets.KAKAO_TOKEN_URL }} \
-e KAKAO_PROFILE_URL=${{ secrets.KAKAO_PROFILE_URL }} \
-e KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} \
-e KAKAO_REDIRECT_URL=${{ secrets.KAKAO_REDIRECT_URL }} \
-e KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} \
-e NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }} \
-e NAVER_SECRET=${{ secrets.NAVER_SECRET }} \
-d ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }}
현재 배포 프로세스 입니다.

환경변수 yml 부분을 파일로 따로 빼고 Load Balancing Blue / Green 무중단 배포로
아래와 같은 배포 프로세스로 리팩토링 진행해보겠습니다.

1. Github Actions으로 S3에 업로드

S3 버킷 생성

IAM 사용자 생성
name: Deploy to Production
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@master
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
- name: Gradle Caching
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: Make application.yml
run: |
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION }}" > ./application.yml
shell: bash
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew clean build --exclude-task test
- name: Make zip file
run: |
mkdir deploy
cp ./build/libs/*.jar ./deploy/
zip -r -qq -j ./spring-build.zip ./deploy
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: Upload to S3
run: |
aws s3 cp \
--region ap-northeast-2 \
./spring-build.zip s3://spring-book-bucket
github actions 를 push 하게 되면 Upload to S3 job 에 따라
방금 생성한 spring-book-bucket 버킷에 zip 한 파일이 업로드 됩니다.

2. AWS CodeDeploy 설정

권한 추가

EC2 IAM Role 생성
EC2에서 CodeDeploy를 사용하기 위해 IAM Role을 생성해줍니다.
IAM Role 은 주로 AWS 서비스들이 직접 다른 AWS 서비스를 제어하기 위해 사용됩니다.
IAM > 역할 > 역할 생성
AmazonEC2RoleforAWS-CodeDeploy 권한을 추가 -> 이름은 role-ec2-codedeploy로 지정 !
CodeDeploy IAM Role 생성



이름을 role-codedeploy 로 지정하고 생성 완료
EC2 IAM 역할 수정

해당 역할을 인스턴스에 연결 후 꼭 인스턴스 재부팅을 해주어야 나중에 CodeDeploy 배포에 아래처럼 성공할 수 있습니다. (재부팅안해줘서 실패한 흔적들,,)

EC2에 AWS CodeDeploy 에이전트 설치
CodeDeploy를 이용하려면 배포하는 환경에 CodeDeploy Agent를 설치해야 합니다.
# 시스템 패키지 업데이트
sudo apt update
sudo apt upgrade -y
# Ruby 설치
sudo apt install -y ruby
# AWS CLI를 사용해 S3에서 설치 스크립트 다운로드
aws s3 cp s3://aws-codedeploy-ap-northeast-2/latest/install . --region ap-northeast-2
# 설치 스크립트에 실행 권한 부여
chmod +x ./install
# CodeDeploy 에이전트 설치
sudo ./install auto
# CodeDeploy 에이전트 상태 확인
sudo service codedeploy-agent status

status 확인후 active 가 나오면 정상적으로 작동하는 것입니다.
CodeDeploy 설정
애플리케이션 생성

배포 그룹 생성
EC2 설정
디렉토리 생성
mkdir ~/app
S3에 있는 소스를 내려받을 app 폴더를 생성합니다.

docker 실행 중임을 확인했습니다.
appspec.yml 작성
CodeDeploy에서 배포가 실행되면 이 appspec.yml파일에 따라 동작하게 됩니다.
version: 0.0
os: linux
files:
- source: /
destination: /home/ubuntu/app
overwrite: yes
permissions:
- object: /
pattern: "**"
owner: ubuntu
group: ubuntu
hooks:
ApplicationStart:
- location: deploy.sh
timeout: 60
runas: ubuntu
deploy.sh 스크립트 작성
#!/bin/bash
cd /home/ubuntu/app
DOCKER_APP_NAME=spring
LOG_FILE="deploy.log"
# 실행중인 blue가 있는지
EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps | grep running)
# green이 실행중이면 blue up
if [ -z "$EXIST_BLUE" ]; then
echo "$(date): blue up" >> $LOG_FILE
docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml up -d --build >> $LOG_FILE 2>&1
sleep 30
docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml down >> $LOG_FILE 2>&1
docker image prune -af >> $LOG_FILE 2>&1
# blue가 실행중이면 green up
else
echo "$(date): green up" >> $LOG_FILE
docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml up -d --build >> $LOG_FILE 2>&1
sleep 30
docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml down >> $LOG_FILE 2>&1
docker image prune -af >> $LOG_FILE 2>&1
fi
저는 로그를 확인해보고 싶어서 deploy.log 파일을 설정해줬지만 생략하셔도 됩니다.
Github Actions Workflow에 CodeDeploy 설정 추가
name: Deploy to Production
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@master
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
- name: Gradle Caching
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: Make application.yml
run: |
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION }}" > ./application.yml
shell: bash
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew clean build --exclude-task test
- name: Make zip file
run: |
mkdir deploy
cp ./appspec.yml ./deploy/
cp ./Dockerfile ./deploy/
cp ./scripts/*.sh ./deploy/
cp ./build/libs/*.jar ./deploy/
zip -r -qq -j ./spring-build.zip ./deploy
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: Upload to S3
run: |
aws s3 cp \
--region ap-northeast-2 \
./spring-build.zip s3://spring-book-bucket
- name: Code Deploy
run: aws deploy create-deployment --application-name spring-deploy
--deployment-config-name CodeDeployDefault.OneAtATime
--deployment-group-name spring-deploy-group
--s3-location bucket=spring-book-bucket,bundleType=zip,key=spring-build.zip

github actions 가 잘 작업을 마쳤음을 확인할 수 있습니다.
3. Application Load Balancer(ALB) 설정
서브 도메인 추가
Route53 > 호스팅 영역 > my-book-note-spring.link > 레코드 생성

ec2 public ip 주소에 대한 레코드를 생성해주면 됩니다.
Certificate Manager SSL 인증서 발급
AWS Certificate Manager > 인증서 > 인증서 요청

인증서 발급 후 레코드를 추가해주면 클릭하시면 Route 53 을 통해 자동으로 CNAME 이 등록되며 DNS 검증이 진행됩니다.
로드 밸런서 생성


target group 을 추가해주고 Load balancer 생성을 눌러주면 됩니다.
HTTPS 리스너 추가


아까 생성해준 인증서를 적용해줍니다.

- HTTPS 요청 수신:
- 클라이언트가 https://api.my-book-note-spring.link 로 요청을 보냅니다.
- 이 요청은 EC2 로드 밸런서의 HTTPS 리스너 (포트 443) 에서 수신됩니다.
- SSL 인증 및 처리:
- 로드 밸런서는 요청을 수신하고, 설정된 SSL 인증서를 사용하여 HTTPS 연결을 인증합니다.
- SSL/TLS 연결이 종료되고 요청이 안전하게 수신됩니다.
- Target Group(book-target-group) 으로 요청 전달:
- 인증이 완료된 후, 로드 밸런서는 요청을 book-target-group으로 전달합니다.
- book-target-group 은 80 포트에 연결된 인스턴스들로 구성되어 있습니다. 즉, 로드 밸런서는 HTTPS 요청을 HTTP로 변환하여 80 포트를 통해 대상 인스턴스에 전달합니다.
- 레코드 설정:
- DNS 레코드(Route 53이나 다른 DNS 서비스에서) 에서 api.my-book-note-spring.link 에 대한 요청이 로드 밸런서의 주소로 트래픽 라우터 대상으로 설정되어 있습니다. -> 바로 밑에 Route53 ALB 연결 참고
- 이를 통해 클라이언트의 요청이 EC2 로드 밸런서로 적절히 routing 되는 흐름입니다!
Route53 ALB 연결

별칭을 활성화하고 Application/Classic Load Balancer에 대한 별칭을 선택하고 등록하면 됩니다.
4. Nginx 설치
sudo apt install nginx -y
sudo systemctl start nginx
nginx 설치하려고 보니 disk 용량 꽉차서 루트 볼륨을 늘려줍니다.


sudo growpart /dev/xvda 1
lsblk
파티션이 잘 적용되었고

sudo resize2fs /dev/xvda1
df -hT
root 가 확장되어 잘 적용됨을 확인할 수 있네요.
nginx 프록시 설정을 변경해줍니다.
sudo vim /etc/nginx/sites-available/my_site.conf
server {
listen 80;
listen [::]:80;
server_name *.my-book-note-spring.link my-book-note-spring.link;
root /usr/share/nginx/html;
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
if ($http_x_forwarded_proto != 'https') {
return 301 https://$host$request_uri;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header HOST $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8080;
proxy_redirect off;
}
}
sudo ln -s /etc/nginx/sites-available/my_site.conf /etc/nginx/sites-enabled/
sudo systemctl restart nginx
restart nginx 해주면 my-book-note-spring.link가
루트 도메인과 서브도메인 모두에 대한 트래픽을 nginx 가 처리하고 있음을 확인할 수 있습니다.

5. 무중단 배포 설정
기존의 배포 방식은 도커 컨테이너를 중단하고 새로운 컨테이너를 만들어 실행하기까지 서비스 중단이 일어나게 되므로
두 개의 동일한 프로덕션 환경(Blue와 Green)을 구성하여, Green 환경에서 새 버전을 배포하고 트래픽을 전환하는 Blue-Green 배포 전략을 사용해보겠습니다.
Blue-Green 배포 방식
Docker Compose 파일 작성
docker-compose.blue.yml
#blue
version: '3'
services:
app-api:
build: .
ports:
- "8081:8080"
container_name: spring-blue
blue 컨테이너는 port가 8081번으로 설정됩니다.
docker-compose.green.yml
#green
version: '3'
services:
app-api:
build: .
ports:
- "8082:8080"
container_name: spring-green
green 컨테이너는 port가 8082번으로 설정됩니다.
deploy.sh 수정
#!/bin/bash
cd /home/ubuntu/app
DOCKER_APP_NAME=spring
# 실행중인 blue가 있는지
EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps | grep running)
# green이 실행중이면 blue up
if [ -z "$EXIST_BLUE" ]; then
echo "blue up"
docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml up -d --build
sleep 30
docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml down
docker image prune -af # 사용하지 않는 이미지 삭제
# blue가 실행중이면 green up
else
echo "green up"
docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml up -d --build
sleep 30
docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml down
docker image prune -af
fi
Github Actions Workflow 수정
main-deploy.yml 수정
- name: Make zip file
run: |
mkdir deploy
cp ./docker-compose.blue.yml ./deploy/
cp ./docker-compose.green.yml ./deploy/
cp ./appspec.yml ./deploy/
cp ./Dockerfile ./deploy/
cp ./scripts/*.sh ./deploy/
cp ./build/libs/*.jar ./deploy/
zip -r -qq -j ./spring-build.zip ./deploy
- docker-compose.blue.yml, docker-compose.green.yml 파일을 복사하는 과정을 추가해줍니다.
Nginx 설정 변경
sudo vim /etc/nginx/sites-available/my_site.conf
# 추가
upstream app-api {
least_conn;
server 127.0.0.1:8081 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8082 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
listen [::]:80;
server_name *.my-book-note-spring.link my-book-note-spring.link;
root /usr/share/nginx/html;
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
if ($http_x_forwarded_proto != 'https') {
return 301 https://$host$request_uri;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header HOST $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app-api;
proxy_redirect off;
}
}
SSL 인증 흐름
- 클라이언트 요청:
- 클라이언트가 https://api.my-book-note-spring.link 에 요청을 보냅니다.
- EC2 로드 밸런서:
- EC2 로드 밸런서는 443 포트에서 HTTPS 요청을 수신합니다.
- 로드 밸런서는 미리 구성된 SSL 인증서를 사용하여 클라이언트와의 SSL 핸드셰이크를 수행합니다. 이 과정에서 로드 밸런서는 클라이언트의 요청을 인증하고, SSL 연결을 설정합니다.
- HTTP로 Nginx에 요청 전달:
- SSL 핸드셰이크가 완료되면, EC2 로드 밸런서는 클라이언트의 요청을 Nginx로 전달합니다. 이 때, 로드 밸런서와 Nginx 간의 연결은 HTTP로 이루어집니다.
- 따라서 Nginx는 SSL 인증서 없이 요청을 받습니다.
- Nginx의 역할:
- Nginx는 클라이언트 요청을 받아 proxy_pass 설정에 따라 Upstream 서버(Spring 애플리케이션)로 요청을 전달합니다.
- Nginx는 SSL 인증서를 가지지 않기 때문에, SSL 관련 처리나 인증을 수행하지 않고, 단순히 클라이언트의 요청을 업스트림으로 routing 해줍니다.
upstream 블록의 이름을 app-api 로 Docker Compose에서 사용한 서비스 이름과 일치시키면 됩니다.
proxy_pass 지시어에서도 docker compose service 명과 일치시켜 http://app-api 이런식으로 사용하시면 됩니다.
sudo service nginx restart
Nginx를 재시작합니다.

Swagger 엔드포인트로 접속하니 Swagger UI 가 성공적으로 나타났고 API 요청도 잘 전달됩니다.
확인

배포 중에 웹사이트 새로고침을 계속 하여도 접속에 이상이 없음을 확인할 수 있습니다.