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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ S3_ENDPOINT=

# ── AI 서비스 ───────────────────────────────────────────────
AI_SERVICE_BASE_URL=http://localhost:8000
AI_SERVICE_CONNECT_TIMEOUT=500ms
AI_OCR_READ_TIMEOUT=18s
AI_RULE_ENGINE_READ_TIMEOUT=2s
AI_RESULT_READ_TIMEOUT=7s
# 로컬 AI가 S3 IAM 조회를 사용하지 않을 때만 true.
AI_OCR_PRESIGNED_FALLBACK_ENABLED=true

# OCR/RuleEngine/Result 전체 작업을 수행하는 전용 비동기 풀.
SCAN_ASYNC_CORE_POOL_SIZE=2
SCAN_ASYNC_MAX_POOL_SIZE=2
SCAN_ASYNC_QUEUE_CAPACITY=0
SCAN_STALE_AFTER=2m
SCAN_RECOVERY_INTERVAL_MS=60000

# ── CORS ────────────────────────────────────────────────────
CORS_ALLOWED_ORIGINS="http://localhost:5173,https://han-spoon.site"
Expand Down
95 changes: 77 additions & 18 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ env:
ECR_REPOSITORY: hanspoon-prod-backend
ECS_CLUSTER: hanspoon-prod-cluster
ECS_SERVICE: hanspoon-prod-app
TASK_FAMILY: hanspoon-prod-app
DEPLOY_LOCK_TABLE: hanspoon-prod-deploy-lock
CONTAINER_NAME: backend
HEALTH_URL: https://api.han-spoon.site/actuator/health

Expand All @@ -50,8 +50,9 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Build jar
run: ./gradlew bootJar -x test
# main CI와 배포가 동시에 시작될 수 있으므로 배포 워크플로도 품질 게이트를 통과해야 한다.
- name: Verify and build jar
run: ./gradlew spotlessCheck test bootJar

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
Expand Down Expand Up @@ -80,30 +81,75 @@ jobs:
fi
echo "uri=$IMAGE" >> "$GITHUB_OUTPUT"

# backend/ai 저장소가 같은 ECS 태스크 정의를 동시에 덮어쓰지 않도록 AWS에서 직렬화.
- name: Acquire ECS deploy lock
id: deploy-lock
env:
LOCK_OWNER: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }}
run: |
for i in $(seq 1 60); do
NOW="$(date +%s)"
EXPIRES_AT="$((NOW + 900))"
ITEM="$(jq -nc \
--arg owner "$LOCK_OWNER" \
--argjson expires "$EXPIRES_AT" \
'{lock_name:{S:"prod-ecs"},owner:{S:$owner},expires_at:{N:($expires|tostring)}}')"
VALUES="$(jq -nc --argjson now "$NOW" '{":now":{N:($now|tostring)}}')"

if aws dynamodb put-item \
--table-name "$DEPLOY_LOCK_TABLE" \
--item "$ITEM" \
--condition-expression 'attribute_not_exists(#lock) OR #expires < :now' \
--expression-attribute-names '{"#lock":"lock_name","#expires":"expires_at"}' \
--expression-attribute-values "$VALUES"; then
echo "owner=$LOCK_OWNER" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Another ECS deployment is running... ($i/60)"
sleep 5
done
echo "Timed out waiting for ECS deploy lock"
exit 1

# Deploy to ECS
- name: Fetch current task definition
# 패밀리의 최신 리비전이 아니라, 현재 서비스에 지정된 정확한 리비전을 기준으로 배포.
- name: Resolve deployed task definition
id: current-task
run: |
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
TASK_DEFINITION_ARN="$(
aws ecs describe-services \
--cluster "$ECS_CLUSTER" \
--services "$ECS_SERVICE" \
--query 'services[0].taskDefinition' \
--output text
)"

if [ -z "$TASK_DEFINITION_ARN" ] || [ "$TASK_DEFINITION_ARN" = "None" ]; then
echo "Failed to resolve the task definition used by ECS service: $ECS_SERVICE"
exit 1
fi

echo "Using deployed task definition: $TASK_DEFINITION_ARN"
echo "arn=$TASK_DEFINITION_ARN" >> "$GITHUB_OUTPUT"

- name: Render task definition
id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
task-definition-arn: ${{ steps.current-task.outputs.arn }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.image.outputs.uri }}
environment-variables: |
AI_SERVICE_CONNECT_TIMEOUT=500ms
AI_OCR_READ_TIMEOUT=18s
AI_RULE_ENGINE_READ_TIMEOUT=2s
AI_RESULT_READ_TIMEOUT=7s
AI_OCR_PRESIGNED_FALLBACK_ENABLED=false
SCAN_ASYNC_CORE_POOL_SIZE=2
SCAN_ASYNC_MAX_POOL_SIZE=2
SCAN_ASYNC_QUEUE_CAPACITY=0
SCAN_STALE_AFTER=2m
SCAN_RECOVERY_INTERVAL_MS=60000

- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
Expand All @@ -126,3 +172,16 @@ jobs:
done
echo "health check failed"
exit 1

- name: Release ECS deploy lock
if: ${{ always() && steps.deploy-lock.outcome == 'success' }}
env:
LOCK_OWNER: ${{ steps.deploy-lock.outputs.owner }}
run: |
VALUES="$(jq -nc --arg owner "$LOCK_OWNER" '{":owner":{S:$owner}}')"
aws dynamodb delete-item \
--table-name "$DEPLOY_LOCK_TABLE" \
--key '{"lock_name":{"S":"prod-ecs"}}' \
--condition-expression '#owner = :owner' \
--expression-attribute-names '{"#owner":"owner"}' \
--expression-attribute-values "$VALUES" || true
54 changes: 54 additions & 0 deletions OCR_PRODUCTION_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# OCR 운영 배포·장애 대응 런북

## 최초 배포 순서

1. `infra/terraform`에서 `terraform plan`을 검토한 뒤 `terraform apply`한다.
- ECS task role의 `s3:GetObjectVersion` 권한
- GitHub Actions용 DynamoDB 배포 락과 IAM 권한
- S3 버전 및 수명주기 정책이 먼저 준비되어야 한다.
2. AI 저장소의 `Deploy (prod)`를 실행한다.
- `storage_key + version_id + expected_etag` 기반 S3 IAM 조회를 지원하는 AI가 먼저 올라가야 한다.
3. backend 저장소의 `Deploy (prod)`를 실행한다.
- 운영에서는 Presigned GET fallback을 끄므로 구버전 AI보다 먼저 배포하면 OCR 요청이 실패한다.
4. 실제 메뉴판 한 장으로 업로드 → 스캔 → 결과 폴링까지 smoke test한다.

최초 배포 이후에는 두 저장소 워크플로가 DynamoDB 락으로 ECS 태스크 정의 갱신을 직렬화한다. 각 워크플로는 패밀리의 최신 리비전이 아니라 ECS 서비스가 실제 사용 중인 리비전에서 시작하므로 반대쪽 컨테이너 이미지가 되돌아가지 않는다.

## 프론트 업로드 계약

`POST /api/v1/uploads/sas` 응답의 `uploadHeaders`를 임의로 재구성하지 말고 그대로 Presigned PUT 요청에 포함한다.

```javascript
await fetch(ticket.uploadUrl, {
method: "PUT",
headers: ticket.uploadHeaders,
body: imageFile,
});
```

현재 필수 헤더는 다음과 같다.

- `Content-Type`: 티켓 발급 요청에서 검증한 이미지 MIME 타입
- `If-None-Match: *`: 같은 URL을 재사용해 객체를 덮어쓰지 못하게 하는 조건

업로드 성공 후 `storageKey`로 `POST /api/v1/scans`를 호출한다. 같은 사용자와 `storageKey`의 중복 요청은 같은 `scanId`를 반환한다.

## 상태 및 재시도

- `processing`: 클라이언트가 짧은 간격으로 폴링한다.
- `completed`: 메뉴 결과를 표시한다.
- `needs_retake`: `retakeReasons`를 이용해 재촬영을 안내한다.
- `failed`: `failureCode`로 안내 문구와 재시도 가능 여부를 결정한다.
- 스캔 시작이 HTTP 503 `SCAN_CAPACITY_EXCEEDED`이면 `Retry-After` 이후 같은 `storageKey`로 다시 요청할 수 있다. 거절된 세션은 서버가 제거한다.
- 처리 중 실패한 세션을 자동으로 다시 실행하지 않는다. 외부 OCR의 성공 여부가 불명확한 타임아웃에서 자동 재시도하면 중복 과금될 수 있기 때문이다. 사용자가 명시적으로 재촬영·재업로드하도록 안내한다.

## 확인할 로그

- `OCR completed`: 백엔드/AI 처리 시간, OCR 호출 횟수, 전처리 선택, 이미지 조회 경로, AI 큐 대기 시간
- `AI stage completed`: rule engine/result 단계별 지연
- `Scan failed`: `failureCode`와 전체 처리 시간
- `Recovered ... stale scan sessions`: 서버 재시작 등으로 유실된 인메모리 작업 회수

## 롤백

ECS 서비스의 이전 정상 **태스크 정의 리비전 전체**로 롤백한다. backend와 AI는 같은 태스크 정의에 있으므로 컨테이너 하나의 태그만 수동 교체하면 API 계약이 어긋날 수 있다. DB 마이그레이션 V2는 새 컬럼과 인덱스를 추가하는 방식이라 구버전 애플리케이션과 호환된다.
255 changes: 255 additions & 0 deletions infra/images/architecture.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 47 additions & 2 deletions infra/terraform/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,26 @@ resource "aws_iam_role" "task" {
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}

