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
278 changes: 147 additions & 131 deletions .github/workflows/deploy.yml

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Production Deployment

This service deploys as an immutable Docker image published to GitHub Container Registry (GHCR). Runtime configuration is injected through the EC2 `.env` file generated by GitHub Actions; private resource files are not copied into the image or bind-mounted from the host.

## Required GitHub Secrets

Deployment access:

- `EC2_HOST`
- `EC2_USER`
- `EC2_SSH_KEY`
- `GHCR_USERNAME`
- `GHCR_READ_TOKEN`

Runtime image and port:

- `BACKEND_HTTP_PORT` (optional, defaults to `8080`)

Spring and database:

- `SPRING_APPLICATION_NAME`
- `SPRING_DATASOURCE_URL`
- `SPRING_DATASOURCE_USERNAME`
- `SPRING_DATASOURCE_PASSWORD`
- `SPRING_DATASOURCE_DRIVER_CLASS_NAME`
- `SPRING_JPA_HIBERNATE_DDL_AUTO`
- `SPRING_FLYWAY_URL`
- `SPRING_FLYWAY_USER`
- `SPRING_FLYWAY_PASSWORD`

Authentication and OAuth:

- `JWT_SECRETKEY`
- `JWT_ACCESS_EXPIRATION`
- `JWT_REFRESH_EXPIRATION`
- `JWT_ACCESS_HEADER`
- `JWT_REFRESH_HEADER`
- `GOOGLE_WEB_CLIENT_ID`
- `GOOGLE_APP_CLIENT_ID`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI`
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE`
- `APPLE_CLIENT_ID`
- `APPLE_TEAM_ID`
- `APPLE_LOGIN_KEY`
- `APPLE_PRIVATE_KEY_BASE64`
- `FEATURE_APPLE_LOGIN_ENABLED` (optional, defaults to `true`)

Firebase:

- `FIREBASE_CREDENTIALS_BASE64`

Set the base64 secrets from the ignored local credential files:

```bash
base64 -i ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json | tr -d '\n' | gh secret set FIREBASE_CREDENTIALS_BASE64 --repo DevKor-github/OnTime-back
```

```bash
base64 -i ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 | tr -d '\n' | gh secret set APPLE_PRIVATE_KEY_BASE64 --repo DevKor-github/OnTime-back
```

## Build And Release Flow

Push to the `deploy` branch to trigger `.github/workflows/deploy.yml`.

The workflow:

1. Builds `ontime-back/Dockerfile` from the `ontime-back/` context.
2. Pushes two GHCR tags:
- `ghcr.io/devkor-github/ontime-back:<commit-sha>`
- `ghcr.io/devkor-github/ontime-back:deploy-latest`
3. Uploads `docker-compose.yml` to `/home/ubuntu/OnTime-back`.
4. Writes `/home/ubuntu/OnTime-back/.env` from GitHub secrets.
5. Runs `docker compose pull && docker compose up -d --remove-orphans`.
6. Waits until the `ontime-container` Docker health status is `healthy`.

## Health Verification

The production image exposes a Docker healthcheck against:

```text
/actuator/health/readiness
```

Manual checks on EC2:

```bash
cd /home/ubuntu/OnTime-back
sudo docker compose ps
sudo docker inspect -f '{{.State.Health.Status}}' ontime-container
curl -fsS http://localhost:8080/actuator/health/readiness
```

## Rollback

Every deploy is tagged by commit SHA. To roll back, set `IMAGE_TAG` in `/home/ubuntu/OnTime-back/.env` to the previous known-good SHA, then restart from the existing Compose file:

```bash
cd /home/ubuntu/OnTime-back
sudo docker compose pull
sudo docker compose up -d --remove-orphans
sudo docker inspect -f '{{.State.Health.Status}}' ontime-container
```

Keep the previous SHA in the release notes or GitHub Actions deploy history so rollback does not depend on `deploy-latest`.
21 changes: 21 additions & 0 deletions ontime-back/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.env
.git
.gitignore
.gradle
build
out
bin
.idea
.vscode
*.iml
*.iws
*.ipr
.DS_Store

src/main/resources/application.properties
src/main/resources/*.json
src/main/resources/key
src/main/resources/**/*.p8

Dockerfile
docker-compose*.yml
48 changes: 39 additions & 9 deletions ontime-back/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
FROM eclipse-temurin:17-jdk
RUN apt-get update && \
apt-get install -y tzdata && \
ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
echo "Asia/Seoul" > /etc/timezone && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
FROM eclipse-temurin:17-jdk-jammy AS builder

WORKDIR /workspace

COPY gradlew build.gradle settings.gradle ./
COPY gradle ./gradle
RUN chmod +x ./gradlew

COPY src ./src
RUN ./gradlew clean bootJar --no-daemon

FROM eclipse-temurin:17-jre-jammy

ENV TZ=Asia/Seoul \
LANG=C.UTF-8 \
SPRING_PROFILES_ACTIVE=prod \
JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom"

RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo "${TZ}" > /etc/timezone \
&& groupadd --system app \
&& useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app \
&& mkdir -p /app \
&& chown -R app:app /app \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY project.jar app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
COPY --from=builder --chown=app:app /workspace/build/libs/*.jar /app/app.jar

USER app
EXPOSE 8080
STOPSIGNAL SIGTERM

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS "http://localhost:${SERVER_PORT:-8080}/actuator/health/readiness" || exit 1

ENTRYPOINT ["java", "-jar", "/app/app.jar"]
31 changes: 19 additions & 12 deletions ontime-back/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
version: "3.8"

services:
backend:
build:
context: .
dockerfile: Dockerfile # Dockerfile 이름
image: ontimedemo # 빌드된 백엔드 이미지
container_name: ontime-container
image: "${BACKEND_IMAGE:-ghcr.io/devkor-github/ontime-back}:${IMAGE_TAG:?IMAGE_TAG is required}"
container_name: "${BACKEND_CONTAINER_NAME:-ontime-container}"
env_file:
- .env
environment:
SPRING_PROFILES_ACTIVE: "${SPRING_PROFILES_ACTIVE:-prod}"
SERVER_PORT: "${SERVER_PORT:-8080}"
JAVA_TOOL_OPTIONS: "${JAVA_TOOL_OPTIONS:--XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom}"
ports:
- "8080:8080"
- "8443:8443"
volumes:
- /home/ubuntu/OnTime-back/ontime-back/src/main/resources/:/app/src/main/resources/
- /home/ubuntu/OnTime-back/ontime-back/src/main/resources/key/:/app/resources/key/
- "${BACKEND_HTTP_PORT:-8080}:${SERVER_PORT:-8080}"
restart: unless-stopped
stop_grace_period: 30s
mem_limit: "${BACKEND_MEMORY_LIMIT:-768m}"
cpus: "${BACKEND_CPU_LIMIT:-1.0}"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER_PORT:-8080}/actuator/health/readiness || exit 1"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,79 @@
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;

@Service
@Slf4j
public class FirebaseInitialization {

@Value("${firebase.credentials.base64:}")
private String firebaseCredentialsBase64;

@Value("${firebase.credentials.json:}")
private String firebaseCredentialsJson;

@Value("${firebase.credentials.path:}")
private String firebaseCredentialsPath;

@Value("${google.application.credentials:}")
private String googleApplicationCredentials;

@PostConstruct
public void initialize() {
try {
InputStream serviceAccount = getClass().getClassLoader().getResourceAsStream("ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json");
if (serviceAccount == null) {
throw new FileNotFoundException("Resource not found: ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json");
if (!FirebaseApp.getApps().isEmpty()) {
return;
}

FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
try (InputStream serviceAccount = resolveCredentials()) {
if (serviceAccount == null) {
log.warn("Firebase credentials were not provided; Firebase push notifications are disabled.");
return;
}

FirebaseApp.initializeApp(options);
FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();

FirebaseApp.initializeApp(options);
}
} catch (IOException e) {
e.printStackTrace();
log.error("Failed to initialize Firebase.", e);
}
}

private InputStream resolveCredentials() throws IOException {
if (hasText(firebaseCredentialsBase64)) {
byte[] decodedCredentials = Base64.getDecoder().decode(firebaseCredentialsBase64);
return new ByteArrayInputStream(decodedCredentials);
}

if (hasText(firebaseCredentialsJson)) {
return new ByteArrayInputStream(firebaseCredentialsJson.getBytes(StandardCharsets.UTF_8));
}

String credentialsPath = hasText(firebaseCredentialsPath)
? firebaseCredentialsPath
: googleApplicationCredentials;
if (hasText(credentialsPath)) {
return Files.newInputStream(Path.of(credentialsPath));
}

return null;
}

private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.frameOptions(frameOptions -> frameOptions.disable()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll()
.requestMatchers("/health", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll()
.requestMatchers("/health", "/actuator/health/**", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private static final List<String> NO_CHECK_URLS = List.of("/login", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login");
private static final List<String> NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login");

private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.springframework.web.util.UriComponentsBuilder;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
Expand All @@ -58,8 +59,12 @@ public class AppleLoginService {
private String teamId;
@Value("${apple.login.key}")
private String keyId;
@Value("${apple.client.secret}")
@Value("${apple.client.secret:}")
private String privateKeyPath;
@Value("${apple.private-key.base64:}")
private String privateKeyBase64;
@Value("${apple.private-key:}")
private String privateKey;

private final ApplePublicKeyGenerator applePublicKeyGenerator;
private final JwtUtils jwtUtils;
Expand Down Expand Up @@ -218,7 +223,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode)
private String generateClientSecret() throws Exception {
log.info("Generate Apple client credential");
// Private Key
String privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath)))
String privateKeyContent = resolvePrivateKey()
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
Expand All @@ -243,6 +248,20 @@ private String generateClientSecret() throws Exception {
.signWith(privateKey, SignatureAlgorithm.ES256)
.compact();
}

private String resolvePrivateKey() throws IOException {
if (privateKeyBase64 != null && !privateKeyBase64.isBlank()) {
byte[] decodedPrivateKey = Base64.getDecoder().decode(privateKeyBase64);
return new String(decodedPrivateKey, StandardCharsets.UTF_8);
}

if (privateKey != null && !privateKey.isBlank()) {
return privateKey;
}

return new String(Files.readAllBytes(Paths.get(privateKeyPath)));
}

public boolean revokeToken(Long userId) throws Exception {
log.info("checkAppleLoginRevoked");
User user = userRepository.findById(userId)
Expand Down
7 changes: 7 additions & 0 deletions ontime-back/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
management.endpoint.health.probes.enabled=true
management.endpoints.web.exposure.include=health
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
server.forward-headers-strategy=framework
Loading