스택: FastAPI(WS) · Uvicorn · Docker · GitHub Actions · AWS EC2(Amazon Linux) · Nginx(리버스 프록시)
아키텍처: 단일 EC2(프리티어, t3.micro), Spring은 Blue-Green, FastAPI는 단일 배포
DB: MongoDB Atlas (컨테이너 미사용)
- 목표: /fastapi 경로로 FastAPI 추론 서버를 외부에 노출하고, develop 브랜치 푸시 → 자동 배포까지!
- 파이프라인:
- 로컬에서 코드/도커파일 작성
- GitHub Actions가 Docker 이미지 빌드 → Docker Hub 푸시
- Actions가 EC2에 SSH → docker compose up -d로 컨테이너 갱신
- Nginx가 /fastapi/*를 127.0.0.1:8000으로 리버스 프록시
- curl /fastapi/health, wscat으로 최종 확인
* FastAPI는 추론서버이기에 BLUE/GREEN으로 나눠놓은 BE와 달리 단일배포가 가능하다. *
FastApi 레포 : https://github.com/3-NoPainNoGain/FastAPI
1) 레포 구조
루트 폴더명이 FastAPI였다. (= 레포 최상단이자 루트)
FASTAPI/
├─ .github/
│ └─ workflows/
│ └─ fastapi-deploy.yml # GitHub Actions 워크플로
├─ ai_model/
│ ├─ predict.py
│ ├─ preprocessing.py
│ └─ models/… # (모델 가중치가 있다면 여기에)
├─ main.py # FastAPI WS 엔드포인트 포함
├─ requirements.txt # torch/vision 제외
├─ Dockerfile # torch/vision은 여기서 설치
└─ .dockerignore # 빌드 컨텍스트 정리
워크플로는 반드시 루트의 .github/workflows/ 에 있어야 동작한다.
2) FastAPI 핵심 코드
- /health 헬스 체크
- /ws WebSocket (웹캠 프레임 base64 → Mediapipe → keypoints → 모델 예측)
- 논외로 속도가 느려지는 듯 하여 error은 운영에서 전송을 줄이고 1번만 표시되게 했다.
# main.py (요지)
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
# ... (중략: mediapipe, numpy, cv2, model imports)
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
# 웹소켓으로 base64 프레임 수신 → keypoints 추출 → 모델 예측
# 결과: live/pred, sentence 생성 로직, 타임아웃/다수결 등
3) Python 의존성 & Dockerfile
* requirements.txt (opencv는 headless, torch/vision은 여기서 설치하지 않음)
fastapi==0.115.12
uvicorn[standard]==0.34.2
numpy==1.26.4
websockets==15.0.1
pydantic==2.11.4
mediapipe==0.10.21
opencv-python-headless==4.11.0.86
# torch/torchvision은 Dockerfile에서 CPU 전용으로 설치
Dockerfile (CPU 추론, torch/vision 짝 맞춰 설치)
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl git libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# CPU용 PyTorch/torchvision (버전쌍 중요)
ARG TORCH_VERSION=2.3.1
ARG TORCHVISION_VERSION=0.18.1
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu \
torch==${TORCH_VERSION} torchvision==${TORCHVISION_VERSION}
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()" || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
.dockerignore
venv
.venv
*/venv
*/.venv
__pycache__/
*.pyc
*.pyo
.env
*.env
.git
.gitignore
*.ipynb
*.log
datasets
tests
4) GitHub Actions (CI/CD)
트리거: develop 브랜치에 푸시되었고, 루트 파일/폴더가 바뀐 경우
# .github/workflows/fastapi-deploy.yml
name: FastAPI CI/CD
on:
push:
branches: ["develop"]
paths:
- "Dockerfile"
- "requirements.txt"
- "main.py"
- "ai_model/**"
- ".github/workflows/fastapi-deploy.yml"
workflow_dispatch:
env:
IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/handdoc-fastapi
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Tag (short sha)
id: tags
run: echo "sha_tag=${GITHUB_SHA::12}" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6
with:
context: . # 루트가 FastAPI이므로 .
push: true
tags: |
docker.io/${{ env.IMAGE_NAME }}:latest
docker.io/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_tag }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.EC2_SSH_HOST }}
username: ${{ secrets.EC2_SSH_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
set -e
export DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}"
export TAG="latest"
docker login -u "${DOCKER_USERNAME}" -p "${{ secrets.DOCKER_PASSWORD }}"
docker pull docker.io/${DOCKER_USERNAME}/handdoc-fastapi:${TAG}
docker network create handdoc-net || true
docker compose -f /home/ec2-user/apps/handdoc/docker-compose.fastapi.yml up -d
docker image prune -f
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
! Secrets 설정도 잊지 말아야 한다 !
(Actions → Settings → Secrets and variables → Actions)
- DOCKER_USERNAME
- DOCKER_PASSWORD
- EC2_SSH_HOST = 43.200.110.36
- EC2_SSH_USER = ec2-user
- EC2_SSH_KEY = PEM 전체 내용
5) 서버 쪽(한 번만 점검)
docker-compose.fastapi.yml (EC2)
/home/ec2-user/apps/handdoc/docker-compose.fastapi.yml
services:
fastapi:
container_name: handdoc-fastapi
image: docker.io/${DOCKER_USERNAME}/handdoc-fastapi:${TAG:-latest}
restart: unless-stopped
env_file:
- /home/ec2-user/apps/handdoc/.env
ports:
- "8000:8000"
networks:
- handdoc-net
networks:
handdoc-net:
name: handdoc-net
external: false
Nginx 리버스 프록시
/etc/nginx/conf.d/handdoc.conf
location /fastapi/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1; # WS 필수
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://127.0.0.1:8000/; # ← 끝의 슬래시 꼭 필요
}
적용:
sudo nginx -t && sudo systemctl reload nginx
6) 최종 확인
로컬에서:
curl http://43.200.110.36/fastapi/health
# {"status":"ok"}
npm i -g wscat
wscat -c ws://43.200.110.36/fastapi/ws
# "connected" 수신 + EC2에서 docker logs로 접속 로그 확인
EC2에서:
ssh -i handdoc-key.pem ec2-user@43.200.110.36
docker ps
docker logs --tail=200 handdoc-fastapi
sudo tail -f /var/log/nginx/{access.log,error.log}
7) 트러블슈팅 기록
7-1. Actions가 안 돌았던 이슈
증상: develop에 push했는데 워크플로가 실행되지 않음
추정되는 원인은 다음과 같았다.
원인1: paths 필터와 대소문자/경로 불일치(이게 문제였다!)
- 루트 폴더명이 FastAPI인데, paths: "fastapi/**"로 써서 필터링에 안 걸림
해결: - 컨텍스트/paths를 루트 기준으로 바꾸기 (context: ., paths는 루트 파일들 지정)
- 내 경우는 오타 문제였기에 경로("FastAPI/**")로 정확히 맞춘다.
원인2: 워크플로 파일이 아직 develop에 반영 안 됨
해결: .github/workflows/fastapi-deploy.yml 를 develop에 포함시키고 다시 push
원인3: 이번 커밋에 paths에 해당하는 파일이 변경되지 않음
해결: main.py에 주석 한 줄 추가 → 재푸시
급하면 Actions → Run workflow(workflow_dispatch)로 수동 실행도 가능하다고 한다.
7-2. 502/404 혹은 WS가 안 붙었던 이슈
증상: /fastapi/health는 502, WS 연결 안 됨
원인: Nginx 설정 누락/오타
- proxy_http_version 1.1 / Upgrade / Connection 헤더 빠짐
- proxy_pass 뒤 슬래시(/) 누락(경로 재작성 문제)
해결: 위의 Nginx 블록 그대로 반영 → sudo nginx -t && sudo systemctl reload nginx
7-3. 이미지가 너무 크고, 빌드 느림
원인: venv/, __pycache__/, .env, datasets가 빌드 컨텍스트에 포함
해결: .dockerignore 추가
7-4. WebSocket 지연/끊김
원인: 매 프레임마다 coordinates를 전송(패킷 과다)
해결: 디버깅 외에는 좌표를 N프레임마다 한 번만 전송
연결을 완료하고 FE와 연결되나 webSocekt 테스트까지 완료했다.
+unhealthy 상태 해결
로 어떤 컨테이너가 떠 있는지 확인해보았는데,
화면처럼 FastAPI 컨테이너에 unhealthy 상태가 떠 있었다! 이유를 찾아보니
- 웹소켓에 접속할 때 무거운 AI 모델(TensorFlow Lite, MediaPipe)을 메모리에 올리는 작업이 시작 - 이 작업은 CPU와 메모리를 많이 사용하며, 몇 초간 애플리케이션의 다른 부분들을 느리게 만듦
- 헬스 체크 시간 초과: 바로 이 모델 로딩 중에 Docker의 헬스 체크 신호가 도착 but FastAPI 서버는 모델을 로딩하느라 바빠서 3초 안에 응답을 보내주지 못함
- unhealthy 상태로 전환: 시간 초과가 계속 반복되면서(FailingStreak": 30), Docker는 결국 해당 컨테이너를 unhealthy 상태로 판정
이 원인이라 한다. 따라서
- docker-compose.fastapi.yml 파일 수정 : Health Check 타임아웃 시간 늘리기
- 권한 root → ec2user로 바꿈 (using sudo)
으로 해결 완~✨
초기설정이 궁금하신 분들은
[HandDoc] 한 대의 EC2로 Spring BE + FastAPI(AI) + Nginx 프록시 배포 (MongoDB Atlas)
HandDoc 졸업프로젝트에서 평소 건드려보고 싶었던 배포를 맡았다 !! 우선 FastAPI 레포, BE 배포를 진행했다. (가능하면 UMC 경험을 살려 FE 배포도 맡을 예정~!) 일단 최대한 서버 한 대로 버텨보자는
hyejuncoding.tistory.com
참고해주세요 :)
'프로젝트 > 캡스톤 졸프' 카테고리의 다른 글
[HandDoc] 한 대의 EC2로 Spring BE + FastAPI(AI) + Nginx 프록시 배포 (MongoDB Atlas) (3) | 2025.08.26 |
---|---|
[졸프스타트] "HandDoc" AI (2) | 2025.05.14 |
[졸프스타트] "HandDoc" 기획 (1) | 2025.05.14 |