CI/CD 파이프라인 with GitHub Actions & NginX & Docker - Stockey [PDA 5th]

chaean·2024년 11월 28일
0

프로젝트 - Stockey

목록 보기
1/4
post-thumbnail

0. 시작

사용 기술

JavaScript React ExpressJS Mysql Python Cron
Docker NginX GitHub Actions AWS EC2
Naver Open API Slakc API 한국투자증권 API

0. 아키텍처

1. ERD

2. CI / CD 파이프라인

  1. Dockerfile 정의
  2. GitHub Actions를 통해 Docker Hub에 이미지를 Push
  3. GitHub Actions를 통해 Docker Hub에 업로드 되어있는 이미지를 서버에 Pull
  4. 가져온 이미지를 실행 (컨테이너)

흐름은 이러하다.

stockey/                               # 메인 프로젝트 폴더
│
├── Backend/                           # 백엔드 관련 코드 및 Dockerfile
│   ├── Dockerfile                      # 백엔드 Dockerfile
│   ├── .github/                        # 백엔드 레포지토리의 CI/CD 설정
│   │   └── workflows/                  # 백엔드 GitHub Actions 워크플로우 설정
│   │       └── deploy.yml              # 백엔드 배포 워크플로우
│   └── ...  							# 기타 백엔드 관련 파일들 (소스 코드 등)
│    
├── Frontend/                           # 프론트엔드 관련 코드 및 Dockerfile
    ├── nginx.conf                      # Nginx 웹 서버의 설정 파일
    ├── Dockerfile                      # 프론트엔드 Dockerfile
    ├── .github/                        # 프론트엔드 레포지토리의 CI/CD 설정
    │   └── workflows/                  # 프론트엔드 GitHub Actions 워크플로우 설정
    │       └── deploy.yml              # 프론트엔드 배포 워크플로우
    └── ...                             # 기타 프론트엔드 관련 파일들 (소스 코드 등)

Docker + GitHub Actions + NginX를 통해 CI/CD를 진행하기위한 기본적인 프로젝트 구조이다.

백엔드와 프론트엔드를 각각의 컨테이너에서 실행시키기 때문에 서로의 network를 연결해주는 과정이 필요하다.

Docker Compose - 여러 개의 서비스가 필요하거나, 환경 설정을 일관되게 유지할 수 있다.

Dockerfile: Docker 이미지를 생성하기 위한 설정 파일
Docker Compose : 여러 개의 컨테이너를 하나의 설정 파일(docker-compose.yml)로 정의

3. GitHub Actions

name: CI/CD Pipeline for Backend

on:
  push:
    branches:
      - main # 배포할 브랜치

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      # 코드 체크아웃
      - name: Checkout Code
        uses: actions/checkout@v3

      # Docker와 Docker Compose 설치 (필요한 경우에만 설치)
      - name: Set up Docker
        run: |
          # Docker 설치 (GitHub Actions의 ubuntu-latest 환경에는 기본적으로 Docker가 설치되어 있음)
          if ! command -v docker &> /dev/null; then
            echo "Docker가 설치되어 있지 않습니다. 설치 중..."
            sudo apt-get update
            sudo apt-get install -y docker.io
            sudo systemctl start docker
            sudo systemctl enable docker
          else
            echo "Docker가 이미 설치되어 있습니다."
          fi

      # Docker Hub 로그인
      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      # Docker 이미지를 빌드하고 푸시
      - name: Build and Push Docker Image
        run: |
          # Docker 이미지를 빌드
          docker build -t ${{ secrets.DOCKER_USERNAME }}/컨테이너명:VERSION .
          # Docker 이미지를 Docker Hub에 푸시
          docker push ${{ secrets.DOCKER_USERNAME }}/컨테이너명:VERSION

      # 서버에 배포 명령어 실행
      - name: Deploy to Server
        uses: appleboy/ssh-action@v0.1.7
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /home/ubuntu

            # 서버에서 Docker 이미지를 pull하고 실행
            docker pull ${{ secrets.DOCKER_USERNAME }}/컨테이너명:VERSION

            # app-network 네트워크가 없으면 생성
            docker network inspect 네트워크명 || docker network create 네트워크명

            # 기존에 실행 중인 컨테이너가 있으면 중지하고 제거
            docker stop stockey-express || true
            docker rm stockey-express || true

            # 새로운 컨테이너 실행 (app-network 네트워크와 연결)
            docker run -d --name 컨테이너명 --network 네트워크명 -e DB_HOST=${{ secrets.SERVER_HOST }} -e DB_USER=${{ secrets.DB_USER }} -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} -e DB_DATABASE=${{ secrets.DB_DATABASE }} -e APP_KEY=${{ secrets.APP_KEY }} -e APP_SECRET=${{ secrets.APP_SECRET }} -p 3000:3000 ${{ secrets.DOCKER_USERNAME }}/stockey-express:latest

secrets.XXX : GitHub Secrets에 저장된 데이터
-e : 환경 변수를 설정하는 Docker 명령어
--network : 컨테이너의 네트워크를 설정을 정의하는 Docker 명령어

전체적인 순서

  1. Docker가 서버에 설치되어있는지 여부에 따라 설치
  2. Docker Hub에 이미지를 업로드하기 위해 로그인
  3. 정의되어 있는 Dockerfile을 통해 이미지를 빌드하고 Docker Hub에 push
  4. 서버 배포

4. NginX + React

NginX는 정적 파일을 서빙(serve)할 수 있다.
React프로젝트를 Build하면 정적 파일(html, css, js)들이 생성된다.
빌드를 통해 생성된 산출물들을 NginX에 정적 파일로 집어넣어 실행시키게 된다.

Dockerfile

# 1단계: 빌드 단계 (node:22 사용)
FROM node:22 AS build

