diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..238405c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.idea +.agents +.claude +.codex +.gradle +build +out +*.log +.env +.env.* +!.env.example +!.env.production.example +secrets +firebase-service-account*.json +*-firebase-adminsdk-*.json +*service-account*.json diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..90d6e32 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,55 @@ +# Copy this file to .env.production on the VM and replace every REPLACE_WITH_* value. +SPRING_PROFILES_ACTIVE=prod +SERVER_PORT=8080 +TZ=Asia/Seoul +JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=25.0 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul + +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=mudda +DB_USERNAME=mudda_prod +DB_PASSWORD=REPLACE_WITH_A_LONG_RANDOM_DATABASE_PASSWORD +DB_POOL_MAX_SIZE=5 +DB_POOL_MIN_IDLE=1 +DB_CONNECTION_TIMEOUT_MS=5000 + +REDIS_HOST=redis +REDIS_PORT=6379 + +AWS_REGION=ap-northeast-2 +AWS_ACCESS_KEY=REPLACE_WITH_AWS_ACCESS_KEY +AWS_SECRET_KEY=REPLACE_WITH_AWS_SECRET_KEY +MEDIA_STORAGE_BUCKET=REPLACE_WITH_PRIVATE_S3_BUCKET +MEDIA_UPLOAD_URL_EXPIRATION=10m +MEDIA_ACCESS_URL_EXPIRATION=5m +MEDIA_MAX_IMAGE_SIZE=10485760 +MEDIA_MAX_VOICE_SIZE=20971520 +MEDIA_MAX_VIDEO_SIZE=104857600 + +# Generate and back up this value separately. Losing it makes existing envelope-encrypted data unreadable. +MUDDA_MASTER_KEY=REPLACE_WITH_BASE64_ENCODED_32_BYTE_KEY + +JWT_SECRET=REPLACE_WITH_A_LONG_RANDOM_JWT_SECRET +JWT_ACCESS_TOKEN_EXPIRATION=3600000 +JWT_REFRESH_TOKEN_EXPIRATION=1209600000 + +OAUTH_KAKAO_CLIENT_ID=REPLACE_WITH_KAKAO_CLIENT_ID +OAUTH_KAKAO_CLIENT_SECRET=REPLACE_WITH_KAKAO_CLIENT_SECRET +OAUTH_GOOGLE_CLIENT_ID=REPLACE_WITH_GOOGLE_CLIENT_ID +OAUTH_GOOGLE_CLIENT_SECRET=REPLACE_WITH_GOOGLE_CLIENT_SECRET +OAUTH_APPLE_CLIENT_ID=REPLACE_WITH_APPLE_CLIENT_ID +OAUTH_APPLE_TEAM_ID=REPLACE_WITH_APPLE_TEAM_ID +OAUTH_APPLE_KEY_ID=REPLACE_WITH_APPLE_KEY_ID +OAUTH_APPLE_PRIVATE_KEY=REPLACE_WITH_APPLE_PRIVATE_KEY + +FCM_ENABLED=false +FCM_CREDENTIALS=file:/run/secrets/firebase-service-account.json +FIREBASE_CREDENTIALS_DIR=/opt/mudda/secrets + +MEDIA_PENDING_RETENTION=1d +MEDIA_CLEANUP_CRON=0 0 4 * * * +CAPSULE_KEY_SHARE_CLEANUP_CRON=0 30 4 * * * +CAPSULE_OPEN_RADIUS_METER=100 +CAPSULE_MAX_ACTIVE_PER_MEMBER=100 +CAPSULE_MAX_EXPIRATION_YEARS=10 +CAPSULE_MAX_NEARBY_RADIUS_METER=5000 diff --git a/.gitignore b/.gitignore index c2362a2..4e81098 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,9 @@ out/ *.class ### ENV ### -.env \ No newline at end of file +.env +.env.* +!.env.example +!.env.production.example +secrets/ +*service-account*.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7434049 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM eclipse-temurin:21-jdk-jammy AS builder + +WORKDIR /workspace + +# Copy the build definition first so dependency resolution remains cached when only application +# sources change. +COPY gradlew settings.gradle.kts build.gradle.kts ./ +COPY gradle gradle + +RUN chmod +x gradlew \ + && ./gradlew dependencies --no-daemon + +COPY src src + +RUN ./gradlew bootJar --no-daemon \ + && find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' -exec cp {} /workspace/app.jar \; + +FROM eclipse-temurin:21-jre-jammy AS runtime + +WORKDIR /app + +# curl is the only runtime package needed for the Compose Actuator healthcheck. +RUN apt-get update \ + && apt-get install --no-install-recommends --yes curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --system --uid 10001 --create-home --shell /usr/sbin/nologin appuser + +COPY --from=builder --chown=appuser:appuser /workspace/app.jar app.jar + +ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=25.0 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" + +USER appuser + +EXPOSE 8080 +STOPSIGNAL SIGTERM + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..de89e4e --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,101 @@ +name: mudda-prod + +x-default-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: mudda-app:prod + env_file: + - .env.production + environment: + SPRING_PROFILES_ACTIVE: prod + SERVER_PORT: "8080" + DB_HOST: postgres + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + TZ: Asia/Seoul + ports: + - "8080:8080" + volumes: + - type: bind + source: ${FIREBASE_CREDENTIALS_DIR:-./secrets} + target: /run/secrets + read_only: true + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl --fail --silent --show-error http://127.0.0.1:8080/actuator/health/readiness || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + restart: unless-stopped + stop_grace_period: 30s + mem_limit: 1024m + cpus: 1.00 + logging: *default-logging + networks: + - backend + + postgres: + image: postgis/postgis:16-3.5-alpine + platform: linux/amd64 + environment: + POSTGRES_DB: ${DB_NAME:?DB_NAME is required} + POSTGRES_USER: ${DB_USERNAME:?DB_USERNAME is required} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + volumes: + - mudda-prod-postgres-data:/var/lib/postgresql/data + - ./src/main/resources/db/init/001_enable_postgis.sql:/docker-entrypoint-initdb.d/001_enable_postgis.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + restart: unless-stopped + stop_grace_period: 30s + mem_limit: 768m + cpus: 0.75 + logging: *default-logging + networks: + - backend + + redis: + image: redis:7.4-alpine + platform: linux/amd64 + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] + volumes: + - mudda-prod-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + restart: unless-stopped + stop_grace_period: 15s + mem_limit: 256m + cpus: 0.25 + logging: *default-logging + networks: + - backend + +volumes: + mudda-prod-postgres-data: + mudda-prod-redis-data: + +networks: + backend: + internal: true diff --git a/docs/GSMSV_MANUAL_DEPLOYMENT.md b/docs/GSMSV_MANUAL_DEPLOYMENT.md new file mode 100644 index 0000000..9914d04 --- /dev/null +++ b/docs/GSMSV_MANUAL_DEPLOYMENT.md @@ -0,0 +1,144 @@ +# GSMSV 수동 운영 배포 + +이 문서는 MUDDA를 GSMSV 학교 VM에 최초 수동 배포하기 위한 절차입니다. 이번 구성은 GitHub Actions CD, GHCR, Discord 알림, Nginx, Certbot 없이 Docker Compose로 실행합니다. + +## 배포 구조 + +```text +https://mudda-api.https.gsmsv.site + │ GSMSV HTTPS Reverse Proxy + ▼ +VM:8080 ── app:8080 ── backend network ── postgres:5432 + └── redis:6379 + app ── AWS S3 / Firebase +``` + +GSMSV 설정값은 다음과 같습니다. + +- 서브도메인: `mudda-api` +- 내부 전달 포트: `8080` +- 예상 주소: `https://mudda-api.https.gsmsv.site` +- VM에서는 Nginx와 Certbot을 설치하거나 설정하지 않습니다. + +PostgreSQL과 Redis는 Compose 내부 `backend` 네트워크에만 연결되며 호스트 포트를 publish하지 않습니다. 외부에 publish되는 포트는 App의 `8080`뿐입니다. + +현재 애플리케이션은 Redis 비밀번호 설정을 사용하지 않습니다. Redis는 외부 포트가 없고 `internal` 네트워크에만 연결되므로 이번 범위에서는 네트워크 격리로 보호합니다. Redis 인증을 추가하려면 Spring 설정, Redis command, healthcheck, Secret 전달 방식을 함께 변경해야 하므로 별도 후속 작업으로 남깁니다. + +## 요구 사양 + +- Ubuntu 22.04.5 LTS, x86_64, 2 vCPU, RAM 7.9GB, 디스크 40GB +- Docker Engine과 Docker Compose v2 +- GSMSV Project Owner VM 및 HTTPS Reverse Proxy 설정 권한 +- AWS S3 bucket과 최소 권한 AWS credential +- OAuth provider credential +- 선택적으로 Firebase service-account JSON + +Docker는 Ubuntu 공식 설치 절차에 따라 설치한 뒤 다음을 확인합니다. + +```bash +docker --version +docker compose version +``` + +## 운영 환경 준비 + +저장소를 VM의 배포 디렉터리에 clone한 뒤 실제 운영 환경 파일을 만듭니다. + +```bash +cp .env.production.example .env.production +chmod 600 .env.production +mkdir -p /opt/mudda/secrets +chmod 700 /opt/mudda/secrets +``` + +`.env.production`의 모든 `REPLACE_WITH_*` 값을 실제 값으로 교체합니다. 이 파일과 Firebase credential은 절대 Git에 커밋하지 않습니다. `.gitignore`는 `.env.*`와 `secrets/`를 제외하며 예시 파일만 예외로 둡니다. + +`MUDDA_MASTER_KEY`는 기존 `EncryptedStringConverter` 데이터의 복호화에 사용됩니다. 최초 설정 시 별도 보관 위치에 백업하고, 분실하거나 임의로 변경하면 기존 봉투 암호화 캡슐을 복구할 수 없습니다. 키를 문서·Issue·로그·Docker image에 기록하지 않습니다. + +예시 값은 생성하지 말고 VM에서 직접 생성하여 운영 환경 파일과 별도 백업에만 기록합니다. + +```bash +openssl rand -base64 32 # MUDDA_MASTER_KEY +openssl rand -base64 48 # JWT_SECRET +openssl rand -hex 24 # DB_PASSWORD +``` + +FCM을 사용할 경우 service-account JSON을 다음 경로에 배치합니다. + +```text +/opt/mudda/secrets/firebase-service-account.json +``` + +그리고 `FCM_ENABLED=true`를 설정합니다. 사용하지 않을 때는 `FCM_ENABLED=false`로 두면 credential 없이도 앱이 시작됩니다. + +## 이미지 빌드 및 실행 + +운영 환경 파일을 명시하여 Compose 변수 치환과 App 환경변수가 같은 파일을 사용하도록 합니다. + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml build --pull +docker compose --env-file .env.production -f docker-compose.prod.yml up -d +``` + +Dockerfile은 Java 21 JDK 빌드 스테이지와 Java 21 JRE 실행 스테이지를 사용합니다. Gradle Wrapper로 `bootJar`를 빌드하고, 실행 컨테이너는 비 root `appuser`로 동작합니다. Secret과 Firebase JSON은 이미지에 복사되지 않습니다. + +운영 Profile은 `SPRING_PROFILES_ACTIVE=prod`이며 Compose가 이를 보장합니다. `spring.docker.compose.enabled=false`로 앱이 로컬 개발용 `docker-compose.yml`을 다시 자동 실행하지 않도록 합니다. Flyway가 전체 마이그레이션을 적용하고 Hibernate는 `ddl-auto=validate`로 스키마를 변경하지 않습니다. + +## 최초 실행 및 상태 확인 + +컨테이너 상태와 healthcheck를 확인합니다. + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml ps +docker compose --env-file .env.production -f docker-compose.prod.yml logs --tail=200 app +curl --fail https://mudda-api.https.gsmsv.site/actuator/health +curl --fail https://mudda-api.https.gsmsv.site/actuator/health/readiness +``` + +최초 App 로그에서 Flyway가 V1부터 최신 버전까지 적용되었는지 확인합니다. readiness는 `readinessState`, PostgreSQL(`db`), Redis(`redis`)를 포함하므로 DB나 Redis가 연결되지 않으면 정상 상태가 되지 않습니다. liveness는 애플리케이션 프로세스 상태만 확인합니다. Actuator 외부 노출 범위는 `health` 하나이며 세부 정보는 노출하지 않습니다. + +SecurityConfig는 `/actuator/health/**`만 인증 없이 허용하고, 다른 API는 기존처럼 인증을 요구합니다. + +호스트 포트와 볼륨을 확인합니다. + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml port app 8080 +docker compose --env-file .env.production -f docker-compose.prod.yml config | grep -E 'ports:|8080:8080|5432|6379' +docker volume ls | grep mudda-prod +``` + +Compose 파일에 PostgreSQL `5432`나 Redis `6379`의 `ports` 항목은 없습니다. 위 출력에는 App의 `8080:8080`만 있어야 합니다. `docker compose config`나 로그에 실제 Secret이 표시되지 않도록 실제 운영 파일을 출력·공유하지 않습니다. + +Flyway와 PostgreSQL 데이터는 `mudda-prod-postgres-data`에, Redis AOF는 `mudda-prod-redis-data`에 보존됩니다. Redis는 refresh token과 access-token blacklist의 TTL 상태를 재시작 뒤에도 유지하기 위해 AOF를 사용합니다. 볼륨은 백업을 대체하지 않으므로 PostgreSQL 백업은 별도 후속 작업입니다. + +## 운영 관리 + +```bash +# 앱만 재시작 +docker compose --env-file .env.production -f docker-compose.prod.yml restart app + +# 전체 서비스의 새 이미지 반영 +docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build + +# 정상 종료(SIGTERM 후 Spring graceful shutdown 대기) +docker compose --env-file .env.production -f docker-compose.prod.yml down +``` + +`restart: unless-stopped`가 적용되어 VM 재부팅이나 프로세스 장애 뒤 서비스를 다시 시작합니다. 로그는 컨테이너별 `json-file` 기준 파일당 10MB, 최대 3개로 제한됩니다. 운영 데이터가 있는 상태에서 `down -v`, `docker volume prune`, 전체 컨테이너 삭제 명령을 사용하지 않습니다. + +JVM은 기본적으로 컨테이너 메모리의 65%를 최대 힙으로 사용하고, `JAVA_TOOL_OPTIONS`로 조정할 수 있습니다. App 1GB, PostgreSQL 768MB, Redis 256MB의 컨테이너 메모리 상한과 총 2 vCPU 이내의 CPU 상한을 사용합니다. 스케줄러와 JVM 시간대는 `Asia/Seoul`로 맞춥니다. + +## 최초 수동 배포 검증 + +다음 항목을 실제 VM에서 확인해야 합니다. + +- Docker image build 및 Compose 기동 성공 +- `postgres`, `redis`, `app` healthcheck가 모두 `healthy` +- Flyway 전체 적용 및 PostgreSQL/PostGIS 연결 +- App readiness와 외부 HTTPS 주소 응답 +- Redis 연결 및 애플리케이션 기능 확인 +- `8080`만 외부 공개되고 `5432`, `6379`는 공개되지 않음 +- 컨테이너 재시작 뒤 DB·Redis 볼륨과 앱 연결 유지 +- 실제 환경 파일·Firebase JSON·Secret이 Git, image history, 로그에 없음 + +이번 PR은 운영 기반 파일과 수동 배포 문서만 추가합니다. GSMSV VM 최초 배포, PostgreSQL 백업, develop 기반 GitHub Actions CD, Discord 웹훅 임베드 알림은 후속 작업입니다. diff --git a/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt b/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt index c727211..4199733 100644 --- a/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt +++ b/src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt @@ -26,7 +26,7 @@ class SecurityConfig(private val objectMapper: ObjectMapper) { accessTokenBlacklist: AccessTokenBlacklist, ): SecurityFilterChain = http .csrf { it.disable() }.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { it.requestMatchers("/api/v1/auth/oauth/**", "/api/v1/auth/reissue", "/api/v1/maps/**", "/actuator/health", "/swagger-ui/**", "/v3/api-docs/**").permitAll().anyRequest().authenticated() } + .authorizeHttpRequests { it.requestMatchers("/api/v1/auth/oauth/**", "/api/v1/auth/reissue", "/api/v1/maps/**", "/actuator/health", "/actuator/health/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll().anyRequest().authenticated() } .exceptionHandling { it.authenticationEntryPoint { _, response, _ -> response.status = HttpStatus.UNAUTHORIZED.value() response.contentType = "application/json" diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..1be1b92 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,91 @@ +spring: + docker: + compose: + enabled: false + datasource: + url: jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + hikari: + maximum-pool-size: ${DB_POOL_MAX_SIZE:5} + minimum-idle: ${DB_POOL_MIN_IDLE:1} + connection-timeout: ${DB_CONNECTION_TIMEOUT_MS:5000} + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + jpa: + hibernate: + ddl-auto: validate + show-sql: false + flyway: + enabled: true + lifecycle: + timeout-per-shutdown-phase: 20s + cloud: + aws: + region: + static: ${AWS_REGION} + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + +server: + port: ${SERVER_PORT:8080} + shutdown: graceful + forward-headers-strategy: framework + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + probes: + enabled: true + show-details: never + group: + liveness: + include: livenessState + readiness: + include: readinessState,db,redis + +mudda: + crypto: + master-key: ${MUDDA_MASTER_KEY} + +jwt: + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} + +media: + storage: + bucket: ${MEDIA_STORAGE_BUCKET} + upload-url-expiration: ${MEDIA_UPLOAD_URL_EXPIRATION:10m} + access-url-expiration: ${MEDIA_ACCESS_URL_EXPIRATION:5m} + max-image-size: ${MEDIA_MAX_IMAGE_SIZE:10485760} + max-voice-size: ${MEDIA_MAX_VOICE_SIZE:20971520} + max-video-size: ${MEDIA_MAX_VIDEO_SIZE:104857600} + pending-retention: ${MEDIA_PENDING_RETENTION:1d} + cleanup: + cron: ${MEDIA_CLEANUP_CRON:0 0 4 * * *} + +capsule: + key-share-cleanup: + cron: ${CAPSULE_KEY_SHARE_CLEANUP_CRON:0 30 4 * * *} + open-radius-meter: ${CAPSULE_OPEN_RADIUS_METER:100} + max-active-per-member: ${CAPSULE_MAX_ACTIVE_PER_MEMBER:100} + max-expiration-years: ${CAPSULE_MAX_EXPIRATION_YEARS:10} + max-nearby-radius-meter: ${CAPSULE_MAX_NEARBY_RADIUS_METER:5000} + +fcm: + enabled: ${FCM_ENABLED:false} + credentials: ${FCM_CREDENTIALS:} + +logging: + level: + root: INFO + org.hibernate.SQL: WARN + org.hibernate.orm.jdbc.bind: WARN diff --git a/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt b/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt index a219fa4..6d73584 100644 --- a/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt +++ b/src/test/kotlin/team/cklob/mudda/global/config/SecurityConfigTest.kt @@ -28,6 +28,7 @@ class SecurityConfigTest(@Autowired private val mockMvc: MockMvc, @Autowired pri every { accessTokenBlacklist.isBlacklisted(any()) } returns false every { accessTokenBlacklist.isRevoked(any(), any()) } returns false mockMvc.perform(get("/api/v1/maps/ping")).andExpect(status().isOk) + mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk) mockMvc.perform(get("/api/v1/private/ping")).andExpect(status().isUnauthorized) mockMvc.perform(get("/api/v1/private/ping").header("Authorization", "Bearer ${jwtTokenProvider.createAccessToken(1)}")).andExpect(status().isOk) } @@ -40,5 +41,6 @@ class SecurityConfigTest(@Autowired private val mockMvc: MockMvc, @Autowired pri @RestController class SecurityTestController { @GetMapping("/api/v1/maps/ping") fun public() = "ok" + @GetMapping("/actuator/health/readiness") fun readiness() = "ok" @GetMapping("/api/v1/private/ping") fun private() = "ok" }