diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f9e162d..16d020b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -24,6 +24,42 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # private 서브모듈(src/main/resources/config)이 체크아웃되지 않은 환경(CI/CD)에서도 + # non-optional classpath import가 깨지지 않도록 placeholder 값만 채운다. + # 실제 프로덕션 DB 접속정보는 컨테이너 실행 시 SPRING_DATASOURCE_* 환경변수로 주입되어 + # 이 값을 덮어쓰므로(Spring 프로퍼티 우선순위상 env var가 우선), public 이미지에 포함되어도 안전하다. + - name: Create build-time placeholder secret + run: | + mkdir -p src/main/resources/config + cat > src/main/resources/config/application-secret.yml <<'YAML' + spring: + application: + name: zipzip-server + datasource: + url: jdbc:postgresql://localhost:5432/placeholder + username: placeholder + password: placeholder + driver-class-name: org.postgresql.Driver + YAML + + # JAR은 러너 네이티브(x86) 아키텍처로 빌드한다. 바이트코드는 아키텍처 무관이라 + # 여기서 굳이 arm64를 QEMU로 에뮬레이션하며 컴파일할 필요가 없다(느림). + - name: Build JAR + run: | + chmod +x gradlew + ./gradlew --no-daemon clean bootJar -x test + mkdir -p app + cp build/libs/*.jar app/app.jar + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -37,7 +73,8 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} # 인스턴스가 ARM64(Ampere A1)뿐이라 arm64 단일 아키텍처만 빌드한다. - # JAR 자체는 바이트코드라 아키텍처 무관하고, 여기서 arm64로 굳는 건 베이스 JRE 레이어뿐. + # 이 단계는 이미 빌드된 JAR을 arm64 JRE 이미지에 얹기만 하므로(COPY/chown) + # QEMU 에뮬레이션이어도 빠르다 — 무거운 컴파일은 위에서 네이티브로 이미 끝남. - name: Build and push image (linux/arm64) uses: docker/build-push-action@v6 with: @@ -64,19 +101,17 @@ jobs: ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts # 이미지는 public이라 서버에서도 로그인 없이 pull 가능. - # DB 접속정보 등 실제 시크릿은 GitHub Actions를 거치지 않고 서버의 ~/zipzip-be.env 파일에만 존재한다. + # 서버의 authorized_keys에 forced command(`~/zipzip-deploy.sh`)가 걸려 있어서 + # 실제로는 아래 커맨드 문자열('deploy')과 무관하게 그 스크립트만 실행된다. + # DB 접속정보는 GitHub Secrets에 개별 등록해두고, stdin으로 흘려보내 + # 서버 스크립트가 그걸 그대로 zipzip-be.env로 기록한 뒤 배포하게 한다. + # (forced command라 인자로 임의 데이터를 못 받으므로 stdin 경로를 씀 — 스크립트는 + # 서버 ~/zipzip-deploy.sh, 구성 설명은 deploy/README.md 참고) - name: Deploy over SSH run: | ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts \ - "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" ' - set -e - docker pull ${{ env.IMAGE_NAME }}:latest - docker stop zipzip-be 2>/dev/null || true - docker rm zipzip-be 2>/dev/null || true - docker run -d \ - --name zipzip-be \ - --restart unless-stopped \ - --env-file ~/zipzip-be.env \ - -p 8080:8080 \ - ${{ env.IMAGE_NAME }}:latest - ' + "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" 'deploy' <<'ENVEOF' + SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }} + SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }} + SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }} + ENVEOF diff --git a/.gitignore b/.gitignore index 47831df..b017076 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .gradle build/ +/app/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ @@ -18,6 +19,7 @@ build/ .env.* !.env.example !.env.*.example +deploy/zipzip-be.env ### JVM ### hs_err_pid* diff --git a/Dockerfile b/Dockerfile index 597c049..ff42cf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,12 @@ -# ---- Build stage ---- -FROM eclipse-temurin:21-jdk AS build -WORKDIR /workspace - -COPY gradlew build.gradle settings.gradle ./ -COPY gradle ./gradle -COPY src ./src - -# private 서브모듈(src/main/resources/config)이 체크아웃되지 않은 환경(CI/CD)에서도 -# non-optional classpath import가 깨지지 않도록 placeholder 값만 채운다. -# 실제 프로덕션 DB 접속정보는 컨테이너 실행 시 SPRING_DATASOURCE_* 환경변수로 주입되어 -# 이 값을 덮어쓰므로(Spring 프로퍼티 우선순위상 env var가 우선), public 이미지에 포함되어도 안전하다. -RUN mkdir -p src/main/resources/config && cat > src/main/resources/config/application-secret.yml <<'YAML' -spring: - application: - name: zipzip-server - datasource: - url: jdbc:postgresql://localhost:5432/placeholder - username: placeholder - password: placeholder - driver-class-name: org.postgresql.Driver -YAML - -RUN chmod +x gradlew && ./gradlew --no-daemon clean bootJar -x test - -# ---- Runtime stage ---- +# JAR은 CD 워크플로우에서 네이티브(x86 러너)로 미리 빌드해서 build/libs에 둔다. +# 여기서는 arm64 JRE 런타임 이미지에 그 결과물만 얹는다 — QEMU 에뮬레이션 위에서 +# Gradle/JDK 컴파일이 돌아가는 걸 피하기 위함 (에뮬레이션은 느림, 단순 COPY/chown은 빠름). FROM eclipse-temurin:21-jre WORKDIR /app RUN groupadd --system spring && useradd --system --gid spring spring -COPY --from=build /workspace/build/libs/*.jar app.jar +COPY app/app.jar app.jar RUN chown spring:spring app.jar USER spring diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..25ba288 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1 @@ +DOCKERHUB_IMAGE=/zipzip-be diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..f2341a1 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,52 @@ +# zipzip-be 프로덕션 배포 스택 + +`zipzip-be` 인스턴스(`~/zipzip-deploy`)에서 실행되는 compose 스택 구성입니다. 저장소 루트의 +`docker-compose.yml`(로컬 개발용 postgres 전용)과는 별개입니다. + +## 구성 + +- `api`: `${DOCKERHUB_IMAGE}:latest` — 스프링 부트 애플리케이션. 호스트에 포트를 노출하지 않고 + `zipzip-net` 내부망에서만 nginx가 접근합니다. +- `nginx`: 80/443 리버스 프록시, TLS 종료. 80은 인증서 갱신용 challenge와 443 리다이렉트만 처리. +- `certbot`: Let's Encrypt 인증서 자동 갱신 루프(`certbot renew`를 12시간마다 실행). + +## 서버에 필요한 파일 (git에 없음, 서버에만 존재) + +- `~/zipzip-deploy/.env`: compose 변수 치환용. `DOCKERHUB_IMAGE=/zipzip-be` (형식은 + `.env.example` 참고). 개인 계정명을 커밋된 파일에 박아두지 않기 위해 분리함 — 시크릿은 아니고 + 단순히 개인 네임스페이스를 코드에서 분리하기 위한 목적. +- `~/zipzip-deploy/zipzip-be.env`: 프로덕션 DB 접속정보(`SPRING_DATASOURCE_*`). git/이미지에 절대 포함하지 않음. + CD가 배포할 때마다 GitHub Secrets 값으로 덮어씀(아래 배포 흐름 참고). +- `~/zipzip-deploy/certbot/conf`: 발급된 인증서. + +## 배포 흐름 + +CD(`​.github/workflows/cd.yml`)가 이미지를 Docker Hub에 push한 뒤, 서버의 CD 전용 SSH 배포키로 접속합니다. +이 키는 `authorized_keys`에 forced command로 제한되어 있어 실제로는 클라이언트가 보낸 명령과 무관하게 +`~/zipzip-deploy.sh`만 실행됩니다. DB 접속정보는 GitHub Secrets(`SPRING_DATASOURCE_URL/USERNAME/PASSWORD`)에 +개별 등록해두고, forced command라 인자로 못 넘기니 **stdin으로 흘려보내** 스크립트가 그대로 +`zipzip-be.env`에 기록한 뒤 배포합니다: + +```bash +#!/bin/bash +set -e +cd "$HOME/zipzip-deploy" +cat > zipzip-be.env # stdin으로 받은 내용을 그대로 기록 +docker compose pull api +docker compose up -d api +``` + +nginx/certbot은 이미지가 자주 바뀌지 않으므로 배포 때마다 재기동하지 않고, 설정을 바꿀 때만 수동으로 +`docker compose up -d nginx` 등으로 반영합니다. + +## 인증서 최초 발급 (1회성, 이미 완료됨) + +```bash +docker compose up -d api nginx # nginx는 80만으로 부트스트랩(HTTP-only 설정) +docker compose run --rm --entrypoint certbot certbot certonly \ + --webroot -w /var/www/certbot -d api.zipzip.site \ + --email <담당자 이메일> --agree-tos --non-interactive +# 이후 nginx/conf.d/api.conf를 443 포함 최종본으로 교체하고 +docker compose exec nginx nginx -s reload +docker compose up -d certbot +``` diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..3a37536 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,44 @@ +# zipzip-be 인스턴스(프로덕션)용 compose 스택. +# 저장소 루트의 docker-compose.yml(로컬 개발용 postgres 전용)과는 별개. +# DB는 별도 인스턴스(zipzip-db)에서 이미 운영 중이라 여기 포함하지 않는다. +services: + api: + image: ${DOCKERHUB_IMAGE}:latest + container_name: zipzip-be + restart: unless-stopped + env_file: + - ./zipzip-be.env + expose: + - "8080" + networks: + - zipzip-net + + nginx: + image: nginx:1.27-alpine + container_name: zipzip-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./certbot/www:/var/www/certbot:ro + - ./certbot/conf:/etc/letsencrypt:ro + depends_on: + - api + networks: + - zipzip-net + + certbot: + image: certbot/certbot + container_name: zipzip-certbot + restart: unless-stopped + volumes: + - ./certbot/www:/var/www/certbot + - ./certbot/conf:/etc/letsencrypt + entrypoint: > + sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done" + +networks: + zipzip-net: + driver: bridge diff --git a/deploy/nginx/conf.d/api.conf b/deploy/nginx/conf.d/api.conf new file mode 100644 index 0000000..13ef813 --- /dev/null +++ b/deploy/nginx/conf.d/api.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name api.zipzip.site; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name api.zipzip.site; + + ssl_certificate /etc/letsencrypt/live/api.zipzip.site/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.zipzip.site/privkey.pem; + + location / { + proxy_pass http://api:8080; + 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; + } +}