Nginx
트래픽 제어 용도로 사용합니다. 새 버전이 Docker 컨테이너로 만들어지면 해당 버전으로 트래픽을 제어합니다.
Docker
Github Actions를 통해 로컬에서 개발한 프로젝트 ( 예를들어 Spring Boot )를 실행가능한 형태로 빌드한 후 파일 형태로 만드는데 이를 실행하기 위한 공간입니다.
Github Actions
Github의 브랜치를 트리거하여 해당 브랜치에 이벤트가 발생하면 정의한 작업을 수행하도록 해주는 도구 입니다.
여기서는 master 혹은 main 브랜치로 merge가 발생하면 배포하는 형태로 진행 합니다.
배포는 아래와 같은 순서로 이루어 집니다.
- 코드 수정 후 github에 코드 업로드
- 트리거된 브랜치로 merge
- Github Actions 에서 코드 빌드 및 EC2로 빌드파일 업로드
- Github 릴리즈 태그 생성
- EC2로 도커파일, 도커컴포즈, Nginx.conf, 배포스크립트 파일 업로드
- EC2에서 배포 스크립트 실행
- 완료
무언가 많은 일이 벌어지는데 이거 세팅하는게 엄청 귀찮아 보입니다.
근데 수동으로 하면 더귀찮으니 날잡고 미리 세팅 해두는 것이 좋습니다.
세팅해야할 항목은 아래와 같습니다.
- Docker, Docker compose, Dockerfile
- Nginx
- github workflow
- deploy.sh
우선 EC2에 Docker와 Docker compose를 설치해야 합니다. ( 설치 되어있다고 가정 )
Nginx는 도커 이미지를 활용할 예정이니 위 두가지만 설치 했습니다.
설치를 완료 했으면 다시 EC2 환경이 아닌 로컬 환경으로 돌아옵니다.
로컬에 있는 프로젝트 폴더의 root 경로에 Dockerfile, docker-compose.yaml, nginx.conf 파일을 생성합니다.
Dockerfile
FROM openjdk:17
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar", "/app.jar"]
사용할 jdk 버전 ( 프로젝트에서 사용한 jdk버전 )을 명시하고 빌드된 jar파일을 복사해옵니다.
ARG는 변수선언 키워드 인데 명시된 경로에 있는 jar 파일을 가리킵니다.
COPY는 빈껍데기만 있는 컨테이너에 실행할 파일을 복사하는 과정입니다.
ENTRYPOINT는 이 컨테이너가 실행될 때 뭘할까 지정 해주는 부분입니다.
Docker compose.yaml
docker compose 실행 시 어떤 형태로 컨테이너를 실행할지 명시한 파일 입니다.
networks:
my-app-network:
driver: bridge
services:
blue:
build: .
image: ${DOCKER_REPO}:${VERSION_TAG}
container_name: blue
expose:
- '8080'
networks:
- my-app-network
green:
build: .
image: ${DOCKER_REPO}:${VERSION_TAG}
container_name: green
expose:
- "8081"
networks:
- my-app-network
nginx:
image: nginx:latest
container_name: nginx
restart: always
ports:
- "80:80"
networks:
- my-app-network
volumes:
- ./data/nginx.conf:/etc/nginx/nginx.conf
네트워크는 도커 컨테이너들이 통신할 수 있는 통신 매개체 입니다. 기본 네트워크를 써도 되지만 여기선 별도의 네트워크를 만들어 줬습니다. blue, green, nginx 는 각 실행 컨테이너 환경을 의미합니다.
네트워크는 아래처럼 설정합니다.
networks:
my-app-network:
driver: bridge
설정한 네트워크를 사용하려면 아래와 같이 하면 됩니다.
services:
blue:
build: .
image: ${DOCKER_REPO}:${VERSION_TAG}
container_name: blue
expose:
- '8080'
networks:
- my-app-network
파일을 보면 ${DOCKER_REPO} 형태로 환경변수라 예상 할 수 있는 값들이 있습니다.
이 값들은 EC2 root 경로에 별도 파일을 생성하여 해당 파일을 참조할 예정 입니다.
이게 싫다면 image 부분에 문자열로 직접 입력 해도 문제는 없지만, 신규 버전 배포시 스크립트 혹은 수동으로 이 값을 바꿔줘야 하는 번거로움이 있습니다.
expose를 통해 8080 포트를 노출 하고 있는데 nginx 에서는 이 포트를 해당 컨테이너와 통신합니다.
위 예시에선 blue, green 에서 각각 8081, 8080 포트를 노출 하고 있습니다.
nginx.conf
nginx 설정 파일입니다.
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
# 컨테이너 이름 "blue"를 기본 값으로 합니다.
proxy_pass http://blue:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
proxy_next_upstream error timeout;
}
}
proxy_pass 부분에서 실행할 컨테이너의 이름을 명시합니다. 이 부분을 보면 자연어 blue라는 값이 들어있는데 이는 해당 이름을 가진 컨테이너를 참조 합니다. 처음엔 upstream server로 명시 해둔 것도 아닌데 왜 되는거임? 하는 생각이 들었습니다.
알고보니 컨테이너 이름이 DNS이름으로 매핑이 되어있어서 그렇다고 합니다. 결국 도커도 서로 통신하기 위해 네트워크를 사용하고 있기 때문입니다. 이를 실제 실행 할 때는 도커에서 정상적인 ip로 변환해줍니다.
deploy.sh
배포 스크립트 파일 입니다. shell script가 익숙하지 않아서 코드가 더러울 수 있음 주의.
#!/bin/bash
# .env 파일이 있는지 확인하고 환경 변수를 로드합니다.
if [ -f .env ]; then
source .env
fi
if [ -f .deploy-color ]; then
source .deploy-color
fi
NGINX_DIR="$PWD/data"
NGINX_CONF="${NGINX_DIR}/nginx.conf"
ls -l $NGINX_CONF
edit_conf_port() {
TARGET_COLOR=$1
TARGET_PORT=$2
sed -i "s/proxy_pass http:\/\/[^:]*:[0-9]\+;/proxy_pass http:\/\/${TARGET_COLOR}:${TARGET_PORT};/" $NGINX_CONF
}
# nginx가 켜져있는지 확인 합니다.
EXIST_NGINX=$(docker compose ps | awk '$1 == "nginx"')
if [ -z "$EXIST_NGINX" ]; then
echo "nginx up"
docker compose up nginx -d
sleep 5
else
echo "nginx is running"
sleep 1
fi
# nginx가 실행 되었다면 정상적으로 실행 되었는지 체크
EXIST_NGINX_UP=""
# 10초간 Nginx 실행 여부 확인 (1초마다)
for i in {1..10}; do
EXIST_NGINX_UP=$(docker compose ps | awk '$1 == "nginx"')
# Nginx 실행 중인 경우, 루프를 종료합니다.
if [ -n "$EXIST_NGINX_UP" ]; then
break
fi
sleep 1
done
# 10초 동안 Nginx 실행 여부를 확인하고, 실행되지 않았을 경우 에러 메시지 출력
if [ -z "$EXIST_NGINX_UP" ]; then
echo "Nginx is not running after 10 seconds of checking."
exit 1
fi
# Blue 컨테이너의 상태를 확인합니다.
EXIST_BLUE=$(docker compose ps | awk '$1 == "blue"')
# 컨테이너 스위칭
if [ -z "$EXIST_BLUE" ]; then
echo "blue up"
docker compose up blue -d
BEFORE_COMPOSE_COLOR="green"
AFTER_COMPOSE_COLOR="blue"
edit_conf_port $AFTER_COMPOSE_COLOR 8080
else
echo "green up"
docker compose up green -d
BEFORE_COMPOSE_COLOR="blue"
AFTER_COMPOSE_COLOR="green"
edit_conf_port $AFTER_COMPOSE_COLOR 8080
fi
sleep 10
# 새로운 컨테이너가 제대로 떴는지 확인
EXIST_AFTER=$(docker compose ps | awk '$1 == "'"${AFTER_COMPOSE_COLOR}"'"')
if [ -n "$EXIST_AFTER" ]; then
# 이전 컨테이너 종료
docker compose restart nginx
docker compose down ${BEFORE_COMPOSE_COLOR}
echo "$BEFORE_COMPOSE_COLOR down"
fi
주요 작업은 아래와 같습니다.
- blue 컨테이너 실행을 체크
- green 컨테이너 실행을 체크
- 새 버전 실행 후 nginx 트래픽 제어
- 이전 버전 종료
- 끝
자 이제 마지막 작업 입니다.
깃허브 브랜치를 트리거하는 워크플로우를 만들어야 합니다!
여기서 사용하는 screts 값들은 github에서 제공하는 github actions 환경 변수 저장소에 저장되어있습니다.
Github actions
주요 작업은 아래와 같습니다.
- Set up JDK - 자바 버전을 세팅 합니다.
- Gradle Caching - ( 생략가능 ) 퍼포먼스 개선을 위한 캐싱 작업 입니다.
- Make application.yaml - 도커 이미지 빌드시 이 파일을 포함하여 빌드하기 위함 입니다.
github 저장소에 비밀 키 정보들을 업로드 하지 않았기 때문에 이 작업이 필요합니다. - Grant execute permission for gradlw - 빌드해야 하기 때문에 적절한 권한을 줍니다.
- Build with Gradle - 빌드 작업.
- Extract version from PR - PR 제목에서 버전 정보를 추출하기 위함 입니다.
- Docker Hub Login - 도커 허브에 .jar 파일이 패키징된 이미지를 업로드 하기 위함 입니다.
- Docker build - 위에서 얻은 버전 정보로 도커 이미지에 태그를 달아 빌드합니다.
image:v0.0.1 형태입니다. 이 글의 예시에선 이미지의 이름을 따로 명시하지 않아서 hub에는 v0.0.1 형태로 저장됩니다. - Push Docker Image - 도커 이미지를 hub에 업로드 합니다.
- Send docker-compose.yaml - 로컬에서 작성한 docker-compose.yaml을 EC2로 전송하는 과정.
결국 docker compose를 실행하는 환경은 EC2 이기 때문에 root에 이 파일을 복사합니다. - Send nginx.conf - 로컬에서 작성한 nginx.conf 파일을 EC2로 전송하는 과정
docker-compose.yaml 파일에서 nginx에 볼륨을 지정해두었습니다. 해당 볼륨 폴더가 EC2에 미리 만들어져 있어야 합니다. - Send Dockerfile - 로컬에서 작성한 Dockerfile을 EC2로 전송하는 과정
- Send Deploy.sh - 로컬에서 작성한 배포 스크립트를 EC2로 전송하는 과정
- Deploy to server with SSH - EC2에 접속해서 컨테이너를 실행하는 과정입니다.
- Create Tag - 이전에 추출했던 버전 태그를 가지고 github repo에 릴리즈 태그를 생성 합니다.
굉장히 많은 일을 하고 있습니다. 물론 필수가 아닌 부분도 많지만 이걸 수동으로 해야한다고 생각하면 어질어질 합니다.
name: Automatic Tagging and Deployment with Docker Compose
on:
pull_request:
branches: [ "main" ]
types: [closed]
permissions:
contents: write
jobs:
build-and-deploy-with-tag:
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Set up JDK 17 - 자바 버전 세팅
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: "adopt"
# gradle 캐싱
- 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.yaml - application.yaml 파일 생성
run: |
cd ./src/main/resources
touch ./application.properties
echo "${{ secrets.PROPERTIES }}" > ./application.yaml
shell: bash
# gradlew에 실행 권한을 부여합니다.
- name: Grant execute permisson for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew clean build -x test --stacktrace
- name: Extract version from PR - PR 에서 버전 정보 추출
id: get_version
run: |
LATEST_VERSION=$(echo "${{ github.event.pull_request.title }}" | grep -oP 'Version:\s*\Kv[0-9]+\.[0-9]+\.[0-9]+')
if [[ ! $LATEST_VERSION ]]; then
echo "Version info not found."
exit 1
fi
echo "::set-output name=version::$LATEST_VERSION"
- name: Docker Hub Login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD_TOKEN }}
- name: Docker Build
run: docker build -t ${{ secrets.DOCKER_REPO }}:${{ steps.get_version.outputs.version }} .
- name: Push Docker Image - 도커 이미지 빌드 및 허브에 배포
run: docker push ${{ secrets.DOCKER_REPO }}:${{ steps.get_version.outputs.version }}
- name: Docker Build
run: docker build -t ${{ secrets.DOCKER_REPO }}:${{ steps.get_version.outputs.version }} .
- name: Push Docker Image
run: docker push ${{ secrets.DOCKER_REPO }}:${{ steps.get_version.outputs.version }}
- name: Print partial repository name
run: echo "${{ secrets.DOCKER_REPO }} | rev | cut -c 1-5 | rev"
- name : current log
run: ls -al .
- name: Send docker-compose.yaml
uses: appleboy/scp-action@master
with:
username: ubuntu
host: ${{ secrets.HOST }}
key: ${{ secrets.KEY }}
source: "./docker-compose.yaml"
target: "/home/ubuntu/"
- name: Send nginx.conf
uses: appleboy/scp-action@master
with:
username: ubuntu
host: ${{ secrets.HOST }}
key: ${{ secrets.KEY }}
source: "./docker-compose.yaml"
target: "/home/ubuntu/data"
- name: Send Dockerfile
uses: appleboy/scp-action@master
with:
username: ubuntu
host: ${{ secrets.HOST }}
key: ${{ secrets.KEY }}
source: "./Dockerfile"
target: "/home/ubuntu"
- name: Send deploy.sh
uses: appleboy/scp-action@master
with:
username: ubuntu
host: ${{ secrets.HOST }}
key: ${{ secrets.KEY }}
source: "./deploy.sh"
target: "/home/ubuntu"
- name: Deploy to server with SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
envs: GITHUB_SHA
script: |
# Docker Hub 로그인 ( ssh로 EC2 에서 다시 로그인 )
echo "Logging in to Docker Hub..."
echo ${{ secrets.DOCKER_PASSWORD_TOKEN }} | sudo docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
export VERSION_TAG=${{ steps.get_version.outputs.version }}
export DOCKER_REPO=${{ secrets.DOCKER_REPO }}
export APP_NAME=demo-app
echo "Create .env file"
echo APP_NAME=demo > /home/ubuntu/.env
echo DOCKER_REPO=$DOCKER_REPO >> /home/ubuntu/.env
echo VERSION_TAG=$VERSION_TAG >> /home/ubuntu/.env
echo deploy $VERSION_TAG
sudo docker pull $DOCKER_REPO:$VERSION_TAG
chmod 777 ./deploy.sh
sudo ./deploy.sh demo
docker image prune -f
- name: Create Tag - PR 에서 추출한 버전 정보로 태그 생성
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git tag ${{ steps.get_version.outputs.version }} ${{ github.event.pull_request.merge_commit_sha }}
git push origin ${{ steps.get_version.outputs.version }}
마치며
파이프라인 구축 하면서 몇가지 문제가 있었는데 이를 기록합니다.
문제
nginx를 EC2에 직접 설치 했을 땐 문제가 없이 잘 작동 했는데 도커에서 이미지를 활용하여 nginx를 컨테이너로 띄우니 트래픽 제어가 제대로 되지 않았습니다. 컨테이너로 통신하는 것 부터 작동하지 않았습니다.
해결
목표 컨테이너와 같은 네트워크에 속하도록 명시 해주었고, 목표 컨테이너를 expose를 통해 포트 노출 해주니 정상 작동 했습니다.
문제
배포스크립트를 통해 컨테이너를 스위칭 하고 nginx 실행여부를 파악해 미리 실해하는 등의 생명주기 관련 작업은 정상적으로 처리했으나 nginx의 트래픽을 제어하는 부분을 어떻게 동적으로 관리할지 고민 이었습니다.
해결
정규식을 통해 proxy_pass 키워드를 매칭 시키고 해당 라인의 url부분을 수정하는 방식으로 문제를 해결 하였습니다.
이외에도 conf 파일을 여러개 만들어서 nginx에서 이 파일의 참조를 스위칭 하는 방식으로도 해결이 가능했는데, 파일이 여러개 생기면 수정 할 일이 생기면 둘다 변경해야 하기에 번거롭다 생각하여 정규식으로 처리했습니다.
'⚙️ BE' 카테고리의 다른 글
로깅 (1) | 2024.06.12 |
---|---|
Vector Embedding and Similairy (0) | 2024.05.14 |
GPT Assistants (0) | 2024.04.17 |
csrf (0) | 2024.04.09 |
XSS ( Cross-site scripting ) ( 1 ) (0) | 2024.04.09 |