diff --git a/.github/.DS_Store b/.github/.DS_Store new file mode 100644 index 0000000..72d939e Binary files /dev/null and b/.github/.DS_Store differ diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml new file mode 100644 index 0000000..6828354 --- /dev/null +++ b/.github/workflows/cd.yaml @@ -0,0 +1,83 @@ +name: CD + +on: + workflow_run: + workflows: ['CI'] + types: [completed] + +jobs: + # ============================================ + # 서버 배포 + # ============================================ + deploy: + name: Deploy to Wisoft Server + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} + + steps: + - name: SSH 키 설정 + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.RASPI_SSH_PRIVATE_KEY }} + + - name: Known Hosts 등록 + env: + JUMP_PORT: ${{ secrets.RASPI_SSH_PORT }} + JUMP_HOST: ${{ secrets.RASPI_HOST }} + run: | + mkdir -p ~/.ssh + ssh-keyscan -p $JUMP_PORT -H $JUMP_HOST >> ~/.ssh/known_hosts + + - name: 서버에 배포 + env: + JUMP_PORT: ${{ secrets.RASPI_SSH_PORT }} + JUMP_HOST: ${{ secrets.RASPI_HOST }} + JUMP_USER: ${{ secrets.RASPI_USER }} + TARGET_HOST: ${{ secrets.RASPI_TARGET_HOST }} + TARGET_USER: ${{ secrets.RASPI_TARGET_USER }} + DEPLOY_PATH: ${{ secrets.RASPI_DEPLOY_PATH_JAVA }} + GHCR_PAT: ${{ secrets.GHCR_PAT }} + GITHUB_ACTOR: ${{ github.actor }} + run: | + ssh -o StrictHostKeyChecking=no -o ProxyJump=$JUMP_USER@$JUMP_HOST:$JUMP_PORT $TARGET_USER@$TARGET_HOST /bin/bash << ENDSSH + set -e + + echo "=== 배포 디렉토리 이동 ===" + cd $DEPLOY_PATH + + echo "=== GHCR 로그인 ===" + echo "$GHCR_PAT" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + + echo "=== 최신 이미지 Pull ===" + docker compose -f docker/docker-compose.prod.yml --env-file .env pull app + + echo "=== 앱 컨테이너 재시작 ===" + docker compose -f docker/docker-compose.prod.yml --env-file .env up -d app + + echo "=== Health Check (최대 60초) ===" + for i in \$(seq 1 12); do + if curl -sf http://localhost:7300/health > /dev/null; then + echo "Health check 통과" + exit 0 + fi + echo "재시도 \$i/12" + sleep 5 + done + + echo "Health check 실패" + docker compose -f docker/docker-compose.prod.yml logs --tail=50 app + exit 1 + ENDSSH + + - name: 오래된 Docker 이미지 정리 + if: success() + env: + JUMP_PORT: ${{ secrets.RASPI_SSH_PORT }} + JUMP_HOST: ${{ secrets.RASPI_HOST }} + JUMP_USER: ${{ secrets.RASPI_USER }} + TARGET_HOST: ${{ secrets.RASPI_TARGET_HOST }} + TARGET_USER: ${{ secrets.RASPI_TARGET_USER }} + run: | + ssh -o StrictHostKeyChecking=no -o ProxyJump=$JUMP_USER@$JUMP_HOST:$JUMP_PORT $TARGET_USER@$TARGET_HOST /bin/bash << ENDSSH + docker image prune -af --filter "until=72h" || true + ENDSSH diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1d89e85 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,106 @@ +name: CI + +on: + push: + branches: + - "*/**" + - main + pull_request: + branches: + - "*/**" + - main + +jobs: + # ============================================ + # Job 1: 테스트 + # ============================================ + test: + name: Test + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: interview_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v4 + + - name: JDK 21 설정 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Gradle 캐싱 + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 테스트 실행 + env: + SPRING_PROFILES_ACTIVE: test + run: ./gradlew test --no-daemon + + # ============================================ + # Job 2: Docker 이미지 빌드 + GHCR 푸시 + # ============================================ + build: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + permissions: + contents: read + packages: write + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v4 + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: GHCR 로그인 + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 이미지 메타데이터 설정 + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest + type=sha,prefix= + + - name: Docker 이미지 빌드 & 푸시 + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 2e94725..b1b9c19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,12 +25,16 @@ FROM mcr.microsoft.com/playwright/java:v1.49.0-noble WORKDIR /app -RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg curl && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/build/libs/*.jar app.jar # 포트 문서화 EXPOSE 7300 +# 컨테이너 레벨 헬스체크 (Docker daemon의 status 표시용) +HEALTHCHECK --interval=24h --timeout=10s --start-period=40s --retries=3 \ + CMD curl -sf http://localhost:7300/health || exit 1 + # 실행 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 93476eb..0e0afc5 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,8 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.26.12' runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose-dev.yml similarity index 97% rename from docker-compose.yml rename to docker-compose-dev.yml index a2cc874..48276ec 100644 --- a/docker-compose.yml +++ b/docker-compose-dev.yml @@ -31,7 +31,7 @@ services: app: build: context: . - dockerfile: Dockerfile + dockerfile: Dockerfile.prev container_name: interview-api ports: - "7300:7300" diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..c7a5bdc --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,70 @@ +services: + fluent-bit: + image: fluent/fluent-bit:3.2 + container_name: interview-fluent-bit + volumes: + - ./fluentbit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro + - ./fluentbit/parsers.conf:/fluent-bit/etc/parsers.conf:ro + ports: + - "24224:24224" + environment: + - ELASTIC_USERNAME=${ELASTIC_USERNAME} + - ELASTIC_PASSWORD=${ELASTIC_PASSWORD} + networks: + - interview-network + - logging-network + restart: unless-stopped + + postgres: + image: postgres:17 + container_name: interview-postgres + ports: + - "${POSTGRES_PORT}:5432" + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - interview-network + restart: unless-stopped + + app: + image: ghcr.io/wisoft-prepair/backend-java:latest + container_name: interview-api + ports: + - "7300:7300" + env_file: + - .env + environment: + - SPRING_PROFILES_ACTIVE=prod + - TZ=Asia/Seoul + depends_on: + postgres: + condition: service_healthy + fluent-bit: + condition: service_started + logging: + driver: fluentd + options: + fluentd-address: localhost:24224 + tag: interview-service + fluentd-async: "true" + networks: + - interview-network + restart: unless-stopped + +volumes: + postgres-data: + +networks: + interview-network: + driver: bridge + logging-network: + external: true \ No newline at end of file diff --git a/docker/fluent-bit/fluent-bit.conf b/docker/fluent-bit/fluent-bit.conf index 0b78219..d38ea45 100644 --- a/docker/fluent-bit/fluent-bit.conf +++ b/docker/fluent-bit/fluent-bit.conf @@ -1,5 +1,6 @@ [SERVICE] Flush 1 + Daemon Off Log_Level info Parsers_File parsers.conf @@ -15,6 +16,12 @@ Parser json Reserve_Data On +[FILTER] + Name modify + Match * + Add source fluent-bit + Add host ${HOSTNAME} + [OUTPUT] Name es Match * @@ -24,4 +31,4 @@ HTTP_Passwd ${ELASTIC_PASSWORD} Index interview-service Retry_Limit 5 - Suppress_Type_Name On + Suppress_Type_Name On \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c685eb2..28a6f06 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -7,7 +7,7 @@ spring: jpa: hibernate: - ddl-auto: validate + ddl-auto: update show-sql: true open-in-view: false properties: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cb8819e..2710d2c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -7,6 +7,6 @@ spring: jpa: hibernate: - ddl-auto: validate + ddl-auto: update open-in-view: false diff --git a/src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java b/src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java deleted file mode 100644 index 8d6ffa8..0000000 --- a/src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.wisoft.prepair.prepair_api.prompt; - -import io.wisoft.prepair.prepair_api.entity.InterviewQuestion; -import io.wisoft.prepair.prepair_api.entity.enums.QuestionType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -@DisplayName("면접 프롬프트 생성 테스트") -class InterviewPromptBuilderTest { - - private PromptBuilder promptBuilder; - - @BeforeEach - void setUp() { - promptBuilder = new PromptBuilder(); - } - - @Test - @DisplayName("직무 정보를 기반으로 프롬프트를 생성한다.") - void 직무_정보_프롬프트_생성() { - // Given - String job = "백엔드 개발자"; - List previousQuestions = List.of(); - - // When - String prompt = promptBuilder.buildDailyQuestionPrompt(job, previousQuestions); - - // Then - assertThat(prompt).contains("백엔드 개발자"); - } - - @Test - @DisplayName("이전 질문이 있으면 프롬프트에 포함된다.") - void 이전_질문_포함() { - // Given - String job = "백엔드 개발자"; - - InterviewQuestion prev = new InterviewQuestion( - UUID.randomUUID(), - "Spring이란?", - QuestionType.TEXT, - "Spring", - null - ); - - List previousQuestions = List.of(prev); - - // When - String prompt = promptBuilder.buildDailyQuestionPrompt(job, previousQuestions); - - // Then - assertThat(prompt).contains("이전에 받은 질문들"); - assertThat(prompt).contains("Spring이란?"); - } -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..13f2644 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,46 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/interview_test + username: test + password: test + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + + mail: + host: smtp.gmail.com + port: 587 + username: test@test.com + password: test + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +external: + member-service: + url: http://localhost:3000 + api-key: test-api-key + + openai: + api-key: test-openai-key + api-url: https://api.openai.com/v1/chat/completions + model: gpt-4o-mini + whisper-url: https://api.openai.com/v1/audio/transcriptions + whisper-model: whisper-1 + +cloud: + aws: + s3: + endpoint: http://localhost:9000 + access-key: test-access-key + secret-key: test-secret-key + region: us-east-1 + bucket: prepair + presigned-url-expiration: 3600