diff --git a/.github/workflows/backend-cd.yml b/.github/workflows/backend-cd.yml index e8036729..0f77eb12 100644 --- a/.github/workflows/backend-cd.yml +++ b/.github/workflows/backend-cd.yml @@ -1,90 +1,74 @@ -name: ✨ Linkiving backend CD ✨ - -on: - workflow_dispatch: - pull_request: - types: [ closed ] - branches: - - main - -jobs: - backend-docker-build-and-push: - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - - steps: - - name: ✨ Checkout repository - uses: actions/checkout@v3 - - - name: ✨ JDK 17 설정 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - - name: ✨ Gradle Caching - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: 🗂️ Make config - run: | - # src/main/resources 경로 이동 - mkdir -p ./src/main/resources - cd ./src/main/resources - - # yml 파일 생성 - - touch ./application.yml - echo "$APPLICATION" > ./application.yml - - env: - APPLICATION: ${{ secrets.APPLICATION }} - shell: bash - - - name: ✨ Gradlew 권한 설정 - run: chmod +x ./gradlew - - - name: ✨ Jar 파일 빌드 - run: | - ./gradlew bootJar - - - name: ✨ DockerHub에 로그인 - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: ✨ Docker Image 빌드 후 DockerHub에 Push - uses: docker/build-push-action@v4 - with: - context: . - file: ./docker/Dockerfile - push: true - platforms: linux/amd64 - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest - - backend-docker-pull-and-run: - runs-on: [ self-hosted, prod ] - needs: [ backend-docker-build-and-push ] - if: ${{ needs.backend-docker-build-and-push.result == 'success' && (github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true) }} - - steps: - - name: ✨ Checkout repository - uses: actions/checkout@v5 - - - name: 🗂️ Grafana 환경변수 설정 - run: | - echo "GRAFANA_ADMIN_USER=admin" > ./docker/.env - echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}" >> ./docker/.env - shell: bash - - - name: ✨ 배포 스크립트 실행 - run: | - chmod +x deploy.sh - ./deploy.sh +name: ✨ Linkiving backend local bundle CD ✨ + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: backend-local-bundle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-local-bundle: + runs-on: ubuntu-latest + + steps: + - name: ✨ Checkout repository + uses: actions/checkout@v5 + + - name: ✨ JDK 17 설정 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: ✨ Gradle caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 🗂️ Create local application profile + shell: bash + env: + APPLICATION_LOCAL: ${{ secrets.APPLICATION_LOCAL }} + run: | + mkdir -p ./src/main/resources + printf '%s\n' "$APPLICATION_LOCAL" > ./src/main/resources/application-local.yml + + grep -q '^spring:' ./src/main/resources/application-local.yml + grep -q '^security:' ./src/main/resources/application-local.yml + grep -q '^app:' ./src/main/resources/application-local.yml + + - name: ✨ Executable permissions + run: chmod +x ./gradlew ./scripts/prepare-local-bundle.sh + + - name: ✨ Jar 파일 빌드 + run: ./gradlew bootJar + + - name: 🐳 Build local Docker bundle + env: + BUNDLE_VERSION: ${{ github.event_name == 'pull_request' && format('pr-{0}-{1}', github.event.pull_request.number, github.sha) || format('main-{0}', github.sha) }} + IMAGE_TAG: linkiving-local:${{ github.sha }} + run: ./scripts/prepare-local-bundle.sh + + - name: 📦 Upload local bundle artifact + uses: actions/upload-artifact@v4 + with: + name: linkiving-core-local-bundle-${{ github.run_number }} + path: | + dist/*.zip + dist/*.sha256 + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e90f9fe5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: 🚀 Linkiving backend release deploy 🚀 + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +concurrency: + group: backend-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + backend-docker-build-and-push: + runs-on: ubuntu-latest + + steps: + - name: ✨ Checkout repository + uses: actions/checkout@v5 + + - name: ✨ JDK 17 설정 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: ✨ Gradle caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 🗂️ Make production config + shell: bash + env: + APPLICATION: ${{ secrets.APPLICATION }} + run: | + mkdir -p ./src/main/resources + printf '%s' "$APPLICATION" > ./src/main/resources/application.yml + + - name: ✨ Gradlew 권한 설정 + run: chmod +x ./gradlew + + - name: ✨ Jar 파일 빌드 + run: ./gradlew bootJar + + - name: ✨ DockerHub에 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: ✨ Docker image 빌드 후 DockerHub에 push + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + push: true + platforms: linux/amd64 + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ github.ref_name }} + ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest + + backend-docker-pull-and-run: + runs-on: [ self-hosted, prod ] + needs: [ backend-docker-build-and-push ] + if: ${{ needs.backend-docker-build-and-push.result == 'success' }} + + steps: + - name: ✨ Checkout repository + uses: actions/checkout@v5 + + - name: ✨ 배포 스크립트 실행 + env: + IMAGE_TAG: ${{ github.ref_name }} + run: | + chmod +x deploy.sh + ./deploy.sh + + create-release: + runs-on: ubuntu-latest + needs: [ backend-docker-pull-and-run ] + if: ${{ needs.backend-docker-pull-and-run.result == 'success' }} + + steps: + - name: 📝 Create GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/.gitignore b/.gitignore index aac9bcd6..59443d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ *.yml !docker/prometheus.yml !docker/grafana/**/*.yml +!docker/local-bundle/*.yml .editorconfig diff --git a/deploy.sh b/deploy.sh index 12287fa5..ced9dc5e 100644 --- a/deploy.sh +++ b/deploy.sh @@ -11,7 +11,11 @@ REPO_ROOT="${SCRIPT_DIR}" COMPOSE_FILE="${REPO_ROOT}/docker/docker-compose.yml" PROJECT="linkiving-core" -COMPOSE="sudo docker compose -p ${PROJECT} -f ${COMPOSE_FILE}" +DEPLOY_IMAGE_TAG="${IMAGE_TAG:-latest}" + +compose() { + sudo IMAGE_TAG="${DEPLOY_IMAGE_TAG}" docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" "$@" +} # compose 파일 존재 확인 if [ ! -f "${COMPOSE_FILE}" ]; then @@ -23,9 +27,10 @@ fi echo "✅ Using compose file: ${COMPOSE_FILE}" echo "✅ Using project name: ${PROJECT}" +echo "✅ Using image tag: ${DEPLOY_IMAGE_TAG}" echo "이미지 업데이트 중..." -if ! ${COMPOSE} pull; then +if ! compose pull; then echo "❌ Docker 이미지 pull 실패! GitHub Actions 빌드를 확인해주세요." echo "❌ 배포를 중단합니다." exit 1 @@ -34,7 +39,7 @@ echo "✅ 새로운 이미지가 성공적으로 pull되었습니다." # Prometheus & Grafana 실행 (설정 변경 시 자동 반영) echo "모니터링 서비스 시작 중..." -${COMPOSE} up -d prometheus grafana +compose up -d prometheus grafana echo "✅ Prometheus & Grafana가 시작되었습니다." echo "사용하지 않는 이미지 정리 중..." @@ -45,14 +50,14 @@ EXIST_BLUE=$(sudo docker ps --filter "name=blue" --filter "status=running" -q) if [ -z "$EXIST_BLUE" ]; then echo "BLUE 컨테이너 실행" - ${COMPOSE} up -d blue + compose up -d blue BEFORE_COLOR="green" AFTER_COLOR="blue" BEFORE_PORT=8081 AFTER_PORT=8080 else echo "GREEN 컨테이너 실행" - ${COMPOSE} up -d green + compose up -d green BEFORE_COLOR="blue" AFTER_COLOR="green" BEFORE_PORT=8080 @@ -87,13 +92,13 @@ done # 헬스체크 실패 시 롤백 if [ $HEALTH_CHECK_COUNT -eq $MAX_RETRY ]; then echo "❌ 서버가 정상적으로 구동되지 않았습니다. 롤백을 시작합니다." - ${COMPOSE} stop ${AFTER_COLOR} - ${COMPOSE} rm -f ${AFTER_COLOR} + compose stop "${AFTER_COLOR}" + compose rm -f "${AFTER_COLOR}" # 이전 컨테이너가 있다면 다시 시작 if [ "${BEFORE_COLOR}" != "" ]; then echo "이전 ${BEFORE_COLOR} 컨테이너를 다시 시작합니다." - ${COMPOSE} up -d ${BEFORE_COLOR} + compose up -d "${BEFORE_COLOR}" fi echo "❌ 배포 실패 - 롤백 완료" @@ -142,8 +147,8 @@ if [ "${BEFORE_COLOR}" != "" ]; then echo "이전 컨테이너 종료 전 30초 대기..." sleep 30 - ${COMPOSE} stop ${BEFORE_COLOR} 2>/dev/null || true - ${COMPOSE} rm -f ${BEFORE_COLOR} 2>/dev/null || true + compose stop "${BEFORE_COLOR}" 2>/dev/null || true + compose rm -f "${BEFORE_COLOR}" 2>/dev/null || true echo "✅ 이전 ${BEFORE_COLOR} 컨테이너가 종료되었습니다." fi diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0a6c4e82..cc55541f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,113 +1,115 @@ -# docker-compose.yml -services: - blue: - image: linkivingsofa/core:latest - container_name: blue - environment: - - LANG=ko_KR.UTF-8 - - TZ=Asia/Seoul - - AI_SERVER_URL=http://n8n-app:5678 - ports: - - '8080:8080' - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/health-check" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - restart: unless-stopped - networks: - - app-network - - linkiving-internal - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - green: - image: linkivingsofa/core:latest - container_name: green - environment: - - LANG=ko_KR.UTF-8 - - TZ=Asia/Seoul - - AI_SERVER_URL=http://n8n-app:5678 - ports: - - '8081:8080' - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/health-check" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - restart: unless-stopped - networks: - - app-network - - linkiving-internal - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - prometheus: - image: prom/prometheus:v2.53.0 - container_name: prometheus - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--storage.tsdb.retention.time=3d' - - '--web.enable-lifecycle' - restart: unless-stopped - networks: - - app-network - deploy: - resources: - limits: - memory: 256M - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - grafana: - image: grafana/grafana:11.1.0 - container_name: grafana - volumes: - - grafana_data:/var/lib/grafana - - ./grafana/provisioning:/etc/grafana/provisioning:ro - - ./grafana/dashboards:/var/lib/grafana/dashboards:ro - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_USERS_ALLOW_SIGN_UP=false - ports: - - '3001:3000' - restart: unless-stopped - networks: - - app-network - deploy: - resources: - limits: - memory: 256M - depends_on: - - prometheus - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -volumes: - prometheus_data: - grafana_data: - -networks: - app-network: - driver: bridge - linkiving-internal: - external: true +# docker-compose.yml +version: '3.8' + +services: + blue: + image: linkivingsofa/core:${IMAGE_TAG:-latest} + container_name: blue + environment: + - LANG=ko_KR.UTF-8 + - TZ=Asia/Seoul + - AI_SERVER_URL=http://n8n-app:5678 + ports: + - '8080:8080' + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/health-check" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + networks: + - app-network + - linkiving-internal + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + green: + image: linkivingsofa/core:${IMAGE_TAG:-latest} + container_name: green + environment: + - LANG=ko_KR.UTF-8 + - TZ=Asia/Seoul + - AI_SERVER_URL=http://n8n-app:5678 + ports: + - '8081:8080' + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/health-check" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + networks: + - app-network + - linkiving-internal + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + prometheus: + image: prom/prometheus:v2.53.0 + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=3d' + - '--web.enable-lifecycle' + restart: unless-stopped + networks: + - app-network + deploy: + resources: + limits: + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + grafana: + image: grafana/grafana:11.1.0 + container_name: grafana + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_USERS_ALLOW_SIGN_UP=false + ports: + - '3001:3000' + restart: unless-stopped + networks: + - app-network + deploy: + resources: + limits: + memory: 256M + depends_on: + - prometheus + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +volumes: + prometheus_data: + grafana_data: + +networks: + app-network: + driver: bridge + linkiving-internal: + external: true diff --git a/docker/local-bundle/Dockerfile b/docker/local-bundle/Dockerfile new file mode 100644 index 00000000..afc58190 --- /dev/null +++ b/docker/local-bundle/Dockerfile @@ -0,0 +1,6 @@ +FROM amazoncorretto:17-alpine-jdk + +COPY app.jar /app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/docker/local-bundle/README.md b/docker/local-bundle/README.md new file mode 100644 index 00000000..6782e547 --- /dev/null +++ b/docker/local-bundle/README.md @@ -0,0 +1,25 @@ +# linkiving-core local bundle + +## Included files + +- `docker-compose.yml` +- `linkiving-core-local-image.tar.gz` + +## Run + +```bash +docker load -i linkiving-core-local-image.tar.gz +docker compose up -d +``` + +## Verify + +```bash +curl -fsS http://localhost:8080/health-check +``` + +## Stop + +```bash +docker compose down -v +``` diff --git a/docker/local-bundle/docker-compose.yml b/docker/local-bundle/docker-compose.yml new file mode 100644 index 00000000..d929d40d --- /dev/null +++ b/docker/local-bundle/docker-compose.yml @@ -0,0 +1,49 @@ +services: + app: + image: __IMAGE_TAG__ + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + - SPRING_PROFILES_ACTIVE=local + - DB_URL=jdbc:postgresql://postgres:5432/linkiving_local + - DB_USERNAME=linkiving + - DB_PASSWORD=linkiving + - REDIS_HOST=redis + - REDIS_PORT=6379 + - AI_SERVER_URL=http://api.linkiving.com:5678 + - SERVER_ADDRESS=0.0.0.0 + - TZ=Asia/Seoul + ports: + - "8080:8080" + + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_DB=linkiving_local + - POSTGRES_USER=linkiving + - POSTGRES_PASSWORD=linkiving + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U linkiving -d linkiving_local"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + pgdata: diff --git a/scripts/prepare-local-bundle.sh b/scripts/prepare-local-bundle.sh new file mode 100644 index 00000000..a9893dc8 --- /dev/null +++ b/scripts/prepare-local-bundle.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DIST_DIR="${REPO_ROOT}/dist" +TEMPLATE_DIR="${REPO_ROOT}/docker/local-bundle" +BUNDLE_VERSION="${BUNDLE_VERSION:-dev}" +IMAGE_TAG="${IMAGE_TAG:-linkiving-local:${BUNDLE_VERSION}}" +BUNDLE_NAME="linkiving-core-local-${BUNDLE_VERSION}" +BUNDLE_DIR="${DIST_DIR}/${BUNDLE_NAME}" +ARCHIVE_PATH="${DIST_DIR}/${BUNDLE_NAME}.zip" +CHECKSUM_PATH="${ARCHIVE_PATH}.sha256" + +APP_JAR="$(find "${REPO_ROOT}/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*plain.jar' | head -n 1)" + +if [ -z "${APP_JAR}" ]; then + echo "Built jar not found in build/libs" >&2 + exit 1 +fi + +rm -rf "${BUNDLE_DIR}" "${ARCHIVE_PATH}" "${CHECKSUM_PATH}" +mkdir -p "${BUNDLE_DIR}" + +cp "${TEMPLATE_DIR}/README.md" "${BUNDLE_DIR}/README.md" +cp "${TEMPLATE_DIR}/docker-compose.yml" "${BUNDLE_DIR}/docker-compose.yml" +cp "${APP_JAR}" "${BUNDLE_DIR}/app.jar" +cp "${TEMPLATE_DIR}/Dockerfile" "${BUNDLE_DIR}/Dockerfile" + +sed -i.bak "s|__IMAGE_TAG__|${IMAGE_TAG}|g" "${BUNDLE_DIR}/docker-compose.yml" +rm -f "${BUNDLE_DIR}/docker-compose.yml.bak" + +docker build \ + --file "${BUNDLE_DIR}/Dockerfile" \ + --tag "${IMAGE_TAG}" \ + "${BUNDLE_DIR}" + +docker save "${IMAGE_TAG}" | gzip > "${BUNDLE_DIR}/linkiving-core-local-image.tar.gz" + +rm -f "${BUNDLE_DIR}/app.jar" "${BUNDLE_DIR}/Dockerfile" + +( + cd "${DIST_DIR}" + zip -qr "${ARCHIVE_PATH}" "${BUNDLE_NAME}" +) + +sha256sum "${ARCHIVE_PATH}" > "${CHECKSUM_PATH}" + +echo "Created local bundle archive: ${ARCHIVE_PATH}"