From e08e8887cda2c87676699f949ff4b58657f999d8 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Thu, 7 May 2026 15:53:12 +0900 Subject: [PATCH] Harden Docker deployment configuration --- .github/workflows/deploy.yml | 278 +++++++++--------- docs/deployment.md | 121 ++++++++ ontime-back/.dockerignore | 21 ++ ontime-back/Dockerfile | 48 ++- ontime-back/docker-compose.yml | 31 +- .../config/FirebaseInitialization.java | 67 ++++- .../ontime_back/config/SecurityConfig.java | 2 +- .../global/jwt/JwtAuthenticationFilter.java | 2 +- .../global/oauth/apple/AppleLoginService.java | 24 +- .../resources/application-prod.properties | 7 + 10 files changed, 434 insertions(+), 167 deletions(-) create mode 100644 docs/deployment.md create mode 100644 ontime-back/.dockerignore create mode 100644 ontime-back/src/main/resources/application-prod.properties diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a822374..5387dbef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,143 +5,159 @@ on: branches: - deploy +permissions: + contents: read + packages: write + +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: devkor-github/ontime-back + IMAGE_TAG: ${{ github.sha }} + jobs: - build-and-docker: + build-and-push: runs-on: ubuntu-latest steps: - # 코드 체크아웃 - - name: Checkout code - uses: actions/checkout@v3 - # JDK 설치 - - name: Set up JDK 17 # build.gradle 버전 확인 - uses: actions/setup-java@v2 + - name: Checkout repository + 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: - java-version: '17' - distribution: 'temurin' - # Gradle Wrapper 검증 - - name: Set up Gradle - uses: gradle/wrapper-validation-action@v1 - # 설정 파일 생성 - - name: Create Config Files - run: | - mkdir -p ontime-back/src/main/resources - mkdir -p ontime-back/src/main/resources/key - echo "spring.application.name=${{ secrets.SPRING_APPLICATION_NAME }}" > ontime-back/src/main/resources/application.properties - echo "spring.datasource.url=${{ secrets.SPRING_DATASOURCE_URL }}" >> ontime-back/src/main/resources/application.properties - echo "spring.datasource.username=${{ secrets.SPRING_DATASOURCE_USERNAME }}" >> ontime-back/src/main/resources/application.properties - echo "spring.datasource.password=${{ secrets.SPRING_DATASOURCE_PASSWORD }}" >> ontime-back/src/main/resources/application.properties - echo "spring.datasource.driver-class-name=${{ secrets.SPRING_DATASOURCE_DRIVER_CLASS_NAME }}" >> ontime-back/src/main/resources/application.properties - echo "spring.jpa.hibernate.ddl-auto=${{ secrets.SPRING_JPA_HIBERNATE_DDL_AUTO }}" >> ontime-back/src/main/resources/application.properties - echo "jwt.secret.key=${{ secrets.JWT_SECRETKEY }}" >> ontime-back/src/main/resources/application.properties - echo "jwt.access.expiration=${{ secrets.JWT_ACCESS_EXPIRATION }}" >> ontime-back/src/main/resources/application.properties - echo "jwt.refresh.expiration=${{ secrets.JWT_REFRESH_EXPIRATION }}" >> ontime-back/src/main/resources/application.properties - echo "jwt.access.header=${{ secrets.JWT_ACCESS_HEADER }}" >> ontime-back/src/main/resources/application.properties - echo "jwt.refresh.header=${{ secrets.JWT_REFRESH_HEADER }}" >> ontime-back/src/main/resources/application.properties - echo "google.web.client-id = ${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> ontime-back/src/main/resources/application.properties - echo "google.app.client-id = ${{ secrets.GOOGLE_APP_CLIENT_ID }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.google.client-secret=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.google.scope=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.google.redirect-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.google.authorization-grant-type=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.google.client-name=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.google.authorization-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.google.token-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.google.user-info-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.google.user-name-attribute=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.kakao.client-id=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.kakao.scope=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.kakao.redirect-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.kakao.authorization-grant-type=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.registration.kakao.client-name=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.kakao.authorization-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.kakao.token-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.kakao.user-info-uri=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI }}" >> ontime-back/src/main/resources/application.properties - echo "spring.security.oauth2.client.provider.kakao.user-name-attribute=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE }}" >> ontime-back/src/main/resources/application.properties - echo "apple.client.id=${{ secrets.APPLE_CLIENT_ID }}" >> ontime-back/src/main/resources/application.properties - echo "apple.client.secret=${{ secrets.APPLE_CLIENT_SECRET }}" >> ontime-back/src/main/resources/application.properties - echo "apple.login.key=${{ secrets.APPLE_LOGIN_KEY }}" >> ontime-back/src/main/resources/application.properties - echo "apple.team.id=${{ secrets.APPLE_TEAM_ID }}" >> ontime-back/src/main/resources/application.properties - echo "spring.flyway.enabled=true" >> ontime-back/src/main/resources/application.properties - echo "spring.flyway.url=${{ secrets.SPRING_FLYWAY_URL }}" >> ontime-back/src/main/resources/application.properties - echo "spring.flyway.user=${{ secrets.SPRING_FLYWAY_USER }}" >> ontime-back/src/main/resources/application.properties - echo "spring.flyway.password=${{ secrets.SPRING_FLYWAY_PASSWORD }}" >> ontime-back/src/main/resources/application.properties - echo "spring.flyway.baseline-on-migrate=true" >> ontime-back/src/main/resources/application.properties - echo "management.endpoints.web.exposure.include=health" >> ontime-back/src/main/resources/application.properties - echo "management.endpoint.health.show-details=always" >> ontime-back/src/main/resources/application.properties - echo "server.forward-headers-strategy=framework" >> ontime-back/src/main/resources/application.properties - echo "${{ secrets.ONTIME_PUSH_FIREBASE_ADMINSDK }}" > ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json - - - echo "${{ secrets.AUTHKEY_743M7R5W3W }}" > ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 - # Gradle 빌드 - - name: Build with Gradle - run: | - cd ontime-back - ./gradlew build -x test - # 파일 위치 변경 - - name: Move file - run: | - mv ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json ./ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json - mv ontime-back/src/main/resources/application.properties ./application.properties - mv ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 ./AuthKey_743M7R5W3W.p8 - mv ontime-back/build/libs/ontime-back-0.0.1-SNAPSHOT.jar ./project.jar - mv ontime-back/docker-compose.yml ./docker-compose.yml - mv ontime-back/Dockerfile ./Dockerfile - # EC2 서버에 업로드 - - name: Upload files to EC2 - uses: appleboy/scp-action@v0.1.7 + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - source: "./project.jar, ./docker-compose.yml, ./Dockerfile, ./ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json, ./application.properties, ./AuthKey_743M7R5W3W.p8" - target: "/home/ubuntu/OnTime-back" - debug: true - create-config-files: - needs: build-and-docker - runs-on: ubuntu-latest - steps: - # EC2 서버에 접근 + 설정 파일 생성 - - name: SSH to EC2 & Create Config Files - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - debug: true - script: | - sudo mkdir -p /home/ubuntu/OnTime-back/ontime-back/src/main/resources/key - sudo mv /home/ubuntu/OnTime-back/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json /home/ubuntu/OnTime-back/ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json - sudo mv /home/ubuntu/OnTime-back/application.properties /home/ubuntu/OnTime-back/ontime-back/src/main/resources/application.properties - sudo mv /home/ubuntu/OnTime-back/AuthKey_743M7R5W3W.p8 /home/ubuntu/OnTime-back/ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 + context: ./ontime-back + file: ./ontime-back/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:deploy-latest + cache-from: type=gha + cache-to: type=gha,mode=max deploy-to-ec2: - needs: create-config-files + needs: build-and-push runs-on: ubuntu-latest steps: - # EC2 서버에 접근 + docker container 배포 - - name: SSH to EC2 & Deploy Docker Containers - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - # 기존 컨테이너 종료 및 삭제 - CONTAINER_ID=$(sudo docker ps -aq --filter "name=ontime-container") - if [ ! -z "$CONTAINER_ID" ]; then - sudo docker stop $CONTAINER_ID - sudo docker rm $CONTAINER_ID - fi - - # 기존 컨테이너 및 볼륨 정리 - sudo docker-compose down - sudo docker container prune -f - sudo docker image prune -a -f - sudo docker volume prune -f - sudo docker network prune -f - - # Docker Compose 실행 - cd /home/ubuntu/OnTime-back - docker-compose up --build -d - + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Upload compose file to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "ontime-back/docker-compose.yml" + target: "/home/ubuntu/OnTime-back" + strip_components: 1 + + - name: Pull image and restart container + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -eu + + DEPLOY_DIR="/home/ubuntu/OnTime-back" + CONTAINER_NAME="ontime-container" + + mkdir -p "$DEPLOY_DIR" + cd "$DEPLOY_DIR" + + umask 077 + cat > .env <<'EOF' + IMAGE_TAG=${{ env.IMAGE_TAG }} + BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + BACKEND_CONTAINER_NAME=ontime-container + BACKEND_HTTP_PORT=${{ secrets.BACKEND_HTTP_PORT || '8080' }} + SERVER_PORT=8080 + SPRING_PROFILES_ACTIVE=prod + JAVA_TOOL_OPTIONS=-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom + + SPRING_APPLICATION_NAME=${{ secrets.SPRING_APPLICATION_NAME }} + SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }} + SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }} + SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }} + SPRING_DATASOURCE_DRIVER_CLASS_NAME=${{ secrets.SPRING_DATASOURCE_DRIVER_CLASS_NAME }} + SPRING_JPA_HIBERNATE_DDL_AUTO=${{ secrets.SPRING_JPA_HIBERNATE_DDL_AUTO }} + + SPRING_FLYWAY_ENABLED=true + SPRING_FLYWAY_URL=${{ secrets.SPRING_FLYWAY_URL }} + SPRING_FLYWAY_USER=${{ secrets.SPRING_FLYWAY_USER }} + SPRING_FLYWAY_PASSWORD=${{ secrets.SPRING_FLYWAY_PASSWORD }} + SPRING_FLYWAY_BASELINE_ON_MIGRATE=true + + JWT_SECRET_KEY=${{ secrets.JWT_SECRETKEY }} + JWT_ACCESS_EXPIRATION=${{ secrets.JWT_ACCESS_EXPIRATION }} + JWT_REFRESH_EXPIRATION=${{ secrets.JWT_REFRESH_EXPIRATION }} + JWT_ACCESS_HEADER=${{ secrets.JWT_ACCESS_HEADER }} + JWT_REFRESH_HEADER=${{ secrets.JWT_REFRESH_HEADER }} + + GOOGLE_WEB_CLIENT_ID=${{ secrets.GOOGLE_WEB_CLIENT_ID }} + GOOGLE_APP_CLIENT_ID=${{ secrets.GOOGLE_APP_CLIENT_ID }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE }} + + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE }} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI }} + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE }} + + APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} + APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }} + APPLE_LOGIN_KEY=${{ secrets.APPLE_LOGIN_KEY }} + APPLE_PRIVATE_KEY_BASE64=${{ secrets.APPLE_PRIVATE_KEY_BASE64 }} + FEATURE_APPLE_LOGIN_ENABLED=${{ secrets.FEATURE_APPLE_LOGIN_ENABLED || 'true' }} + + FIREBASE_CREDENTIALS_BASE64=${{ secrets.FIREBASE_CREDENTIALS_BASE64 }} + EOF + + echo "${{ secrets.GHCR_READ_TOKEN }}" | sudo docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin + + if sudo docker compose version >/dev/null 2>&1; then + sudo docker compose pull + sudo docker compose up -d --remove-orphans + else + sudo docker-compose pull + sudo docker-compose up -d --remove-orphans + fi + + for attempt in $(seq 1 30); do + STATUS="$(sudo docker inspect -f '{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)" + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy." + exit 0 + fi + echo "Waiting for healthy container status; current status: ${STATUS:-unknown}" + sleep 5 + done + + sudo docker logs --tail=200 "$CONTAINER_NAME" || true + exit 1 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..c8eb53dd --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,121 @@ +# Production Deployment + +This service deploys as an immutable Docker image published to GitHub Container Registry (GHCR). Runtime configuration is injected through the EC2 `.env` file generated by GitHub Actions; private resource files are not copied into the image or bind-mounted from the host. + +## Required GitHub Secrets + +Deployment access: + +- `EC2_HOST` +- `EC2_USER` +- `EC2_SSH_KEY` +- `GHCR_USERNAME` +- `GHCR_READ_TOKEN` + +Runtime image and port: + +- `BACKEND_HTTP_PORT` (optional, defaults to `8080`) + +Spring and database: + +- `SPRING_APPLICATION_NAME` +- `SPRING_DATASOURCE_URL` +- `SPRING_DATASOURCE_USERNAME` +- `SPRING_DATASOURCE_PASSWORD` +- `SPRING_DATASOURCE_DRIVER_CLASS_NAME` +- `SPRING_JPA_HIBERNATE_DDL_AUTO` +- `SPRING_FLYWAY_URL` +- `SPRING_FLYWAY_USER` +- `SPRING_FLYWAY_PASSWORD` + +Authentication and OAuth: + +- `JWT_SECRETKEY` +- `JWT_ACCESS_EXPIRATION` +- `JWT_REFRESH_EXPIRATION` +- `JWT_ACCESS_HEADER` +- `JWT_REFRESH_HEADER` +- `GOOGLE_WEB_CLIENT_ID` +- `GOOGLE_APP_CLIENT_ID` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE` +- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI` +- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE` +- `APPLE_CLIENT_ID` +- `APPLE_TEAM_ID` +- `APPLE_LOGIN_KEY` +- `APPLE_PRIVATE_KEY_BASE64` +- `FEATURE_APPLE_LOGIN_ENABLED` (optional, defaults to `true`) + +Firebase: + +- `FIREBASE_CREDENTIALS_BASE64` + +Set the base64 secrets from the ignored local credential files: + +```bash +base64 -i ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json | tr -d '\n' | gh secret set FIREBASE_CREDENTIALS_BASE64 --repo DevKor-github/OnTime-back +``` + +```bash +base64 -i ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 | tr -d '\n' | gh secret set APPLE_PRIVATE_KEY_BASE64 --repo DevKor-github/OnTime-back +``` + +## Build And Release Flow + +Push to the `deploy` branch to trigger `.github/workflows/deploy.yml`. + +The workflow: + +1. Builds `ontime-back/Dockerfile` from the `ontime-back/` context. +2. Pushes two GHCR tags: + - `ghcr.io/devkor-github/ontime-back:` + - `ghcr.io/devkor-github/ontime-back:deploy-latest` +3. Uploads `docker-compose.yml` to `/home/ubuntu/OnTime-back`. +4. Writes `/home/ubuntu/OnTime-back/.env` from GitHub secrets. +5. Runs `docker compose pull && docker compose up -d --remove-orphans`. +6. Waits until the `ontime-container` Docker health status is `healthy`. + +## Health Verification + +The production image exposes a Docker healthcheck against: + +```text +/actuator/health/readiness +``` + +Manual checks on EC2: + +```bash +cd /home/ubuntu/OnTime-back +sudo docker compose ps +sudo docker inspect -f '{{.State.Health.Status}}' ontime-container +curl -fsS http://localhost:8080/actuator/health/readiness +``` + +## Rollback + +Every deploy is tagged by commit SHA. To roll back, set `IMAGE_TAG` in `/home/ubuntu/OnTime-back/.env` to the previous known-good SHA, then restart from the existing Compose file: + +```bash +cd /home/ubuntu/OnTime-back +sudo docker compose pull +sudo docker compose up -d --remove-orphans +sudo docker inspect -f '{{.State.Health.Status}}' ontime-container +``` + +Keep the previous SHA in the release notes or GitHub Actions deploy history so rollback does not depend on `deploy-latest`. diff --git a/ontime-back/.dockerignore b/ontime-back/.dockerignore new file mode 100644 index 00000000..40b0eaf1 --- /dev/null +++ b/ontime-back/.dockerignore @@ -0,0 +1,21 @@ +.env +.git +.gitignore +.gradle +build +out +bin +.idea +.vscode +*.iml +*.iws +*.ipr +.DS_Store + +src/main/resources/application.properties +src/main/resources/*.json +src/main/resources/key +src/main/resources/**/*.p8 + +Dockerfile +docker-compose*.yml diff --git a/ontime-back/Dockerfile b/ontime-back/Dockerfile index fdc20045..23f81c6f 100644 --- a/ontime-back/Dockerfile +++ b/ontime-back/Dockerfile @@ -1,10 +1,40 @@ -FROM eclipse-temurin:17-jdk -RUN apt-get update && \ - apt-get install -y tzdata && \ - ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ - echo "Asia/Seoul" > /etc/timezone && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +FROM eclipse-temurin:17-jdk-jammy AS builder + +WORKDIR /workspace + +COPY gradlew build.gradle settings.gradle ./ +COPY gradle ./gradle +RUN chmod +x ./gradlew + +COPY src ./src +RUN ./gradlew clean bootJar --no-daemon + +FROM eclipse-temurin:17-jre-jammy + +ENV TZ=Asia/Seoul \ + LANG=C.UTF-8 \ + SPRING_PROFILES_ACTIVE=prod \ + JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl tzdata \ + && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && echo "${TZ}" > /etc/timezone \ + && groupadd --system app \ + && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app \ + && mkdir -p /app \ + && chown -R app:app /app \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app -COPY project.jar app.jar -ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file +COPY --from=builder --chown=app:app /workspace/build/libs/*.jar /app/app.jar + +USER app +EXPOSE 8080 +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -fsS "http://localhost:${SERVER_PORT:-8080}/actuator/health/readiness" || exit 1 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/ontime-back/docker-compose.yml b/ontime-back/docker-compose.yml index 88d6cc61..e21529d8 100644 --- a/ontime-back/docker-compose.yml +++ b/ontime-back/docker-compose.yml @@ -1,15 +1,22 @@ -version: "3.8" - services: backend: - build: - context: . - dockerfile: Dockerfile # Dockerfile 이름 - image: ontimedemo # 빌드된 백엔드 이미지 - container_name: ontime-container + image: "${BACKEND_IMAGE:-ghcr.io/devkor-github/ontime-back}:${IMAGE_TAG:?IMAGE_TAG is required}" + container_name: "${BACKEND_CONTAINER_NAME:-ontime-container}" + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: "${SPRING_PROFILES_ACTIVE:-prod}" + SERVER_PORT: "${SERVER_PORT:-8080}" + JAVA_TOOL_OPTIONS: "${JAVA_TOOL_OPTIONS:--XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom}" ports: - - "8080:8080" - - "8443:8443" - volumes: - - /home/ubuntu/OnTime-back/ontime-back/src/main/resources/:/app/src/main/resources/ - - /home/ubuntu/OnTime-back/ontime-back/src/main/resources/key/:/app/resources/key/ + - "${BACKEND_HTTP_PORT:-8080}:${SERVER_PORT:-8080}" + restart: unless-stopped + stop_grace_period: 30s + mem_limit: "${BACKEND_MEMORY_LIMIT:-768m}" + cpus: "${BACKEND_CPU_LIMIT:-1.0}" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER_PORT:-8080}/actuator/health/readiness || exit 1"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/FirebaseInitialization.java b/ontime-back/src/main/java/devkor/ontime_back/config/FirebaseInitialization.java index 3e543af7..be56de74 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/FirebaseInitialization.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/FirebaseInitialization.java @@ -4,32 +4,79 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; -import java.io.FileInputStream; -import java.io.FileNotFoundException; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; @Service +@Slf4j public class FirebaseInitialization { + @Value("${firebase.credentials.base64:}") + private String firebaseCredentialsBase64; + + @Value("${firebase.credentials.json:}") + private String firebaseCredentialsJson; + + @Value("${firebase.credentials.path:}") + private String firebaseCredentialsPath; + + @Value("${google.application.credentials:}") + private String googleApplicationCredentials; + @PostConstruct public void initialize() { try { - InputStream serviceAccount = getClass().getClassLoader().getResourceAsStream("ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json"); - if (serviceAccount == null) { - throw new FileNotFoundException("Resource not found: ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json"); + if (!FirebaseApp.getApps().isEmpty()) { + return; } - FirebaseOptions options = new FirebaseOptions.Builder() - .setCredentials(GoogleCredentials.fromStream(serviceAccount)) - .build(); + try (InputStream serviceAccount = resolveCredentials()) { + if (serviceAccount == null) { + log.warn("Firebase credentials were not provided; Firebase push notifications are disabled."); + return; + } - FirebaseApp.initializeApp(options); + FirebaseOptions options = new FirebaseOptions.Builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + } } catch (IOException e) { - e.printStackTrace(); + log.error("Failed to initialize Firebase.", e); + } + } + + private InputStream resolveCredentials() throws IOException { + if (hasText(firebaseCredentialsBase64)) { + byte[] decodedCredentials = Base64.getDecoder().decode(firebaseCredentialsBase64); + return new ByteArrayInputStream(decodedCredentials); + } + + if (hasText(firebaseCredentialsJson)) { + return new ByteArrayInputStream(firebaseCredentialsJson.getBytes(StandardCharsets.UTF_8)); + } + + String credentialsPath = hasText(firebaseCredentialsPath) + ? firebaseCredentialsPath + : googleApplicationCredentials; + if (hasText(credentialsPath)) { + return Files.newInputStream(Path.of(credentialsPath)); } + + return null; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 76ea1e4c..43ce7baa 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -71,7 +71,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .frameOptions(frameOptions -> frameOptions.disable())) .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll() - .requestMatchers("/health", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll() + .requestMatchers("/health", "/actuator/health/**", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html").permitAll() .requestMatchers("/error").permitAll() .requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java index 25bc5616..8ff742e3 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java @@ -32,7 +32,7 @@ @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final List NO_CHECK_URLS = List.of("/login", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login"); + private static final List NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login"); private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java index 15a3147b..35a76525 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java @@ -34,6 +34,7 @@ import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.KeyFactory; @@ -58,8 +59,12 @@ public class AppleLoginService { private String teamId; @Value("${apple.login.key}") private String keyId; - @Value("${apple.client.secret}") + @Value("${apple.client.secret:}") private String privateKeyPath; + @Value("${apple.private-key.base64:}") + private String privateKeyBase64; + @Value("${apple.private-key:}") + private String privateKey; private final ApplePublicKeyGenerator applePublicKeyGenerator; private final JwtUtils jwtUtils; @@ -194,7 +199,6 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) String clientSecret = generateClientSecret(); log.info("getAppleAccessTokenAndRefreshToken"); log.info("client_id: {}", clientId); - log.info("client_secret: {}", clientSecret); MultiValueMap requestBody = new LinkedMultiValueMap<>(); requestBody.add("grant_type", "authorization_code"); requestBody.add("code", authCode); @@ -220,7 +224,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) private String generateClientSecret() throws Exception { log.info("generageClientSecret"); // Private Key - String privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath))) + String privateKeyContent = resolvePrivateKey() .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); @@ -245,6 +249,20 @@ private String generateClientSecret() throws Exception { .signWith(privateKey, SignatureAlgorithm.ES256) .compact(); } + + private String resolvePrivateKey() throws IOException { + if (privateKeyBase64 != null && !privateKeyBase64.isBlank()) { + byte[] decodedPrivateKey = Base64.getDecoder().decode(privateKeyBase64); + return new String(decodedPrivateKey, StandardCharsets.UTF_8); + } + + if (privateKey != null && !privateKey.isBlank()) { + return privateKey; + } + + return new String(Files.readAllBytes(Paths.get(privateKeyPath))); + } + public boolean revokeToken(Long userId) throws Exception { log.info("checkAppleLoginRevoked"); User user = userRepository.findById(userId) diff --git a/ontime-back/src/main/resources/application-prod.properties b/ontime-back/src/main/resources/application-prod.properties new file mode 100644 index 00000000..851a6410 --- /dev/null +++ b/ontime-back/src/main/resources/application-prod.properties @@ -0,0 +1,7 @@ +management.endpoint.health.probes.enabled=true +management.endpoints.web.exposure.include=health +management.health.readinessstate.enabled=true +management.health.livenessstate.enabled=true +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=30s +server.forward-headers-strategy=framework