From d3a7457d348382ea7f3b79018204412f805f1500 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 16:53:30 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7chore:=20AWS=20ECS=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EB=A1=9C=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 + .env.example | 118 ++++++---------------------- .github/workflows/deploy-prod.yml | 123 +++++++++++++++++++++--------- .gitignore | 2 + docker/Dockerfile | 29 +++---- infra/terraform/compute.tf | 28 +++++-- infra/terraform/variables.tf | 7 +- 7 files changed, 151 insertions(+), 158 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0c9ea89..f7263ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ .gradle build **/build +!build/libs +!build/libs/*.jar .idea *.iml logs diff --git a/.env.example b/.env.example index ddaefee..37e8206 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 8cc0231..46ab8de 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index dbab077..1b394b5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ out/ ### .env ### .env +.env.* +!.env.example ### docs ### docs/ diff --git a/docker/Dockerfile b/docker/Dockerfile index 3becbdd..939be2f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf index f140da7..f3ca7d7 100644 --- a/infra/terraform/compute.tf +++ b/infra/terraform/compute.tf @@ -123,10 +123,11 @@ data "aws_iam_policy_document" "github_assume" { values = ["sts.amazonaws.com"] } + # 저장소/브랜치를 반드시 좁힐 것. 빠뜨리면 아무 저장소나 이 역할을 가정할 수 있다. condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" - values = ["repo:${var.github_repository}:ref:refs/heads/main"] + values = [for repo in var.github_repositories : "repo:${repo}:ref:refs/heads/main"] } } } @@ -137,20 +138,34 @@ resource "aws_iam_role" "github_actions" { } data "aws_iam_policy_document" "github_deploy" { + # 계정 단위 액션이라 리소스를 지정할 수 없다 + statement { + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + # 나머지 ECR 액션은 우리 저장소로 한정 statement { actions = [ - "ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload", "ecr:PutImage", + "ecr:BatchGetImage", + "ecr:DescribeImages", ] - resources = ["*"] + resources = [for repo in aws_ecr_repository.app : repo.arn] } + # RegisterTaskDefinition 은 리소스 수준 권한을 지원하지 않는다 statement { - actions = ["ecs:UpdateService", "ecs:DescribeServices", "ecs:RegisterTaskDefinition"] + actions = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition", + "ecs:UpdateService", + "ecs:DescribeServices", + ] resources = ["*"] } @@ -325,7 +340,10 @@ resource "aws_ecs_task_definition" "app" { container_definitions = jsonencode([ { - name = "ai" + name = "ai" + # :latest 는 부트스트랩용 플레이스홀더. ECR 이 IMMUTABLE 이라 재푸시가 불가하므로 + # 실제 배포는 GitHub Actions 가 커밋 SHA 태그로 새 리비전을 등록한다. + # aws_ecs_service 의 ignore_changes = [task_definition] 이 이를 보장한다. image = "${aws_ecr_repository.app["ai"].repository_url}:latest" essential = true memory = local.ai_memory diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 27d76ae..06b8cc0 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -41,9 +41,10 @@ variable "db_instance_class" { default = "db.t4g.micro" } -variable "github_repository" { - type = string - default = "Han-Spoon/backend-api" +variable "github_repositories" { + description = "OIDC 로 배포를 허용할 저장소 목록. 백엔드와 AI 서비스가 같은 태스크 정의를 갱신하므로 역할을 공유한다." + type = list(string) + default = ["Han-Spoon/backend-api", "Han-Spoon/ai"] } variable "alert_email" {