From 9ed3a48fe96dcb1685b05ba2f44b0e581ebca455 Mon Sep 17 00:00:00 2001 From: junmin Date: Sun, 6 Sep 2026 20:28:44 +0900 Subject: [PATCH 1/3] ci: add guarded server deployment workflows --- .github/workflows/ci-cd.yml | 44 ++++++++ README.md | 4 + docker-compose.pi.yml | 11 +- docs/CI-CD.md | 120 +++++++++++++++++++++ ml-server/deploy-ai.sh | 129 ++++++++++++++++++++--- pi/deploy.sh | 204 +++++++++++++++++++++++++++++++++--- 6 files changed, 484 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci-cd.yml create mode 100644 docs/CI-CD.md diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..f73bcde --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,44 @@ +name: DevOps CI/CD + +on: + push: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: devops-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + verify: + name: Validate deployment configuration + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Validate shell syntax + run: bash -n pi/deploy.sh ml-server/deploy-ai.sh + - name: Validate local Compose model + run: docker compose -f docker-compose.yml config --quiet + - name: Validate Pi Compose model + run: docker compose -f docker-compose.pi.yml --env-file .env.example config --quiet + + deploy: + name: Deploy Pi configuration + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: verify + runs-on: [self-hosted, linux, rallytrack-pi] + environment: production-pi + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Run the verified deployment script + env: + CONFIGURED_RALLYTRACK_ROOT: ${{ vars.RALLYTRACK_ROOT }} + run: | + deployment_root="${CONFIGURED_RALLYTRACK_ROOT:-/home/junmin/RallyTrack}" + RALLYTRACK_ROOT="$deployment_root" ./pi/deploy.sh devops "$GITHUB_SHA" diff --git a/README.md b/README.md index 0580f78..91362b8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ RallyTrack 서비스의 **배포 구성 저장소**입니다. 애플리케이션 | `pi/nginx-rallytrack.conf` | 호스트 nginx vhost 템플릿 (도메인 직접 연결 시 사용) | | `ml-server/deploy-ai.sh` | AI 서버 배포 스크립트 | | `ml-server/rallytrack-ai.service` | AI 분석 서버 systemd 유닛 템플릿 | +| `docs/CI-CD.md` | CI 선택 근거, GitHub 설정, 배포·롤백, 서버 규모 트레이드오프 | `backend` / `frontend` / `aiAnalysis-server` 를 이 저장소와 같은 상위 폴더에 두고 clone해야 compose의 `build:` 상대 경로와 배포 스크립트가 맞습니다. @@ -57,6 +58,9 @@ pi/deploy.sh 상태 확인은 `docker compose -f ps`, 로그는 `... logs -f backend`. +GitHub Actions 기반 CI/CD 구조와 최초 runner 설정은 [`docs/CI-CD.md`](docs/CI-CD.md)를 +따릅니다. PR에서는 검증만 수행하고, 검증된 기본 브랜치 커밋만 사설 서버 runner가 배포합니다. + ## 환경 변수 모든 시크릿과 호스트 주소는 `.env`로 주입하며 저장소에 커밋하지 않습니다. diff --git a/docker-compose.pi.yml b/docker-compose.pi.yml index e88490e..ed20fdd 100644 --- a/docker-compose.pi.yml +++ b/docker-compose.pi.yml @@ -52,6 +52,7 @@ services: retries: 5 backend: + image: rallytrack/backend:local build: ../backend container_name: rally-backend environment: @@ -91,16 +92,24 @@ services: retries: 5 frontend: + image: rallytrack/frontend:local build: ../frontend container_name: rally-frontend ports: - "8082:80" # LAN 노출: http://:8082 (도메인 도입 시 host nginx vhost 경유로 전환) depends_on: - - backend + backend: + condition: service_healthy networks: - rallytrack-net mem_limit: 128m restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/"] + interval: 15s + timeout: 5s + start_period: 10s + retries: 5 # Cloudflare Tunnel replica — Proxmox 호스트의 커넥터가 꺼져도 # app.rallytrack.win 웹 접속을 Pi 단독으로 유지 (동일 터널, 동일 토큰) diff --git a/docs/CI-CD.md b/docs/CI-CD.md new file mode 100644 index 0000000..adc4941 --- /dev/null +++ b/docs/CI-CD.md @@ -0,0 +1,120 @@ +# RallyTrack CI/CD 운영 설계 + +## 1. 목표와 완료 기준 + +CI는 Pull Request와 모든 브랜치 push에서 애플리케이션 계약을 검증합니다. CD는 각 저장소의 +기본 브랜치에 합쳐진 **검증된 커밋 SHA**만 사설 서버에 반영합니다. + +| 저장소 | 기본 브랜치 | CI 검증 | CD 대상 | +|---|---|---|---| +| frontend | `develop` | Node 22, 잠금 파일 설치, 단위 테스트, Vite 운영 빌드 | Pi 5 | +| backend | `main` | Java 17, H2 격리 테스트, 실행 JAR 빌드 | Pi 5 | +| aiAnalysis-server | `main` | Python 3.12 구문 검사, 모드/라벨 계약 테스트 | ML 서버 | +| devops | `main` | 셸 구문, 로컬/Pi Compose 구성 검증 | Pi 5 | + +서버가 켜졌다는 사실만으로 성공 처리하지 않습니다. Pi 배포는 API 문서와 프론트 HTTP 응답을, +ML 배포는 systemd 상태와 `/health` 응답을 확인해야 성공합니다. + +## 2. 이 구조를 선택한 이유 + +- CI는 GitHub-hosted runner에서 실행합니다. 개발자 PC나 운영 서버 상태와 무관하게 동일한 + 도구 버전으로 재현할 수 있고, 운영 서버의 CPU·메모리를 테스트가 점유하지 않습니다. +- CD는 `rallytrack-pi` 또는 `rallytrack-ml` 라벨을 가진 self-hosted runner에서만 실행합니다. + 두 서버가 `192.168.219.x` 사설망에 있어 GitHub-hosted runner가 직접 접근할 수 없기 때문입니다. +- 배포 job은 PR에서 절대 실행하지 않고 기본 브랜치 push에서만 실행합니다. 권한은 기본값을 + 없앤 뒤 `contents: read`만 부여했습니다. 저장소는 private으로 유지하고 외부 fork 코드를 + self-hosted runner에서 실행하지 않습니다. +- 앱별 배포는 변경된 서비스만 순차 빌드합니다. Pi에서 backend와 frontend를 동시에 빌드해 + 메모리 피크가 커지는 것을 피하면서 배포 시간도 전체 재빌드보다 줄입니다. +- 컨테이너 레지스트리와 다중 아키텍처 이미지 배포는 현재 단계에서 제외했습니다. 지금 규모에는 + source build가 운영 복잡도와 시크릿을 줄이는 이점이 큽니다. 배포 빈도나 Pi 빌드 시간이 + 병목이 되면 GHCR의 ARM64 이미지 빌드로 전환하는 것이 다음 단계입니다. +- AI CI는 수 GB 가중치와 GPU 추론을 매번 실행하지 않습니다. hosted CI에서는 순수 계약과 + Python 구문을 빠르게 검사하고, 배포 서버에서 실제 venv import와 헬스 체크를 수행합니다. + 대표 영상 정확도 회귀는 모델/라벨 변경 시 별도 수동 검증 항목입니다. + +## 3. GitHub 최초 설정 + +각 저장소의 Settings에서 다음을 한 번 설정해야 합니다. + +1. `production-pi`, `production-ml` Environment를 만들고 필요한 경우 승인자를 지정합니다. +2. Pi에 조직 또는 각 저장소용 self-hosted runner를 설치하고 `rallytrack-pi` 라벨을 추가합니다. +3. ML 서버 runner에는 `rallytrack-ml` 라벨을 추가합니다. ML 서버가 x86_64인 현재 구성에 맞춰 + workflow는 `self-hosted, linux, x64, rallytrack-ml`을 요구합니다. +4. 두 Environment에 `RALLYTRACK_ROOT=/home/junmin/RallyTrack` 변수를 등록합니다. 경로가 + 기본값과 같으면 생략해도 됩니다. +5. 기본 브랜치 보호 규칙에 해당 저장소의 `verify` job을 필수 상태 검사로 지정하고, + 직접 push 대신 PR을 요구합니다. + +runner 사용자는 Pi에서 GitHub 저장소를 fetch하고 Docker를 실행할 수 있어야 합니다. ML 서버에서는 +비밀번호 입력 없이 아래 두 명령만 수행할 수 있도록 sudoers 권한을 좁게 부여합니다. + +```text +/usr/bin/systemctl restart rallytrack-ai +/usr/bin/systemctl is-active rallytrack-ai +``` + +설정 후 `workflow_dispatch`로 CI/CD를 한 번 실행해 runner 라벨, 환경 승인, 작업 경로를 검증합니다. + +## 4. 배포 동작과 롤백 + +Pi 스크립트는 다음 순서로 동작합니다. + +1. 중복 배포를 파일 잠금으로 차단하고, 대상 저장소에 로컬 변경이 있으면 중단합니다. +2. 기본 브랜치에 포함된 workflow의 검증 SHA인지 확인하고 fast-forward합니다. +3. Compose 구성을 검사하고 변경 서비스의 이미지만 빌드합니다. +4. 컨테이너를 재기동한 뒤 backend/frontend HTTP 헬스 체크를 반복합니다. +5. 실패하면 이전 Git SHA와 이전 이미지 태그를 복원하고 스택을 다시 기동합니다. + +ML 스크립트는 requirements가 바뀐 경우에만 기존 venv를 갱신하고 `pip check`, 계약 테스트, +FastAPI import를 확인한 뒤 systemd를 재시작합니다. 헬스 체크 실패 시 이전 SHA와 이전 requirements로 +되돌린 뒤 서비스를 다시 시작합니다. + +자동 롤백은 Git 코드와 애플리케이션 이미지에만 적용됩니다. MariaDB/MinIO 볼륨은 배포 중 삭제하지 +않지만, 호환되지 않는 DB 스키마 변경은 자동 복구하지 않습니다. 운영 중에는 `DDL_AUTO=validate`를 +유지하고 스키마 변경은 백업, 호환 가능한 선행 migration, 앱 배포, 구버전 제거 순서로 수행해야 합니다. + +## 5. 현재 서버 규모의 트레이드오프 + +Pi compose의 상한은 MariaDB 600 MB, MinIO 512 MB, backend 1.2 GB, frontend 128 MB, +cloudflared 128 MB입니다. Java heap은 768 MB로 제한됩니다. 이 구성은 소규모 데모/팀 테스트와 +낮은 동시 접속에는 적합하지만 아래 기능은 같은 Pi에 추가하지 않는 편이 안전합니다. + +- 영상 인코딩이나 AI 추론: ML 서버에서 처리 +- 대규모 로그/메트릭 스택: 외부 서비스 또는 가벼운 단일 에이전트 사용 +- Pi에서 병렬 Docker 빌드: 배포 스크립트가 의도적으로 순차 실행 +- 무중단 다중 replica: 현재 단일 호스트 메모리와 고정 컨테이너 이름에서는 비용 대비 이점이 작음 + +실사용 트래픽이 늘면 먼저 Pi 빌드를 GHCR 사전 빌드 이미지 pull로 바꾸고, 그다음 backend replica와 +외부 managed DB/object storage를 검토합니다. 평균/최대 요청 지연, 메모리 OOM, 업로드 처리량을 +관측하지 않은 상태에서 replica 수나 heap을 임의로 늘리지는 않습니다. + +## 6. 트러블슈팅 기록 + +구축 전 기준 테스트에서 다음 문제가 확인됐습니다. + +- backend `contextLoads`가 개발자 PC의 MariaDB `localhost:3307`에 연결해 실패했습니다. + 테스트 프로필을 H2(MariaDB 호환 모드)로 분리하고 JWT/S3 테스트 값을 주입해 외부 서비스 없이 + 컨텍스트가 올라오도록 수정했습니다. +- frontend는 `framer-motion`을 직접 선언하지 않은 채 transitive dependency에 의존했습니다. + npm과 pnpm의 설치 구조 차이에서 빌드가 깨졌고, 이미 직접 의존 중인 `motion/react` 경로로 바꿨습니다. +- 로컬 sandbox에서 Gradle의 파일 잠금 소켓과 dependency 다운로드가 차단됐습니다. 이는 코드 실패와 + 분리해 허용된 환경에서 다시 실행했고 전체 테스트 통과를 확인했습니다. +- Python compileall의 기본 캐시 경로가 workspace 밖이라 실패했습니다. CI/배포 모두 전용 임시 + `PYTHONPYCACHEPREFIX`를 사용하게 해 소스와 무관한 권한 문제를 제거했습니다. +- 프론트 번들은 정상 빌드되지만 약 1 MB의 단일 JS chunk 경고가 남습니다. 기능 오류는 아니므로 + 이번 배포의 차단 조건은 아니며, 초기 로딩 지표를 측정한 뒤 route 단위 code splitting을 적용합니다. + +## 7. 운영 확인 명령 + +```bash +# Pi +cd /home/junmin/RallyTrack/devops +docker compose -p rallytrack -f docker-compose.pi.yml --env-file .env ps +docker compose -p rallytrack -f docker-compose.pi.yml --env-file .env logs --tail=200 backend frontend + +# ML 서버 +systemctl status rallytrack-ai +journalctl -u rallytrack-ai -n 200 --no-pager +curl -fsS http://127.0.0.1:8000/health +``` diff --git a/ml-server/deploy-ai.sh b/ml-server/deploy-ai.sh index 6b7beab..edb4e14 100755 --- a/ml-server/deploy-ai.sh +++ b/ml-server/deploy-ai.sh @@ -1,17 +1,120 @@ -#!/bin/bash -# RallyTrack AI 서버 배포 스크립트 — GitHub에서 pull 후 서비스 재시작 -# 사용: ~/RallyTrack/aiAnalysis-server 클론 후 이 스크립트 실행 (ml-server에서) -set -e +#!/usr/bin/env bash +# Usage: ml-server/deploy-ai.sh [verified-commit-sha] +set -Eeuo pipefail +umask 027 -ROOT="$HOME/RallyTrack/aiAnalysis-server" +log() { + printf '[ml-deploy] %s\n' "$*" +} -echo "=== git pull ===" -git -C "$ROOT" pull --ff-only +fail() { + log "ERROR: $*" + return 1 +} -echo "=== 의존성 갱신 ===" -"$ROOT/.venv/bin/pip" install -q -r "$ROOT/requirements.txt" +for command_name in git curl flock systemctl sudo; do + command -v "$command_name" >/dev/null || fail "required command is missing: $command_name" +done -echo "=== 서비스 재시작 ===" -sudo systemctl restart rallytrack-ai -sleep 3 -systemctl is-active rallytrack-ai && curl -s http://localhost:8000/health && echo +exec 9>/tmp/rallytrack-ml-deploy.lock +flock -n 9 || fail "another RallyTrack ML deployment is already running" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +DEPLOY_ROOT="${RALLYTRACK_ROOT:-$DEFAULT_ROOT}" +AI_DIR="$DEPLOY_ROOT/aiAnalysis-server" +EXPECTED_SHA="${1:-}" +BRANCH="main" +PREVIOUS_REVISION="" +REQUIREMENTS_CHANGED=false +ROLLBACK_REQUIRED=false + +[[ -d "$AI_DIR/.git" ]] || fail "AI repository is missing: $AI_DIR" +[[ -x "$AI_DIR/.venv/bin/python" ]] || fail "AI virtual environment is missing" +[[ -z "$(git -C "$AI_DIR" status --porcelain --untracked-files=normal)" ]] || + fail "AI repository has local changes; deployment refused" + +for model_file in \ + tracknetv3/ckpts/TrackNet_best.pt \ + tracknetv3/ckpts/InpaintNet_best.pt \ + weights/yolov8n-pose.pt; do + [[ -f "$AI_DIR/$model_file" ]] || fail "required model file is missing: $model_file" +done + +wait_for_health() { + local attempt + for ((attempt = 1; attempt <= 36; attempt++)); do + if systemctl is-active --quiet rallytrack-ai && + curl --fail --silent --show-error --max-time 5 http://127.0.0.1:8000/health >/dev/null; then + log "AI health check passed" + return 0 + fi + sleep 5 + done + fail "AI health check failed" +} + +rollback() { + local exit_code="$1" + trap - ERR + set +e + + if [[ "$ROLLBACK_REQUIRED" != true ]]; then + exit "$exit_code" + fi + + log "deployment failed; restoring $PREVIOUS_REVISION" + git -C "$AI_DIR" reset --hard "$PREVIOUS_REVISION" + if [[ "$REQUIREMENTS_CHANGED" == true ]]; then + "$AI_DIR/.venv/bin/pip" install -q -r "$AI_DIR/requirements.txt" + fi + sudo -n systemctl restart rallytrack-ai + wait_for_health + log "rollback attempted; inspect journalctl before retrying" + exit "$exit_code" +} + +trap 'rollback $?' ERR + +PREVIOUS_REVISION="$(git -C "$AI_DIR" rev-parse HEAD)" +git -C "$AI_DIR" fetch --prune origin "$BRANCH" +if git -C "$AI_DIR" show-ref --verify --quiet "refs/heads/$BRANCH"; then + git -C "$AI_DIR" switch "$BRANCH" +else + git -C "$AI_DIR" switch --track -c "$BRANCH" "origin/$BRANCH" +fi + +TARGET_REVISION="origin/$BRANCH" +if [[ -n "$EXPECTED_SHA" ]]; then + git -C "$AI_DIR" cat-file -e "$EXPECTED_SHA^{commit}" + git -C "$AI_DIR" merge-base --is-ancestor "$EXPECTED_SHA" "origin/$BRANCH" || + fail "$EXPECTED_SHA is not on origin/$BRANCH" + TARGET_REVISION="$EXPECTED_SHA" +fi + +if ! git -C "$AI_DIR" merge-base --is-ancestor "$TARGET_REVISION" HEAD; then + git -C "$AI_DIR" merge --ff-only "$TARGET_REVISION" +fi +ROLLBACK_REQUIRED=true + +if ! git -C "$AI_DIR" diff --quiet "$PREVIOUS_REVISION" HEAD -- requirements.txt; then + REQUIREMENTS_CHANGED=true + log "requirements changed; updating the existing virtual environment" + "$AI_DIR/.venv/bin/pip" install -q -r "$AI_DIR/requirements.txt" +fi + +"$AI_DIR/.venv/bin/pip" check +PYTHONPYCACHEPREFIX=/tmp/rallytrack-ai-deploy-pyc \ + "$AI_DIR/.venv/bin/python" -m compileall -q \ + "$AI_DIR/analysis" "$AI_DIR/config" "$AI_DIR/routers" "$AI_DIR/services" "$AI_DIR/main.py" +( + cd "$AI_DIR" + "$AI_DIR/.venv/bin/python" -m unittest discover -s tests -v + "$AI_DIR/.venv/bin/python" -c 'from main import app; assert app.title == "RallyTrack AI Analysis Server"' +) + +sudo -n systemctl restart rallytrack-ai +wait_for_health +ROLLBACK_REQUIRED=false +trap - ERR +log "AI deployment completed" diff --git a/pi/deploy.sh b/pi/deploy.sh index 5fc3d9d..0cb96ab 100755 --- a/pi/deploy.sh +++ b/pi/deploy.sh @@ -1,18 +1,194 @@ -#!/bin/bash -# RallyTrack Pi 배포 스크립트 — GitHub에서 pull 후 재빌드/재기동 -# 사용: ~/RallyTrack/devops/pi/deploy.sh (Pi에서 실행) -set -e +#!/usr/bin/env bash +# Usage: pi/deploy.sh [backend|frontend|devops|all] [verified-commit-sha] +set -Eeuo pipefail +umask 027 -ROOT="$HOME/RallyTrack" +log() { + printf '[pi-deploy] %s\n' "$*" +} -echo "=== git pull ===" -git -C "$ROOT/backend" pull --ff-only -git -C "$ROOT/frontend" pull --ff-only -git -C "$ROOT/devops" pull --ff-only +fail() { + log "ERROR: $*" + return 1 +} -echo "=== docker compose up ===" -cd "$ROOT/devops" -docker compose -f docker-compose.pi.yml --env-file .env up -d --build +for command_name in git docker curl flock; do + command -v "$command_name" >/dev/null || fail "required command is missing: $command_name" +done -echo "=== 상태 ===" -docker compose -f docker-compose.pi.yml ps +exec 9>/tmp/rallytrack-pi-deploy.lock +flock -n 9 || fail "another RallyTrack Pi deployment is already running" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +DEPLOY_ROOT="${RALLYTRACK_ROOT:-$DEFAULT_ROOT}" +DEVOPS_DIR="$DEPLOY_ROOT/devops" +ENV_FILE="${RALLYTRACK_ENV_FILE:-$DEVOPS_DIR/.env}" +COMPONENT="${1:-all}" +EXPECTED_SHA="${2:-}" + +case "$COMPONENT" in + backend|frontend|devops|all) ;; + *) fail "component must be backend, frontend, devops, or all" ;; +esac + +[[ -f "$ENV_FILE" ]] || fail "environment file is missing: $ENV_FILE" +[[ -f "$DEVOPS_DIR/docker-compose.pi.yml" ]] || fail "Pi compose file is missing" + +declare -A BRANCHES=( + [backend]="main" + [frontend]="develop" + [devops]="main" +) +declare -A PREVIOUS_REVISIONS=() +declare -A PREVIOUS_IMAGES=() +UPDATED_REPOSITORIES=() +BUILT_SERVICES=() +ROLLBACK_REQUIRED=false + +check_clean_repository() { + local repository="$1" + local directory="$DEPLOY_ROOT/$repository" + [[ -d "$directory/.git" ]] || fail "repository is missing: $directory" + [[ -z "$(git -C "$directory" status --porcelain --untracked-files=normal)" ]] || + fail "$repository has local changes; deployment refused" +} + +update_repository() { + local repository="$1" + local branch="${BRANCHES[$repository]}" + local directory="$DEPLOY_ROOT/$repository" + local target_revision="origin/$branch" + + check_clean_repository "$repository" + PREVIOUS_REVISIONS["$repository"]="$(git -C "$directory" rev-parse HEAD)" + UPDATED_REPOSITORIES+=("$repository") + + log "fetching $repository/$branch" + git -C "$directory" fetch --prune origin "$branch" + + if git -C "$directory" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$directory" switch "$branch" + else + git -C "$directory" switch --track -c "$branch" "origin/$branch" + fi + + if [[ -n "$EXPECTED_SHA" && "$repository" == "$COMPONENT" ]]; then + git -C "$directory" cat-file -e "$EXPECTED_SHA^{commit}" + git -C "$directory" merge-base --is-ancestor "$EXPECTED_SHA" "origin/$branch" || + fail "$EXPECTED_SHA is not on origin/$branch" + target_revision="$EXPECTED_SHA" + fi + + if git -C "$directory" merge-base --is-ancestor "$target_revision" HEAD; then + log "$repository already contains the verified revision" + else + git -C "$directory" merge --ff-only "$target_revision" + fi +} + +capture_image() { + local service="$1" + local image="rallytrack/$service:local" + PREVIOUS_IMAGES["$service"]="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || true)" + BUILT_SERVICES+=("$service") +} + +wait_for_http() { + local name="$1" + local url="$2" + local attempts="${3:-36}" + local attempt + + for ((attempt = 1; attempt <= attempts; attempt++)); do + if curl --fail --silent --show-error --max-time 5 "$url" >/dev/null; then + log "$name health check passed" + return 0 + fi + sleep 5 + done + fail "$name health check failed: $url" +} + +rollback() { + local exit_code="$1" + trap - ERR + set +e + + if [[ "$ROLLBACK_REQUIRED" != true ]]; then + exit "$exit_code" + fi + + log "deployment failed; restoring the previous revision" + for repository in "${UPDATED_REPOSITORIES[@]}"; do + git -C "$DEPLOY_ROOT/$repository" reset --hard "${PREVIOUS_REVISIONS[$repository]}" + done + + local rebuild_previous=false + local service + for service in "${BUILT_SERVICES[@]}"; do + if [[ -n "${PREVIOUS_IMAGES[$service]:-}" ]]; then + docker image tag "${PREVIOUS_IMAGES[$service]}" "rallytrack/$service:local" + else + rebuild_previous=true + fi + done + + local compose=(docker compose -p rallytrack -f "$DEVOPS_DIR/docker-compose.pi.yml" --env-file "$ENV_FILE") + if [[ "$rebuild_previous" == true ]]; then + for service in "${BUILT_SERVICES[@]}"; do + "${compose[@]}" build "$service" + done + fi + "${compose[@]}" up -d --no-build + "${compose[@]}" ps + log "rollback attempted; inspect service logs before retrying" + exit "$exit_code" +} + +trap 'rollback $?' ERR + +if [[ "$COMPONENT" == "all" ]]; then + update_repository backend + update_repository frontend + update_repository devops +else + update_repository "$COMPONENT" +fi +ROLLBACK_REQUIRED=true + +COMPOSE=(docker compose -p rallytrack -f "$DEVOPS_DIR/docker-compose.pi.yml" --env-file "$ENV_FILE") +"${COMPOSE[@]}" config --quiet + +case "$COMPONENT" in + backend) + capture_image backend + "${COMPOSE[@]}" build backend + "${COMPOSE[@]}" up -d --no-build backend + ;; + frontend) + capture_image frontend + "${COMPOSE[@]}" build frontend + "${COMPOSE[@]}" up -d --no-build frontend + ;; + devops|all) + # Sequential builds keep peak memory usage predictable on the Pi. + capture_image backend + "${COMPOSE[@]}" build backend + capture_image frontend + "${COMPOSE[@]}" build frontend + "${COMPOSE[@]}" up -d --no-build + ;; +esac + +if [[ "$COMPONENT" != "frontend" ]]; then + wait_for_http backend http://127.0.0.1:8080/v3/api-docs +fi +if [[ "$COMPONENT" != "backend" ]]; then + wait_for_http frontend http://127.0.0.1:8082/ +fi + +"${COMPOSE[@]}" ps +ROLLBACK_REQUIRED=false +trap - ERR +log "$COMPONENT deployment completed" From 23d6820e3ade5f4c9014a1cc645ea1f26d999421 Mon Sep 17 00:00:00 2001 From: junmin Date: Sun, 6 Sep 2026 20:43:25 +0900 Subject: [PATCH 2/3] docs: record hosted CI configuration fix --- docs/CI-CD.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CI-CD.md b/docs/CI-CD.md index adc4941..75b5190 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -96,6 +96,9 @@ cloudflared 128 MB입니다. Java heap은 768 MB로 제한됩니다. 이 구성 - backend `contextLoads`가 개발자 PC의 MariaDB `localhost:3307`에 연결해 실패했습니다. 테스트 프로필을 H2(MariaDB 호환 모드)로 분리하고 JWT/S3 테스트 값을 주입해 외부 서비스 없이 컨텍스트가 올라오도록 수정했습니다. +- 첫 hosted CI에서는 로컬에서만 존재하는 `application.yml`이 빠지자 JWT 만료시간, CORS, AI URL + placeholder가 없어서 다시 실패했습니다. 로컬 파일을 배제한 조건으로 재현한 뒤 테스트 프로필에 + 필요한 설정을 모두 명시하고 같은 조건에서 전체 테스트와 JAR 빌드를 다시 통과시켰습니다. - frontend는 `framer-motion`을 직접 선언하지 않은 채 transitive dependency에 의존했습니다. npm과 pnpm의 설치 구조 차이에서 빌드가 깨졌고, 이미 직접 의존 중인 `motion/react` 경로로 바꿨습니다. - 로컬 sandbox에서 Gradle의 파일 잠금 소켓과 dependency 다운로드가 차단됐습니다. 이는 코드 실패와 From eca022aa38ee1e0683447bea7c9ad9a761cffa0f Mon Sep 17 00:00:00 2001 From: junmin Date: Sun, 6 Sep 2026 20:46:48 +0900 Subject: [PATCH 3/3] ci: gate public-repository deployment --- .github/workflows/ci-cd.yml | 2 +- docs/CI-CD.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index f73bcde..d831222 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -29,7 +29,7 @@ jobs: deploy: name: Deploy Pi configuration - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: vars.ENABLE_CD == 'true' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: verify runs-on: [self-hosted, linux, rallytrack-pi] environment: production-pi diff --git a/docs/CI-CD.md b/docs/CI-CD.md index 75b5190..f72f89f 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -35,6 +35,10 @@ ML 배포는 systemd 상태와 `/health` 응답을 확인해야 성공합니다. ## 3. GitHub 최초 설정 +현재 저장소는 public이고 self-hosted runner가 등록돼 있지 않으므로 CD는 기본적으로 비활성입니다. +public 저장소의 fork PR이 사설 runner에서 실행될 여지를 만들지 않도록, 저장소를 private으로 +전환하거나 배포 전용 private 저장소/제한된 runner group으로 격리한 뒤 활성화해야 합니다. + 각 저장소의 Settings에서 다음을 한 번 설정해야 합니다. 1. `production-pi`, `production-ml` Environment를 만들고 필요한 경우 승인자를 지정합니다. @@ -43,7 +47,9 @@ ML 배포는 systemd 상태와 `/health` 응답을 확인해야 성공합니다. workflow는 `self-hosted, linux, x64, rallytrack-ml`을 요구합니다. 4. 두 Environment에 `RALLYTRACK_ROOT=/home/junmin/RallyTrack` 변수를 등록합니다. 경로가 기본값과 같으면 생략해도 됩니다. -5. 기본 브랜치 보호 규칙에 해당 저장소의 `verify` job을 필수 상태 검사로 지정하고, +5. 안전한 runner 격리와 Environment 승인 설정을 확인한 뒤 저장소 변수 `ENABLE_CD=true`를 + 등록합니다. 이 값이 없으면 기본 브랜치 CI는 통과하지만 배포 job은 안전하게 skip됩니다. +6. 기본 브랜치 보호 규칙에 해당 저장소의 `verify` job을 필수 상태 검사로 지정하고, 직접 push 대신 PR을 요구합니다. runner 사용자는 Pi에서 GitHub 저장소를 fetch하고 Docker를 실행할 수 있어야 합니다. ML 서버에서는