diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 09877566..aad0730e 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -60,6 +60,8 @@ jobs: GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} GOOGLE_OAUTH_REDIRECT_URI: ${{ secrets.GOOGLE_OAUTH_REDIRECT_URI }} DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + # 백업 실패 알림용(infra/backup/backup.sh). cron 에는 환경변수가 없어 .env 로 전달한다. + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} run: | cd "$DEPLOY_DIR" if [ ! -f .env ]; then @@ -117,6 +119,7 @@ jobs: upsert_env "GITHUB_OAUTH_CLIENT_SECRET" "$GITHUB_OAUTH_CLIENT_SECRET" upsert_env "GITHUB_OAUTH_REDIRECT_URI" "https://stack-up.shop/auth/callback" upsert_env "DEEPGRAM_API_KEY" "$DEEPGRAM_API_KEY" + upsert_env "DISCORD_WEBHOOK_URL" "$DISCORD_WEBHOOK_URL" # STT_PROVIDER 자동 선택. auto 로 두면 AI factory 가 보유 키 기준으로 선택 (deepgram > mock). # 키가 있으면 auto 로 설정해두면 충분 — 명시적 전환 불필요. if [ -n "$DEEPGRAM_API_KEY" ]; then diff --git a/backend/src/main/java/com/stackup/stackup/common/health/BackupHealthIndicator.java b/backend/src/main/java/com/stackup/stackup/common/health/BackupHealthIndicator.java new file mode 100644 index 00000000..e22f3227 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/health/BackupHealthIndicator.java @@ -0,0 +1,70 @@ +package com.stackup.stackup.common.health; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.stereotype.Component; + +/** + * 백업 신선도. + * + *
백업 스크립트가 실패하면 Discord 로 알린다. 하지만 cron 자체가 죽어 아예 실행되지 + * 않는 경우는 스크립트가 알릴 수 없다 — 돌지 않았으니까. 그건 밖에서 보는 수밖에 없고, + * 그게 이 인디케이터다. + * + *
백업이 낡았다고 서비스가 고장난 건 아니므로 전체 상태(aggregate)에는 반영하지 + * 않는다({@code SystemHealthService} 의 informational 처리). 여기서 DOWN 이 뜨면 + * "면접이 안 된다"가 아니라 "복구 수단이 없다"는 뜻이다. + * + *
빈 이름이 곧 Actuator 컴포넌트 키가 된다 — {@code backupHealthIndicator} → {@code "backup"}.
+ */
+@Component
+public class BackupHealthIndicator implements HealthIndicator {
+
+ private final Path markerPath;
+ private final Duration maxAge;
+
+ public BackupHealthIndicator(
+ @Value("${backup.marker-path:/var/backups/stackup/LAST_SUCCESS}") String markerPath,
+ @Value("${backup.max-age-hours:36}") long maxAgeHours
+ ) {
+ this.markerPath = Path.of(markerPath);
+ // 일 1회 백업 + 여유. 26시간이면 한 번 건너뛴 걸 못 잡고, 48시간이면 이틀을 놓친다.
+ this.maxAge = Duration.ofHours(maxAgeHours);
+ }
+
+ @Override
+ public Health health() {
+ if (!Files.isReadable(markerPath)) {
+ // 로컬 개발에는 마운트가 없어 항상 여기로 온다 — 그래서 aggregate 에서 빼 둔다.
+ return Health.down()
+ .withDetail("reason", "백업 성공 기록이 없습니다 (한 번도 성공하지 않았거나 마운트 누락)")
+ .withDetail("markerPath", markerPath.toString())
+ .build();
+ }
+
+ Instant lastSuccess;
+ try {
+ lastSuccess = Instant.parse(Files.readString(markerPath).trim());
+ } catch (IOException | DateTimeParseException e) {
+ return Health.down()
+ .withDetail("reason", "백업 성공 기록을 읽을 수 없습니다: " + e.getClass().getSimpleName())
+ .withDetail("markerPath", markerPath.toString())
+ .build();
+ }
+
+ Duration age = Duration.between(lastSuccess, Instant.now());
+ Health.Builder builder = age.compareTo(maxAge) > 0 ? Health.down() : Health.up();
+ return builder
+ .withDetail("lastSuccess", lastSuccess.toString())
+ .withDetail("ageHours", age.toHours())
+ .withDetail("maxAgeHours", maxAge.toHours())
+ .build();
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java b/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java
index ab2bc8f0..bb5dc6d5 100644
--- a/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java
+++ b/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java
@@ -4,6 +4,7 @@
import com.stackup.stackup.system.application.dto.SystemHealthResponse;
import com.stackup.stackup.system.application.dto.SystemLiveResponse;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -19,10 +20,14 @@ public class SystemHealthService {
// Actuator 키는 Spring 이 등록하는 빈 이름에서 접미사를 뗀 값이다 —
// DataSourceHealthContributor→"db", rabbitHealthContributor→"rabbit".
// "rabbitmq" 로 조회하면 항상 null 이라 rabbitmq 컴포넌트가 영구 UNKNOWN 이 된다.
- private static final ComponentSpec DATABASE = new ComponentSpec("database", "db");
- private static final ComponentSpec RABBITMQ = new ComponentSpec("rabbitmq", "rabbit");
- private static final ComponentSpec S3 = new ComponentSpec("s3", "s3");
- private static final ComponentSpec AI_SERVER = new ComponentSpec("aiServer", "aiServer");
+ private static final ComponentSpec DATABASE = new ComponentSpec("database", "db", false);
+ private static final ComponentSpec RABBITMQ = new ComponentSpec("rabbitmq", "rabbit", false);
+ private static final ComponentSpec S3 = new ComponentSpec("s3", "s3", false);
+ private static final ComponentSpec AI_SERVER = new ComponentSpec("aiServer", "aiServer", false);
+ // 백업이 낡아도 서비스는 정상 동작한다 — 전체 상태를 끌어내리면 "면접이 안 된다"와
+ // "복구 수단이 없다"가 구분되지 않고, /health 를 보는 업타임 감시가 헛울린다.
+ // 값은 보여주되 aggregate 에서는 뺀다.
+ private static final ComponentSpec BACKUP = new ComponentSpec("backup", "backup", true);
private final HealthEndpoint healthEndpoint;
@@ -39,16 +44,21 @@ public SystemHealthResponse ready() {
}
public SystemHealthResponse health() {
- return buildResponse(List.of(DATABASE, RABBITMQ, S3, AI_SERVER));
+ return buildResponse(List.of(DATABASE, RABBITMQ, S3, AI_SERVER, BACKUP));
}
private SystemHealthResponse buildResponse(List 백업 신선도는 "복구 수단이 없다"는 운영 신호이지 "면접이 안 된다"가 아니다.
+ * aggregate 에 넣으면 업타임 감시가 장애로 오인해 헛울리고, 진짜 장애와 구분도 안 된다.
+ * 그래서 값은 보여주되 집계에서만 뺀다.
+ */
+ @Test
+ void health_backupStaleDoesNotDragOverallStatusDown() {
+ HealthEndpoint healthEndpoint = healthEndpoint(Map.of(
+ "db", indicator(Status.UP, Map.of()),
+ "rabbit", indicator(Status.UP, Map.of()),
+ "s3", indicator(Status.UP, Map.of()),
+ "aiServer", indicator(Status.UP, Map.of()),
+ "backup", indicator(Status.DOWN, Map.of())
+ ));
+ SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint);
+
+ var response = systemHealthService.health();
+
+ assertThat(response.components().get("backup").status()).isEqualTo(Status.DOWN.getCode());
+ assertThat(response.status()).isEqualTo(Status.UP.getCode());
+ }
+
+ @Test
+ void health_realOutageStillDragsOverallStatusDown() {
+ // 위 예외 처리가 진짜 장애까지 삼키면 안 된다.
+ HealthEndpoint healthEndpoint = healthEndpoint(Map.of(
+ "db", indicator(Status.DOWN, Map.of()),
+ "rabbit", indicator(Status.UP, Map.of()),
+ "s3", indicator(Status.UP, Map.of()),
+ "aiServer", indicator(Status.UP, Map.of()),
+ "backup", indicator(Status.UP, Map.of())
+ ));
+ SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint);
+
+ assertThat(systemHealthService.health().status()).isEqualTo(Status.DOWN.getCode());
+ }
+
+ @Test
+ void ready_doesNotIncludeBackup() {
+ // 준비 상태(트래픽 수용 가능)와 백업은 무관하다.
+ HealthEndpoint healthEndpoint = healthEndpoint(Map.of(
+ "db", indicator(Status.UP, Map.of()),
+ "rabbit", indicator(Status.UP, Map.of()),
+ "backup", indicator(Status.DOWN, Map.of())
+ ));
+ SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint);
+
+ var response = systemHealthService.ready();
+
+ assertThat(response.components()).doesNotContainKey("backup");
+ assertThat(response.status()).isEqualTo(Status.UP.getCode());
+ }
+
}
diff --git a/docker-compose.yml b/docker-compose.yml
index cfad621d..4aa7ea3b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -203,6 +203,10 @@ services:
S3_REGION: ${S3_REGION:-us-east-1}
S3_PATH_STYLE: "true"
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
+ volumes:
+ # 백업 신선도 확인용(BackupHealthIndicator). 호스트 경로라 compose 가 소유한다.
+ # 읽기 전용 — 앱이 백업 파일을 건드릴 일은 없다.
+ - ${BACKUP_DIR:-/home/stackup/backups}:/var/backups/stackup:ro
depends_on:
postgres:
condition: service_healthy
diff --git a/infra/backup/README.md b/infra/backup/README.md
index 16b4f496..8ee6ef6b 100644
--- a/infra/backup/README.md
+++ b/infra/backup/README.md
@@ -22,10 +22,30 @@ RabbitMQ 볼륨은 받지 않는다 — 큐는 재선언되고 메시지는 휘
0 4 * * * /home/stackup/stackup/infra/backup/backup.sh >> /home/stackup/backups/backup.log 2>&1
```
-## 백업이 살아 있는지 확인
+## 실패를 어떻게 알아채는가
-cron 출력은 아무도 보지 않으므로 **성공 시각 마커**를 남긴다. 이게 하루 이상 낡았으면
-백업이 멈춘 것이다.
+실패 모드가 두 가지이고 잡는 방법도 다르다.
+
+| 실패 모드 | 감지 | 비고 |
+|---|---|---|
+| **돌았는데 실패** (pg_dump 오류, 디스크 부족 등) | 스크립트가 **Discord 알림** | `DISCORD_WEBHOOK_URL` 필요. 알림 실패는 삼켜서 백업 결과를 덮지 않는다 |
+| **아예 안 돎** (cron 죽음, 서버 재부팅 등) | 백엔드 헬스체크 `backup` 컴포넌트 | 스크립트가 안 돌았으니 스스로는 못 알린다 — 밖에서 봐야 한다 |
+
+`GET /api/system/health` 의 `backup` 컴포넌트가 `LAST_SUCCESS` 나이를 본다
+(`backup.max-age-hours`, 기본 36시간 = 일 1회 + 여유).
+
+**이 컴포넌트는 전체 상태(`status`)에 반영되지 않는다.** 백업이 낡은 건 "복구 수단이 없다"는
+뜻이지 "면접이 안 된다"가 아니다. aggregate 에 넣으면 업타임 감시가 헛울리고 진짜 장애와
+구분도 안 된다. 값만 보여주고 집계에서는 뺀다.
+
+```json
+{ "status": "UP",
+ "components": { "backup": { "name": "backup", "status": "DOWN" } } }
+```
+
+컨테이너는 `${BACKUP_DIR}` 를 `/var/backups/stackup` 에 **읽기 전용**으로 마운트해 마커만 본다.
+
+## 직접 확인
```bash
cat ~/backups/LAST_SUCCESS # 예: 2026-09-26T15:44:58+09:00