겸손하기 꾸준하기 건강하기

[맛,잇다] 배포/트러블슈팅 EP2 (어플리케이션 및 인프라 구성) 본문

project

[맛,잇다] 배포/트러블슈팅 EP2 (어플리케이션 및 인프라 구성)

seminss 2024. 9. 2. 01:42

맛잇다에서 ①팀장, ②인프라, ③ 화상채팅(오픈비두+제스처 감지+ STT)을 맡아 개발했다.

 

배포 과정에서 겪은 문제들, 배운 점들을 중점으로 간단하게 회고하고자 한다.

1. SSL 인증서 발급 및 OpenVidu 배포
2. 어플리케이션 및 인프라 구성
3. Nginx와 리버스 프록시 설정

 

🙇‍♀️ 공부하면서 작성한 내용이기 때문에, 틀린 부분이 있다면 여과 없이 지적해 주시면 감사하겠습니다. 🙇‍♀️


🎯 어플리케이션 및 인프라 구성

DockerHub, Docker Compose를 이용한 배포 과정을 거쳤다.

 

📌 도커 허브 레포지토리 생성

로컬 환경에서 빌드하고 배포 환경에서 내려받아 편리하게 배포하기 위해, 도커 이미지를 도커 허브에 올렸다. 이미지 버전 관리, 재사용성, 빌드와 배포의 분리 등에서 이점이 많다.

  • 순서 : 도커 허브 가입, 엑세스 토큰 발급, 레포지토리 생성, 서버에서 로그인

이런 식으로 관리된다.

처음에는 이 방식을 몰라서 git에 올리고 clone 받고 pull 받고 build 하고 겁나 힘들게 배포하다가.... 다른 팀에서 배포를 담당한 오빠가 도커 허브를 이용하는 방식을 알려줘서 편하게 배포할 수 있게 되었다. 감사합니당  (--)(__)

 

📌 백엔드 서버 설정 및 도커 파일 작성(메인서버+채팅서버)

우리 프로젝트는 백엔드 서버가 2개가 있었다. 메인서버와 채팅 서버다. 

채팅 서버는 채팅 및 번역을 담당한다. 설계 당시에는 STT까지 백엔드단에서 처리할 예정이었기 때문에, 이 모든 작업이 메인 서버에서 진행된다면 너무 큰 부하가 있으리라 생각하여 메인서버(회원관리,쿠킹클래스관리 등등 전부)와 채팅서버(채팅, 번역, STT)로 분리하게 되었다. 두 서버는 모두 스프링을 사용하여 배포 과정이 동일하기 때문에 함께 설명하겠다.

 

Application.properties 및 환경 변수 설정

우선 스프링 서버의 application.properties의 주요 값들은 환경 변수로 구성했다. 

# Application Name
spring.application.name=tastyties
server.servlet.context-path=/api/v1

## 사용할 포트
server.port=${SERVER_PORT}

## 오픈비두 관련
server.ssl.enabled=${SERVER_SSL_ENABLED:false}
openvidu_url=${OPENVIDU_URL}
openvidu_secret=${OPENVIDU_SECRET}

## 데이터베이스 관련
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

...

이에 맞춰 .env 파일을 작성했다.

#로컬에 대한 설정
SERVER_PORT=8080
SERVER_SSL_ENABLED=false
OPENVIDU_URL=http://localhost:4443/
OPENVIDU_SECRET=MY_SECRET
...

 

환경 변수로 추출했을 때의 장점은 3가지가 있었다.

  1. 환경별 설정 관리 용이: 로컬과 배포 환경의 설정을 .env 파일로 쉽게 분리하여 관리할 수 있게 되었다. 설정 파일을 여러 개 유지할 필요 없이 환경 변수만 조정하면 되므로 관리가 간편했다.
  2. 보안과 협업 개선: application.properties 파일을 Git에 안전하게 올릴 수 있게 되어 설정 변경이 발생해도 팀원 간 공유가 쉬웠다. 에러의 빈도가 줄었다.
  3. 빌드와 배포의 일관성 유지: 로컬에서 빌드한 도커 이미지를 배포 환경에서도 동일하게 사용하고, Docker Compose에서 환경 변수를 덮어 씌워 배포할 수 있어 일관된 배포를 가능하게 했다.

 

