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
54 changes: 48 additions & 6 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0

- name: Resolve application version
run: |
chmod +x gradlew
APP_VERSION=$(./gradlew -q printVersion)
SHORT_SHA=${GITHUB_SHA::12}
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV"
echo "APP_IMAGE_TAG=${APP_VERSION}-dev-${SHORT_SHA}" >> "$GITHUB_ENV"

- name: Build and test direct push
if: ${{ steps.push-source.outputs.direct_push == 'true' }}
run: |
Expand All @@ -83,13 +91,19 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build & push image (arm64, :dev)
- name: Build & push versioned image (arm64)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/arm64
push: true
tags: ghcr.io/nalssilog/api:dev
tags: |
ghcr.io/nalssilog/api:dev
ghcr.io/nalssilog/api:${{ env.APP_IMAGE_TAG }}
ghcr.io/nalssilog/api:sha-${{ github.sha }}
build-args: |
APP_VERSION=${{ env.APP_VERSION }}
VCS_REF=${{ github.sha }}

- name: Open SSH (add runner IP to SG)
env:
Expand Down Expand Up @@ -124,6 +138,8 @@ jobs:
- name: Write .env.dev & deploy on EC2
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
APP_VERSION: ${{ env.APP_VERSION }}
APP_IMAGE_TAG: ${{ env.APP_IMAGE_TAG }}
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DB_HOST: ${{ secrets.DEV_DB_HOST }}
Expand All @@ -135,6 +151,10 @@ jobs:
OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }}
OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }}
OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }}
APPLE_CLIENT_ID: ${{ secrets.DEV_OAUTH_APPLE_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.DEV_OAUTH_APPLE_TEAM_ID }}
APPLE_KEY_ID: ${{ secrets.DEV_OAUTH_APPLE_KEY_ID }}
APPLE_PRIVATE_KEY_BASE64: ${{ secrets.DEV_OAUTH_APPLE_PRIVATE_KEY_BASE64 }}
DEV_R2_PUBLIC_BASE_URL: ${{ secrets.DEV_R2_PUBLIC_BASE_URL }}
DEV_R2_ENDPOINT: ${{ secrets.DEV_R2_ENDPOINT }}
DEV_R2_BUCKET: ${{ secrets.DEV_R2_BUCKET }}
Expand All @@ -144,25 +164,47 @@ jobs:
host: ${{ secrets.EC2_HOST_DEV }}
username: ${{ secrets.EC2_USER_DEV }}
key: ${{ secrets.EC2_SSH_KEY_DEV }}
envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY
envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY
script: |
set -eu
cd /home/ubuntu/nalssilog-dev-api
for name in GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD DEV_R2_PUBLIC_BASE_URL DEV_R2_ENDPOINT DEV_R2_BUCKET DEV_R2_ACCESS_KEY DEV_R2_SECRET_KEY; do
for name in APP_VERSION APP_IMAGE_TAG GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD DEV_R2_PUBLIC_BASE_URL DEV_R2_ENDPOINT DEV_R2_BUCKET DEV_R2_ACCESS_KEY DEV_R2_SECRET_KEY; do
if [ -z "$(printenv "$name")" ]; then
echo "Missing required deployment value: $name"
exit 1
fi
done
APPLE_VALUE_COUNT=0
for name in APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY_BASE64; do
if [ -n "$(printenv "$name" 2>/dev/null || true)" ]; then
APPLE_VALUE_COUNT=$((APPLE_VALUE_COUNT + 1))
fi
done
if [ "$APPLE_VALUE_COUNT" -ne 0 ] && [ "$APPLE_VALUE_COUNT" -ne 4 ]; then
echo "Apple OAuth secrets must be configured all together."
exit 1
fi
ACTIVE_PROFILES=dev
if [ "$APPLE_VALUE_COUNT" -eq 4 ]; then
ACTIVE_PROFILES=dev,apple
fi
export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES
umask 077
cat > .env.dev <<EOF
APP_VERSION=$APP_VERSION
APP_IMAGE_TAG=$APP_IMAGE_TAG
SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES
DEV_DB_URL=jdbc:postgresql://$DB_HOST:$DB_PORT/$DB_NAME?sslmode=require
DEV_DB_USERNAME=$DB_USERNAME
DEV_DB_PASSWORD=$DB_PASSWORD
OAUTH_GOOGLE_CLIENT_ID=${OAUTH_GOOGLE_CLIENT_ID:-dummy}
OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET:-dummy}
OAUTH_KAKAO_CLIENT_ID=${OAUTH_KAKAO_CLIENT_ID:-dummy}
OAUTH_KAKAO_CLIENT_SECRET=${OAUTH_KAKAO_CLIENT_SECRET:-dummy}
OAUTH_APPLE_CLIENT_ID=${APPLE_CLIENT_ID:-}
OAUTH_APPLE_TEAM_ID=${APPLE_TEAM_ID:-}
OAUTH_APPLE_KEY_ID=${APPLE_KEY_ID:-}
OAUTH_APPLE_PRIVATE_KEY_BASE64=${APPLE_PRIVATE_KEY_BASE64:-}
DEV_R2_PUBLIC_BASE_URL=$DEV_R2_PUBLIC_BASE_URL
DEV_R2_ENDPOINT=$DEV_R2_ENDPOINT
DEV_R2_BUCKET=$DEV_R2_BUCKET
Expand All @@ -174,8 +216,8 @@ jobs:
sudo chmod 0755 logs
echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GHCR_USERNAME" --password-stdin
trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT
docker compose -f compose-dev.yml pull
docker compose -f compose-dev.yml up -d
docker compose --env-file .env.dev -f compose-dev.yml pull
docker compose --env-file .env.dev -f compose-dev.yml up -d
healthy=false
for attempt in $(seq 1 30); do
if curl --fail --silent --max-time 3 http://127.0.0.1:8080/api/health >/dev/null; then
Expand Down
57 changes: 50 additions & 7 deletions .github/workflows/cd-prod.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: CD (prod)

