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
3 changes: 3 additions & 0 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 백업 신선도.
*
* <p>백업 스크립트가 실패하면 Discord 로 알린다. 하지만 <b>cron 자체가 죽어 아예 실행되지
* 않는 경우</b>는 스크립트가 알릴 수 없다 — 돌지 않았으니까. 그건 밖에서 보는 수밖에 없고,
* 그게 이 인디케이터다.
*
* <p>백업이 낡았다고 서비스가 고장난 건 아니므로 <b>전체 상태(aggregate)에는 반영하지
* 않는다</b>({@code SystemHealthService} 의 informational 처리). 여기서 DOWN 이 뜨면
* "면접이 안 된다"가 아니라 "복구 수단이 없다"는 뜻이다.
*
* <p>빈 이름이 곧 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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<ComponentSpec> specs) {
Map<String, ComponentHealthResponse> components = new LinkedHashMap<>();
List<ComponentHealthResponse> 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
);
Expand Down Expand Up @@ -85,6 +95,7 @@ private String aggregateStatus(Iterable<ComponentHealthResponse> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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("읽을 수 없습니다");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,60 @@ public AdditionalHealthEndpointPath getAdditionalPath() {
private static HealthIndicator indicator(Status status, Map<String, Object> details) {
return () -> Health.status(status).withDetails(details).build();
}

/**
* 백업이 낡아도 전체 상태는 끌어내리지 않는다.
*
* <p>백업 신선도는 "복구 수단이 없다"는 운영 신호이지 "면접이 안 된다"가 아니다.
* 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());
}

}
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions infra/backup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading