diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 38904f9..9603afc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -97,7 +97,10 @@ jobs: test -n "$PROD_ENV" mkdir -p deploy-bundle cp docker-compose.prod.yml deploy-bundle/docker-compose.yml + cp scripts/check-hair-analysis-model.sh deploy-bundle/check-hair-analysis-model.sh printf '%s\n' "$PROD_ENV" > deploy-bundle/.env + # Non-secret analysis settings are versioned; existing secrets stay in GitHub. + cat config/hair-analysis.prod.env >> deploy-bundle/.env - name: Upload compose and environment env: @@ -108,6 +111,7 @@ jobs: run: | ssh -i ~/.ssh/deploy_key -p "$EC2_PORT" "$EC2_USER@$EC2_HOST" "mkdir -p '$DEPLOY_PATH'" scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/docker-compose.yml "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/docker-compose.yml" + scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/check-hair-analysis-model.sh "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/check-hair-analysis-model.sh" scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/.env "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/.env" - name: Pull and restart containers @@ -123,6 +127,8 @@ jobs: cd "$DEPLOY_PATH" echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin APP_IMAGE_TAG="${{ github.sha }}" docker compose pull app + # 기존 앱을 내리기 전에 후보 컨테이너의 사용자/마운트로 모델 접근을 검사한다. + APP_IMAGE_TAG="${{ github.sha }}" docker compose run --rm -T --no-deps --entrypoint sh app -s < check-hair-analysis-model.sh APP_IMAGE_TAG="${{ github.sha }}" docker compose up -d --remove-orphans docker image prune -f EOF diff --git a/config/hair-analysis.prod.env b/config/hair-analysis.prod.env new file mode 100644 index 0000000..6ae959d --- /dev/null +++ b/config/hair-analysis.prod.env @@ -0,0 +1,9 @@ +# Non-secret production overrides. Applied after PROD_ENV by Deploy. +# Model must be provisioned on the host before enabling analysis. +HAIR_ANALYSIS_ENABLED=true +HAIR_ANALYSIS_MODEL_HOST_DIR=/home/ubuntu/heddy-models +HAIR_ANALYSIS_MODEL_PATH=/opt/heddy/models/face-parsing.onnx +HAIR_ANALYSIS_MODEL_VERSION=segformer-b5-int8-758b82e-320-v1 +HAIR_ANALYSIS_INPUT_SIZE=320 +HAIR_ANALYSIS_WORKER_THREADS=1 +JAVA_TOOL_OPTIONS=-Xmx256m -Xms64m diff --git a/docs/deploy-setup.md b/docs/deploy-setup.md index 2d5fbc0..513959f 100644 --- a/docs/deploy-setup.md +++ b/docs/deploy-setup.md @@ -67,9 +67,10 @@ HAIR_ANALYSIS_MODEL_HOST_DIR=/home/ubuntu/heddy-models HAIR_ANALYSIS_MODEL_PATH=/opt/heddy/models/face-parsing.onnx HAIR_ANALYSIS_MODEL_VERSION=<학습 데이터와 가중치를 식별하는 버전> HAIR_ANALYSIS_WORKER_THREADS=1 +HAIR_ANALYSIS_INPUT_SIZE=320 ``` -- ONNX 입력은 RGB `float32 [1,3,512,512]`, 출력은 19개 이상의 클래스 logits +- ONNX 입력은 RGB `float32 [1,3,S,S]`(기본 S=512, 소형 운영 호스트 S=320), 출력은 19개 이상의 클래스 logits `[1,C,H,W]`여야 한다. 사용하는 클래스 번호는 skin=1, nose=2, left-eye=4, right-eye=5, hair=13이다. - 모델 파일이 없거나 열리지 않으면 앱을 분석 활성 상태로 기동하지 않는다. 기능을 끈 환경의 @@ -77,6 +78,23 @@ HAIR_ANALYSIS_WORKER_THREADS=1 - 참고용 공개 `jonathandinu/face-parsing`/CelebAMask-HQ 가중치는 비상업 용도 제한이 있으므로 운영 서비스에 그대로 배포하지 않는다. 실제 운영에는 사용 권리를 확보한 가중치를 같은 ONNX 계약으로 내보내 배치한다. +- 배포 워크플로는 기존 앱을 재시작하기 전에 후보 이미지의 실제 사용자와 모델 마운트로 + `scripts/check-hair-analysis-model.sh`를 실행한다. 분석이 켜졌는데 파일이 없거나 비어 있거나 + 읽을 수 없으면 기존 컨테이너를 유지하고 배포를 실패시킨다. 비활성화 상태는 503 경고를 남긴다. + 이 검사는 파일 접근만 검증하므로 ONNX 호환성과 실제 사진 추론은 별도로 확인해야 한다. +- 작은 호스트에서 분석 실행은 엔진 단위로 직렬화하고 ONNX 내부 스레드는 1개로 제한한다. + CPU arena와 memory pattern 캐시를 끄므로 추론 버퍼를 계속 보유하지 않는다. + 기본 512px와 기존 지표 계산식은 유지한다. 2GB 운영 서버는 실제 추론 검증을 거친 + `HAIR_ANALYSIS_INPUT_SIZE=320`을 사용한다. 작은 입력에서는 눈 등 세부 영역을 찾지 못해 + 재촬영 안내가 늘 수 있으므로 입력 크기를 모델 버전에도 포함한다. +- 작은 운영 호스트의 JVM 힙은 `JAVA_TOOL_OPTIONS=-Xmx256m -Xms64m`로 제한한다. + 모델 추론은 네이티브 메모리도 쓰므로 JVM 힙 외 여유가 필요하다. 호스트 swap 설정은 변경하지 않는다. +- 공개 검증 모델의 정확한 버전·체크섬·사용 제한은 `docs/hair-analysis-model-NOTICE.txt`에 기록한다. + 모델을 배치하는 경우 해당 NOTICE도 모델 파일과 함께 보관한다. +- 현재 운영 분석 설정은 `config/hair-analysis.prod.env`에서 관리하며 배포 시 `PROD_ENV` 뒤에 + 적용한다(동일 키는 이 파일의 값이 우선). 기존 시크릿은 변경하거나 다시 읽어 올 필요가 없다. + 분석을 끄거나 입력 크기를 바꿀 때는 이 파일을 수정하고 Actions로 배포한다. + 서버 `.env`만 수정하면 다음 배포에서 다시 덮어써진다. - 인스턴스 역할에는 기존 업로드 권한과 함께 시술 사진 객체에 대한 `s3:GetObject`가 필요하다. 서버가 분석 시 S3 원본을 직접 읽으며 Presigned URL을 외부 AI 서비스에 전달하지 않는다. diff --git a/docs/hair-analysis-model-NOTICE.txt b/docs/hair-analysis-model-NOTICE.txt new file mode 100644 index 0000000..72c2e04 --- /dev/null +++ b/docs/hair-analysis-model-NOTICE.txt @@ -0,0 +1,9 @@ +Face parsing model: jonathandinu/face-parsing (SegFormer B5, CelebAMask-HQ). +Upstream: https://huggingface.co/jonathandinu/face-parsing +Revision: 758b82e15a0178c9db39c1ff666a8b56e3a550c8 +File: onnx/model_quantized.onnx (unmodified) +SHA256: 5bab9bfb3cb979f3098ac3b934b1641dbf87f835e0b03c2ca6d88dcf18c83d27 +Model-card license: non-commercial research and educational purposes. +The weights are not stored in Git or bundled into the application image. +Heddy computes visual metrics from the model's segmentation output; the model +is not a trained classifier of treatment quality or physical hair damage. diff --git a/scripts/check-hair-analysis-model.sh b/scripts/check-hair-analysis-model.sh new file mode 100644 index 0000000..1501711 --- /dev/null +++ b/scripts/check-hair-analysis-model.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Run inside the candidate app container, with its real user, env and model mount. +set -eu + +case "${HAIR_ANALYSIS_ENABLED:-false}" in + true|TRUE|True) ;; + false|FALSE|False|'') + echo 'Hair analysis is disabled; analysis requests will return 503.' + exit 0 + ;; + *) + echo 'Invalid HAIR_ANALYSIS_ENABLED; expected true or false.' >&2 + exit 1 + ;; +esac + +model_path=${HAIR_ANALYSIS_MODEL_PATH:-} +if [ -z "$model_path" ] || [ ! -f "$model_path" ] || [ ! -r "$model_path" ] || [ ! -s "$model_path" ]; then + echo 'Hair analysis is enabled, but its model is missing, unreadable or empty. Deployment stopped before restarting app.' >&2 + exit 1 +fi + +echo 'Hair analysis model file is readable. ONNX compatibility must be validated before activation.' diff --git a/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java b/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java index 67a3d0d..fba936b 100644 --- a/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java +++ b/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java @@ -60,9 +60,15 @@ public OnnxFaceParsingHairAnalysisAdapter( } try { environment = OrtEnvironment.getEnvironment(); - OrtSession.SessionOptions options = new OrtSession.SessionOptions(); - options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); - session = environment.createSession(path.toString(), options); + try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) { + options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); + // Small production hosts also run the API, DB and recommendation model. + options.setIntraOpNumThreads(1); + options.setInterOpNumThreads(1); + options.setCPUArenaAllocator(false); + options.setMemoryPatternOptimization(false); + session = environment.createSession(path.toString(), options); + } if (session.getInputNames().size() != 1) { throw new IllegalStateException("얼굴 파싱 모델 입력은 하나여야 합니다"); } @@ -81,7 +87,7 @@ public boolean isReady() { } @Override - public HairAnalysisOutcome analyze(byte[] imageBytes) { + public synchronized HairAnalysisOutcome analyze(byte[] imageBytes) { final BufferedImage image; try { image = decode(imageBytes);