환경변수로 추출한 뒤로 프로세스가 매우 간편해졌다...🔥

@SpringBootApplication
@EnableScheduling
public class TastytiesApplication {

	public static void main(String[] args) {
		Dotenv dotenv = Dotenv.configure().load();
		dotenv.entries().forEach(entry ->
				System.setProperty(entry.getKey(), entry.getValue())
		);
		ConfigurableApplicationContext ctx = SpringApplication.run(TastytiesApplication.class, args);
	}

}

환경변수를 application.properties에서 불러오게 하게 하기 위해, @SpringBootApplication이 붙은 가장 상위 클래스에 관련 설정을 추가해 줬다. 해당 설정이 있다면 .env 파일을 반드시 불러오기 때문에, 설정 파일이 없다면 에러가 난다.

 

SpringSecurity

configuration.addAllowedOrigin("https://i11b206.p.ssafy.io");

두 서버 모두 스프링 시큐리티를 적용하고 있어, 배포 환경에서 프론트엔드 서버의 도메인을 허용해야 했다. SecurityConfig에서 허용할 도메인을 지정하여 프론트 서버에서 API 요청이 가능하도록 설정했다.

Nginx 리버스 프록시는 Http를 Https로 리다이렉션 해서 백엔드 서버에 요청을 보내기 때문에, 백앤드 서버에서 열어둬야 하는 주소는 https://도메인이다. 리버스 프록시 관련해서는 <Nginx와 리버스 프록시 설정> 편에서 다룬다.

 

Dockerfile

# Start with a base image containing Java runtime
FROM openjdk:17-jdk-slim

# Add a volume pointing to /tmp
VOLUME /tmp

# Make port 8080 available to the world outside this container
EXPOSE 8080

# The application's jar file
ARG JAR_FILE=build/libs/tastyties-0.0.1-SNAPSHOT.jar

# Add the application's jar to the container
COPY ${JAR_FILE} app.jar

COPY ./src/main/resources/data.sql /app/resources/data.sql

# Run the jar file
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
  • EXPOSE 8080: 도커 컨테이너가 외부에서 접근할 수 있도록 8080 포트를 연다.
  • ENTRYPOINT: 컨테이너가 시작될 때 자바 애플리케이션을 실행한다.

채팅 서버의 Dockerfile은 포트 번호와 JAR명과 다르고 기타 설정은 동일하다.

더보기

채팅 서버 Dockerfile

# Start with a base image containing Java runtime
FROM openjdk:17-jdk-slim

# Add a volume pointing to /tmp
VOLUME /tmp

# Make port 8081 available to the world outside this container
EXPOSE 8081

# The application's jar file
ARG JAR_FILE=build/libs/tastytieschat-0.0.1-SNAPSHOT.jar

# Add the application's jar to the container
COPY ${JAR_FILE} app.jar

# Run the jar file
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

여기까지가 세팅이다.


백엔드 빌드, 도커 이미지 업로드

그리고 배포 과정마다 매번 반복되는 단계가 있었는데, 앞서 언급한, [빌드->이미지 생성->허브 업로드]이다.

테스트 제외하고 빌드 (프로젝트 최상단, build.gradle이 있는 위치)

빌드 과정에서 DB와 커넥션을 시도하는데, 빌드를 할 때는 DB 서버가 올라가 있지 않은 경우도 있어서, 빌드 실패를 하기도 했다. 이 경우를 방지하기 위해 테스트를 제외하고 빌드했다.

./gradlew build -x test

도커 이미지 생성

docker build --no-cache -t seminss/tastytieschat:latest .
docker build --no-cache -t <계정명>/<이미지명>:<태그명> .

도커 이미지를 도커 허브에 업로드

docker push seminss/tastytieschat:latest
docker push <계정명>/<이미지명>:<태그명>

 

이 과정은 새로운 버전으로 배포를 하고 싶을 때마다 반복되어 진행된다. 그리고, 로컬에서 진행해도 된다. 어짜피 이미지가 허브에 올라가면 배포 환경에서는 이미지만 내려받고, 배포 환경에 맞는 환경 변수로 덮어 씌워버릴 것이기 때문이다. 


 