# main PR CI 통과 후 merge → prod 이미지(ghcr :prod) → prod EC2 배포.
# main PR CI 통과 후 merge → 버전·커밋 태그 이미지 생성 → 해당 불변 태그로 prod EC2 배포.
on:
push:
branches: [main]
Expand Down Expand Up @@ -53,6 +53,14 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0

- name: Resolve application version
run: |
chmod +x gradlew
APP_VERSION=$(./gradlew -q printVersion)
SHORT_SHA=${GITHUB_SHA::12}
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV"
echo "APP_IMAGE_TAG=${APP_VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"

- name: Build bootJar
run: |
chmod +x gradlew
Expand All @@ -68,13 +76,20 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build & push image (arm64, :prod)
- name: Build & push versioned image (arm64)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/arm64
push: true
tags: ghcr.io/nalssilog/api:prod
tags: |
ghcr.io/nalssilog/api:prod
ghcr.io/nalssilog/api:${{ env.APP_VERSION }}
ghcr.io/nalssilog/api:${{ env.APP_IMAGE_TAG }}
ghcr.io/nalssilog/api:sha-${{ github.sha }}
build-args: |
APP_VERSION=${{ env.APP_VERSION }}
VCS_REF=${{ github.sha }}

# GitHub 러너 IP 는 매번 바뀌므로 SSH 전에 SG 22번에 추가, 끝나면 제거(항상). (aws CLI 는 러너에 기본 설치)
- name: Open SSH (add runner IP to SG)
Expand Down Expand Up @@ -110,6 +125,8 @@ jobs:
- name: Write .env.prod & deploy on EC2
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
APP_VERSION: ${{ env.APP_VERSION }}
APP_IMAGE_TAG: ${{ env.APP_IMAGE_TAG }}
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DB_HOST: ${{ secrets.PROD_DB_HOST }}
Expand All @@ -124,6 +141,10 @@ jobs:
OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }}
OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }}
OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }}
APPLE_CLIENT_ID: ${{ secrets.PROD_OAUTH_APPLE_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.PROD_OAUTH_APPLE_TEAM_ID }}
APPLE_KEY_ID: ${{ secrets.PROD_OAUTH_APPLE_KEY_ID }}
APPLE_PRIVATE_KEY_BASE64: ${{ secrets.PROD_OAUTH_APPLE_PRIVATE_KEY_BASE64 }}
PROD_R2_PUBLIC_BASE_URL: ${{ secrets.PROD_R2_PUBLIC_BASE_URL }}
PROD_R2_ENDPOINT: ${{ secrets.PROD_R2_ENDPOINT }}
PROD_R2_BUCKET: ${{ secrets.PROD_R2_BUCKET }}
Expand All @@ -133,18 +154,36 @@ jobs:
host: ${{ secrets.EC2_HOST_PROD }}
username: ${{ secrets.EC2_USER_PROD }}
key: ${{ secrets.EC2_SSH_KEY_PROD }}
envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY
envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY
script: |
set -eu
cd /home/ubuntu/nalssilog-prod-api
for name in GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD REDIS_HOST REDIS_PORT REDIS_PASSWORD PROD_R2_PUBLIC_BASE_URL PROD_R2_ENDPOINT PROD_R2_BUCKET PROD_R2_ACCESS_KEY PROD_R2_SECRET_KEY; do
for name in APP_VERSION APP_IMAGE_TAG GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD REDIS_HOST REDIS_PORT REDIS_PASSWORD PROD_R2_PUBLIC_BASE_URL PROD_R2_ENDPOINT PROD_R2_BUCKET PROD_R2_ACCESS_KEY PROD_R2_SECRET_KEY; do
if [ -z "$(printenv "$name")" ]; then
echo "Missing required deployment value: $name"
exit 1
fi
done
APPLE_VALUE_COUNT=0
for name in APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY_BASE64; do
if [ -n "$(printenv "$name" 2>/dev/null || true)" ]; then
APPLE_VALUE_COUNT=$((APPLE_VALUE_COUNT + 1))
fi
done
if [ "$APPLE_VALUE_COUNT" -ne 0 ] && [ "$APPLE_VALUE_COUNT" -ne 4 ]; then
echo "Apple OAuth secrets must be configured all together."
exit 1
fi
ACTIVE_PROFILES=prod
if [ "$APPLE_VALUE_COUNT" -eq 4 ]; then
ACTIVE_PROFILES=prod,apple
fi
export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES
umask 077
cat > .env.prod <<EOF
APP_VERSION=$APP_VERSION
APP_IMAGE_TAG=$APP_IMAGE_TAG
SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES
PROD_DB_URL=jdbc:postgresql://$DB_HOST:$DB_PORT/$DB_NAME?sslmode=require
PROD_DB_USERNAME=$DB_USERNAME
PROD_DB_PASSWORD=$DB_PASSWORD
Expand All @@ -155,6 +194,10 @@ jobs:
OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET:-dummy}
OAUTH_KAKAO_CLIENT_ID=${OAUTH_KAKAO_CLIENT_ID:-dummy}
OAUTH_KAKAO_CLIENT_SECRET=${OAUTH_KAKAO_CLIENT_SECRET:-dummy}
OAUTH_APPLE_CLIENT_ID=${APPLE_CLIENT_ID:-}
OAUTH_APPLE_TEAM_ID=${APPLE_TEAM_ID:-}
OAUTH_APPLE_KEY_ID=${APPLE_KEY_ID:-}
OAUTH_APPLE_PRIVATE_KEY_BASE64=${APPLE_PRIVATE_KEY_BASE64:-}
PROD_R2_PUBLIC_BASE_URL=$PROD_R2_PUBLIC_BASE_URL
PROD_R2_ENDPOINT=$PROD_R2_ENDPOINT
PROD_R2_BUCKET=$PROD_R2_BUCKET
Expand All @@ -166,8 +209,8 @@ jobs:
sudo chmod 0755 logs
echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GHCR_USERNAME" --password-stdin
trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT
docker compose -f compose-prod.yml pull
docker compose -f compose-prod.yml up -d
docker compose --env-file .env.prod -f compose-prod.yml pull
docker compose --env-file .env.prod -f compose-prod.yml up -d
healthy=false
for attempt in $(seq 1 30); do
if curl --fail --silent --max-time 3 http://127.0.0.1:8081/api/health >/dev/null; then
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
# JAR 은 아키텍처 무관(JVM 바이트코드)이고 base 는 multi-arch 라, buildx --platform linux/arm64 로 t4g(ARM) 이미지 생성 가능.
FROM eclipse-temurin:25-jre

