From 4363831bd0a86bbd12b99ba5a7074eea1b4f188f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 5 Sep 2026 15:46:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20ARM64=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 13 + .../workflows/deploy-to-dev-ec2-docker.yml | 105 ++++++- .github/workflows/deploy-to-dev-ec2.yml | 88 ------ .gitignore | 5 +- Dockerfile | 11 +- deploy/docker-compose.dev.yml | 72 +++-- deploy/nginx/saerok-dev-http.conf | 24 ++ deploy/postgres-ko/Dockerfile | 18 +- docs/dev-t4g-migration-runbook.md | 294 ++++++++++++++++++ .../auth/application/KakaoLoginService.java | 4 - .../auth/application/TokenRefreshService.java | 5 - .../domain/auth/infra/AppleApiClient.java | 8 +- .../domain/auth/infra/KakaoApiClient.java | 6 +- .../security/jwt/JwtAuthenticationFilter.java | 2 +- 14 files changed, 504 insertions(+), 151 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/workflows/deploy-to-dev-ec2.yml create mode 100644 deploy/nginx/saerok-dev-http.conf create mode 100644 docs/dev-t4g-migration-runbook.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ec6900d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.gradle +.idea +.env +.env.* +build +deploy +docs +node_modules +**/postgres-data +**/redis-data +*.log diff --git a/.github/workflows/deploy-to-dev-ec2-docker.yml b/.github/workflows/deploy-to-dev-ec2-docker.yml index 3c5bcfa9..58214a83 100644 --- a/.github/workflows/deploy-to-dev-ec2-docker.yml +++ b/.github/workflows/deploy-to-dev-ec2-docker.yml @@ -4,9 +4,25 @@ on: push: branches: [ develop ] workflow_dispatch: + inputs: + deploy_mode: + description: 전체 서비스를 배포하거나 이전을 위해 PostgreSQL과 Redis만 준비합니다 + type: choice + required: true + default: full + options: + - full + - infrastructure-only + +concurrency: + group: deploy-dev + cancel-in-progress: false jobs: deploy: + # 서버와 데이터를 옮기는 동안 develop push 자동 배포를 잠근다. + # 새 서버 전환이 끝난 뒤 저장소 변수 DEV_ARM64_AUTODEPLOY를 true로 설정한다. + if: ${{ github.event_name == 'workflow_dispatch' || vars.DEV_ARM64_AUTODEPLOY == 'true' }} runs-on: ubuntu-latest permissions: contents: read @@ -21,6 +37,11 @@ jobs: id: normalize run: echo "image_repo=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - name: 🏗️ Setup QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: 🧱 Setup Buildx uses: docker/setup-buildx-action@v3 @@ -41,15 +62,17 @@ jobs: type=sha,format=long,prefix=dev- - name: 🏗️ Build & Push Image + id: build uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile + platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache - cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache,mode=max + cache-from: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache-arm64 + cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache-arm64,mode=max - name: 📤 Copy compose files to EC2 uses: appleboy/scp-action@v0.1.7 @@ -57,8 +80,8 @@ jobs: host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} - source: "deploy/docker-compose.yml,deploy/docker-compose.dev.yml,deploy/postgres-ko/Dockerfile" - target: ~/saerok/ + source: "deploy/docker-compose.yml,deploy/docker-compose.dev.yml,deploy/postgres-ko/Dockerfile,deploy/nginx/saerok-dev-http.conf" + target: /srv/saerok/ strip_components: 1 - name: 🚀 Deploy on EC2 (pull & up) @@ -70,7 +93,20 @@ jobs: script: | set -euo pipefail - cd ~/saerok + DEPLOY_MODE="${{ inputs.deploy_mode || 'full' }}" + IMAGE_REF="${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" + + # 기존 DB 디렉터리의 소유권은 강제로 바꾸지 않는다. + # 각 컨테이너의 시작 스크립트가 데이터 디렉터리 소유권을 관리한다. + for data_path in \ + /srv/saerok/data/postgres \ + /srv/saerok/data/redis \ + /srv/saerok/backup; do + if [ ! -d "$data_path" ]; then + sudo install -d -m 0750 -o "$(id -un)" -g "$(id -gn)" "$data_path" + fi + done + cd /srv/saerok # 0) Docker & Compose 확인 docker --version @@ -89,7 +125,7 @@ jobs: add_kv() { printf '%s\n' "$1=$2" >> "$ENV_FILE"; } add_kv SPRING_PROFILES_ACTIVE "dev" - add_kv DB_URL "${{ secrets.DB_URL }}" + add_kv DB_URL "jdbc:postgresql://postgres:5432/saerok" add_kv DB_USERNAME "${{ secrets.DB_USERNAME }}" add_kv DB_PASSWORD "${{ secrets.DB_PASSWORD }}" add_kv APPLE_CLIENT_ID "${{ secrets.APPLE_CLIENT_ID }}" @@ -111,29 +147,56 @@ jobs: add_kv KAKAO_CLIENT_SECRET "${{ secrets.KAKAO_CLIENT_SECRET }}" add_kv KAKAO_APP_ID "${{ secrets.KAKAO_APP_ID }}" add_kv KMS_KEY_ID "${{ secrets.KMS_KEY_ID }}" + add_kv IMAGE_REF "$IMAGE_REF" + add_kv ENV_FILE "$ENV_FILE" - # 3) 커스텀 PostGIS + ko_KR UTF-8 이미지 빌드 (BuildKit 끄고, buildx 안 씀) - DOCKER_BUILDKIT=0 docker build -t saerok-postgres:ko ./postgres-ko + # 3) ARM64 EC2에서 해당 아키텍처용 PostGIS 이미지를 직접 빌드 + docker build -t saerok-postgres:17-postgis ./postgres-ko - # 4) app 이미지만 pull + # 4) 데이터가 유지되는 PostgreSQL과 Redis를 먼저 시작 + # 최초 이전 때는 앱을 실행하기 전에 이 상태에서 pg_restore를 수행한다. docker compose \ --env-file "$ENV_FILE" \ -p saerok \ -f docker-compose.yml \ -f docker-compose.dev.yml \ - pull --quiet app + up -d postgres redis - # 5) compose up (postgres는 로컬 saerok-postgres:ko 이미지 사용) + echo "Waiting for PostgreSQL and Redis..." + for i in {1..30}; do + postgres_health="$(docker inspect --format '{{.State.Health.Status}}' saerok-postgres-dev 2>/dev/null || true)" + redis_health="$(docker inspect --format '{{.State.Health.Status}}' saerok-redis-dev 2>/dev/null || true)" + if [ "$postgres_health" = "healthy" ] && [ "$redis_health" = "healthy" ]; then + break + fi + if [ "$i" -eq 30 ]; then + docker compose --env-file "$ENV_FILE" -p saerok -f docker-compose.yml -f docker-compose.dev.yml ps + # 공개 GitHub Actions 로그에는 컨테이너 원문 로그를 올리지 않는다. + # 상세 원인은 EC2에서 docker logs 명령으로 직접 확인한다. + docker inspect --format 'postgres status={{.State.Status}} health={{.State.Health.Status}} restart={{.RestartCount}}' saerok-postgres-dev || true + docker inspect --format 'redis status={{.State.Status}} health={{.State.Health.Status}} restart={{.RestartCount}}' saerok-redis-dev || true + exit 1 + fi + sleep 4 + done + + if [ "$DEPLOY_MODE" = "infrastructure-only" ]; then + docker compose --env-file "$ENV_FILE" -p saerok -f docker-compose.yml -f docker-compose.dev.yml ps + echo "Infrastructure is ready for pg_restore; application was not started." + exit 0 + fi + + # 5) digest로 고정된 앱 이미지를 받고 앱 컨테이너만 다시 생성 + docker pull "$IMAGE_REF" docker compose \ --env-file "$ENV_FILE" \ -p saerok \ -f docker-compose.yml \ -f docker-compose.dev.yml \ - up -d --force-recreate --quiet-pull + up -d --force-recreate --no-deps app # 6) 상태 확인 - docker compose -p saerok -f docker-compose.yml -f docker-compose.dev.yml ps - docker logs --tail=120 saerok-dev || true + docker compose --env-file "$ENV_FILE" -p saerok -f docker-compose.yml -f docker-compose.dev.yml ps # 7) Healthcheck echo "Waiting for health endpoint..." @@ -149,5 +212,17 @@ jobs: done echo "❌ Healthcheck failed after retries" - docker logs --tail=200 saerok-dev || true + # 사용자 인증정보가 포함될 수 있는 앱 원문 로그는 Actions에 출력하지 않는다. + docker inspect --format 'app status={{.State.Status}} health={{.State.Health.Status}} restart={{.RestartCount}}' saerok-dev || true exit 1 + + - name: Summarize deployment + if: ${{ success() }} + run: | + { + echo "## Dev arm64 deploy" + echo "- Mode: ${{ inputs.deploy_mode || 'full' }}" + echo "- Platform: linux/arm64" + echo "- Image: ${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" + echo "- Commit: ${{ github.sha }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml deleted file mode 100644 index 504f18e9..00000000 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Deploy to Dev EC2 - -on: - workflow_dispatch: - -jobs: - deploy: - name: Build and Deploy to EC2 - runs-on: ubuntu-latest - environment: dev - - steps: - - name: Checkout source code - uses: actions/checkout@v3 - - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - distribution: 'corretto' - java-version: '21' - - - name: Grant execute permission for Gradle - run: chmod +x gradlew - - - name: Fix Docker API compatibility for Testcontainers - run: echo 'api.version=1.44' > ~/.docker-java.properties - - - name: Build with Gradle - run: ./gradlew test bootJar - - - name: Copy JAR to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} - source: build/libs/*.jar - target: ~/saerok/ - strip_components: '2' - - - name: Restart application on EC2 - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} - debug: true - script: | - set -euxo pipefail - - cd ~/saerok - echo ">>> 현재 위치: $(pwd)" - echo ">>> 파일:" - ls -al - - # JAR 확인 - JAR_NAME=$(ls -1t *.jar | head -n1) - [ -n "$JAR_NAME" ] || { echo "❌ JAR 없음"; exit 2; } - echo ">>> 선택된 JAR: $JAR_NAME" - - # 기존 프로세스 종료 - pkill -f "$JAR_NAME" || true - - # 백그라운드 실행 - DB_URL="${{ secrets.DB_URL }}" \ - DB_USERNAME="${{ secrets.DB_USERNAME }}" \ - DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ - IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \ - IAM_SECRET_KEY="${{ secrets.IAM_SECRET_KEY }}" \ - UPLOAD_IMAGE_BUCKET_NAME="${{ vars.UPLOAD_IMAGE_BUCKET_NAME }}" \ - UPLOAD_IMAGE_DOMAIN="${{ vars.UPLOAD_IMAGE_DOMAIN }}" \ - DEX_IMAGE_BUCKET_NAME="${{ vars.DEX_IMAGE_BUCKET_NAME }}" \ - DEX_IMAGE_DOMAIN="${{ vars.DEX_IMAGE_DOMAIN }}" \ - APPLE_CLIENT_ID="${{ secrets.APPLE_CLIENT_ID }}" \ - APPLE_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}" \ - APPLE_KEY_ID="${{ secrets.APPLE_KEY_ID }}" \ - APPLE_PRIVATE_KEY="${{ secrets.APPLE_PRIVATE_KEY }}" \ - JWT_SECRET="${{ secrets.JWT_SECRET }}" \ - KAKAO_ADMIN_KEY="${{ secrets.KAKAO_ADMIN_KEY }}" \ - KAKAO_CLIENT_ID="${{ secrets.KAKAO_CLIENT_ID }}" \ - KAKAO_REDIRECT_URI="${{ secrets.KAKAO_REDIRECT_URI }}" \ - KAKAO_CLIENT_SECRET="${{ secrets.KAKAO_CLIENT_SECRET }}" \ - KMS_KEY_ID="${{ secrets.KMS_KEY_ID }}" \ - FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ - nohup java -Duser.timezone=Asia/Seoul -jar $JAR_NAME --spring.profiles.active=dev > app.log 2>&1 & - - echo "✅ 배포 완료" - diff --git a/.gitignore b/.gitignore index 4937214a..ba3923a5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ node_modules # Firebase Admin Key src/main/resources/config/firebase-admin-key.json -.claude \ No newline at end of file +# 실제 인프라 구성과 비용이 포함된 로컬 분석 문서는 공개 저장소에 올리지 않는다. +docs/rds-to-ec2-docker-plan.md + +.claude diff --git a/Dockerfile b/Dockerfile index a34d0c2d..bbd2af48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ -# ===== Build stage ===== -FROM gradle:8.8-jdk21 AS builder +# syntax=docker/dockerfile:1 + +# Spring Boot JAR는 CPU 아키텍처와 무관하다. 시간이 오래 걸리는 빌드는 CI 러너에서 +# 실행하고, Buildx가 요청한 실행 아키텍처(운영 amd64, 개발 arm64) 이미지를 만든다. +FROM --platform=$BUILDPLATFORM gradle:8.8-jdk21-jammy AS builder WORKDIR /app COPY build.gradle settings.gradle gradlew ./ @@ -11,8 +14,8 @@ COPY . . RUN chmod +x gradlew RUN ./gradlew --no-daemon clean bootJar -# ===== Runtime stage ===== -FROM eclipse-temurin:21-jre +# 이 런타임 이미지는 amd64와 arm64를 모두 지원한다. +FROM eclipse-temurin:21-jre-jammy ENV TZ=Asia/Seoul WORKDIR /app diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 2c9759f6..985efb33 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -1,20 +1,24 @@ services: app: - image: ghcr.io/devkor-github/saerok-be:dev-latest + image: ${IMAGE_REF:-ghcr.io/devkor-github/saerok-be:dev-latest} container_name: saerok-dev restart: unless-stopped + init: true stop_grace_period: 15s - ports: ["8080:8080"] + ports: + - "127.0.0.1:8080:8080" - # t2.micro 안전 제한 - cpus: "0.25" + # t4g.micro에서 시작 시 CPU를 탄력적으로 쓰되 메모리 사용량은 제한한다. + cpus: "0.75" mem_limit: "512m" + mem_reservation: "320m" env_file: - - /run/saerok/env.dev + - ${ENV_FILE:-/run/saerok/env.dev} environment: SPRING_PROFILES_ACTIVE: "dev" + DB_URL: ${DB_URL:-jdbc:postgresql://postgres:5432/saerok} JAVA_OPTS: >- -Duser.timezone=Asia/Seoul -Xms128m -Xmx256m @@ -28,29 +32,54 @@ services: SPRING_DATA_REDIS_PORT: "6379" depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis: + condition: service_healthy + + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health >/dev/null || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 30s + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" postgres: - image: saerok-postgres:ko + image: saerok-postgres:17-postgis container_name: saerok-postgres-dev restart: unless-stopped - stop_grace_period: 15s - - # 로컬 개발/디버깅도 필요하면 열어두고, 진짜 막고 싶으면 나중에 빼면 됨 - ports: - - "5432:5432" + stop_grace_period: 30s + shm_size: "64m" environment: POSTGRES_DB: saerok POSTGRES_USER: ${DB_USERNAME} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_INITDB_ARGS: "--locale=ko_KR.utf8 --encoding=UTF8" volumes: - - ./postgres-data:/var/lib/postgresql/data + - /srv/saerok/data/postgres:/var/lib/postgresql/data - # t2.micro 고려한 소박한 리소스 - cpus: "0.25" + cpus: "0.50" mem_limit: "256m" + mem_reservation: "128m" + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" redis: image: redis:7-alpine @@ -80,12 +109,19 @@ services: - "--maxmemory-policy" - "allkeys-lru" volumes: - - ./redis-data:/data # dump.rdb 저장 위치(단일 파일로 덮어씀) + - /srv/saerok/data/redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 20 start_period: 5s - cpus: "0.15" + cpus: "0.25" mem_limit: "192m" + mem_reservation: "64m" + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/deploy/nginx/saerok-dev-http.conf b/deploy/nginx/saerok-dev-http.conf new file mode 100644 index 00000000..bd6da640 --- /dev/null +++ b/deploy/nginx/saerok-dev-http.conf @@ -0,0 +1,24 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + listen [::]:80; + server_name dev-api.saerok.app dev-admin.saerok.app; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + 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_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 60s; + } +} diff --git a/deploy/postgres-ko/Dockerfile b/deploy/postgres-ko/Dockerfile index fe39def8..3644afb9 100644 --- a/deploy/postgres-ko/Dockerfile +++ b/deploy/postgres-ko/Dockerfile @@ -1,12 +1,16 @@ -# deploy/postgres-ko/Dockerfile -# PostGIS + ko_KR.UTF-8 -FROM postgis/postgis:17-3.5 +# syntax=docker/dockerfile:1 + +# postgis/postgis:17-3.5 이미지는 amd64 전용이다. 아래 PostgreSQL 공식 이미지와 +# PGDG PostGIS 패키지는 amd64와 arm64를 모두 지원한다. +FROM postgres:17-bookworm -# ko_KR.UTF-8 로캘 생성 RUN apt-get update && \ - apt-get install -y locales && \ - sed -i 's/# ko_KR.UTF-8 UTF-8/ko_KR.UTF-8 UTF-8/' /etc/locale.gen && \ - locale-gen && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + locales \ + postgresql-17-postgis-3 \ + postgresql-17-postgis-3-scripts && \ + sed -i 's/^# *\(ko_KR.UTF-8 UTF-8\)/\1/' /etc/locale.gen && \ + locale-gen ko_KR.UTF-8 && \ rm -rf /var/lib/apt/lists/* ENV LANG=ko_KR.UTF-8 diff --git a/docs/dev-t4g-migration-runbook.md b/docs/dev-t4g-migration-runbook.md new file mode 100644 index 00000000..0f55964e --- /dev/null +++ b/docs/dev-t4g-migration-runbook.md @@ -0,0 +1,294 @@ +# 개발 EC2 t4g.micro 전환 실행서 + +## 목표 구성 + +- Ubuntu 24.04 ARM64 / `t4g.micro` +- 앱, PostgreSQL 17 + PostGIS, Redis를 Docker Compose로 실행 +- 앱은 호스트의 `127.0.0.1:8080`에만 바인딩하고 Nginx가 80/443을 프록시 +- PostgreSQL과 Redis 데이터는 `/srv/saerok/data`에 영속화 +- 기존 개발 서버와 동일하게 Elastic IP는 사용하지 않고, EC2의 일반 퍼블릭 IPv4를 사용 +- 기존 t2 서버는 검증과 DNS 전환이 끝날 때까지 유지 + +일반 퍼블릭 IPv4는 인스턴스를 **중지 후 다시 시작하면 바뀔 수 있다**. 운영 중 +재부팅만 하는 경우에는 보통 유지되지만, 인스턴스 유형 변경처럼 중지/시작이 필요한 +작업 뒤에는 반드시 AWS 콘솔에서 주소를 다시 확인한다. 주소가 바뀌면 GitHub +`EC2_HOST`와 Route 53의 두 A 레코드를 함께 수정한다. + +## 이번 변경 내용과 이유 + +### 애플리케이션 이미지의 ARM64 지원 + +[`Dockerfile`](../Dockerfile)은 빌드 단계와 실행 단계를 분리한다. Spring Boot JAR는 +CPU 아키텍처에 종속되지 않으므로 Gradle 빌드는 GitHub Actions 러너의 원래 +아키텍처에서 실행하고, 최종 실행 이미지만 배포 대상인 ARM64로 만든다. 이렇게 하면 +ARM 에뮬레이션 안에서 Gradle 전체 빌드를 수행하는 것보다 빠르고 안정적이다. + +실행 기반 이미지인 `eclipse-temurin:21-jre-jammy`는 AMD64와 ARM64를 모두 +지원한다. 따라서 개발 배포는 ARM64 이미지를 만들고, 기존 운영 배포가 같은 +Dockerfile로 AMD64 이미지를 만드는 것도 가능하다. + +### PostgreSQL과 PostGIS의 ARM64 지원 + +기존 `postgis/postgis:17-3.5` 이미지는 현재 사용 방식에서 ARM64로 실행할 수 없다. +[`deploy/postgres-ko/Dockerfile`](../deploy/postgres-ko/Dockerfile)은 ARM64를 지원하는 +PostgreSQL 17 공식 이미지를 기반으로 하고, PostgreSQL 패키지 저장소에 포함된 +PostGIS 패키지를 설치하도록 변경했다. 기존 DB와 동일하게 `ko_KR.UTF-8` 로캘도 +생성한다. + +이 이미지는 GitHub Actions 러너에서 만들어 전송하지 않고 새 ARM64 EC2에서 직접 +빌드한다. 따라서 결과 이미지는 서버 아키텍처와 일치한다. 빌드 후에는 PostgreSQL +17, PostGIS 확장, 한글 로캘을 모두 사용할 수 있어야 한다. + +### Docker Compose 구성과 데이터 유지 + +[`deploy/docker-compose.dev.yml`](../deploy/docker-compose.dev.yml)의 주요 변경은 다음과 +같다. + +- 앱의 `8080` 포트는 `127.0.0.1`에만 연결한다. 인터넷에서 8080으로 직접 접근하지 + 못하게 하고, 외부 요청은 Nginx의 80/443 포트만 통과시킨다. +- PostgreSQL의 `5432` 포트는 호스트에 공개하지 않는다. 앱은 Docker 내부 + 네트워크에서 `postgres:5432`로 접속한다. +- DB 접속 주소를 `jdbc:postgresql://postgres:5432/saerok`으로 고정한다. 개발 + 환경에서는 더 이상 외부 RDS 주소를 사용하지 않는다. +- PostgreSQL 데이터는 `/srv/saerok/data/postgres`, Redis 데이터는 + `/srv/saerok/data/redis`에 저장한다. 컨테이너를 삭제하거나 이미지를 교체해도 이 + 디렉터리는 남는다. +- PostgreSQL과 Redis가 `healthy`가 된 뒤에만 앱을 시작한다. DB가 준비되기 전에 + 앱이 먼저 시작되어 반복적으로 실패하는 상황을 줄인다. +- 앱에도 `/health` 기반 상태 확인을 추가했다. 컨테이너 실행 여부뿐 아니라 실제 + 애플리케이션 응답 여부를 확인할 수 있다. +- Docker의 JSON 로그를 파일당 10MB, 최대 3개로 순환시킨다. 로그가 루트 디스크를 + 계속 채우는 것을 방지한다. +- `init: true`를 적용해 앱 컨테이너 내부의 종료 신호와 자식 프로세스를 정상적으로 + 처리한다. + +절대 경로에 데이터를 저장하는 것은 **컨테이너 재생성에 대한 보호**다. EC2 또는 +EBS 자체가 손실되는 상황까지 보호하지는 않는다. 현재 루트 EBS는 인스턴스 종료 시 +자동 삭제하지 않는 설정이지만, 볼륨 오삭제나 AZ 장애에 대비하려면 별도의 dump와 +EBS 스냅샷이 필요하다. + +### t4g.micro 자원 제한 + +새 서버는 메모리가 약 1GiB이므로 컨테이너별 상한을 둔다. + +| 컨테이너 | CPU 상한 | 메모리 예약 | 메모리 상한 | +|---|---:|---:|---:| +| 애플리케이션 | 0.75 vCPU | 320MB | 512MB | +| PostgreSQL | 0.50 vCPU | 128MB | 256MB | +| Redis | 0.25 vCPU | 64MB | 192MB | + +실측 테스트에서는 앱 약 340MiB, PostgreSQL 약 139MiB, Redis 약 10MiB를 사용했다. +다만 세 컨테이너가 동시에 상한까지 사용하면 호스트 메모리를 초과할 수 있다. 현재 +설정한 1GiB 비상 스왑은 순간적인 메모리 급증 때 종료 가능성을 낮추지만, 지속적인 +메모리 부족을 해결하지는 않는다. 스왑 사용량 증가, OOM 종료 또는 응답 지연이 +반복되면 `t4g.small`로 올리는 것이 다음 조치다. + +### GitHub Actions 배포 흐름 + +[`deploy-to-dev-ec2-docker.yml`](../.github/workflows/deploy-to-dev-ec2-docker.yml)은 +다음 순서로 동작한다. + +1. QEMU와 Buildx를 준비하고 앱의 `linux/arm64` 이미지를 만든다. +2. 이미지를 GHCR에 push하고, 배포에는 변경되지 않는 digest 참조값을 사용한다. + 같은 `dev-latest` 태그가 나중에 바뀌어도 현재 실행에서 선택한 이미지는 바뀌지 + 않는다. +3. Compose 파일, PostGIS Dockerfile, Nginx 설정을 `/srv/saerok`으로 복사한다. +4. GitHub Secrets와 Variables로 `/run/saerok/env.dev`를 만들고 권한을 제한한다. + 작업이 끝나면 이 임시 파일을 삭제한다. +5. 새 서버에서 PostGIS 이미지를 빌드하고 PostgreSQL과 Redis부터 시작한다. +6. 두 컨테이너가 정상 상태인지 확인한다. +7. `full` 모드라면 앱 이미지를 받고 앱만 새로 생성한 뒤 `/health`를 확인한다. + +수동 실행에서 `infrastructure-only`를 선택하면 6단계에서 멈춘다. 데이터 복원 전에 +새 앱이 빈 DB에 접속하거나 Flyway를 먼저 실행하지 않도록 하기 위한 이전 전용 +모드다. 복원이 끝난 뒤 `full`로 다시 실행하면 앱이 시작된다. + +`concurrency`는 개발 배포를 한 번에 하나만 실행하고, 진행 중인 배포를 새 실행이 +강제로 취소하지 않게 한다. `DEV_ARM64_AUTODEPLOY` 조건은 서버 전환 중 +`develop` push가 의도하지 않은 서버로 자동 배포되는 것을 막는다. 수동 실행은 이 +변수와 무관하게 가능하다. + +기존 JAR 직접 배포 워크플로 `deploy-to-dev-ec2.yml`은 제거한다. 두 배포 방식이 +동시에 남아 있으면 한쪽은 호스트 Java 프로세스를, 다른 쪽은 Docker 컨테이너를 +실행해 포트 충돌이나 서로 다른 버전 실행을 일으킬 수 있기 때문이다. + +### Nginx와 빌드 컨텍스트 + +[`saerok-dev-http.conf`](../deploy/nginx/saerok-dev-http.conf)는 두 개발 도메인의 HTTP +요청을 호스트 내부의 `127.0.0.1:8080`으로 전달한다. 실제 클라이언트 IP와 프로토콜 +정보, WebSocket 업그레이드 헤더를 앱에 넘기며 업로드 최대 크기는 20MB다. 첫 전환 +때는 HTTP 설정으로 문법과 연결을 확인하고, DNS 변경 후 Certbot이 HTTPS 설정을 +추가한다. + +루트의 [`.dockerignore`](../.dockerignore)는 `.env`, Git 메타데이터, 빌드 결과, +문서와 로컬 DB 데이터가 Docker 빌드 컨텍스트에 포함되지 않게 한다. 비밀값이나 큰 +데이터 디렉터리가 GitHub Actions의 빌드 엔진으로 전송되는 것을 막고 빌드 시간을 +줄인다. + +## 1. 자동 배포 잠금 확인 + +마이그레이션 중 `develop` 병합이 기존 서버에 자동 배포되지 않도록 워크플로가 +다음 조건을 사용한다. + +```text +DEV_ARM64_AUTODEPLOY == true +``` + +전환이 끝날 때까지 GitHub Repository variable `DEV_ARM64_AUTODEPLOY`은 만들지 +않거나 `false`로 둔다. + +이 브랜치의 워크플로와 Docker 설정을 먼저 `develop`에 반영한다. 이때 발생하는 +`develop` push는 위 조건 때문에 자동 배포되지 않는다. 새 ARM64 서버의 주소로 +`EC2_HOST`를 바꾸기 전에 반드시 이 변경부터 반영해야 기존 AMD64 전용 워크플로가 +새 ARM64 서버에서 실행되는 일을 막을 수 있다. + +## 2. 새 서버의 퍼블릭 IPv4 확인 + +Elastic IP는 연결하지 않는다. AWS EC2 콘솔에서 새 ARM64 인스턴스의 현재 +`퍼블릭 IPv4 주소`를 확인해 별도로 기록한다. 아직 Route 53 레코드는 변경하지 +않는다. + +코드가 `develop`에 반영된 다음 GitHub `dev` environment의 `EC2_HOST`를 방금 +확인한 새 퍼블릭 IPv4로 변경한다. 기존 키 페어를 재사용했으므로 +`EC2_SSH_PRIVATE_KEY`는 그대로 사용할 수 있다. + +이 단계부터 DNS 전환이 끝날 때까지는 새 인스턴스를 중지하지 않는다. 부득이하게 +중지/시작했다면 바뀐 퍼블릭 IPv4를 다시 확인하고 `EC2_HOST`부터 수정한 후 다음 +단계를 진행한다. + +## 3. 새 서버의 DB와 Redis만 시작 + +GitHub Actions에서 `Deploy Dev (Docker Compose via GHCR)`를 수동 실행하고 +`deploy_mode=infrastructure-only`를 선택한다. 실행 후 새 서버에서 확인한다. + +```bash +docker ps +docker inspect --format '{{.State.Health.Status}}' saerok-postgres-dev +docker inspect --format '{{.State.Health.Status}}' saerok-redis-dev +``` + +두 컨테이너 모두 `healthy`여야 한다. + +## 4. 기존 PostgreSQL 최종 백업 + +새 DB가 준비된 뒤, 기존 t2 서버에서 애플리케이션을 중지해 추가 +쓰기를 막고 dump를 만든다. + +```bash +docker stop saerok-dev +mkdir -p ~/migration-backup + +docker exec saerok-postgres-dev sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc --no-owner' \ + > ~/migration-backup/saerok-dev-final.dump + +test -s ~/migration-backup/saerok-dev-final.dump +sha256sum ~/migration-backup/saerok-dev-final.dump +docker exec -i saerok-postgres-dev pg_restore -l \ + < ~/migration-backup/saerok-dev-final.dump | head +``` + +DNS는 아직 기존 서버를 가리키므로 복원이 실패하면 `docker start saerok-dev`로 +기존 서비스를 즉시 되돌릴 수 있다. + +## 5. dump 전송 및 복원 + +로컬 PC를 경유해 기존 서버의 dump를 새 서버로 복사한다. + +```bash +scp -i dev-Saerok.pem \ + ubuntu@OLD_PUBLIC_IP:~/migration-backup/saerok-dev-final.dump . + +scp -i dev-Saerok.pem \ + saerok-dev-final.dump \ + ubuntu@NEW_PUBLIC_IP:/srv/saerok/backup/ +``` + +여기서 `OLD_PUBLIC_IP`와 `NEW_PUBLIC_IP`는 각각 AWS 콘솔에 표시되는 기존 서버와 +새 서버의 현재 퍼블릭 IPv4로 바꿔 입력한다. + +새 서버에서 복원한다. + +```bash +docker exec -i saerok-postgres-dev sh -lc \ + 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + --clean --if-exists --no-owner --no-privileges --exit-on-error' \ + < /srv/saerok/backup/saerok-dev-final.dump +``` + +복원 결과를 확인한다. + +```bash +docker exec saerok-postgres-dev sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc \ + "SELECT count(*) FROM flyway_schema_history; SELECT postgis_version();"' +``` + +## 6. 애플리케이션 시작 + +같은 GitHub Actions를 `deploy_mode=full`로 다시 실행한다. 새 서버에서 확인한다. + +```bash +curl -fsS http://127.0.0.1:8080/health +docker compose \ + --env-file /run/saerok/env.dev \ + -p saerok \ + -f /srv/saerok/docker-compose.yml \ + -f /srv/saerok/docker-compose.dev.yml ps +``` + +배포 종료 후 `/run/saerok/env.dev`는 보안상 삭제되므로 두 번째 명령은 배포 중 +진단용이다. 평상시에는 `docker ps`와 `docker logs`를 사용한다. + +## 7. Nginx와 HTTPS 전환 + +새 서버에서 HTTP 설정을 설치한다. + +```bash +sudo install -m 0644 \ + /srv/saerok/nginx/saerok-dev-http.conf \ + /etc/nginx/sites-available/saerok-dev +sudo ln -sfn /etc/nginx/sites-available/saerok-dev \ + /etc/nginx/sites-enabled/saerok-dev +sudo nginx -t +sudo systemctl reload nginx +``` + +AWS 콘솔에서 새 서버의 퍼블릭 IPv4가 2단계에서 기록한 값과 같은지 한 번 더 +확인한다. Route 53의 다음 A 레코드를 그 퍼블릭 IPv4로 변경한다. + +- `dev-api.saerok.app` +- `dev-admin.saerok.app` + +DNS 전파 후 인증서를 발급한다. + +```bash +sudo apt-get update +sudo apt-get install -y certbot python3-certbot-nginx +sudo certbot --nginx \ + -d dev-api.saerok.app \ + -d dev-admin.saerok.app +``` + +최종 확인: + +```bash +curl -fsS https://dev-api.saerok.app/health +curl -sSI https://dev-admin.saerok.app | head +sudo certbot renew --dry-run +``` + +## 8. 전환 완료 + +GitHub Repository variable `DEV_ARM64_AUTODEPLOY=true`를 설정해 `develop` push +자동 배포를 다시 활성화한다. 기존 t2 서버는 즉시 삭제하지 않고 3~7일 보관한다. +문제가 없으면 최종 EBS 스냅샷과 dump를 확인한 뒤 기존 인스턴스를 종료한다. +기존 루트 볼륨은 종료 시 자동 삭제되지 않으므로 보관 기간 이후 별도로 삭제한다. + +향후 새 개발 서버를 중지/시작하거나 인스턴스 유형을 변경했다면 아래 항목을 한 +묶음으로 처리한다. + +1. EC2 콘솔에서 새 퍼블릭 IPv4 확인 +2. GitHub `dev` environment의 `EC2_HOST` 수정 +3. Route 53의 `dev-api.saerok.app`, `dev-admin.saerok.app` A 레코드 수정 +4. 두 HTTPS 주소와 GitHub Actions 배포를 다시 확인 diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/KakaoLoginService.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/KakaoLoginService.java index da2bf4aa..675c432e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/KakaoLoginService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/KakaoLoginService.java @@ -1,6 +1,5 @@ package org.devkor.apu.saerok_server.domain.auth.application; -import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.auth.application.facade.AuthTokenService; import org.devkor.apu.saerok_server.domain.auth.core.dto.SocialUserInfo; import org.devkor.apu.saerok_server.domain.auth.core.repository.SocialAuthRepository; @@ -12,7 +11,6 @@ import org.devkor.apu.saerok_server.global.shared.util.dto.ClientInfo; import org.springframework.stereotype.Service; -@Slf4j @Service public class KakaoLoginService extends AbstractSocialLoginService { @@ -56,8 +54,6 @@ public LoginResult authenticate( userInfo = kakaoAuthClient.fetch(null, accessToken); } - log.info("[fixlog] sub: {}, email: {}, channel: {}", userInfo.sub(), userInfo.email(), channel); - return authenticateWithUserInfo(userInfo, ci); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/TokenRefreshService.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/TokenRefreshService.java index 13323ba2..79da9545 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/TokenRefreshService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/TokenRefreshService.java @@ -2,7 +2,6 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.auth.application.facade.AuthTokenService; import org.devkor.apu.saerok_server.domain.auth.core.entity.UserRefreshToken; import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; @@ -11,7 +10,6 @@ import org.devkor.apu.saerok_server.global.shared.util.dto.ClientInfo; import org.springframework.stereotype.Service; -@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -29,9 +27,6 @@ public class TokenRefreshService { * @param refreshTokenCookie 클라이언트가 보낸 리프레시 토큰 */ public LoginResult refresh(String refreshTokenCookie, ClientInfo clientInfo) { - - log.info("refreshTokenCookie: {}", refreshTokenCookie); - UserRefreshToken userRefreshToken = userRefreshTokenRepository .findByRefreshTokenHash(refreshTokenProvider.hash(refreshTokenCookie)) .orElseThrow(() -> new UnauthorizedException("리프레시 토큰이 유효하지 않아요 (not found)")); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/AppleApiClient.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/AppleApiClient.java index d9f35d2b..b2cf0e58 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/AppleApiClient.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/AppleApiClient.java @@ -43,7 +43,7 @@ public AppleTokenResponse requestToken(String authorizationCode) { .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(AppleErrorResponse.class).flatMap(error -> { - log.error("Apple 인증 에러: {} (code: {})", error.getError(), authorizationCode); + log.error("Apple 인증 에러: {}", error.getError()); RuntimeException ex = switch (error.getError()) { case "invalid_grant" -> new OAuthException("유효하지 않거나 만료된 인가 코드", 401); @@ -58,17 +58,15 @@ public AppleTokenResponse requestToken(String authorizationCode) { try { response = responseMono.block(); } catch (RuntimeException e) { - log.error("Apple 인증 서버 통신 중 예외 발생 (code: {})", authorizationCode, e); + log.error("Apple 인증 서버 통신 중 예외 발생", e); throw e; } if (response == null || response.getIdToken() == null) { - log.error("Apple 인증 서버 응답 오류: idToken 없음 (code: {})", authorizationCode); + log.error("Apple 인증 서버 응답 오류: idToken 없음"); throw new IllegalStateException("Apple 인증 서버 응답 오류"); } - log.info("Apple response 전체 정보: {}", response); - return response; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/KakaoApiClient.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/KakaoApiClient.java index 0799c1c2..b2dc4066 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/KakaoApiClient.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/infra/KakaoApiClient.java @@ -52,7 +52,7 @@ public String requestIdToken(String authorizationCode, String redirectUriOverrid .retrieve() .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(KakaoErrorResponse.class).flatMap(error -> { - log.error("Kakao 인증 에러: {} (code: {}), redirect_uri={}", error.getErrorCode(), authorizationCode, redirectUri); + log.error("Kakao 인증 에러: {}", error.getErrorCode()); RuntimeException ex = switch (error.getErrorCode()) { case "KOE320" -> new OAuthException("유효하지 않거나 만료된 인가 토큰", 401); @@ -68,12 +68,12 @@ public String requestIdToken(String authorizationCode, String redirectUriOverrid try { response = responseMono.block(); } catch (RuntimeException e) { - log.error("Kakao 인증 서버 통신 중 예외 발생 (code: {})", authorizationCode); + log.error("Kakao 인증 서버 통신 중 예외 발생"); throw e; } if (response == null || response.getIdToken() == null) { - log.error("Kakao 인증 서버 응답 오류: idToken 없음 (code: {})", authorizationCode); + log.error("Kakao 인증 서버 응답 오류: idToken 없음"); throw new IllegalStateException("Kakao 인증 서버 응답 오류"); } diff --git a/src/main/java/org/devkor/apu/saerok_server/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/devkor/apu/saerok_server/global/security/jwt/JwtAuthenticationFilter.java index e05dbe66..32fc6b3c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/security/jwt/JwtAuthenticationFilter.java @@ -44,7 +44,7 @@ protected void doFilterInternal(HttpServletRequest request, // log.info("JWT 인증 성공 - id: {}", userId); } catch (Exception e) { - log.warn("JWT 인증 실패: {}", e.getMessage()); + log.warn("JWT 인증 실패: {}", e.getClass().getSimpleName()); jwtAuthenticationEntryPoint.commence(request, response, new InsufficientAuthenticationException("JWT Invalid", e)); return; From 9e157d97df1250d77904817ae4e8a47ca096b18c Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 5 Sep 2026 16:01:37 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B0=B0=ED=8F=AC=20=EA=B2=BD=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/deploy-to-dev-ec2-docker.yml | 30 ++++++++++++++----- deploy/docker-compose.dev.yml | 9 ++++-- docs/dev-t4g-migration-runbook.md | 20 +++++++------ src/main/resources/application-dev.yml | 8 +++-- src/main/resources/application-prod.yml | 5 ++-- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/.github/workflows/deploy-to-dev-ec2-docker.yml b/.github/workflows/deploy-to-dev-ec2-docker.yml index 58214a83..bab02734 100644 --- a/.github/workflows/deploy-to-dev-ec2-docker.yml +++ b/.github/workflows/deploy-to-dev-ec2-docker.yml @@ -74,6 +74,17 @@ jobs: cache-from: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache-arm64 cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache-arm64,mode=max + - name: 📁 Prepare deployment directory + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + script: | + set -euo pipefail + # 최초 배포 시에도 사용자가 디렉터리를 직접 만들 필요가 없게 한다. + install -d -m 0750 "${HOME}/saerok" + - name: 📤 Copy compose files to EC2 uses: appleboy/scp-action@v0.1.7 with: @@ -81,7 +92,8 @@ jobs: username: ubuntu key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} source: "deploy/docker-compose.yml,deploy/docker-compose.dev.yml,deploy/postgres-ko/Dockerfile,deploy/nginx/saerok-dev-http.conf" - target: /srv/saerok/ + # 기존 개발 서버와 동일하게 ubuntu 사용자의 홈 아래에 배포한다. + target: ~/saerok/ strip_components: 1 - name: 🚀 Deploy on EC2 (pull & up) @@ -95,18 +107,22 @@ jobs: DEPLOY_MODE="${{ inputs.deploy_mode || 'full' }}" IMAGE_REF="${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" + DEPLOY_DIR="${HOME}/saerok" - # 기존 DB 디렉터리의 소유권은 강제로 바꾸지 않는다. + # 최초 배포에서도 별도 서버 설정 없이 필요한 영속 디렉터리를 자동 생성한다. + # 이미 생성된 DB 디렉터리의 소유권은 강제로 바꾸지 않는다. # 각 컨테이너의 시작 스크립트가 데이터 디렉터리 소유권을 관리한다. - for data_path in \ - /srv/saerok/data/postgres \ - /srv/saerok/data/redis \ - /srv/saerok/backup; do + for relative_path in \ + data/postgres \ + data/redis \ + backup \ + accesslogs; do + data_path="${DEPLOY_DIR}/${relative_path}" if [ ! -d "$data_path" ]; then sudo install -d -m 0750 -o "$(id -un)" -g "$(id -gn)" "$data_path" fi done - cd /srv/saerok + cd "$DEPLOY_DIR" # 0) Docker & Compose 확인 docker --version diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 985efb33..a3611ec2 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -31,6 +31,10 @@ services: SPRING_DATA_REDIS_HOST: redis SPRING_DATA_REDIS_PORT: "6379" + volumes: + # 컨테이너를 다시 만들어도 접근 로그가 ~/saerok/accesslogs에 남는다. + - ./accesslogs:/app/accesslogs + depends_on: postgres: condition: service_healthy @@ -62,7 +66,8 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_INITDB_ARGS: "--locale=ko_KR.utf8 --encoding=UTF8" volumes: - - /srv/saerok/data/postgres:/var/lib/postgresql/data + # 상대 경로는 Compose 파일이 있는 ~/saerok을 기준으로 해석된다. + - ./data/postgres:/var/lib/postgresql/data cpus: "0.50" mem_limit: "256m" @@ -109,7 +114,7 @@ services: - "--maxmemory-policy" - "allkeys-lru" volumes: - - /srv/saerok/data/redis:/data + - ./data/redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s diff --git a/docs/dev-t4g-migration-runbook.md b/docs/dev-t4g-migration-runbook.md index 0f55964e..60bb1cea 100644 --- a/docs/dev-t4g-migration-runbook.md +++ b/docs/dev-t4g-migration-runbook.md @@ -5,7 +5,7 @@ - Ubuntu 24.04 ARM64 / `t4g.micro` - 앱, PostgreSQL 17 + PostGIS, Redis를 Docker Compose로 실행 - 앱은 호스트의 `127.0.0.1:8080`에만 바인딩하고 Nginx가 80/443을 프록시 -- PostgreSQL과 Redis 데이터는 `/srv/saerok/data`에 영속화 +- 배포 파일과 영속 데이터는 기존 개발 서버와 동일한 `~/saerok` 아래에 배치 - 기존 개발 서버와 동일하게 Elastic IP는 사용하지 않고, EC2의 일반 퍼블릭 IPv4를 사용 - 기존 t2 서버는 검증과 DNS 전환이 끝날 때까지 유지 @@ -50,9 +50,11 @@ PostGIS 패키지를 설치하도록 변경했다. 기존 DB와 동일하게 `ko 네트워크에서 `postgres:5432`로 접속한다. - DB 접속 주소를 `jdbc:postgresql://postgres:5432/saerok`으로 고정한다. 개발 환경에서는 더 이상 외부 RDS 주소를 사용하지 않는다. -- PostgreSQL 데이터는 `/srv/saerok/data/postgres`, Redis 데이터는 - `/srv/saerok/data/redis`에 저장한다. 컨테이너를 삭제하거나 이미지를 교체해도 이 +- PostgreSQL 데이터는 `~/saerok/data/postgres`, Redis 데이터는 + `~/saerok/data/redis`에 저장한다. 컨테이너를 삭제하거나 이미지를 교체해도 이 디렉터리는 남는다. +- 애플리케이션 접근 로그는 `~/saerok/accesslogs`에 저장한다. 컨테이너 내부의 + `/app/accesslogs`와 연결하므로 앱 컨테이너를 다시 만들어도 로그가 유지된다. - PostgreSQL과 Redis가 `healthy`가 된 뒤에만 앱을 시작한다. DB가 준비되기 전에 앱이 먼저 시작되어 반복적으로 실패하는 상황을 줄인다. - 앱에도 `/health` 기반 상태 확인을 추가했다. 컨테이너 실행 여부뿐 아니라 실제 @@ -92,7 +94,7 @@ EBS 스냅샷이 필요하다. 2. 이미지를 GHCR에 push하고, 배포에는 변경되지 않는 digest 참조값을 사용한다. 같은 `dev-latest` 태그가 나중에 바뀌어도 현재 실행에서 선택한 이미지는 바뀌지 않는다. -3. Compose 파일, PostGIS Dockerfile, Nginx 설정을 `/srv/saerok`으로 복사한다. +3. Compose 파일, PostGIS Dockerfile, Nginx 설정을 `~/saerok`으로 복사한다. 4. GitHub Secrets와 Variables로 `/run/saerok/env.dev`를 만들고 권한을 제한한다. 작업이 끝나면 이 임시 파일을 삭제한다. 5. 새 서버에서 PostGIS 이미지를 빌드하고 PostgreSQL과 Redis부터 시작한다. @@ -201,7 +203,7 @@ scp -i dev-Saerok.pem \ scp -i dev-Saerok.pem \ saerok-dev-final.dump \ - ubuntu@NEW_PUBLIC_IP:/srv/saerok/backup/ + ubuntu@NEW_PUBLIC_IP:~/saerok/backup/ ``` 여기서 `OLD_PUBLIC_IP`와 `NEW_PUBLIC_IP`는 각각 AWS 콘솔에 표시되는 기존 서버와 @@ -213,7 +215,7 @@ scp -i dev-Saerok.pem \ docker exec -i saerok-postgres-dev sh -lc \ 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ --clean --if-exists --no-owner --no-privileges --exit-on-error' \ - < /srv/saerok/backup/saerok-dev-final.dump + < ~/saerok/backup/saerok-dev-final.dump ``` 복원 결과를 확인한다. @@ -233,8 +235,8 @@ curl -fsS http://127.0.0.1:8080/health docker compose \ --env-file /run/saerok/env.dev \ -p saerok \ - -f /srv/saerok/docker-compose.yml \ - -f /srv/saerok/docker-compose.dev.yml ps + -f ~/saerok/docker-compose.yml \ + -f ~/saerok/docker-compose.dev.yml ps ``` 배포 종료 후 `/run/saerok/env.dev`는 보안상 삭제되므로 두 번째 명령은 배포 중 @@ -246,7 +248,7 @@ docker compose \ ```bash sudo install -m 0644 \ - /srv/saerok/nginx/saerok-dev-http.conf \ + ~/saerok/nginx/saerok-dev-http.conf \ /etc/nginx/sites-available/saerok-dev sudo ln -sfn /etc/nginx/sites-available/saerok-dev \ /etc/nginx/sites-enabled/saerok-dev diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a359f355..2f4c92a1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -26,11 +26,13 @@ server: tomcat: accesslog: enabled: true - directory: /home/ubuntu/saerok/accesslogs + # Docker 호스트의 ~/saerok/accesslogs와 연결되는 컨테이너 내부 경로 + directory: /app/accesslogs prefix: access_log suffix: .log rotate: true - pattern: '%h %t "%m %U%q" %s %b %D' + # OAuth 인가 코드 등이 포함될 수 있는 쿼리 문자열(%q)은 기록하지 않는다. + pattern: '%h %t "%m %U" %s %b %D' logging: level: @@ -81,4 +83,4 @@ app: cors: allowed-origins: - http://localhost:3000 - - http://localhost:5173 \ No newline at end of file + - http://localhost:5173 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 51548c6e..a658bd34 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -30,7 +30,8 @@ server: prefix: access_log suffix: .log rotate: true - pattern: '%h %t "%m %U%q" %s %b %D' + # OAuth 인가 코드 등이 포함될 수 있는 쿼리 문자열(%q)은 기록하지 않는다. + pattern: '%h %t "%m %U" %s %b %D' logging: level: @@ -85,4 +86,4 @@ cors: allowed-origins: - https://saerok.app - https://www.saerok.app - - http://localhost:5173 \ No newline at end of file + - http://localhost:5173 From 7b64a90acc7a3892c52cb301a3043b3811985d88 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 5 Sep 2026 16:57:17 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20ARM64=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/deploy-to-dev-ec2-docker.yml | 9 + deploy/nginx/saerok-dev-http.conf | 23 ++- docs/dev-t4g-migration-runbook.md | 170 +++++++++++++++--- 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/.github/workflows/deploy-to-dev-ec2-docker.yml b/.github/workflows/deploy-to-dev-ec2-docker.yml index bab02734..44feb568 100644 --- a/.github/workflows/deploy-to-dev-ec2-docker.yml +++ b/.github/workflows/deploy-to-dev-ec2-docker.yml @@ -196,6 +196,15 @@ jobs: sleep 4 done + # PostGIS 패키지 설치와 DB 확장 활성화는 별개다. + # 빈 DB에서도 데이터 복원 전에 geometry 타입을 사용할 수 있도록 항상 확인한다. + docker exec saerok-postgres-dev sh -lc \ + 'psql -v ON_ERROR_STOP=1 \ + -U "$POSTGRES_USER" \ + -d "$POSTGRES_DB" \ + -c "CREATE EXTENSION IF NOT EXISTS postgis;" \ + -c "SELECT postgis_version();"' + if [ "$DEPLOY_MODE" = "infrastructure-only" ]; then docker compose --env-file "$ENV_FILE" -p saerok -f docker-compose.yml -f docker-compose.dev.yml ps echo "Infrastructure is ready for pg_restore; application was not started." diff --git a/deploy/nginx/saerok-dev-http.conf b/deploy/nginx/saerok-dev-http.conf index bd6da640..6d796042 100644 --- a/deploy/nginx/saerok-dev-http.conf +++ b/deploy/nginx/saerok-dev-http.conf @@ -6,7 +6,7 @@ map $http_upgrade $connection_upgrade { server { listen 80; listen [::]:80; - server_name dev-api.saerok.app dev-admin.saerok.app; + server_name dev-api.saerok.app; client_max_body_size 20m; @@ -22,3 +22,24 @@ server { proxy_read_timeout 60s; } } + +server { + listen 80; + listen [::]:80; + server_name dev-admin.saerok.app; + + client_max_body_size 20m; + + location / { + # 관리자 애플리케이션은 별도 컨테이너의 8081 포트로 전달한다. + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + 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_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 60s; + } +} diff --git a/docs/dev-t4g-migration-runbook.md b/docs/dev-t4g-migration-runbook.md index 60bb1cea..2a953fc6 100644 --- a/docs/dev-t4g-migration-runbook.md +++ b/docs/dev-t4g-migration-runbook.md @@ -3,8 +3,9 @@ ## 목표 구성 - Ubuntu 24.04 ARM64 / `t4g.micro` -- 앱, PostgreSQL 17 + PostGIS, Redis를 Docker Compose로 실행 -- 앱은 호스트의 `127.0.0.1:8080`에만 바인딩하고 Nginx가 80/443을 프록시 +- API, 관리자 앱, PostgreSQL 17 + PostGIS, Redis를 Docker Compose로 실행 +- API는 `127.0.0.1:8080`, 관리자 앱은 `127.0.0.1:8081`에만 바인딩하고 + Nginx가 도메인별로 80/443 요청을 전달 - 배포 파일과 영속 데이터는 기존 개발 서버와 동일한 `~/saerok` 아래에 배치 - 기존 개발 서버와 동일하게 Elastic IP는 사용하지 않고, EC2의 일반 퍼블릭 IPv4를 사용 - 기존 t2 서버는 검증과 DNS 전환이 끝날 때까지 유지 @@ -78,12 +79,14 @@ EBS 스냅샷이 필요하다. | 애플리케이션 | 0.75 vCPU | 320MB | 512MB | | PostgreSQL | 0.50 vCPU | 128MB | 256MB | | Redis | 0.25 vCPU | 64MB | 192MB | +| 관리자 앱 | 0.25 vCPU | 64MB | 256MB | -실측 테스트에서는 앱 약 340MiB, PostgreSQL 약 139MiB, Redis 약 10MiB를 사용했다. -다만 세 컨테이너가 동시에 상한까지 사용하면 호스트 메모리를 초과할 수 있다. 현재 -설정한 1GiB 비상 스왑은 순간적인 메모리 급증 때 종료 가능성을 낮추지만, 지속적인 -메모리 부족을 해결하지는 않는다. 스왑 사용량 증가, OOM 종료 또는 응답 지연이 -반복되면 `t4g.small`로 올리는 것이 다음 조치다. +기존 개발 서버 실측값은 API 약 251MiB, PostgreSQL 약 60MiB, Redis 약 1.4MiB, +관리자 앱 약 34MiB로 합계 약 347MiB였다. 낮은 트래픽에서는 `t4g.micro`로 시작할 +수 있지만 모든 컨테이너가 동시에 상한까지 사용하면 호스트 메모리를 초과한다. 현재 +설정한 1GiB 비상 스왑은 순간적인 메모리 급증 때 종료 가능성을 낮출 뿐, 지속적인 +메모리 부족을 해결하지는 않는다. 전체 배포 뒤 가용 메모리와 스왑을 반드시 다시 +측정하고, 스왑 증가, OOM 종료 또는 응답 지연이 반복되면 `t4g.small`로 올린다. ### GitHub Actions 배포 흐름 @@ -99,7 +102,8 @@ EBS 스냅샷이 필요하다. 작업이 끝나면 이 임시 파일을 삭제한다. 5. 새 서버에서 PostGIS 이미지를 빌드하고 PostgreSQL과 Redis부터 시작한다. 6. 두 컨테이너가 정상 상태인지 확인한다. -7. `full` 모드라면 앱 이미지를 받고 앱만 새로 생성한 뒤 `/health`를 확인한다. +7. 빈 DB에도 PostGIS 확장을 활성화하고 버전을 확인한다. +8. `full` 모드라면 앱 이미지를 받고 앱만 새로 생성한 뒤 `/health`를 확인한다. 수동 실행에서 `infrastructure-only`를 선택하면 6단계에서 멈춘다. 데이터 복원 전에 새 앱이 빈 DB에 접속하거나 Flyway를 먼저 실행하지 않도록 하기 위한 이전 전용 @@ -116,11 +120,12 @@ EBS 스냅샷이 필요하다. ### Nginx와 빌드 컨텍스트 -[`saerok-dev-http.conf`](../deploy/nginx/saerok-dev-http.conf)는 두 개발 도메인의 HTTP -요청을 호스트 내부의 `127.0.0.1:8080`으로 전달한다. 실제 클라이언트 IP와 프로토콜 -정보, WebSocket 업그레이드 헤더를 앱에 넘기며 업로드 최대 크기는 20MB다. 첫 전환 -때는 HTTP 설정으로 문법과 연결을 확인하고, DNS 변경 후 Certbot이 HTTPS 설정을 -추가한다. +[`saerok-dev-http.conf`](../deploy/nginx/saerok-dev-http.conf)는 +`dev-api.saerok.app`을 `127.0.0.1:8080`으로, +`dev-admin.saerok.app`을 `127.0.0.1:8081`로 전달한다. 실제 클라이언트 IP와 +프로토콜 정보, WebSocket 업그레이드 헤더를 앱에 넘기며 업로드 최대 크기는 20MB다. +첫 전환 때는 HTTP 설정으로 두 localhost 연결을 확인하고, DNS 변경 후 Certbot이 +HTTPS 설정을 추가한다. 루트의 [`.dockerignore`](../.dockerignore)는 `.env`, Git 메타데이터, 빌드 결과, 문서와 로컬 DB 데이터가 Docker 빌드 컨텍스트에 포함되지 않게 한다. 비밀값이나 큰 @@ -171,17 +176,109 @@ docker inspect --format '{{.State.Health.Status}}' saerok-redis-dev 두 컨테이너 모두 `healthy`여야 한다. -## 4. 기존 PostgreSQL 최종 백업 +PostGIS도 자동 활성화되어야 한다. + +```bash +docker exec saerok-postgres-dev sh -lc \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc \ + "SELECT postgis_version();"' +``` + +## 4. 관리자 앱 ARM64 배포 + +`saerok-admin` 저장소의 ARM64 변경을 먼저 `develop`에 반영한다. 전환이 끝날 +때까지 해당 저장소의 `DEV_ARM64_AUTODEPLOY`도 만들지 않거나 `false`로 둔다. +머지 작업이 `Skipped`된 것을 확인한 뒤 `dev` Environment Secret `EC2_HOST`를 +새 서버의 퍼블릭 IPv4로 바꾼다. + +Admin 저장소의 `Deploy Admin to Dev (ARM64 EC2 + Docker Compose)`를 수동 +실행한다. 새 서버에서 확인한다. + +```bash +docker inspect --format \ + 'status={{.State.Status}} health={{.State.Health.Status}} ports={{json .HostConfig.PortBindings}}' \ + saerok-admin-dev +curl -fsSI http://127.0.0.1:8081/login +``` + +8081 포트는 `127.0.0.1`에만 바인딩되어야 한다. 전체 컨테이너의 실제 자원 사용량도 +확인한다. + +```bash +free -h +swapon --show +docker stats --no-stream \ + --format 'table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' +``` + +가용 메모리가 지속적으로 150MiB 미만이거나 스왑 사용량이 계속 증가하면 최종 +전환 전에 `t4g.small`로 올린다. + +## 5. 무중단 시험 백업과 복원 + +기존 앱을 중지하기 전에 시험 dump를 만들어 새 PostgreSQL 17.11과 PostGIS +3.6에서 복원되는지 확인한다. `pg_dump`는 일관된 스냅샷을 사용하므로 시험 중에도 +기존 서비스를 계속 운영할 수 있다. + +기존 서버에서 실행한다. + +```bash +mkdir -p ~/migration-backup + +docker exec saerok-postgres-dev sh -lc \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -Fc --no-owner --no-privileges' \ + > ~/migration-backup/saerok-dev-trial.dump + +test -s ~/migration-backup/saerok-dev-trial.dump +sha256sum ~/migration-backup/saerok-dev-trial.dump +docker exec -i saerok-postgres-dev pg_restore -l \ + < ~/migration-backup/saerok-dev-trial.dump | head +``` + +로컬 PC를 경유해 신규 서버로 전송한다. + +```bash +scp -i dev-Saerok.pem \ + ubuntu@OLD_PUBLIC_IP:~/migration-backup/saerok-dev-trial.dump . + +scp -i dev-Saerok.pem \ + saerok-dev-trial.dump \ + ubuntu@NEW_PUBLIC_IP:~/saerok/backup/ +``` + +신규 서버에서 복원하고 확인한다. + +```bash +docker exec -i saerok-postgres-dev sh -lc \ + 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + --clean --if-exists --no-owner --no-privileges --exit-on-error' \ + < ~/saerok/backup/saerok-dev-trial.dump + +docker exec saerok-postgres-dev sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc \ + "SELECT count(*) FROM flyway_schema_history; + SELECT postgis_version(); + SELECT pg_size_pretty(pg_database_size(current_database())); + SELECT count(*) FROM users; + SELECT count(*) FROM user_bird_collection;"' +``` + +Flyway 이력은 기존과 같은 93개여야 한다. 시험 복원이 실패해도 기존 앱과 DNS에는 +영향이 없다. + +## 6. 기존 PostgreSQL 최종 백업 새 DB가 준비된 뒤, 기존 t2 서버에서 애플리케이션을 중지해 추가 쓰기를 막고 dump를 만든다. ```bash -docker stop saerok-dev +docker stop saerok-dev saerok-admin-dev mkdir -p ~/migration-backup docker exec saerok-postgres-dev sh -lc \ - 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc --no-owner' \ + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -Fc --no-owner --no-privileges' \ > ~/migration-backup/saerok-dev-final.dump test -s ~/migration-backup/saerok-dev-final.dump @@ -190,10 +287,14 @@ docker exec -i saerok-postgres-dev pg_restore -l \ < ~/migration-backup/saerok-dev-final.dump | head ``` -DNS는 아직 기존 서버를 가리키므로 복원이 실패하면 `docker start saerok-dev`로 -기존 서비스를 즉시 되돌릴 수 있다. +DNS는 아직 기존 서버를 가리키므로 복원이 실패하면 아래 명령으로 기존 서비스를 +즉시 되돌릴 수 있다. + +```bash +docker start saerok-dev saerok-admin-dev +``` -## 5. dump 전송 및 복원 +## 7. 최종 dump 전송 및 복원 로컬 PC를 경유해 기존 서버의 dump를 새 서버로 복사한다. @@ -226,12 +327,13 @@ docker exec saerok-postgres-dev sh -lc \ "SELECT count(*) FROM flyway_schema_history; SELECT postgis_version();"' ``` -## 6. 애플리케이션 시작 +## 8. API 애플리케이션 시작 같은 GitHub Actions를 `deploy_mode=full`로 다시 실행한다. 새 서버에서 확인한다. ```bash curl -fsS http://127.0.0.1:8080/health +curl -fsSI http://127.0.0.1:8081/login docker compose \ --env-file /run/saerok/env.dev \ -p saerok \ @@ -242,7 +344,9 @@ docker compose \ 배포 종료 후 `/run/saerok/env.dev`는 보안상 삭제되므로 두 번째 명령은 배포 중 진단용이다. 평상시에는 `docker ps`와 `docker logs`를 사용한다. -## 7. Nginx와 HTTPS 전환 +두 localhost 확인이 모두 성공해야 Nginx와 DNS 전환을 진행한다. + +## 9. Nginx와 HTTPS 전환 새 서버에서 HTTP 설정을 설치한다. @@ -256,6 +360,13 @@ sudo nginx -t sudo systemctl reload nginx ``` +DNS 변경 전에 도메인 Host 헤더로 두 프록시를 확인한다. + +```bash +curl -fsS -H 'Host: dev-api.saerok.app' http://127.0.0.1/health +curl -fsSI -H 'Host: dev-admin.saerok.app' http://127.0.0.1/login +``` + AWS 콘솔에서 새 서버의 퍼블릭 IPv4가 2단계에서 기록한 값과 같은지 한 번 더 확인한다. Route 53의 다음 A 레코드를 그 퍼블릭 IPv4로 변경한다. @@ -280,17 +391,22 @@ curl -sSI https://dev-admin.saerok.app | head sudo certbot renew --dry-run ``` -## 8. 전환 완료 +## 10. 전환 완료 + +BE와 Admin 두 저장소에 GitHub Repository variable +`DEV_ARM64_AUTODEPLOY=true`를 설정해 `develop` push 자동 배포를 다시 +활성화한다. 기존 t2 서버는 즉시 삭제하지 않고 중지 상태로 3~7일 보관한다. 문제가 +없으면 최종 EBS 스냅샷과 dump를 확인한 뒤 기존 인스턴스를 종료한다. 기존 루트 +볼륨은 종료 시 자동 삭제되지 않으므로 보관 기간 이후 별도로 삭제한다. -GitHub Repository variable `DEV_ARM64_AUTODEPLOY=true`를 설정해 `develop` push -자동 배포를 다시 활성화한다. 기존 t2 서버는 즉시 삭제하지 않고 3~7일 보관한다. -문제가 없으면 최종 EBS 스냅샷과 dump를 확인한 뒤 기존 인스턴스를 종료한다. -기존 루트 볼륨은 종료 시 자동 삭제되지 않으므로 보관 기간 이후 별도로 삭제한다. +Redis에는 광고 중복 방지용 단기 키와 만료되는 알림 배치가 저장된다. 개발 서버 +전환에서는 기존 Redis 데이터를 옮기지 않으므로 전환 시점에 남은 임시 키와 미처리 +배치는 사라질 수 있다. 향후 새 개발 서버를 중지/시작하거나 인스턴스 유형을 변경했다면 아래 항목을 한 묶음으로 처리한다. 1. EC2 콘솔에서 새 퍼블릭 IPv4 확인 -2. GitHub `dev` environment의 `EC2_HOST` 수정 +2. BE와 Admin 저장소 `dev` environment의 `EC2_HOST` 수정 3. Route 53의 `dev-api.saerok.app`, `dev-admin.saerok.app` A 레코드 수정 4. 두 HTTPS 주소와 GitHub Actions 배포를 다시 확인 From 578d78765e7d609c419ed7c772b0e584c3502640 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Fri, 11 Sep 2026 00:36:08 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20EC2=20ARM64?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/deploy-to-prod-ec2-docker.yml | 40 ++++++++- .github/workflows/deploy-to-prod-ec2.yml | 88 ------------------- 2 files changed, 36 insertions(+), 92 deletions(-) delete mode 100644 .github/workflows/deploy-to-prod-ec2.yml diff --git a/.github/workflows/deploy-to-prod-ec2-docker.yml b/.github/workflows/deploy-to-prod-ec2-docker.yml index a003c2ca..f03fa6ea 100644 --- a/.github/workflows/deploy-to-prod-ec2-docker.yml +++ b/.github/workflows/deploy-to-prod-ec2-docker.yml @@ -3,15 +3,31 @@ name: Deploy to Prod (EC2 Docker Compose via GHCR) on: push: branches: [ main ] - workflow_dispatch: {} + workflow_dispatch: + inputs: + deploy_mode: + description: 앱을 배포하거나 이전 검증을 위해 이미지만 빌드·업로드합니다 + type: choice + required: true + default: full + options: + - full + - image-only + +concurrency: + group: saerok-prod-deploy + cancel-in-progress: false jobs: deploy: + if: ${{ github.event_name == 'workflow_dispatch' || vars.PROD_ARM64_AUTODEPLOY == 'true' }} runs-on: ubuntu-latest permissions: contents: read packages: write environment: prod + env: + DEPLOY_MODE: ${{ inputs.deploy_mode || 'full' }} steps: - name: Checkout @@ -21,6 +37,11 @@ jobs: id: normalize run: echo "image_repo=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - name: Set up ARM64 emulation + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: Set up Buildx uses: docker/setup-buildx-action@v3 @@ -37,8 +58,9 @@ jobs: with: images: ${{ steps.normalize.outputs.image_repo }} tags: | - type=raw,value=prod-latest - type=sha,format=long,prefix=prod- + type=raw,value=prod-latest,enable=${{ env.DEPLOY_MODE == 'full' }} + type=sha,format=long,prefix=prod-,enable=${{ env.DEPLOY_MODE == 'full' }} + type=raw,value=migration-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ env.DEPLOY_MODE == 'image-only' }} - name: Build & Push (BE image) id: build @@ -46,6 +68,7 @@ jobs: with: context: . file: ./Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -53,6 +76,7 @@ jobs: cache-to: type=registry,ref=${{ steps.normalize.outputs.image_repo }}:buildcache,mode=max - name: Copy compose to EC2 + if: ${{ env.DEPLOY_MODE == 'full' }} uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.EC2_HOST }} @@ -63,6 +87,7 @@ jobs: strip_components: 1 - name: Deploy on EC2 + if: ${{ env.DEPLOY_MODE == 'full' }} uses: appleboy/ssh-action@v1 with: host: ${{ secrets.EC2_HOST }} @@ -120,16 +145,22 @@ jobs: docker pull "$IMAGE_REF" # 이번 릴리스 digest를 사용해 재기동 - docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --force-recreate --quiet-pull app + docker compose --env-file "$ENV_FILE" -p saerok-prod -f docker-compose.yml -f docker-compose.prod.yml up -d --force-recreate --quiet-pull app echo "Waiting for BE /health..." + healthy=false for i in {1..40}; do if curl -fsS --max-time 2 http://127.0.0.1:8080/health >/dev/null; then echo "✅ BE is healthy" + healthy=true break fi echo "⏳ ($i/40)"; sleep 3 done + if [ "$healthy" != true ]; then + echo 'BE health check timed out' >&2 + exit 1 + fi docker image prune -af --filter "until=168h" || true docker builder prune -af --filter "until=168h" || true @@ -139,6 +170,7 @@ jobs: run: | { echo "## Prod deploy" + echo "- Mode: $DEPLOY_MODE" echo "- Image: ${{ steps.normalize.outputs.image_repo }}@${{ steps.build.outputs.digest }}" echo "- Commit: ${{ github.sha }}" echo "- Actor: ${{ github.actor }}" diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml deleted file mode 100644 index 81c09bfd..00000000 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Deploy to Prod EC2 - -on: - workflow_dispatch: - -jobs: - deploy: - name: Build and Deploy to EC2 - runs-on: ubuntu-latest - environment: prod - - steps: - - name: Checkout source code - uses: actions/checkout@v3 - - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - distribution: 'corretto' - java-version: '21' - - - name: Grant execute permission for Gradle - run: chmod +x gradlew - - - name: Fix Docker API compatibility for Testcontainers - run: echo 'api.version=1.44' > ~/.docker-java.properties - - - name: Build with Gradle - run: ./gradlew test bootJar - - - name: Copy JAR to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} - source: build/libs/*.jar - target: ~/saerok/ - strip_components: '2' - - - name: Restart application on EC2 - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} - debug: true - script: | - set -euxo pipefail - - cd ~/saerok - echo ">>> 현재 위치: $(pwd)" - echo ">>> 파일:" - ls -al - - # JAR 확인 - JAR_NAME=$(ls -1t *.jar | head -n1) - [ -n "$JAR_NAME" ] || { echo "❌ JAR 없음"; exit 2; } - echo ">>> 선택된 JAR: $JAR_NAME" - - # 기존 프로세스 종료 - pkill -f "$JAR_NAME" || true - - # 백그라운드 실행 - DB_URL="${{ secrets.DB_URL }}" \ - DB_USERNAME="${{ secrets.DB_USERNAME }}" \ - DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ - IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \ - IAM_SECRET_KEY="${{ secrets.IAM_SECRET_KEY }}" \ - UPLOAD_IMAGE_BUCKET_NAME="${{ vars.UPLOAD_IMAGE_BUCKET_NAME }}" \ - UPLOAD_IMAGE_DOMAIN="${{ vars.UPLOAD_IMAGE_DOMAIN }}" \ - DEX_IMAGE_BUCKET_NAME="${{ vars.DEX_IMAGE_BUCKET_NAME }}" \ - DEX_IMAGE_DOMAIN="${{ vars.DEX_IMAGE_DOMAIN }}" \ - APPLE_CLIENT_ID="${{ secrets.APPLE_CLIENT_ID }}" \ - APPLE_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}" \ - APPLE_KEY_ID="${{ secrets.APPLE_KEY_ID }}" \ - APPLE_PRIVATE_KEY="${{ secrets.APPLE_PRIVATE_KEY }}" \ - JWT_SECRET="${{ secrets.JWT_SECRET }}" \ - KAKAO_ADMIN_KEY="${{ secrets.KAKAO_ADMIN_KEY }}" \ - KAKAO_CLIENT_ID="${{ secrets.KAKAO_CLIENT_ID }}" \ - KAKAO_REDIRECT_URI="${{ secrets.KAKAO_REDIRECT_URI }}" \ - KAKAO_CLIENT_SECRET="${{ secrets.KAKAO_CLIENT_SECRET }}" \ - KMS_KEY_ID="${{ secrets.KMS_KEY_ID }}" \ - FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ - nohup java -Duser.timezone=Asia/Seoul -jar $JAR_NAME --spring.profiles.active=prod > app.log 2>&1 & - - echo "✅ 배포 완료" -