From b3d1bc7c6cc3e46fc4232e5f3a0e278e85886668 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 27 Sep 2026 10:46:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(infra):=20=EB=B0=B1=EC=97=85=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=95=8C=EB=A6=BC(Discord)=20+=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A0=EB=8F=84=20=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #249 로 백업은 생겼지만 **실패하면 아무도 모르는** 상태였다. cron 출력은 읽는 사람이 없고 LAST_SUCCESS 를 들여다볼 사람도 없다 — 오늘 반복해서 당한 "조용히 멈춤" 패턴 그대로다. 실패 모드가 둘이고 잡는 방법이 다르다: 1) 돌았는데 실패 (pg_dump 오류·디스크 부족) → 스크립트가 Discord 알림. 팀이 이미 보는 채널이라 로그 파일보다 실제로 눈에 띈다. 2) 아예 안 돎 (cron 죽음·재부팅) → 스크립트는 못 알린다. 돌지 않았으니까. 백엔드 헬스체크가 밖에서 LAST_SUCCESS 나이를 본다(기본 36시간). - infra/backup/backup.sh: fail() + ERR trap 에서 Discord 알림. 알림 실패는 전부 삼켜 백업 결과를 덮지 않는다(curl -m 10, || true). cron 에는 환경변수가 없으므로 .env 에서 웹훅 한 줄만 꺼내 쓴다(통째 source 하면 다른 값을 덮는다). - deploy-app.yml: DISCORD_WEBHOOK_URL 을 운영 .env 에 주입(GitHub Secret 에만 있어서 서버에서는 쓸 수 없었다). - BackupHealthIndicator + /api/system/health 의 backup 컴포넌트. **전체 status 에는 반영하지 않는다** — 백업이 낡은 건 "복구 수단이 없다"이지 "면접이 안 된다"가 아니다. aggregate 에 넣으면 업타임 감시가 헛울리고 진짜 장애와 구분도 안 된다. ready 에서도 뺀다(트래픽 수용 가능 여부와 무관). - compose: 백업 디렉토리를 backend 에 읽기 전용 마운트. 검증: 실패 시 exit 1 + LAST_SUCCESS 미갱신(그래야 헬스체크가 잡는다), 웹훅이 죽어도 지연 0초, 웹훅 미설정에서도 정상 동작. --- .github/workflows/deploy-app.yml | 3 + .../common/health/BackupHealthIndicator.java | 70 ++++++++++++++++++ .../application/SystemHealthService.java | 27 +++++-- .../health/BackupHealthIndicatorTest.java | 74 +++++++++++++++++++ .../application/SystemHealthServiceTest.java | 56 ++++++++++++++ docker-compose.yml | 4 + infra/backup/README.md | 26 ++++++- 7 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/health/BackupHealthIndicator.java create mode 100644 backend/src/test/java/com/stackup/stackup/common/health/BackupHealthIndicatorTest.java 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 specs) { Map components = new LinkedHashMap<>(); + List aggregated = new ArrayList<>(); for (ComponentSpec spec : specs) { - components.put(spec.name(), resolveComponent(spec)); + ComponentHealthResponse component = resolveComponent(spec); + components.put(spec.name(), component); + if (!spec.informational()) { + aggregated.add(component); + } } return new SystemHealthResponse( - aggregateStatus(components.values()), + aggregateStatus(aggregated), Instant.now(), components ); @@ -85,6 +95,7 @@ private String aggregateStatus(Iterable components) { return hasUnknown ? Status.UNKNOWN.getCode() : Status.UP.getCode(); } - private record ComponentSpec(String name, String actuatorPath) { + // informational=true 면 응답에는 싣되 전체 상태 집계에서는 제외한다. + private record ComponentSpec(String name, String actuatorPath, boolean informational) { } } diff --git a/backend/src/test/java/com/stackup/stackup/common/health/BackupHealthIndicatorTest.java b/backend/src/test/java/com/stackup/stackup/common/health/BackupHealthIndicatorTest.java new file mode 100644 index 00000000..efe169fd --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/health/BackupHealthIndicatorTest.java @@ -0,0 +1,74 @@ +package com.stackup.stackup.common.health; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; + +// 백업 스크립트는 실패하면 Discord 로 알린다. 하지만 cron 이 죽어 **아예 안 돈** 경우는 +// 스크립트가 알릴 수 없다 — 그건 밖에서 보는 수밖에 없고 그게 이 인디케이터다. +class BackupHealthIndicatorTest { + + @TempDir Path dir; + + private BackupHealthIndicator indicator(long maxAgeHours) { + return new BackupHealthIndicator(dir.resolve("LAST_SUCCESS").toString(), maxAgeHours); + } + + private void writeMarker(Instant at) throws IOException { + Files.writeString(dir.resolve("LAST_SUCCESS"), at.toString() + "\n"); + } + + @Test + void 최근_백업이_있으면_UP() throws IOException { + writeMarker(Instant.now().minus(2, ChronoUnit.HOURS)); + + Health health = indicator(36).health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("ageHours", 2L); + } + + @Test + void 한도를_넘긴_백업은_DOWN() throws IOException { + // cron 이 죽어 이틀째 안 돈 상황. + writeMarker(Instant.now().minus(50, ChronoUnit.HOURS)); + + Health health = indicator(36).health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()).containsEntry("ageHours", 50L); + } + + @Test + void 경계값_직전은_UP() throws IOException { + writeMarker(Instant.now().minus(35, ChronoUnit.HOURS)); + assertThat(indicator(36).health().getStatus()).isEqualTo(Status.UP); + } + + @Test + void 기록이_없으면_DOWN() { + // 한 번도 성공하지 않았거나, 마운트가 빠졌거나. 둘 다 "복구 수단 없음" 이다. + Health health = indicator(36).health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails().get("reason").toString()).contains("백업 성공 기록이 없습니다"); + } + + @Test + void 깨진_기록은_DOWN() throws IOException { + Files.writeString(dir.resolve("LAST_SUCCESS"), "not-a-timestamp"); + + Health health = indicator(36).health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails().get("reason").toString()).contains("읽을 수 없습니다"); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java b/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java index 5ccba9ca..88742c88 100644 --- a/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java @@ -170,4 +170,60 @@ public AdditionalHealthEndpointPath getAdditionalPath() { private static HealthIndicator indicator(Status status, Map details) { return () -> Health.status(status).withDetails(details).build(); } + + /** + * 백업이 낡아도 전체 상태는 끌어내리지 않는다. + * + *

백업 신선도는 "복구 수단이 없다"는 운영 신호이지 "면접이 안 된다"가 아니다. + * 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