Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 49 additions & 14 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.gradle
build/
/app/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
Expand All @@ -18,6 +19,7 @@ build/
.env.*
!.env.example
!.env.*.example
deploy/zipzip-be.env

### JVM ###
hs_err_pid*
Expand Down
31 changes: 4 additions & 27 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DOCKERHUB_IMAGE=<dockerhub-username>/zipzip-be
52 changes: 52 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -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=<dockerhub-username>/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
```
44 changes: 44 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions deploy/nginx/conf.d/api.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading