Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
.gradle
build
**/build
!build/libs
!build/libs/*.jar
.idea
*.iml
logs
Expand Down
118 changes: 22 additions & 96 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,107 +1,33 @@
# Han-Spoon Backend — 환경변수 예시
# 사용법: 이 파일을 복사해 .env 로 만들고 값을 채우세요. (cp .env.example .env)
# .env 는 .gitignore 에 등록되어 커밋되지 않습니다. 민감정보(secret/password/key)는 절대 커밋하지 마세요.
# 아래 키들은 src/main/resources/application*.yml 의 ${VAR:default} 와 1:1 대응합니다. (default 가 있으면 생략 가능)
#
# [Key Vault 미사용 대비] prod 는 원래 일부 시크릿을 Azure Key Vault(kv-secret-*)에서 주입하지만,
# KV 가 없거나 못 쓸 때를 대비해 동일 시크릿을 환경변수로 폴백받도록 구성돼 있습니다.
# Han-Spoon Backend — 로컬 개발용 환경변수
# set -a && source .env && set +a && ./gradlew bootRun

# ============================================================
# Google OAuth (소셜 로그인)
# ============================================================
GOOGLE_CLIENT_ID=43362237352-l6jq3pff1bt43dnak41qaoi3o14useet.apps.googleusercontent.com
GOOGLE_JWK_SET_URI=https://www.googleapis.com/oauth2/v3/certs
# ── 프로파일 ────────────────────────────────────────────────
# ⚠ prod 를 넣지 말 것. 로컬에서 RDS·SSM 을 찾다가 기동에 실패한다.
SPRING_PROFILES_ACTIVE=local
SERVER_PORT=8080

# ============================================================
# JWT (자체 토큰)
# ============================================================
# JWT_SECRET: HS256 서명 키. 최소 32바이트 이상의 랜덤 문자열을 직접 생성해 채우세요. (예: openssl rand -base64 48)
# prod 에서도 KV(kv-secret-jwt-signing-key) 부재 시 이 값으로 폴백됩니다.
JWT_SECRET=
JWT_ACCESS_TOKEN_EXPIRATION=30m
JWT_REFRESH_TOKEN_EXPIRATION=14d

# ============================================================
# Local Database (PostgreSQL)
# ============================================================
# ── 로컬 DB ─────────────────────────────────────────────────
# docker compose -f docker/docker-compose.yml up -d postgres
LOCAL_DB_URL=jdbc:postgresql://localhost:5432/hanspoon
LOCAL_DB_USERNAME=hanspoon
LOCAL_DB_PASSWORD=hanspoon
POSTGRES_MAJOR_VERSION=16
POSTGRES_MINOR_VERSION=0

# ============================================================
# CORS (프론트 출처 허용)
# ============================================================
# 프론트 dev 서버 출처와 일치시킬 것. 여러 개면 콤마로 구분.
CORS_ALLOWED_ORIGINS=http://localhost:5173

# ============================================================
# Azure AI (OpenAI / Document Intelligence)
# ============================================================
AZURE_AI_ENDPOINT=https://team1-gpt-4o-mini.openai.azure.com/
# AZURE_AI_API_KEY: Azure AI 키 (민감정보 → 비워둠).
AZURE_AI_API_KEY=
# ── 인증 ────────────────────────────────────────────────────
GOOGLE_CLIENT_ID=
# HS256 서명 키. 32바이트 이상 랜덤 문자열: openssl rand -base64 48
JWT_SECRET=

# ============================================================
# Azure Blob Storage (이미지 업로드 SAS 발급)
# ============================================================
BLOB_ACCOUNT_NAME=sthanspoonprod
BLOB_CONTAINER=board-images
# BLOB_ACCOUNT_KEY: 스토리지 계정 키 (민감정보 → 비워둠). SAS 서명에 사용.
# 비우면 로컬에선 placeholder 로 기동만 되고 실제 SAS 발급은 실패합니다. (KV 대신 이 env 로 주입)
BLOB_ACCOUNT_KEY=
# (선택) SAS 만료/허용 타입 — 기본값 있어 생략 가능
BLOB_UPLOAD_SAS_TTL=10m
BLOB_READ_SAS_TTL=15m
BLOB_ALLOWED_CONTENT_TYPES=image/jpeg,image/png,image/webp
# ── AWS S3 ──────────────────────────────────────────────────
S3_BUCKET=hanspoon-prod-images-917dedbe
AWS_REGION=ap-northeast-2
# MinIO 등 S3 호환 스토리지를 쓸 때만 채움.
S3_ENDPOINT=

# ============================================================
# AI Service (OCR / Rule Engine — FastAPI 래퍼)
# ============================================================
# ── AI 서비스 ───────────────────────────────────────────────
AI_SERVICE_BASE_URL=http://localhost:8000
AI_SERVICE_CONNECT_TIMEOUT=3s
AI_SERVICE_READ_TIMEOUT=30s

# ============================================================
# 선택 — 기본값이 있어 생략 가능 (튜닝 시에만 설정)
# ============================================================
# Async ThreadPool
ASYNC_CORE_POOL_SIZE=4
ASYNC_MAX_POOL_SIZE=16
ASYNC_QUEUE_CAPACITY=100
# Caffeine Cache
CACHE_MAXIMUM_SIZE=10000
CACHE_EXPIRE_AFTER_WRITE=10m
# RestClient timeouts (Azure OpenAI 직통)
REST_CLIENT_CONNECT_TIMEOUT=3s
REST_CLIENT_READ_TIMEOUT=10s
# Bucket4j (OpenAI 호출 quota)
OPENAI_RATE_LIMIT_CAPACITY=30
OPENAI_RATE_LIMIT_REFILL_PERIOD=1m
# 로그 파일 경로(local)
LOG_FILE=logs/han-spoon-api-local.log
# ── CORS ────────────────────────────────────────────────────
CORS_ALLOWED_ORIGINS="http://localhost:5173,https://han-spoon.site"

# ============================================================
# Production(Azure) — Key Vault 미사용 대비
# ------------------------------------------------------------
# prod 프로파일(SPRING_PROFILES_ACTIVE=prod)을 KV 없이 구동할 때 채울 값들.
# KV import 는 optional 이라 부재 시 아래 환경변수로 폴백됩니다(우선순위: KV > env).
# 로컬(local 프로파일) 구동에는 불필요합니다.
# ============================================================
# --- KV 시크릿 대체 (민감정보, KV 못 쓸 때 필수) ---
# DB_PASSWORD= # ← kv-secret-db-password 대체
# JWT_SECRET= # ← kv-secret-jwt-signing-key 대체 (위 JWT 섹션과 동일 키)
# AZURE_AI_API_KEY= # ← kv-secret-azure-openai-api-key 대체 (위 Azure AI 섹션과 동일 키)
# BLOB_ACCOUNT_KEY= # ← Blob 계정 키 (위 Azure Blob 섹션과 동일 키)
# --- prod 인프라/접속 정보 ---
# AZURE_KEYVAULT_ENDPOINT=https://kv-hanspoon-prod.vault.azure.net/ # KV 안 쓰면 무시됨(optional import)
# DB_PRIVATE_ENDPOINT_URL=jdbc:postgresql://psql-hanspoon-prod.privatelink.postgres.database.azure.com:5432/hanspoon?sslmode=require
# DB_USERNAME=hanspoon_app
# DB_MAX_POOL_SIZE=10
# DB_MIN_IDLE=2
# AZURE_AI_PRIVATE_ENDPOINT=https://aoai-hanspoon-prod.openai.azure.com
# JWT_ISSUER=https://api.han-spoon.com
# SERVER_PORT=8080
# SWAGGER_UI_ENABLED=false
# OPENAPI_DOCS_ENABLED=false
# ── 로그 ────────────────────────────────────────────────────
LOG_FILE=logs/han-spoon-api-local.log
123 changes: 87 additions & 36 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
name: Deploy (prod)

# main 머지(push) → ACR 클라우드 빌드(새 태그) → ACA 새 이미지로 배포.
# 인증은 GitHub OIDC 페더레이션(장기 비밀번호 없음). 수동 실행도 허용.
# main 머지(push) → ECR 푸시(커밋 SHA 태그) → 태스크 정의 새 리비전 등록 → ECS 배포.

# Checkout → JDK 21 → Gradle → bootJar
# → AWS OIDC → ECR 로그인 → 이미지 빌드·푸시
# → 현재 태스크 정의 조회 → 이미지 교체 → 새 리비전 등록·배포
# → 헬스체크
on:
push:
branches: [main]
paths-ignore:
- "infra/**"
- "docs/**"
- "**.md"
workflow_dispatch:

# 동시 배포 방지: 같은 ref의 이전 배포가 끝날 때까지 새 배포는 대기(취소하지 않음).
# 동시 배포 방지.
concurrency:
group: deploy-prod
cancel-in-progress: false
Expand All @@ -17,55 +25,98 @@ permissions:
contents: read

env:
RG: smu-team1
ACR: acrhanspoonprod
APP: ca-hanspoon-api
IMAGE_REPO: hanspoon-api
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: hanspoon-prod-backend
ECS_CLUSTER: hanspoon-prod-cluster
ECS_SERVICE: hanspoon-prod-app
TASK_FAMILY: hanspoon-prod-app
CONTAINER_NAME: backend
HEALTH_URL: https://api.han-spoon.site/actuator/health

jobs:
deploy:
runs-on: ubuntu-latest
# ARM64 native runner.
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Azure login (OIDC)
uses: azure/login@v2
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Build jar
run: ./gradlew bootJar -x test

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}

# 커밋 SHA를 태그로 → 매 배포가 고유·불변 이미지가 되어 ACA가 반드시 새 리비전 생성.
- name: Set image tag
id: tag
run: echo "tag=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
- name: Login to ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2

- name: ACR build (cloud)
# Build and push Docker image to ECR
- name: Build and push image
id: image
env:
REGISTRY: ${{ steps.ecr.outputs.registry }}
run: |
az acr build -r "$ACR" \
-t "$IMAGE_REPO:${{ steps.tag.outputs.tag }}" \
-t "$IMAGE_REPO:latest" \
-f docker/Dockerfile .
IMAGE="$REGISTRY/$ECR_REPOSITORY:${GITHUB_SHA::12}"
docker build -f docker/Dockerfile -t "$IMAGE" .
docker push "$IMAGE"
echo "uri=$IMAGE" >> "$GITHUB_OUTPUT"

- name: Deploy to Container Apps
# Deploy to ECS
- name: Fetch current task definition
run: |
az containerapp update -g "$RG" -n "$APP" \
--image "$ACR.azurecr.io/$IMAGE_REPO:${{ steps.tag.outputs.tag }}" \
-o none
aws ecs describe-task-definition \
--task-definition "$TASK_FAMILY" \
--query 'taskDefinition | {
family: family,
taskRoleArn: taskRoleArn,
executionRoleArn: executionRoleArn,
networkMode: networkMode,
containerDefinitions: containerDefinitions,
requiresCompatibilities: requiresCompatibilities,
cpu: cpu,
memory: memory,
runtimePlatform: runtimePlatform
}' > task-definition.json

- name: Render task definition
id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.image.outputs.uri }}

- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true

# 인스턴스 1대 + network_mode host: 교체형 배포. 30~60초 다운타임 후 복귀.
- name: Health check
run: |
FQDN=$(az containerapp show -g "$RG" -n "$APP" \
--query properties.configuration.ingress.fqdn -o tsv)
echo "App URL: https://$FQDN"
# 새 리비전이 트래픽을 받을 때까지 최대 ~2.5분 폴링
for i in $(seq 1 15); do
code=$(curl -s -o /dev/null -w '%{http_code}' "https://$FQDN/actuator/health" || true)
echo " attempt $i: HTTP $code"
if [ "$code" = "200" ]; then
echo "✅ Health OK"; exit 0
for i in $(seq 1 30); do
if curl -fsS --max-time 10 "$HEALTH_URL" > /dev/null; then
echo "healthy"
exit 0
fi
echo "waiting... ($i/30)"
sleep 10
done
echo "::warning::Health check가 200을 반환하지 않았습니다. ACA 로그를 확인하세요."
echo "health check failed"
exit 1
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ out/

### .env ###
.env
.env.*
!.env.example

### docs ###
docs/
Expand Down
29 changes: 11 additions & 18 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
# syntax=docker/dockerfile:1
# 멀티스테이지: 소스로부터 bootJar 빌드 → JRE 런타임 이미지
# 빌드 컨텍스트는 레포 루트. (az acr build / docker build -f docker/Dockerfile .)

# 1) Build stage
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /workspace
# native로 jar 빌드 → arm64 런타임 이미지에 COPY

# 의존성 캐시 레이어 (빌드 스크립트/래퍼 먼저 복사)
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle gradle
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies >/dev/null 2>&1 || true
FROM eclipse-temurin:21-jre

# 소스 복사 후 실행 가능한 jar 생성 (테스트/스폿리스는 CI에서 수행 → 이미지 빌드는 스킵)
COPY src src
RUN ./gradlew --no-daemon clean bootJar -x test -x spotlessCheck
# install curl (ECS healthCheck)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

# 2) Runtime stage
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
# bootJar 산출물 (plain jar는 build.gradle.kts에서 비활성)
COPY --from=builder /workspace/build/libs/*-SNAPSHOT.jar app.jar
COPY build/libs/*-SNAPSHOT.jar app.jar

EXPOSE 8080
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

# Run the application
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Loading
Loading