# backend/ai 두 저장소가 하나의 ECS 태스크 정의를 수정하므로 교차 저장소 배포를 직렬화한다.
resource "aws_dynamodb_table" "deploy_lock" {
name = "${local.name_prefix}-deploy-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "lock_name"

attribute {
name = "lock_name"
type = "S"
}

ttl {
attribute_name = "expires_at"
enabled = true
}
}

data "aws_iam_policy_document" "task_s3" {
statement {
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
actions = ["s3:GetObject", "s3:GetObjectVersion", "s3:PutObject", "s3:DeleteObject"]
resources = ["${aws_s3_bucket.images.arn}/*"]
}
statement {
Expand Down Expand Up @@ -144,6 +161,11 @@ data "aws_iam_policy_document" "github_deploy" {
resources = ["*"]
}

statement {
actions = ["dynamodb:PutItem", "dynamodb:DeleteItem"]
resources = [aws_dynamodb_table.deploy_lock.arn]
}

# 나머지 ECR 액션은 우리 저장소로 한정
statement {
actions = [
Expand Down Expand Up @@ -350,6 +372,19 @@ resource "aws_ecs_task_definition" "app" {

environment = [
{ name = "PORT", value = "8000" },

# AI 컨테이너가 Presigned URL 없이 S3에서 직접 이미지를 읽음.
{ name = "OCR_S3_FETCH_ENABLED", value = "true" },
{ name = "OCR_S3_BUCKET", value = aws_s3_bucket.images.bucket },
{ name = "AWS_REGION", value = var.region },

# 운영 SLA 명시.
{ name = "OCR_REQUEST_BUDGET_SECONDS", value = "16" },
{ name = "OCR_TOTAL_BUDGET_SECONDS", value = "14" },
{ name = "OCR_MAX_CONCURRENT_SCANS", value = "2" },
{ name = "OCR_QUEUE_WAIT_SECONDS", value = "1" },
{ name = "OCR_ENABLE_GPT_POST_PROCESS", value = "false" },
{ name = "OCR_ENABLE_GPT_JUDGMENT", value = "false" },
]

secrets = [
Expand Down Expand Up @@ -387,6 +422,16 @@ resource "aws_ecs_task_definition" "app" {
{ name = "SPRING_PROFILES_ACTIVE", value = "prod" },
{ name = "SERVER_PORT", value = "8080" },
{ name = "AI_SERVICE_BASE_URL", value = "http://localhost:8000" },
{ name = "AI_SERVICE_CONNECT_TIMEOUT", value = "500ms" },
{ name = "AI_OCR_READ_TIMEOUT", value = "18s" },
{ name = "AI_RULE_ENGINE_READ_TIMEOUT", value = "2s" },
{ name = "AI_RESULT_READ_TIMEOUT", value = "7s" },
{ name = "AI_OCR_PRESIGNED_FALLBACK_ENABLED", value = "false" },
{ name = "SCAN_ASYNC_CORE_POOL_SIZE", value = "2" },
{ name = "SCAN_ASYNC_MAX_POOL_SIZE", value = "2" },
{ name = "SCAN_ASYNC_QUEUE_CAPACITY", value = "0" },
{ name = "SCAN_STALE_AFTER", value = "2m" },
{ name = "SCAN_RECOVERY_INTERVAL_MS", value = "60000" },
{ name = "S3_BUCKET", value = aws_s3_bucket.images.bucket },
{ name = "AWS_REGION", value = var.region },
{ name = "JAVA_TOOL_OPTIONS", value = "-XX:MaxRAMPercentage=60" },
Expand Down Expand Up @@ -468,4 +513,4 @@ resource "aws_budgets_budget" "monthly" {
notification_type = "FORECASTED"
subscriber_email_addresses = [var.alert_email]
}
}
}
4 changes: 3 additions & 1 deletion infra/terraform/storage.tf
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ resource "aws_s3_bucket_lifecycle_configuration" "images" {
status = "Enabled"
filter { prefix = "scans/" }
expiration { days = 90 }
# Versioning 버킷에서 expiration은 삭제 마커만 만들므로 실제 원본 버전도 정리한다.
noncurrent_version_expiration { noncurrent_days = 1 }
}

# 대표 메뉴 이미지는 영구 보관, 옛 버전은 정리
Expand Down Expand Up @@ -134,4 +136,4 @@ resource "aws_ssm_parameter" "db_password_seed" {
name = "/${local.name_prefix}/db_password_seed"
type = "SecureString"
value = random_password.db.result
}
}
Loading
Loading