From 8817b2521bb252e14ff409c09294a46f29d7db36 Mon Sep 17 00:00:00 2001 From: sunm2n Date: Sun, 31 May 2026 21:38:43 +0900 Subject: [PATCH] =?UTF-8?q?[Build]=20CI/CD=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=B0=8F=20Docker=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile 작성: app 소스 선복사로 hatchling 빌드 정상화, non-root(appuser) 실행, exec 기반 graceful shutdown, HEALTHCHECK 추가 - entrypoint.sh: 기동 시 alembic 마이그레이션 자동 적용 후 uvicorn 실행 - .dockerignore로 secret/테스트/로컬 DB 등 빌드 컨텍스트 제외 - CI 워크플로우: ruff lint/format, mypy, pytest 실행 - CD 워크플로우: Docker Hub 빌드·푸시 후 SSM으로 EC2 docker compose 배포 IssueNum #42 --- .dockerignore | 35 ++++++++++++++++++ .github/workflows/cd.yml | 80 ++++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 28 ++++++++++++++ Dockerfile | 37 +++++++++++++++++++ entrypoint.sh | 4 ++ 5 files changed, 184 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100755 entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1672fcc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +.git +.github +.gitignore +.idea +.vscode +__pycache__/ +*.py[cod] +.venv/ +venv/ +env/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +coverage.xml +tests/ +test-sites/ +docs/ +*.md +.env +.env.* +data/ +*.db +*.db-journal +*.db-wal +*.db-shm +*.log +logs/ +node_modules/ +.DS_Store +Dockerfile +.dockerignore +.pre-commit-config.yaml +Makefile diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..6a6d164 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,80 @@ +name: CD + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + workflow_dispatch: # 수동 실행 (테스트/재배포/롤백) + +permissions: + id-token: write # OIDC + contents: read + +jobs: + build-and-push: + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.vars.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + - id: vars + run: echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ vars.IMAGE_NAME }}:latest + ${{ vars.IMAGE_NAME }}:${{ steps.vars.outputs.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Deploy via SSM SendCommand + run: | + cmd_id=$(aws ssm send-command \ + --instance-ids "${{ secrets.APP_INSTANCE_ID }}" \ + --document-name "AWS-RunShellScript" \ + --comment "deploy fastapi ${{ needs.build-and-push.outputs.sha }}" \ + --parameters commands='[ + "set -e", + "cd ${{ vars.DEPLOY_DIR }}", + "docker compose pull ${{ vars.DEPLOY_SERVICE }}", + "docker compose up -d ${{ vars.DEPLOY_SERVICE }}", + "docker image prune -f" + ]' \ + --query "Command.CommandId" --output text) + echo "CommandId=$cmd_id" + # 완료까지 폴링 후 상태 검증 + for i in $(seq 1 30); do + status=$(aws ssm get-command-invocation \ + --command-id "$cmd_id" \ + --instance-id "${{ secrets.APP_INSTANCE_ID }}" \ + --query "Status" --output text 2>/dev/null || echo Pending) + echo "status=$status" + case "$status" in + Success) break ;; + Failed|Cancelled|TimedOut) + aws ssm get-command-invocation --command-id "$cmd_id" \ + --instance-id "${{ secrets.APP_INSTANCE_ID }}" \ + --query "StandardErrorContent" --output text + exit 1 ;; + esac + sleep 10 + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e30963 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + branches: [dev] + push: + branches: [dev, main] + +jobs: + check: + runs-on: ubuntu-latest + env: + TLDEXTRACT_CACHE: /tmp/tldextract # 리눅스 러너 캐시 경로 보정 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + - run: pip install -e ".[dev]" + - name: Ruff lint + run: ruff check app tests + - name: Ruff format check + run: ruff format --check app tests + - name: Mypy + run: mypy app + - name: Pytest + run: pytest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9c0dcc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + # app 이 site-packages 에 설치돼 alembic/uvicorn 이 그쪽을 import 하므로 + # SQLite 경로를 절대경로로 고정 (PROJECT_ROOT 추론이 site-packages 를 가리켜 + # 쓰기 불가 위치에 DB 디렉토리를 만들려다 실패하는 문제 방지). + SQLITE_PATH=/app/data/linclean.db \ + TLDEXTRACT_CACHE=/tmp/tldextract + +WORKDIR /app + +# hatchling 이 app 패키지를 빌드해야 하므로 install 전에 소스 복사 +COPY pyproject.toml ./ +COPY app ./app +RUN pip install . + +# 마이그레이션 + 기동 스크립트 +COPY alembic.ini ./ +COPY alembic ./alembic +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh + +# non-root + 데이터 디렉토리(SQLite) 소유권 +RUN useradd -m -u 1000 appuser \ + && mkdir -p /app/data \ + && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health').status==200 else 1)" + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..6be3486 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +alembic upgrade head +exec uvicorn app.main:app --host 0.0.0.0 --port 8000