무중단 배포를 적용하기 위해 프록시 서버로 nginx를 사용했다.(nginx : https://kimdozzi.tistory.com/256)
배포 플로우
Blue-Green 방식
가장 흔히 알려진 방식으로 롤링 배포 방식이 있다. 다른 배포 방식이 궁금하다면 https://hudi.blog/zero-downtime-deployment/ 를 참고하자. 해당 방식은 두 대의 서버를 사용한다. 하지만, 나는 EC2 한 대에 도커 컨테이너 두 개를 구축하고, 새 버전의 이미지가 올라올 때만 띄워서 교체하는 방식으로 선택했다. 이전에 선택한 방법은 AWS EC2에서 2개의 Springoot Jar 파일을 포트번호만 달리하여 띄우는 방식이었다. 하지만, 내부적으로 문제가 발생함과 동시에 해당 방식은 무중단 배포가 아닌 것 같다는 결정을 내리고, Docker Compose를 이용한 방식으로 전환하게 되었다.
결론적으로 Nginx를 한 서버 내의 로드 밸런서로 이용하여 Blue-Green 무중단 배포를 선택하였다. AWS EC2 서버를 두 대 사용하는 것 보다 비용적인 이점이 크다.
1. AWS EC2 생성 및 환경 설정
- AWS 계정을 생성하는 과정은 생략하겠다. AWS에 접속하고, 검색창에 EC2를 검색한다. 인스턴스에 접속하여 인스턴스 시작을 누른다.
이름을 설정하고, OS 이미지를 선택한다. 나는 Amazon Linux를 사용하였다.
키 페어를 생성해주어야 한다. 키페어 이름 입력, 키 페어 유형(RSA), 키 파일 형식(.pem)을 선택하고 키 페어 생성 버튼을 클릭한다.
네트워크 보안 그룹도 설정한다. 오른쪽 상단의 편집을 클릭하고, 보안 그룹 이름과 설명, 인바운드 보안 그룹 규칙을 입력한다. 보안 그룹 이름과 설명은 원하는대로 수정해도 되고, 안해도 상관없다. default로 제공되는 규칙은 SSH이고, 우리는 nginx를 사용할 예정이기에 80번 포트(HTTP)를 허용해줘야 한다.
모든 설정이 끝나면 맨 하단의 인스턴스 시작을 누르고 인스턴스를 생성한다. (스토리지 구성에서 프리 티어에서 제공되는 최대 용량은 30GB이다. 원하면 용량을 늘려준다.) 이로써 AWS EC2 서버 생성은 끝났다.
2. AWS EC2에서 Nginx 설치
생성된 인스턴스를 누르고 "연결"을 클릭한다. EC2 인스턴스 연결로 바로 서버에 접속한다. 다른 방식으로 접속을 원하면 검색해보고 연결해보길 권장한다.
먼저 AWS EC2 서버에 nginx를 설치하고, 정상적으로 nginx가 동작하는지 확인해본다.
# nginx 설치
sudo yum install -y nginx
# nginx version 확인
nginx -version
# nginx 시작
sudo service nginx start
# nginx 동작 상태 확인
sudo service nginx status
Active 상태가 확인이 되면 Public Ip로 접속해보자. 아래 화면까지 본다면 nginx 설치는 완료된 것이다.
3. nginx 설정 파일 이해하기
nginx의 메인 설정 파일인 nginx.conf 파일에 대해 알아본다. 위치는 /etc/nginx/ 폴더의 하위에 존재하며, 아래 conf파일 수정을 통해 다양한 작업을 진행할 수 있다.
/etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice; # nginx의 에러 로그가 쌓이는 경로
pid /run/nginx.pid; # nginx의 프로세스 아이디(PID)가 저장되는 경로
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024; # worker process가 동시에 처리할 수 있는 접속자의 수 = default : 1024로 설정
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' # 후술한 로그 형태에 따라 로그가 작성되고 기록된다
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main; # 접속 로그가 쌓이는 경로
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
types_hash_max_size 4096;
include /etc/nginx/mime.types;
default_type application/octet-stream; # 웹 서버의 기본 Content-Type
# Load modular configuration files from the /etc/nginx/conf.d directory.
# See http://nginx.org/en/docs/ngx_core_module.html#include
# for more information.
include /etc/nginx/conf.d/*.conf;
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
error_page 404 /404.html;
location = /404.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
}
- include ⇒ 포함시킬 외부 파일 정의
include /usr/share/nginx/modules/*.conf;
- nginx의 에러 로그가 쌓이는 경로
error_log /var/log/nginx/error.log notice;
- nginx의 프로세스 아이디(PID)가 저장되는 경로
pid /run/nginx.pid;
- worker process가 동시에 처리할 수 있는 접속자의 수 = default : 1024로 설정
events {
worker_connections 1024;
}
- 후술한 로그 형태에 따라 로그가 작성되고 기록된다.
/etc/nginx/sites-available/
nginx에서 관리되는 호스트 정보
/etc/nginx/sites-enabled/
sites-available/ 에서 만든 사이트를 sites-enabled/ 에 추가해야 활성화된다. sites-available/ 에 추가한 사이트를 sites-enabled/ 에 심볼릭 링크하여 사이트를 활성화할 수 있다.
아마 위의 경로에 폴더가 없을 수도 있다. 나도 폴더가 없어서 따로 생성해줬다.무중단 배포를 진행하는 다른 블로그들을 보면 위 두 폴더를 자주 이용한다. 궁금하면 접은 글을 보길 바란다.
- sites-available/{사이트명} 으로 conf 파일 생성
- 추가한 사이트를 sites-enabled/에 심볼릭 링크하여 활성화
ln -s /etc/nginx/sites-available/{사이트명} /etc/nginx-sites-enabled/{사이트명}
- nginx -t 해당 명령어 실행 후 다음과 출력되면 정상
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
- nginx -s reload nginx 재시작
아래는 해당 실습을 진행한 코드이다.
nginx.conf :
user root;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
types_hash_max_size 4096;
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
server {
listen 80;
listen [::]:80;
server_name _;
root /home/ec2-user/public_html;
include /etc/nginx/default.d/*.conf;
location /application1 {
proxy_pass http://localhost:8080/sampleApp;
}
location /images {
root /home/ec2-user/data;
}
error_page 404 /404.html;
location = /404.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
}
/etc/nginx/sites-available/default.conf
server {
listen 8080;
root /home/ec2-user/data/server2;
}
nginx 접속 및 proxy server 테스트가 완료되었으니 다음 단계를 진행해본다. 나는 실제 프로젝트를 docker Image로 생성하고, dockerhub에 저장할 예정이다. 저장된 이미지 파일을 AWS EC2 서버에서 받아온 다음, EC2 서버에 배포되는 것 까지 테스트해보자.
아래 사이트에서 nginx가 어떤 식으로 동작하고, proxy-pass를 할 수 있는지 연습해보길 권장한다. 예제를 따라해보고 온다면 좀 더 수월하게 이해할 수 있을 것 이다.
4. Git Actions + Dockerhub
Git Actions 스크립트 파일을 작성하기 전에 프로젝트 생성 및 Github Repository를 만들어주자. (이정도는 각자 할 수 있겠죠~?!?!!?) 나는 스프링부트 프로젝트를 Java17, Gradle로 설정하고 생성했다. 그리고 테스트를 위해 DeployController 클래스 파일을 하나 만들었다.
@Controller
public class DeployController {
@GetMapping("/")
public ResponseEntity hello() {
return ResponseEntity.ok("배포 자동화 테스트");
}
}
진행할 레포지토리에서 Actions 탭으로 접속한다. gradle.yml 을 편집할 수 있는 화면이 보여질텐데 나는 main.yml로 파일명을 수정하고 아래와 같이 스크립트 파일을 작성하였다.
아래 코드를 보면 Docker Image로 빌드하기 위해 dockerhub 어쩌구저쩌구 하는 코드를 볼 수 있다. 우리의 목표를 다시 살표보면, 빌드된 프로젝트 파일을 도커 이미지로 만들고, 해당 이미지를 도커허브에 저장해야 한다. 그러기 위해선 Git Actions에서 Dockerhub에 접근할 수 있어야한다.
name: Java CI with Gradle
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
shell: bash
- name: Setup Gradle
uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0
- name: Build with Gradle
run: ./gradlew clean build
- name: docker login
uses: docker /login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: docker image build
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo .
- name: docker hub push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
Settings 탭에서 Security -> Secrets and variables에 접속한다.
New repository Secret을 입력하고 두 파일을 작성한다. 실제 사용중인 dockerhub의 username과 password를 각각 입력하고 저장해주자.
그리고 마지막으로 Dockerfile을 생성한다. 스프링부트 프로젝트의 루트 위치에 Dockerfile이라는 이름의 파일을 생성해준다. 해당 파일은 절대 다른 이름으로 만들어서는 안된다.
build.gradle
bootJar {
archiveBaseName = 'GitGetApplication'
archiveFileName = 'GitGetApplication.jar'
archiveVersion = "0.0.1"
}
Dockerfile
# Start with a base image containing Java runtime.
FROM openjdk:17-jdk
# The application's jar file.
ARG JAR_FILE=./build/libs/GitGetApplication.jar
# Add the application's jar to the container.
COPY ${JAR_FILE} GitGetApplication.jar
# Run the jar file.
CMD ["java", "-jar", "GitGetApplication.jar"]
모두 완료했다면, 코드를 push해보자 !!!! Actions 탭에서 정상적으로 빌드가 되었고, 파란색 불이 떴다면 Dockerhub에 접속하여 정상적으로 이미지가 업로드 되었는지 확인해본다.
이제 .jar 파일을 도커허브에 푸시하는 데 까지 자동화가 완료되었다. 도커허브에 푸시된 이미지를 EC2에서 다운로드 받고, 서버를 실행시키는 부분까지 자동화를 진행해본다. 여기서 우리는 Self-hosted runner를 사용한다. Self-hosted runner는 Github actions에서 작업을 실행하기 위해 배포하고 관리하는 시스템이다. 자세한 내용은 해당 링크를 참고하자.
About self-hosted runners - GitHub Docs
5. AWS EC2에 Self-hosted runner 설치 및 설정
AWS EC2에 본인 환경에 맞는 Java를 설치해주어야 한다.
sudo yum install java-17-amazon-corretto
https://docs.aws.amazon.com/corretto/latest/corretto-17-ug/amazon-linux-install.html
아래 링크에 self-hosted runner 설치에 대한 설명이 잘 되어있다. 참고해보자.
https://e-room.tistory.com/145
./config.sh 명령어 실행 중 Must not run with sudo라는 에러메시지가 발생할 경우 export RUNNER_ALLOW_RUNASROOT=”1” 를 입력하고 다시 시도해보자. 나는 아래 명령어를 입력하지 않고, ./config.sh 파일 실행 권한을 부여해서 해결하였다. 정상적으로 실행한 후 Enter를 계속 입력하고, Runner Setting이 완료된다면 exit로 Runner를 빠져나온다. 그리고 아래 명령어를 차례대로 입력하여 Github Actions를 수신할 준비를 완료해준다.
sudo ./svc.sh install
sudo ./svc.sh start
Self-hosted runner 설치가 완료되었다면 main.yml을 수정하고, 스프링부트 프로젝트를 위해 8080 포트를 개방해주어야 한다. AWS EC2 보안그룹의 인바운드 규칙에서 8080 포트도 개방해주자. (지금까지의 단계에서 필요한 포트는 22, 80, 8080이다.)
main.yml
name: Java CI with Gradle
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-docker-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
shell: bash
- name: Setup Gradle
uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0
- name: Build with Gradle
run: ./gradlew clean build
- name: docker login
uses: docker /login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: docker image build
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo .
- name: dockerhub push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
run-docker-image-on-ec2:
needs: build-docker-image
runs-on: self-hosted
steps:
- name: docker pull
run: sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
- name: docker stop container
run: sudo docker stop $(sudo docker ps -q) 2>/dev/null || true
- name: docker run new container
run: sudo docker run --name github-actions-demo --rm -d -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
- name: delete old docker image !
run: sudo docker system prune -f
AWS EC2에 접속하고 self-hosted runner가 제대로 동작하는 지 확인해보겠다. 수정된 파일을 push하고 Actions 탭에서 정상적으로 파란불이 들어오는 지 확인해본다. 그리고 AWS EC2에서 docker ps 라는 명령어로 dockerhub에서 다운받은 파일이 정상적으로 실행 중인지 확인해본다.
docker ps 라는 명령어를 입력했을 때, /var/run/docker.sock의 permission denied가 발생한다면 아래 명령어를 입력하고 다시 시도하자.
sudo chmod 666 /var/run/docker.sock
아래를 보면, 정상적으로 동작하는 모습을 확인할 수 있다!!!!!!!!
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
61192760a4e9 geniusgitget/github-actions-demo "java -jar GitGetApp…" 51 minutes ago Up 51 minutes 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp github-actions-demo
지금까지 완료된 것들을 정리하자면,
- AWS EC2에서 nginx proxy pass 테스트 완료
- AWS EC2 설치
- Nginx 설치
- nginx.conf, default.conf 작성
- 보안그룹 80포트 개방 (IPv4)
- Git Actions에서 프로젝트 파일 도커라이징 후 dockerhub 저장
- main.yml 작성
- Dockerfile 작성
- secrets 파일 추가
- AWS EC2 self-hosted runner 설치 후 dockerhub에 저장된 이미지 다운로드
- Git Actions main.yml에서 runner 실행하는 job 추가
- 8080포트 개방 (IPv4)
- 서버에서 Java, Docker, Self-hosted runner 설치
길다.......길어!!!!!!!!!!!!!!!!!!!!!! 이제 마지막으로 docker-compose를 이용한 무중단 배포를 적용해보자 !!!!!!!!!!!
6. docker-compose 설치 및 무중단 배포 적용
Amazon linux에서 docker-compose 설치
$ sudo curl -L https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose
docker-compose 명령어 실행 권한 부여
$ sudo chmod +x /usr/local/bin/docker-compose
docker-compose 설치 확인
docker-compose --version
여기서 main.yml 파일을 수정해야 한다. 위에서 작성한 코드는 실제로 docker image가 서버에서 정상적으로 동작하는 지 확인하기 위해 설정했었던 것이고, 는 main.yml에서 deploy.sh를 실행시켜서 docker-compose.yml에 작성된 코드를 실행시키는 것이 목표다. 왜 그렇게 하냐고? 방법은 다양하다. 나는 그 중에서도 가장 간단한 방법을 쓰기 위해 이런 식으로 구현한 것이다. (너무 어려웠어 ㅠㅠㅠㅠㅠㅠㅠ) 다시 main.yml을 아래와 같이 수정해보자. run-docker-image-on-ec2에서 build-docker-image가 수행되었다면 AWS EC2 서버에 존재하는 deploy.sh를 실행시켜주도록 한다.
main.yml
name: Java CI with Gradle
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-docker-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
shell: bash
- name: Setup Gradle
uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0
- name: Build with Gradle
run: ./gradlew clean build
- name: docker login
uses: docker /login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: docker image build
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo .
- name: dockerhub push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
run-docker-image-on-ec2:
needs: build-docker-image
runs-on: self-hosted
steps:
# - name: docker pull
# run: sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
#
# - name: docker stop container
# run: sudo docker stop $(sudo docker ps -q) 2>/dev/null || true
#
# - name: docker run new container
# run: sudo docker run --name github-actions-demo --rm -d -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/github-actions-demo
#
# - name: delete old docker image
# run: sudo docker system prune -f
- name: execute deploy.sh
run: sh /home/ec2-user/deploy.sh
deploy.sh
이 파일은 AWS EC2의 루트 경로에 작성해준다.
# !/bin/bash
IS_BLUE=$(docker ps | grep blue)
if [ -z "$IS_BLUE" ];then
echo "blue Deploy..."
docker-compose pull blue
docker-compose up -d blue
BEFORE_PORT=8081
AFTER_PORT=8080
for cnt in {1..10}
do
echo "blue health check...."
REQUEST=$(curl http://127.0.0.1:8080/api/auth/health-check )
echo $REQUEST
if [ -n "$REQUEST" ]; then
break ;
fi
sleep 3
done;
if [ $cnt -eq 10 ]
then
echo "서버가 정상적으로 구동되지 않았습니다.(blue)"
exit 1
fi
echo "Nginx reload!!"
sudo sed -i "s/${BEFORE_PORT}/${AFTER_PORT}/" /etc/nginx/conf.d/service-url.inc
sudo nginx -s reload
echo "Blue Deploy Completed!!"
docker-compose stop green
else
echo "green Deploy..."
docker-compose pull green
docker-compose up -d green
BEFORE_PORT=8080
AFTER_PORT=8081
for cnt in {1..10}
do
echo "green health check...."
REQUEST=$(curl http://127.0.0.1:8081/api/auth/health-check)
echo $REQUEST
if [ -n "$REQUEST" ]; then
break ;
fi
sleep 3
done;
if [ $cnt -eq 10 ]
then
echo "서버가 정상적으로 구동되지 않았습니다.(green)"
exit
fi
echo "Nginx reload!!"
sudo sed -i "s/${BEFORE_PORT}/${AFTER_PORT}/" /etc/nginx/conf.d/service-url.inc
sudo nginx -s reload
echo "Green Deploy Completed!!"
docker-compose stop blue
fi
docker-compose.yml
services:
blue:
image: geniusgitget/github-actions-demo:latest
container_name: myapp-blue
restart: always
environment:
- 'MESSAGE=blue,v1'
ports:
- '8080:8080'
green:
image: geniusgitget/github-actions-demo:latest
container_name: myapp-green
restart: always
environment:
- 'MESSAGE=green,v1'
ports:
- '8081:8080'
#networks:
# default:
# external:
# name: service-network
nginx.conf
user root;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
types_hash_max_size 4096;
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
server {
listen 80;
listen [::]:80;
server_name localhost;
include /etc/nginx/default.d/*.conf;
include /etc/nginx/conf.d/service-url.inc;
location / {
proxy_pass $service_url;
proxy_set_header X-Real-Ip $remote_addr;
proxy_set_header x-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
}
}
service-url.inc
# /etc/nginx/conf.d/service-url.inc
set $service_url http://127.0.0.1:8081;
이제 진짜로 무중단 배포가 완성되었다..........하 정말 긴 시간동안 삽질하느라 보냈다.
무중단 배포가 완성되었다!
참고 자료
- Nginx proxy pass Test : https://nginxstore.com/blog/aws/자습서-nginx-및-nginx-plus로-aws-reverse-proxy-설정/
- Docker + Github Actions로 Springboot 자동화 배포 구축 : https://e-room.tistory.com/171
- Self-hosted Runner 설치 방법 : https://e-room.tistory.com/145
- 자체 호스트형 실행기 애플리케이션을 서비스로 구성 : https://docs.github.com/ko/enterprise-cloud@latest/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service
- docker 설치 후 /var/run/docker.sock의 permission denied 발생하는 경우 : https://github.com/occidere/TIL/issues/116
- [ Trouble Shooting ] Shasum command not found :https://velog.io/@jhkang1517/Trouble-Shooting-Shasum-command-not-found
- docker-compose 설치 : https://kim-zzaisang.tistory.com/31
- Docker+Nginx+Github Actions를 활용한 Spring 서버 무중단 배포 작업 : https://mindybughunter.com/dockernginxgithub-actions%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-spring-%EC%84%9C%EB%B2%84-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EC%9E%91%EC%97%85/Github Actions self-hosted runner, nginx, docker 설정 : https://engineerinsight.tistory.com/266#google_vignette
- 스왑 메모리 : https://diary-developer.tistory.com/32