ARG APP_VERSION=development
ARG VCS_REF=unknown

LABEL org.opencontainers.image.title="nalssilog-api" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.source="https://github.com/nalssilog/api"

WORKDIR /app

# 애플리케이션은 특권이 필요 없으므로 고정 UID/GID의 비-root 사용자로 실행한다.
Expand Down
6 changes: 6 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ plugins {
id 'org.springframework.boot'
}

springBoot {
buildInfo {
excludes = ['time']
}
}

dependencies {
implementation project(':common')
implementation project(':auth')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
package com.nalssilog.app.api;

import com.nalssilog.auth.config.AuthCookieManager;
import com.nalssilog.auth.domain.RefreshRejectedException;
import com.nalssilog.auth.core.RefreshRejectedException;
import com.nalssilog.auth.web.AuthCookieManager;
import com.nalssilog.common.exception.ErrorCode;
import com.nalssilog.common.exception.ErrorResponse;
import com.nalssilog.common.exception.NalssiLogException;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@Slf4j
Expand All @@ -33,6 +37,7 @@ public ErrorResponse handleNalssiLogException(
}

ErrorCode errorCode = exception.getErrorCode();

response.setStatus(errorCode.getStatus().value());
log.warn("NalssiLogException [{}] {} (status={})",
errorCode.getCode(), exception.getMessage(), errorCode.getStatus().value());
Expand All @@ -51,6 +56,19 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e
return new ErrorResponse("VALIDATION_ERROR", message);
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler({
ConstraintViolationException.class,
HandlerMethodValidationException.class,
MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class
})
public ErrorResponse handleRequestParameterValidation(Exception exception) {
return new ErrorResponse(
"VALIDATION_ERROR",
"요청 파라미터가 올바르지 않습니다.");
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ErrorResponse handleNotReadable(HttpMessageNotReadableException exception) {
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/java/com/nalssilog/app/api/HealthController.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package com.nalssilog.app.api;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.info.BuildProperties;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 로드밸런서·모니터링용 라이브니스 체크. 의존 상태 점검이 필요하면 Actuator 도입을 고려한다.
*/
@RestController
@RequiredArgsConstructor
public class HealthController {

private final BuildProperties buildProperties;

@GetMapping("/api/health")
public HealthResponse health() {
return new HealthResponse("UP");
return new HealthResponse("UP", buildProperties.getVersion());
}

public record HealthResponse(String status) {
public record HealthResponse(String status, String version) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.nalssilog.app.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {
}
Loading