📌 프론트 서버 설정 및 도커 파일 작성

프론트 서버는 Nginx를 이용해 정적 파일을 서빙하도록 설정했다. Nginx는 정적 파일을 빠르게 서빙한다는 장점이 있어, 리액트 어플리케이션 배포에 적합하다고 판단하였다.

nginx.conf 

server {
    listen 3000;
    server_name localhost;

    location / {
        root /app/dist;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}

해당 파일은 프로젝트 최상단에 작성하며, /app/dist는 빌드 파일 위치이다. 본 프로젝트는 리액트를 사용했으므로 뷰나 앵귤러를 사용한다면 참조 위치가 달라질 수 있으므로 확인이 필요하다.

try_files $uri $uri/ /index.html;는 SPA(Single Page Application)를 지원하기 위한 설정으로, 모든 요청을 index.html로 리다이렉트 하여 클라이언트 측 라우팅을 가능하게 한다.

 

.env

VITE_MAIN_SERVER=https://i11b206.p.ssafy.io/api/v1
VITE_FRONT_SERVER=https://i11b206.p.ssafy.io/
VITE_CHAT_SERVER=wss://i11b206.p.ssafy.io/chat
VITE_CHAT_SERVER_URL=https://i11b206.p.ssafy.io/chatapi

# firebase
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTO_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_FIREBASE_VAPID_KEY=

 

Dockerfile

# Dockerfile

# nginx 이미지를 사용한다. 뒤에 tag가 없으면 latest 를 사용한다.
FROM nginx

# root 에 app 폴더를 생성
RUN mkdir /app

# work dir 고정
WORKDIR /app

# work dir 에 build 폴더 생성 /app/build
RUN mkdir ./build

# host pc의 현재경로의 build 폴더를 workdir 의 build 폴더로 복사
COPY ./dist ./dist

# nginx 의 default.conf 를 삭제
RUN rm /etc/nginx/conf.d/default.conf

# host pc 의 nginx.conf 를 아래 경로에 복사
COPY ./nginx.conf /etc/nginx/conf.d

# 3000 포트 오픈
EXPOSE 3000

# container 실행 시 자동으로 실행할 command. nginx 시작함
CMD ["nginx", "-g", "daemon off;"]

 


 프론트 빌드, 도커 이미지 업로드

프론트도 마찬가지로 코드가 업데이트가 되고, 새로운 버전으로 배포를 하고 싶을 때마다 아래  과정을 반복했다.

빌드

npm run build

도커 이미지 생성

docker build -t seminss/frontend:npm <태그> .
docker build -t seminss/frontend:latest .

도커 이미지를 도커 허브에 업로드

docker push seminss/frontend:<태그>
docker push seminss/frontend:latest

 


📌 Docker Compose를 통한 전체 구성 (MySQL, MongoDB, Redis, RabbitMQ 등..)

네트워크 생성

도커 컨테이너가 동일한 네트워크 상에서 동작하도록 하기 위해 공유 네트워크를 생성했다. docker-compose.yml 파일 내부에서 진행해도 되지만 그냥 밖에서 만들었다.

docker network create shared_network

전부 같은 네트워크에 있어야 했는데, 메인서버(tastyties)는 존재하지 않았다.

동일한 네트워크 상에 컨테이너들이 존재하지 않는다면 커넥션이 안되고,,, 자꾸 재시작을 할지도 모른다. 네트워크를 공유하지 않으면 서비스 간 통신이 불가능하며, 이로 인해 커넥션 에러가 나는 것이다. 이 경우에는 같은 네트워크를 공유하고 있는지 확인하고, 꼭 같은 네트워크 상에 있도록 해야 함을 명시해야 한다.

services:
  컨테이너:
	...
    networks:
      - shared_network

 

docker-compose.yml 파일 작성

앞서 .env 파일에 설정해 둔 환경 변수 값이 로컬 환경을 위한 것(빌드를 위한 것)이었다면, 여기서 정말 배포를 위한 환경 변수로 설정한다. 앞서 설정한 환경 변수는 docker-compose 설정 값에 의해 덮어 씌워진다. 때문에 특히 주의해서 설정해야 한다 ⭐⭐

services:
  frontend:
    image: seminss/frontend:latest #앞서 도커 허브에 올린 이미지를 사용한다.
    container_name: frontend #컨테이너 이름
    restart: always
    ports:
      - "3000:3000"
    networks:
      - shared_network
  
  tastyties:
    image: seminss/tastyties:latest
    container_name: tastyties
    restart: always
    ports:
      - "8080:8080"
    depends_on: #tastyties 컨테이너가 올라가기 전에 미리 올라가야 하는 컨테이너 지정.
      - db
      - redis
      - rabbitmq
    environment:
      SERVER_PORT: 8080
      OPENVIDU_URL: https://i11b206.p.ssafy.io:8443/ #앞서 설정한 오픈비두의 https 포트로 지정
      OPENVIDU_SECRET: #오픈비두 시크릿 키, 임의 설정
      DB_URL: jdbc:mysql://db:3306/tastyties #db라는 컨테이너에서 tastyties라는 데이터베이스
      DB_USERNAME: # mysql 계정
      DB_PASSWORD: # mysql PWD
      UPLOAD_IMAGE_DIR: /app/files/image
      UPLOAD_VIDEO_DIR: /app/files/video
      FILE_SERVER_URL: http://localhost:8080/api/v1/files
      REDIS_HOST: redis
      REDIS_PORT: 6379
      AWS_ACCESS_KEY: #S3 ACCESS KEY
      AWS_SECRET_KEY: #S3 SECRET KEY
      AWS_BUCKET_NAME: #S3 버킷 이름
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_PORT: 5672
      RABBITMQ_USERNAME: # RabbitMQ는 배포 환경에서 guest로 접속 불가, 별도의 admin 계정 생성
      RABBITMQ_PASSWORD: # RabbitMQ PWD
      ADMIN_PWD: #어플리케이션의 관리자 계정 PWD
    volumes:
      - /home/ubuntu/b206/S11P12B206/backend/tastyties/tastyties/src/main/resources/serviceAccountKey.json:/src/main/resources/serviceAccountKey.json #파이어베이스 키 볼륨 설정
    networks:
      - shared_network

  tastytieschat:
    image: seminss/tastytieschat:latest
    container_name: tastytieschat
    restart: always
    ports:
      - "8081:8081"
    depends_on:
      - mongo
      - rabbitmq
      - redis
    environment:
      SERVER_PORT: 8081
      DB_URL: jdbc:mysql://db:3306/tastyties
      DB_USERNAME: # mysql 계정
      DB_PASSWORD: # mysql PWD
      MONGO_HOST: mongo
      MONGO_PORT: 27017
      MONGO_DATABASE: tastyties
      MONGO_USERNAME: # mongoDB 계정
      MONGO_PASSWORD: # mongoDB PWD
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_PORT: 5672
      RABBITMQ_USERNAME: # RabbitMQ admin 계정
      RABBITMQ_PASSWORD: # RabbitMQ admin PWD
      OPENAI_SECRET_KEY: # OPEN AI (GPT) SECRET KEY
      SPEECH_FLOW_KEY_ID: # 클로바 CLIENT SCERET(현재는 사용 x)
      SPEECH_FLOW_KEY_SECRET: # 클로바 CLIENT SCERET(현재는 사용 x)
      FFMPEG_PATH: /usr/local/bin/ffmpeg # (현재는 사용 x)
      CLOVA_CLIENT_ID: # 클로바 CLIENT SCERET(현재는 사용 x)
      CLOVA_CLIENT_SECRET: # 클로바 CLIENT SCERET(현재는 사용 x)
      REDIS_HOST: redis
      REDIS_PORT: 6379
    volumes:
      - /home/ubuntu/b206/S11P12B206/backend/tastytieschat/src/main/resources/serviceAccountKey.json:/src/main/resources/serviceAccountKey.json
    networks:
      - shared_network

  db: #여기서 설정한 걸 프&백 어플리케이션에서 사용하는 것
    image: mysql:8
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: # mysql 루트 비밀번호
      MYSQL_DATABASE: # 사용할 데이터베이스
      MYSQL_USER: # 사용할 계정
      MYSQL_PASSWORD: # 사용한 계정 PWD
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    networks:
      - shared_network

  mongo:
    image: mongo:latest
    container_name: mongo
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: # mongoDB 루트 계정 이름
      MONGO_INITDB_ROOT_PASSWORD: # mongoDB 루트 비밀번호
    volumes:
      - mongo_data:/data/db
    networks:
      - shared_network

  rabbitmq:
    image: rabbitmq:management
    container_name: rabbitmq
    ports:
      - "5672:5672"
      - "15672:15672"
    environment:
      RABBITMQ_DEFAULT_USER: # RabbitMQ admin 계정
      RABBITMQ_DEFAULT_PASS: # admin 계정 비밀번호
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    networks:
      - shared_network

  redis:
    image: redis:latest
    container_name: redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes --save "900 1" --save "300 10" --save "60 10000"
    networks:
      - shared_network

volumes:
  mysql_data:
  mongo_data:
  redis_data:
  rabbitmq_data:
  tastytieschat:
  tastyties:

networks:
  shared_network:
    external: true

주석으로 간략한 설명은 포함되어 있지만, 추가적으로 각각에서 신경 써야 할 부분을 언급해 보겠다.

 

MySQL, MongoDB

  • 데이터베이스는 MySQL과 MongoDB 모두 어플리케이션 서버가 올라오기 전에 먼저 실행되어야 한다. 이를 위해 depends_on 옵션을 통해 우선순위를 지정했다.
  • 볼륨 설정을 해야 컨테이너를 재시작해도 데이터가 유지된다. 볼륨 설정을 하지 않으면 귀중한 서버 데이터가 날아간다.😭
  • MySQL 같은 경우에는 보안을 위해 관리자 계정을 사용하지 않았는데(사실 몽고 DB도 사용하면 안 되었지만 놓쳤다..) 이 경우에는 권한 설정을 별도로 해주어야 한다.

실제로 권한이 없어서 어플리케이션에서 접속을 하지 못하는 에러가 있었다.


MySQL CLI에 들어가서, root 계정으로 사용하고자 하는 계정이 데이터베이스에 접근할 수 있도록 권한을 줘서 해결했다.

CREATE DATABASE IF NOT EXISTS tastyties;
CREATE USER IF NOT EXISTS 'b206'@'%' IDENTIFIED BY 'B206!Ssafy206!';
GRANT ALL PRIVILEGES ON tastyties.* TO 'b206'@'%';
FLUSH PRIVILEGES;

 

 

RabbitMQ

  • RabbitMQ도 데이터 지속성을 위해 볼륨 설정이 필요하다. 메시지 큐를 이용한 통신을 할 때 메시지가 유실될 가능성이 있기 때문이다.
  • guest 계정은 로컬에서만 사용할 수 있으므로, 별도의 admin 계정을 생성해야 한다.

하지 않아서 RabbitMQ와 서버와의 커넥션에서 계속 문제가 발생했다.

      RABBITMQ_USERNAME: guest
      RABBITMQ_PASSWORD: guest

 guest 계정으로 접속할 수 있는 건 localhost로 들어오는 접속, 또는 127.0.0.1/ 로 들어오는 요청뿐이라고 한다. 따라서 별도의 admin 계정을 만들어 접속하니 해결되었다. 

 

 

Redis

  • 볼륨 설정을 통해 데이터가 지속되도록 해야 한다.
  • command 옵션을 사용해 Redis의 데이터 지속성 설정을 커스터마이징 한다. Redis 데이터가 메모리뿐만 아니라 디스크에도 저장되도록 하여 데이터 손실을 방지하는 것이다.

레디스를 일별, 주별, 월별 회원별 마일리지 랭킹을 조회할 때 사용을 했었는데, 자꾸만 마일리지가 날아가서 확인해 보니 레디스에 볼륨과 command 설정이 되어있지 않아 발생한 문제였다.

Redis는 휘발성 메모리이므로 서버를 종료하면 데이터가 모두 유실된다. 따라서 데이터를 디스크에 주기적으로 저장해서 서버 재시작 후에도 데이터를 복구할 수 있도록 하였다.

 


https://github.com/Tasty-Ties

 

Tasty-Ties

맛잇다: 온라인 쿠킹 클래스 서비스. Tasty-Ties has one repository available. Follow their code on GitHub.

github.com

 

참고 자료