# 작업 디렉토리 설정
WORKDIR /app

# 의존성 파일 복사
COPY package*.json ./

# 의존성 설치
RUN npm install

# 소스 코드 복사
COPY . .

# 빌드 실행
RUN npm run build

# 2단계: Nginx로 배포 (경량화된 Nginx 이미지를 사용)
FROM nginx:alpine

# Dockerfile과 같은 경로에 있는 nginx.conf 파일을 컨테이너 내부로 복사
COPY nginx.conf /etc/nginx/nginx.conf

# 빌드된 파일만 Nginx로 복사
COPY --from=build /app/dist /usr/share/nginx/html

# 80 포트 개방
EXPOSE 80

# Nginx 실행
CMD ["nginx", "-g", "daemon off;"]

nginx.conf

# 필수 이벤트 블록
events {}

http {
    # MIME 타입 설정 파일 포함
    # 확장자 자동 매핑?
    include /etc/nginx/mime.types; 
    # MIME 타입을 알 수 없는 파일에 대해 기본적으로 사용될 MIME 타입을 지정
    # 이진 데이터의 기본 MIME 타입
    default_type application/octet-stream;

    server {
        listen 80;

        # 서버 이름 설정
        server_name localhost;

        # 정적 파일의 기본 경로 설정
        root /usr/share/nginx/html;

        # SPA를 위한 기본 라우팅 설정
        location / {
            try_files $uri /index.html;
        }

        # 백엔드 (Express) API 요청 처리
        location /api/ {
            proxy_pass http://컨테이너명:3000;  # Express 백엔드 컨테이너로 요청 전달
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

nginx.conf파일을 정의해야한다.
기본 경로로 오는 요청들은 Nginx에 집어넣은 React 정적 파일로 라우팅 해야하고
/api/로 오는 요청들은 백엔드로 보내주어 처리해야한다.

==>Reverse Proxy

  • 리버스 프록시는 클라이언트가 직접 백엔드 서버에 요청을 보내는 것이 아니라, 중간에 위치한 서버(여기서는 Nginx)가 클라이언트의 요청을 대신 받아 백엔드 서버로 전달하고, 백엔드 서버의 응답을 다시 클라이언트에게 반환하는 방식입니다. 이를 통해 여러 가지 이점(보안, 로드 밸런싱 등)을 제공합니다.

5. 트러블 슈팅

1. 리버스 프록시 경로 처리

# 백엔드 (Express) API 요청 처리
location /api/ {
    proxy_pass http://컨테이너명:3000/;  # Express 백엔드 컨테이너로 요청 전달
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
    proxy_pass http://컨테이너명:3000;  # Express 백엔드 컨테이너로 요청 전달
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

둘의 차이가 보이시는가??
1. proxy_pass http://컨테이너명:3000/
2. proxy_pass http://컨테이너명:3000
뒤에 슬래시('/')하나 차이지만 이는 다르게 처리된다.

1번은 http://컨테이너명:3000/ 로 요청이 처리되고
2번은 http://컨테이너명:3000/api/ 로 요청이 처리된다.

이는 NignX의 경로 처리방식 때문이라고 한다.

'
'
'

졸업작품 프로젝트를 하면서 직접 배포하는 방식이 비효율적이고 생산성이 좋지않다고 느꼈다.
CI / CD를 처음부터 끝까지 혼자 구현해보며 많은 어려움이 있었지만, 관련 지식이 한층 더 올라간 기분이다.
아직 프로젝트 마무리가 일주일 남았다. 화이팅 😬

2. 소켓 경로 오류

// ChattingPage.jsx
// 소켓 백엔드와 연결
export const socket = io(`${import.meta.env.VITE_SERVER_HOST}`, {
  transports: ['websocket'],
});
// nginx.conf 에 추가
# WebSocket 요청 프록시
location /socket.io/ {
	proxy_pass http://stockey-express:3000; # Express 서버로 전달
	proxy_http_version 1.1;
	proxy_set_header Upgrade $http_upgrade;
	proxy_set_header Connection "Upgrade";
	proxy_set_header Host $host;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
// www (express.js config file)
const io = new Server(server, {
  cors: {
    // origin: [process.env.CLIENT_URL, "http://localhost:5173"],
    origin: ["서버 IP주소"],
    methods: ["GET", "POST", "DELETE", "PUT"],
    credentials: true,
  },
});

socketHandler(io);
  1. Front의 io객체의 기본 path는/socket.io/

  2. Back의 io객체도 기본 path는 /socket.io/

    고로 둘이 경로를 맞춰줄 필요없음.
    → 서버의 `${import.meta.env.VITE_SERVER_HOST}` 는 `/` 이다.
    → `/`로 설정하면 `/socket.io/` 로 요청을 보내고 Nginx의 설정을 통해 백엔드로 `/socket.io/`로  요청을 보냄
    → 서버의 www파일에서 Server객체를 통해 기본 경로인 `/socket.io/` 로 들어온 경로를 소켓을 열게되고 성공적으로 연결됨.
  3. 개발, 서버를 고려하여 연결 URL을 환경변수로 설정하였다.

  4. VITE에서는 프로젝트가 빌드되는 시점에만 환경변수를 지정할 수 있다. 고로 빌드되기 전 환경변수를 설정해주어야함.

3. 컨테이너와 LocalHost

React와 ExpressJS를 각각의 컨테이너에서 실행시키고 있다면,
하나의 네트워크로 묶었다고해서 localhost:3000과 localhost:5173로 서로 접근할 수 있는 것이 아니다.
각각의 localhost는 각각 컨테이너의 가르키고 있기때문에 컨테이너의 이름으로 접근해야한다.

profile
백엔드 개발자

0개의 댓글