앞서 배포했던 방식은
로컬 스프링부트 프로젝트를 빌드 후 .zip 파일을 Elastic Beanstalk에 배포했었다.
그림으로 보여주자면 과정은 이렇다.
하지만, 연동하기에 너무 급급했던 탓에 elastic beanstalk 내부에서는 어떤식으로 동작을 하여 ec2에 배포를 하게되는지 알아보지를 못했다.
그리하여 이번에는 ec2에 직접 배포를 해보되, Docker를 이용한 컨테이너 배포를 수행해보았다.
처음에는 Dockerfile을 작성하여 jar파일을 이미지로 만들어 Docker Hub에 push, 그리고 ec2에 pull하는 방식으로 배포를 하고, 해당 ec2에서 nginx를 설치하여 프록시 설정을 하면 되겠거니~ 했다.
하지만 내가 이해한 바, 컨테이너는 매우 독립적인, 마치 하나의 애플리케이션으로 돌아가는 그 자체였다.
이미지와 컨테이너의 개념적 차이를 알아보기 위해 참고했던 포스팅글이다.
https://hoon93.tistory.com/48
이걸보고 딱 느껴졌던 것은 빌드를 통해 얻어지는 Docker Image는 객체지향의 클래스, 도커 런을 통해 만들어지는 Docker Container는 클래스로 부터 만들어지는 객체라고 비유하고 싶었다. 내 느낌이 그랬다.. 하지만 더 공부를 해봐야 할 것 같다.
다시 본론으로 들어가서, 결국엔 nginx의 설정을 담은 컨테이너를 하나 더 배포를 해야하는 상황에 놓였다.
배포구조는 다음과 같다.
1. 로컬에서 작업 후 Github에 Push한다.
2. 내가 작성한 워크플로우에 따라(main 브랜치에 push했을 때 github actions 실행)
3. Nginx 컨테이너의 80포트로 요청이 들어오면 SpringBoot 컨테이너의 8081포트로 프록시
4. SpringBoot 컨테이너는 따로 RDS와 소통이 가능
여기까지하면 되게 할만했다. 하면서 어려웠던 점은 따로 트러블 슈팅으로 정리해놓아야겠다.
FROM amazoncorretto:11
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","/app.jar"]
FROM amazoncorretto:11
이 라인은 이미지의 기본으로 사용될 다른 이미지를 지정한다. 이 경우, amazoncorretto:11 이미지가 사용된다. Amazon Corretto는 Amazon이 유지 관리하는 OpenJDK이며, 여기서는 Java 11 버전을 사용한다.
ARG JAR_FILE=build/libs/*.jar
이 라인은 빌드 시에 사용될 인수를 선언하는 부분이다. 기본값은 build/libs/*.jar이며, 이는 일반적으로 Spring Boot 애플리케이션을 빌드할 때 생성되는 JAR 파일의 위치에 해당한다.
COPY ${JAR_FILE} app.jar
이 라인은 JAR_FILE 인수에 지정된 위치의 JAR 파일을 Docker 이미지의 /app.jar라는 이름으로 복사하는 라인이다.
ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","/app.jar"]
이 라인은 컨테이너가 시작될 때 실행될 명령을 설정하는 부분이다. 여기서는 java -jar -Dspring.profiles.active=prod /app.jar 명령이 실행되며, 이는 app.jar 파일을 실행하고, Spring profile을 prod로 설정하는 것이다.
Amazon Corretto 11 기반의 Docker 이미지를 빌드하고, 이 이미지는 Spring Boot 애플리케이션의 JAR 파일을 포함하며, 컨테이너가 시작될 때 이 JAR 파일이 실행된다고 보면 된다. 이때 Spring 애플리케이션은 prod 프로파일로 실행된다.
FROM nginx
COPY ./nginx/conf.d/nginx.conf /etc/nginx/conf.d
FROM nginx
이 라인은 빌드할 이미지의 기반(base image)을 지정하는데, 이 경우, 공식 Nginx 이미지가 기반 이미지로 사용된다.
COPY ./nginx/conf.d/nginx.conf /etc/nginx/conf.d
이 라인은 호스트 시스템의 ./nginx/conf.d/nginx.conf 파일을 Docker 이미지의 /etc/nginx/conf.d 디렉터리로 복사한다.
기본 Nginx 이미지를 가져와서 사용자 정의 Nginx 설정을 적용한 Docker 이미지를 생성한다.
version: '3'
services:
server:
container_name: server
image: kimjiseop/jaetteoli-server
ports:
- 8081:8081
restart: "always"
nginx:
container_name: nginx
image: kimjiseop/jaetteoli-nginx
ports:
- 80:80
depends_on:
- "server"
server:
이 서비스는 애플리케이션의 서버 부분을 나타낸다.
container_name: server
이 라인은 생성될 컨테이너의 이름을 server로 지정한다.
server :
이름과 동일하게 해야 헷갈리지 않았던 것 같다. image: kimjiseop/jaetteoli-server
이 라인은 사용할 Docker 이미지를 지정하는 부분인데, 여기서는 kimjiseop/jaetteoli-server 이미지를 사용한다는 뜻이다.
kimjiseop
이고, 여기에 저장된 이미지 이름이 jaetteoli-server
이다.ports:
이 라인은 컨테이너와 호스트 사이에 포트를 매핑하는 부분이다. 8081:8081
은 호스트의 8081번 포트를 컨테이너의 8081번 포트에 연결한다는 의미인데.
proxy_pass http://server:8082
이고, 컨테이너 내부의 포트가 8081이라면, 해당 컨테이너의 ports 바인딩은 8082:8081로 수정을 해야한다. restart: "always"
이 라인은 컨테이너가 항상 재시작하도록 설정하는 부분이다. 이는 컨테이너가 어떤 이유로든 중단되면 자동으로 다시 시작됨을 의미한다.
depends_on:
이 라인은 nginx 서비스가 server 서비스에 의존함을 나타내는데, 이는 nginx 컨테이너가 server 컨테이너가 시작된 후에만 시작됨을 의미한다.
따라서 이 Docker Compose 파일은 server와 nginx라는 두 개의 컨테이너를 실행하고, 각각은 지정된 이미지를 기반으로 하는 것이다.
server {
listen 80;
server_name *.compute.amazonaws.com
access_log off;
location / {
proxy_pass http://server:8081;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
access_log off;
이 부분은 Nginx가 클라이언트의 요청에 대한 로그를 남기지 않도록 하는 지시어인데, 이를 제거하면 모든 HTTP 요청에 대한 정보가 Nginx의 액세스 로그에 기록된다고 보면된다.
location /
이 라인에서 /
는 모든 경로에 대한 요청을 나타내는 부분이다.
proxy_pass http://server:8081;
이 라인은 모든 요청을 server:8081로 프록시하는 부분이다. server는 Docker Container 이름이다.
proxy_set_header Host $host:$server_port;
이 라인은 프록시 요청의 Host 헤더를 설정한다. $host:$server_port
는 요청이 처음 도착했을 때의 호스트 이름과 서버 포트를 말한다.
Host
헤더는 www.example.com:80이 될 것이다. 이 헤더를 통해 서버는 가상 호스팅을 수행할 수 있다고 한다.proxy_set_header X-Forwarded-Host $server_name;
이 라인은 프록시 요청의 X-Forwarded-Host 헤더를 설정한다. $server_name
은 서버 블록에서 정의한 서버 이름이다.
proxy_set_header X-Real-IP $remote_addr;
이 라인은 프록시 요청의 X-Real-IP 헤더를 설정한다. $remote_addr
은 원래 클라이언트의 IP 주소를 말한다.
192.0.2.1
이라면 X-Real-IP 헤더는 192.0.2.1
이 될 것이다.proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
이 라인은 프록시 요청의 X-Forwarded-For 헤더를 설정합니다. $proxy_add_x_forwarded_for
은 원래 클라이언트의 IP 주소와 이전 프록시 서버들의 IP 주소 목록이다.
192.0.2.1
에서 요청을 보내고, 이 요청이 IP 주소가 203.0.113.1
인 프록시 서버를 거쳤다면 X-Forwarded-For 헤더는 192.0.2.1
, 203.0.113.1
이 될 것이다.이런 설정을 하는 주요 이유는 클라이언트의 원래 정보를 유지하고 웹 서버가 이를 인식할 수 있게 하기 위해서다. 프록시 서버나 로드 밸런서를 통과하는 요청의 경우, 서버에 도달했을 때 원래 클라이언트의 정보가 변경되거나 손실될 수 있다.
예를 들어, 클라이언트의 실제 IP 주소는 애플리케이션 로깅에서 중요한 역할을 하는데, 로그를 통해 시스템에서 어떤 IP 주소에서 많은 요청이 오는지, 어떤 IP 주소에서 문제가 발생하는지 등을 파악할 수 있는 것이다. 그러나 프록시를 통과한 요청의 경우, 클라이언트의 실제 IP 주소를 알 수 없게 되므로 정확한 로깅이 어려워진다. 이 경우 X-Real-IP
또는 X-Forwarded-For
헤더를 사용하면 클라이언트의 실제 IP 주소를 알 수 있다.
또한, Host 및 X-Forwarded-Host 헤더는 가상 호스팅 환경
에서 매우 중요하다.
가상 호스팅
은 하나의 웹 서버에서 여러 도메인을 호스팅하는 기술인데, 이 경우, 웹 서버는 Host 헤더를 보고 어떤 도메인에 대한 요청인지를 판별한다. 프록시를 통과한 요청의 경우, 원래의 Host 헤더가 변경될 수 있으므로 X-Forwarded-Host
헤더를 통해 원래의 호스트 정보를 유지할 수 있는 것이다.name: Java CI with Gradle
on:
push:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'corretto'
## gradle caching
- 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: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build with Gradle
run: ./gradlew clean build -x test
env:
DB_ENDPOINT: ${{ secrets.DB_ENDPOINT }}
DB_SCHEMA: ${{ secrets.DB_SCHEMA }}
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
- name: Create env file
run: |
echo DB_ENDPOINT=${{ secrets.DB_ENDPOINT }} >> .env
echo DB_SCHEMA=${{ secrets.DB_SCHEMA }} >> .env
echo DB_USERNAME=${{ secrets.DB_USERNAME }} >> .env
echo DB_PASSWORD=${{ secrets.DB_PASSWORD }} >> .env
echo JWT_TOKEN=${{ secrets.JWT_TOKEN }} >> .env
## 이미지 빌드 및 도커허브에 push
- name: server docker build and push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -f Dockerfile -t ${{ secrets.DOCKER_REPO }}/jaetteoli-server .
docker push ${{ secrets.DOCKER_REPO }}/jaetteoli-server
- name: nginx docker build and push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -f Dockerfile-nginx -t ${{ secrets.DOCKER_REPO }}/jaetteoli-nginx .
docker push ${{ secrets.DOCKER_REPO }}/jaetteoli-nginx
- name: Copy docker-compose.yaml to EC2
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
source: "/github/workspace/docker-compose.yaml"
target: "/home/ubuntu/"
- name: Copy .env to EC2
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
source: "/github/workspace/.env"
target: "/home/ubuntu/"
## docker compose up
# HOST : 인스턴스 주소
# KEY : rsa - 전부 복사, % 제외
- name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: |
if [ "$(docker ps -qa)" ]; then
sudo docker rm -f $(docker ps -qa)
fi
sudo docker pull ${{ secrets.DOCKER_REPO }}/jaetteoli-server
sudo docker pull ${{ secrets.DOCKER_REPO }}/jaetteoli-nginx
docker-compose -f /home/ubuntu/github/workspace/docker-compose.yaml up -d
docker image prune -f
name: Java CI with Gradle
on:
push:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'corretto'
## gradle caching
- 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: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build with Gradle
run: ./gradlew clean build -x test
env:
DB_ENDPOINT: ${{ secrets.DB_ENDPOINT }}
DB_SCHEMA: ${{ secrets.DB_SCHEMA }}
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
Set up JDK 11
이 단계에서는 Java 11 JDK를 설치하고 설정한다. 사용하는 배포판은 Amazon의 Corretto JDK이다.
Gradle Caching
이 단계에서는 Gradle의 의존성들을 캐시하여 빌드 시간을 단축시키는 역할을 한다. 이를 통해 빌드에 필요한 의존성들을 다운로드 받는 시간을 줄여준다.
Grant execute permission for gradlew
이 단계에서는 Gradle Wrapper에 실행 권한을 부여하는 것인데, 실행권한이 부여되지 않았을 경우, Github Actions Runner가 빌드를 못하게되는 현상이 발생할 수 있다.
Build with Gradle
이 단계에서는 애플리케이션의 빌드를 수행합니다. ./gradlew clean build -x test
명령을 실행한다. 여기서 -x test
는 테스트를 실행하지 않고 빌드만 수행하라는 의미이다.
- name: Create env file
run: |
echo DB_ENDPOINT=${{ secrets.DB_ENDPOINT }} >> .env
echo DB_SCHEMA=${{ secrets.DB_SCHEMA }} >> .env
echo DB_USERNAME=${{ secrets.DB_USERNAME }} >> .env
echo DB_PASSWORD=${{ secrets.DB_PASSWORD }} >> .env
echo JWT_TOKEN=${{ secrets.JWT_TOKEN }} >> .env
## 이미지 빌드 및 도커허브에 push
- name: server docker build and push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -f Dockerfile -t ${{ secrets.DOCKER_REPO }}/jaetteoli-server .
docker push ${{ secrets.DOCKER_REPO }}/jaetteoli-server
- name: nginx docker build and push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -f Dockerfile-nginx -t ${{ secrets.DOCKER_REPO }}/jaetteoli-nginx .
docker push ${{ secrets.DOCKER_REPO }}/jaetteoli-nginx
Create env file
이 단계에서는 .env 파일을 생성한다. 이 파일에는 데이터베이스 연결 정보와 JWT 토큰 등의 환경 변수들이 포함되며, Docker 컨테이너에서 사용된다.
server docker build and push
이 단계에서는 서버 애플리케이션의 Docker 이미지를 빌드하고 Docker 레지스트리에 푸시하는 워크플로우다. Dockerfile을 사용하여 이미지를 빌드하고, Docker Hub 또는 다른 Docker 레지스트리에 이미지를 푸시한다.
nginx docker build and push
이 단계에서는 Nginx의 Docker 이미지를 빌드하고 Docker 레지스트리에 푸시한다. Dockerfile-nginx를 사용하여 이미지를 빌드하고, Docker Hub 또는 다른 Docker 레지스트리에 이미지를 푸시한다.
- name: Copy docker-compose.yaml to EC2
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
source: "/github/workspace/docker-compose.yaml"
target: "/home/ubuntu/"
- name: Copy .env to EC2
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
source: "/github/workspace/.env"
target: "/home/ubuntu/"
Copy docker-compose.yaml to EC2
및 Copy .env to EC2
: 이 두 단계에서는 SCP를 사용하여 docker-compose.yaml 파일과 .env 파일을 EC2 인스턴스로 복사하는 과정이다./home/ubuntu/github/workspace
경로에 .env
와 docker-compose.yaml
파일들이 배포된다.docker-compose의 위치
가 중요한게, 모든 도커관련 작업(ssl인증을 위한 certbot 컨테이너
배포, 볼륨 마운트
설정)들이 여기를 기준으로 이루어지기 때문이다.. - name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: |
if [ "$(docker ps -qa)" ]; then
sudo docker rm -f $(docker ps -qa)
fi
sudo docker pull ${{ secrets.DOCKER_REPO }}/jaetteoli-server
sudo docker pull ${{ secrets.DOCKER_REPO }}/jaetteoli-nginx
docker-compose -f /home/ubuntu/github/workspace/docker-compose.yaml up -d
docker image prune -f
executing remote ssh commands using password
이 마지막 단계에서는 SSH를 사용하여 EC2 인스턴스에서 명령을 실행한다. 이 명령은 이전에 배포된 Docker 컨테이너를 제거하고, Docker 이미지를 새로 끌어온 후, docker-compose를 사용하여 애플리케이션을 배포하고, 불필요한 Docker 이미지를 정리하는 워크플로우다.server:
port: 8081
spring:
main:
allow-circular-references: true
mvc:
path match:
matching-strategy: ant_path_matcher
redis:
host: localhost
port: 6379
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_ENDPOINT}:3306/${DB_SCHEMA}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jwt:
secret: ${JWT_TOKEN}
jpa:
open-in-view: true
hibernate:
ddl-auto: create
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
use-new-id-generator-mappings: false
database: mysql
show-sql: false
properties:
hibernate:
format_sql: false
이 yml내용은 스프링부트 개발하는 사람이라면 누구나 알아야 할 설정이다.
위에서 보이는 것 처럼,
redis
에 대한 설정은 해뒀지만, 아직 초기상태라 Redis 사용은 하지않은 상태이다. 나중에 RefreshToken을 도입하게 되면 쓰게될 것이다.
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_ENDPOINT}:3306/${DB_SCHEMA}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jwt:
secret: ${JWT_TOKEN}
이 부분은 github에는 올리기는 싫지만, 올려야 하는 상황이였어서.. 어쩔수 없이 저렇게 환경변수로 감싸주고, 이 변수들을 github secrets에 저장한 것이었다.
글 잘 봤습니다, 감사합니다.