From 181bc3d3ba9472c10e5348c000b6f02574bab237 Mon Sep 17 00:00:00 2001 From: wooh Date: Sun, 10 May 2026 16:33:44 +0900 Subject: [PATCH] =?UTF-8?q?[Chore]=20Docker=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B0=8F=20CI/CD=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC=EC=B6=95=20(#15,=20#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spring Boot 애플리케이션 컨테이너화를 위한 Dockerfile 추가 - 로컬 개발 환경에서 API, PostgreSQL, Redis를 함께 실행할 수 있도록 docker-compose.yml 추가 - 운영 서버에서 GHCR 이미지를 실행할 수 있도록 docker-compose.prod.yml 추가 - 로컬 및 운영 환경변수 예시 파일(.env.example, .env.production.example) 추가 - GitHub Actions CI 워크플로우 추가 - main, develop 브랜치 push 및 PR 시 Gradle 테스트 실행 - 테스트 성공 후 Docker 이미지 빌드 검증 - GitHub Actions CD 워크플로우 추가 - main 브랜치 push 또는 수동 실행 시 GHCR 이미지 빌드 및 푸시 - 배포 서버 secret이 설정된 경우 SSH 접속 후 Docker Compose로 API 재배포 - prod 프로필의 datasource, mail, OAuth2, JWT, actuator health 설정 정리 - 배포 및 상태 확인을 위한 spring-boot-starter-actuator 의존성 추가 - CI 환경에서 contextLoads 테스트가 안정적으로 실행되도록 test 프로필 및 H2 설정 추가 - Docker/CI 관련 사용 방법과 필요한 GitHub Actions secrets를 README에 문서화 --- .dockerignore | 14 ++++ .env.example | 30 ++++++++ .env.production.example | 34 ++++++++ .github/workflows/ci.yml | 48 ++++++++++++ .github/workflows/deploy.yml | 77 +++++++++++++++++++ .gitignore | 6 +- Dockerfile | 27 +++++++ README.md | 33 ++++++++ build.gradle | 1 + docker-compose.prod.yml | 11 +++ docker-compose.yml | 59 ++++++++++++++ src/main/resources/application-prod.yaml | 40 ++++++++-- .../jobdri_api/JobdriApiApplicationTests.java | 2 + src/test/resources/application-test.yaml | 60 +++++++++++++++ 14 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 src/test/resources/application-test.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..345b52e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.gradle +.idea +build +out +*.iml +*.iws +*.ipr +.DS_Store +*.log +.env +.env.* +application-local.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0feb472 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +DB_URL=jdbc:postgresql://localhost:5432/jobdri +DB_USERNAME=jobdri +DB_PASSWORD=change-me +DB_DRIVER=org.postgresql.Driver +JPA_DDL_AUTO=update + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_SSL_ENABLED=false + +JWT_SECRET_KEY=base64-encoded-secret +JWT_ACCESS_TOKEN_EXPIRATION=3600000 +JWT_REFRESH_TOKEN_EXPIRATION=1209600000 + +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=jobdri.official@gmail.com +MAIL_PASSWORD=change-me +MAIL_SMTP_AUTH=true +MAIL_SMTP_STARTTLS_ENABLE=true +MAIL_SMTP_CONNECTION_TIMEOUT=5000 +MAIL_SMTP_TIMEOUT=5000 +MAIL_SMTP_WRITE_TIMEOUT=5000 + +GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=change-me +APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect + +MANAGEMENT_HEALTH_SHOW_DETAILS=always diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..45c6709 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,34 @@ +APP_PORT=8080 +IMAGE_NAME=ghcr.io/jobdri-developer/backend +IMAGE_TAG=latest + +DB_URL=jdbc:postgresql://your-db-host:5432/jobdri +DB_USERNAME=jobdri +DB_PASSWORD=change-me +DB_DRIVER=org.postgresql.Driver +JPA_DDL_AUTO=update + +REDIS_HOST=your-upstash-host +REDIS_PORT=6379 +REDIS_PASSWORD=change-me +REDIS_SSL_ENABLED=true + +JWT_SECRET_KEY=base64-encoded-secret +JWT_ACCESS_TOKEN_EXPIRATION=3600000 +JWT_REFRESH_TOKEN_EXPIRATION=1209600000 + +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=jobdri.official@gmail.com +MAIL_PASSWORD=change-me +MAIL_SMTP_AUTH=true +MAIL_SMTP_STARTTLS_ENABLE=true +MAIL_SMTP_CONNECTION_TIMEOUT=5000 +MAIL_SMTP_TIMEOUT=5000 +MAIL_SMTP_WRITE_TIMEOUT=5000 + +GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=change-me +APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect + +MANAGEMENT_HEALTH_SHOW_DETAILS=never diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..28fa465 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant Gradle permission + run: chmod +x ./gradlew + + - name: Run tests + run: ./gradlew --no-daemon clean test + + docker-build: + name: Docker Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t jobdri-api:${{ github.sha }} . diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..556e069 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,77 @@ +name: Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/jobdri-developer/backend + +jobs: + build-and-push: + name: Build and Push Image + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + + deploy: + name: Deploy to Server + runs-on: ubuntu-latest + needs: build-and-push + env: + HAS_DEPLOY_SECRETS: ${{ secrets.DEPLOY_HOST != '' && secrets.DEPLOY_USER != '' && secrets.DEPLOY_SSH_KEY != '' && secrets.DEPLOY_PATH != '' && secrets.GHCR_USERNAME != '' && secrets.GHCR_TOKEN != '' }} + + steps: + - name: Skip deploy when server secrets are missing + if: env.HAS_DEPLOY_SECRETS != 'true' + run: echo "Deployment skipped because required server secrets are not configured." + + - name: Deploy with Docker Compose + if: env.HAS_DEPLOY_SECRETS == 'true' + uses: appleboy/ssh-action@v1.2.0 + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + IMAGE_TAG: latest + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || 22 }} + envs: IMAGE_NAME,IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN + script: | + cd "${{ secrets.DEPLOY_PATH }}" + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin + export IMAGE_NAME="$IMAGE_NAME" + export IMAGE_TAG="$IMAGE_TAG" + docker compose -f docker-compose.prod.yml pull api + docker compose -f docker-compose.prod.yml up -d api + docker image prune -f diff --git a/.gitignore b/.gitignore index 85544e9..43d7664 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md .gradle +.gradle-home build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ @@ -44,6 +45,9 @@ out/ # Env .env +.env.* +!.env.example +!.env.production.example # application secrets -application-local.yml \ No newline at end of file +application-local.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..03fe4f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder + +WORKDIR /workspace + +COPY gradlew settings.gradle build.gradle ./ +COPY gradle ./gradle +RUN chmod +x gradlew + +COPY src ./src +RUN ./gradlew --no-daemon clean bootJar -x test + +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +RUN addgroup -S jobdri && adduser -S jobdri -G jobdri + +COPY --from=builder /workspace/build/libs/*.jar app.jar + +USER jobdri + +EXPOSE 8080 + +ENV SPRING_PROFILES_ACTIVE=prod +ENV JAVA_OPTS="" + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"] diff --git a/README.md b/README.md index 96451dd..c85104e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # BackEnd Repository of JobDri BackEnd + +## Docker + +로컬 실행: + +```bash +cp .env.example .env +docker compose up --build +``` + +배포 서버 실행: + +```bash +cp .env.production.example .env +docker compose -f docker-compose.prod.yml up -d +``` + +`prod` 프로필은 `/actuator/health`를 노출합니다. + +## CI/CD + +- `CI`: `main`, `develop` 브랜치 push 및 PR에서 테스트와 Docker 이미지 빌드를 실행합니다. +- `Deploy`: `main` 브랜치 push 또는 수동 실행 시 GHCR에 이미지를 푸시하고, 배포 서버 secret이 있으면 SSH로 `docker-compose.prod.yml`을 갱신합니다. + +GitHub Actions 배포 secret: + +- `DEPLOY_HOST` +- `DEPLOY_USER` +- `DEPLOY_PORT` optional, default `22` +- `DEPLOY_SSH_KEY` +- `DEPLOY_PATH` +- `GHCR_USERNAME` +- `GHCR_TOKEN` diff --git a/build.gradle b/build.gradle index 0c5f639..27afc18 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' //web + implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..8c81322 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,11 @@ +services: + api: + image: ${IMAGE_NAME:-ghcr.io/jobdri-developer/backend}:${IMAGE_TAG:-latest} + container_name: jobdri-api + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: prod + ports: + - "${APP_PORT:-8080}:8080" + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eafcfc1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: jobdri-api + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL:-jdbc:postgresql://postgres:5432/jobdri} + DB_USERNAME: ${DB_USERNAME:-jobdri} + DB_PASSWORD: ${DB_PASSWORD:-jobdri} + DB_DRIVER: ${DB_DRIVER:-org.postgresql.Driver} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: + REDIS_SSL_ENABLED: false + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: jobdri-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: jobdri-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-jobdri} + POSTGRES_USER: ${DB_USERNAME:-jobdri} + POSTGRES_PASSWORD: ${DB_PASSWORD:-jobdri} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-jobdri} -d ${POSTGRES_DB:-jobdri}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres-data: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 2c9ddf7..5eefe49 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -2,13 +2,13 @@ spring: application: name: jobdri-api datasource: - url: ${DB_URL:jdbc:h2:mem:jobdri;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE} + url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} - driver-class-name: ${DB_DRIVER} + driver-class-name: ${DB_DRIVER:org.postgresql.Driver} jpa: hibernate: - ddl-auto: update + ddl-auto: ${JPA_DDL_AUTO:update} properties: hibernate: format_sql: true @@ -28,20 +28,46 @@ spring: default-encoding: UTF-8 properties: mail: - SMTP: + smtp: auth: ${MAIL_SMTP_AUTH} - STARTTLS: + starttls: enable: ${MAIL_SMTP_STARTTLS_ENABLE} - connection timeout: ${MAIL_SMTP_CONNECTION_TIMEOUT} + connectiontimeout: ${MAIL_SMTP_CONNECTION_TIMEOUT} timeout: ${MAIL_SMTP_TIMEOUT} - write timeout: ${MAIL_SMTP_WRITE_TIMEOUT} + writetimeout: ${MAIL_SMTP_WRITE_TIMEOUT} + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - email + - profile + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" mail: from: ${MAIL_FROM:${MAIL_USERNAME}} +app: + oauth2: + redirect-uri: ${APP_OAUTH2_REDIRECT_URI} + server: port: 8080 +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: ${MANAGEMENT_HEALTH_SHOW_DETAILS:never} + probes: + enabled: true + jwt: secret: key: ${JWT_SECRET_KEY} diff --git a/src/test/java/com/jobdri/jobdri_api/JobdriApiApplicationTests.java b/src/test/java/com/jobdri/jobdri_api/JobdriApiApplicationTests.java index 96429b7..28061c6 100644 --- a/src/test/java/com/jobdri/jobdri_api/JobdriApiApplicationTests.java +++ b/src/test/java/com/jobdri/jobdri_api/JobdriApiApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class JobdriApiApplicationTests { @Test diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..a8d7044 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,60 @@ +spring: + datasource: + url: jdbc:h2:mem:jobdri-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + open-in-view: false + data: + redis: + host: localhost + port: 6379 + password: + ssl: + enabled: false + mail: + host: localhost + port: 1025 + username: + password: + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: false + starttls: + enable: false + connectiontimeout: 1000 + timeout: 1000 + writetimeout: 1000 + security: + oauth2: + client: + registration: + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + scope: + - email + - profile + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + +mail: + from: test@example.com + +app: + oauth2: + redirect-uri: http://localhost:3000/oauth2/redirect + +jwt: + secret: + key: am9iZHJpLWxvY2FsLXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LTIwMjYtam9iZHJp + expiration: + access-token: 3600000 + refresh-token: 1209600000