diff --git a/backend/src/main/java/com/stackup/stackup/common/health/AiServerHealthIndicator.java b/backend/src/main/java/com/stackup/stackup/common/health/AiServerHealthIndicator.java new file mode 100644 index 00000000..3f42b650 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/health/AiServerHealthIndicator.java @@ -0,0 +1,58 @@ +package com.stackup.stackup.common.health; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import org.springframework.amqp.core.AmqpAdmin; +import org.springframework.amqp.core.QueueInformation; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.stereotype.Component; + +/** + * AI 서버 생존 여부 — **작업 큐의 컨슈머 수**로 판단한다. + * + *

Core 는 AI 서버를 HTTP 로 호출하지 않는다(아키텍처 §4.1: RabbitMQ 경유). 헬스체크 하나 + * 때문에 Core→AI HTTP 의존을 새로 만들 이유가 없고, 컨슈머 수는 오히려 더 정확한 신호다 — + * 프로세스가 살아 있는 것보다 큐를 실제로 구독하고 있는지가 중요하다. + * 컨슈머가 0이면 질문 생성·꼬리질문·피드백이 전부 큐에 쌓이기만 한다. + * + *

빈 이름이 곧 Actuator 컴포넌트 키다 — {@code aiServerHealthIndicator} → {@code "aiServer"}. + */ +@Component +public class AiServerHealthIndicator implements HealthIndicator { + + private final AmqpAdmin amqpAdmin; + private final RabbitMqProperties properties; + + public AiServerHealthIndicator(AmqpAdmin amqpAdmin, RabbitMqProperties properties) { + this.amqpAdmin = amqpAdmin; + this.properties = properties; + } + + @Override + public Health health() { + // 대표 큐 하나로 판단한다. 이 큐에 컨슈머가 없으면 면접 자체가 시작되지 않는다. + String queue = properties.queues().names().aiGenerateQuestions(); + try { + QueueInformation info = amqpAdmin.getQueueInfo(queue); + if (info == null) { + return Health.down() + .withDetail("queue", queue) + .withDetail("reason", "queue not found") + .build(); + } + int consumers = info.getConsumerCount(); + Health.Builder builder = consumers > 0 ? Health.up() : Health.down(); + return builder + .withDetail("queue", queue) + .withDetail("consumers", consumers) + .withDetail("pendingMessages", info.getMessageCount()) + .build(); + } catch (RuntimeException e) { + // 브로커 자체가 죽었으면 rabbitmq 컴포넌트가 따로 알려준다. 여기선 판단 불가로 둔다. + return Health.unknown() + .withDetail("queue", queue) + .withDetail("reason", e.getMessage()) + .build(); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/health/S3HealthIndicator.java b/backend/src/main/java/com/stackup/stackup/common/health/S3HealthIndicator.java new file mode 100644 index 00000000..d6981b34 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/health/S3HealthIndicator.java @@ -0,0 +1,40 @@ +package com.stackup.stackup.common.health; + +import com.stackup.stackup.common.config.properties.S3Properties; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.stereotype.Component; + +/** + * 객체 스토리지(S3/MinIO) 도달성. + * + *

빈 이름이 곧 Actuator 컴포넌트 키가 된다 — {@code s3HealthIndicator} → {@code "s3"}. + * `SystemHealthService` 가 그 키로 조회하므로 이름을 바꾸면 UNKNOWN 으로 돌아간다. + * + *

스토리지가 죽으면 이력서 업로드·음성 답변·TTS 재생이 전부 실패한다. + */ +@Component +public class S3HealthIndicator implements HealthIndicator { + + private final ObjectStorageClient storage; + private final S3Properties properties; + + public S3HealthIndicator(ObjectStorageClient storage, S3Properties properties) { + this.storage = storage; + this.properties = properties; + } + + @Override + public Health health() { + try { + storage.verifyAvailable(); + return Health.up().withDetail("bucket", properties.bucket()).build(); + } catch (RuntimeException e) { + return Health.down() + .withDetail("bucket", properties.bucket()) + .withDetail("reason", e.getMessage()) + .build(); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java index b9ff699a..48afd413 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java @@ -13,4 +13,10 @@ public interface ObjectStorageClient { void delete(String key); URI createPresignedGetUrl(String key, Duration ttl); + + /** + * 스토리지에 도달 가능한지 확인한다(헬스체크 전용). 실패하면 {@link StorageException}. + * 키를 모르고도 확인할 수 있어야 해서 별도 메서드로 둔다 — get/put 은 대상 키가 필요하다. + */ + void verifyAvailable(); } diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java index 1c8abbb7..40ce59d1 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java @@ -54,6 +54,17 @@ public S3ObjectStorageClient(S3Properties properties) { .build(); } + // 버킷 존재·자격증명·엔드포인트 도달성을 한 번에 확인하는 가장 싼 호출. + @Override + public void verifyAvailable() { + try { + s3Client.headBucket(b -> b.bucket(properties.bucket())); + } catch (RuntimeException e) { + throw new StorageException(StorageErrorType.UNAVAILABLE, + "object storage is not reachable: " + e.getMessage(), e); + } + } + @Override public StoredObject put(String key, InputStream content, long size, String contentType) { requireKey(key); diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java b/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java index d4cb51a7..002c4655 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java @@ -5,5 +5,7 @@ public enum StorageErrorType { UPLOAD_FAILED, DOWNLOAD_FAILED, DELETE_FAILED, - PRESIGNED_URL_FAILED + PRESIGNED_URL_FAILED, + // 헬스체크: 엔드포인트·자격증명·버킷 도달 실패. + UNAVAILABLE } diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index 2124d230..16094e04 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -1,3 +1,13 @@ +# 테스트 컨텍스트는 DataSource 를 제외하므로 db 컨트리뷰터가 없다. +# 그룹 멤버십 검증은 **켜둔 채로**(운영에서 이름 오타가 조용히 무시되지 않게 — 실제로 +# rabbitmq 키 오타가 헬스체크를 무력화한 적이 있다) 테스트에서만 그룹을 축소한다. +management: + endpoint: + health: + group: + readiness: + include: readinessState + spring: autoconfigure: exclude: diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a792ab8d..590ffb36 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -27,6 +27,12 @@ management: health: probes: enabled: true + # 컨테이너 healthcheck 는 이 그룹을 본다(docker-compose). 백엔드가 **자기 일을 하려면 + # 반드시 필요한 것**만 넣는다 — DB·RabbitMQ. s3/aiServer 는 종합(/actuator/health)에만 + # 들어간다: AI 가 죽었다고 백엔드를 rotation 에서 빼면 로그인·히스토리까지 못 쓰게 된다. + group: + readiness: + include: readinessState, db, rabbit springdoc: api-docs: diff --git a/backend/src/test/java/com/stackup/stackup/common/health/AiServerHealthIndicatorTest.java b/backend/src/test/java/com/stackup/stackup/common/health/AiServerHealthIndicatorTest.java new file mode 100644 index 00000000..0537170d --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/health/AiServerHealthIndicatorTest.java @@ -0,0 +1,103 @@ +package com.stackup.stackup.common.health; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.core.AmqpAdmin; +import org.springframework.amqp.core.QueueInformation; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; + +/** + * AI 생존을 HTTP 가 아니라 큐 컨슈머 수로 판단한다 — Core→AI HTTP 의존을 만들지 않기 위해서다 + * (아키텍처 §4.1). 컨슈머 0 은 "프로세스는 떠 있지만 일을 안 받는" 상태까지 잡아낸다. + */ +@ExtendWith(MockitoExtension.class) +class AiServerHealthIndicatorTest { + + private static final String QUEUE = "ai.generate.questions"; + + @Mock AmqpAdmin amqpAdmin; + + AiServerHealthIndicator indicator; + + @BeforeEach + void setUp() { + indicator = new AiServerHealthIndicator(amqpAdmin, propertiesWithQueue(QUEUE)); + } + + @Test + void up_whenQueueHasConsumers() { + when(amqpAdmin.getQueueInfo(QUEUE)).thenReturn(new QueueInformation(QUEUE, 3, 2)); + + Health health = indicator.health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("consumers", 2); + assertThat(health.getDetails()).containsEntry("pendingMessages", 3L); + } + + // 큐는 있는데 아무도 안 먹고 있으면 면접이 시작되지 않는다 — UP 으로 볼 수 없다. + @Test + void down_whenNoConsumers() { + when(amqpAdmin.getQueueInfo(QUEUE)).thenReturn(new QueueInformation(QUEUE, 12, 0)); + + Health health = indicator.health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()).containsEntry("consumers", 0); + // 쌓인 메시지 수가 함께 보여야 얼마나 밀렸는지 판단할 수 있다. + assertThat(health.getDetails()).containsEntry("pendingMessages", 12L); + } + + @Test + void down_whenQueueMissing() { + when(amqpAdmin.getQueueInfo(QUEUE)).thenReturn(null); + + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + } + + // 브로커가 죽은 경우는 rabbitmq 컴포넌트가 따로 알려준다. 여기서 DOWN 을 겹쳐 내면 + // "AI 가 죽었다" 로 오독된다 — 판단 불가로 남긴다. + @Test + void unknown_whenBrokerUnreachable() { + when(amqpAdmin.getQueueInfo(QUEUE)).thenThrow(new IllegalStateException("connection refused")); + + Health health = indicator.health(); + + assertThat(health.getStatus()).isEqualTo(Status.UNKNOWN); + assertThat(health.getDetails()).containsEntry("queue", QUEUE); + } + + private RabbitMqProperties propertiesWithQueue(String generateQuestions) { + return new RabbitMqProperties( + "core", "1", + new RabbitMqProperties.Message("application/json", "UTF-8", "X-Trace-Id"), + new RabbitMqProperties.Template(true), + new RabbitMqProperties.Exchanges(true, false, + new RabbitMqProperties.Exchanges.Names("core.ai", "ai.core", "realtime")), + new RabbitMqProperties.Queues(true, + new RabbitMqProperties.Queues.Names( + "ai.analyze.resume", "ai.analyze.repository", "ai.analyze.web", + "ai.analyze.cover_letter", generateQuestions, "ai.generate.followup", + "ai.generate.feedback", "ai.analyze.voice", "ai.generate.tts", + "core.callback.analysis", "core.callback.questions", "core.callback.feedback", + "core.callback.voice", "core.callback.tts")), + new RabbitMqProperties.RoutingKeyProperties( + "analyze.resume", "analyze.repository", "analyze.web", "analyze.cover_letter", + "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", + "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", + "callback.voice", "callback.tts", "session.notify", "realtime.user.notify", + "realtime.document.notify"), + new RabbitMqProperties.DeadLetter("dlx", "dlq."), + new RabbitMqProperties.Retry(3, java.time.Duration.ofSeconds(1), 2.0, + java.time.Duration.ofSeconds(10)) + ); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/health/S3HealthIndicatorTest.java b/backend/src/test/java/com/stackup/stackup/common/health/S3HealthIndicatorTest.java new file mode 100644 index 00000000..b0853864 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/health/S3HealthIndicatorTest.java @@ -0,0 +1,48 @@ +package com.stackup.stackup.common.health; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; + +import com.stackup.stackup.common.config.properties.S3Properties; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.common.storage.StorageErrorType; +import com.stackup.stackup.common.storage.StorageException; +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; + +@ExtendWith(MockitoExtension.class) +class S3HealthIndicatorTest { + + @Mock ObjectStorageClient storage; + + @Test + void up_whenStorageIsReachable() { + Health health = new S3HealthIndicator(storage, properties()).health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("bucket", "stackup"); + } + + // 스토리지가 죽으면 이력서 업로드·음성 답변·TTS 재생이 전부 실패한다 — 조용히 UP 이면 안 된다. + @Test + void down_whenStorageIsUnreachable() { + doThrow(new StorageException(StorageErrorType.UNAVAILABLE, "connection refused")) + .when(storage).verifyAvailable(); + + Health health = new S3HealthIndicator(storage, properties()).health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails().get("reason").toString()).contains("connection refused"); + } + + private S3Properties properties() { + // record 순서: endpoint, accessKey, secretKey, bucket, region, pathStyle + return new S3Properties( + URI.create("http://localhost:9000"), "key", "secret", "stackup", "us-east-1", true); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index a6f002bf..9f54179f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -194,7 +194,9 @@ services: minio: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:38010/actuator/health >/dev/null || exit 1"] + # 종합(/actuator/health)이 아니라 readiness 그룹을 본다 — s3/aiServer 장애로 + # 백엔드 컨테이너가 unhealthy 가 되면 정작 멀쩡한 로그인·히스토리까지 끊긴다. + test: ["CMD-SHELL", "curl -sf http://localhost:38010/actuator/health/readiness >/dev/null || exit 1"] interval: 10s timeout: 5s retries: 15 diff --git a/docs/observability.md b/docs/observability.md index 65e64102..a8ce9935 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -170,12 +170,19 @@ GET /api/system/health - **응답 키와 Actuator 컴포넌트 키는 다르다.** Actuator 키는 Spring 이 등록하는 빈 이름에서 접미사를 뗀 값이라 `database`→`db`, `rabbitmq`→**`rabbit`** 이다. 여기를 틀리면 조회가 null 을 돌려줘 그 컴포넌트가 **영구 UNKNOWN** 이 된다(에러가 아니라 조용한 무응답). -- `s3`·`aiServer` 는 아직 커스텀 indicator 가 없어 UNKNOWN 이다 → `/health` 의 종합 status 도 - UNKNOWN 으로 고정된다. 종합 판단이 필요하면 현재는 `/ready`(database·rabbitmq)를 쓴다. +- `s3` 는 `headBucket`(엔드포인트·자격증명·버킷을 한 번에 확인), `aiServer` 는 **작업 큐의 + 컨슈머 수**로 판단한다. AI 를 HTTP 로 찌르지 않는 이유는 아키텍처 §4.1 — Core→AI 는 + RabbitMQ 전용이고, 컨슈머 수가 더 정확한 신호이기도 하다(프로세스 생존보다 "큐를 실제로 + 구독 중인가"가 중요). 컨슈머 0 이면 DOWN, 브로커 자체가 죽었으면 UNKNOWN(그건 rabbitmq + 컴포넌트가 알려준다). - K8s liveness: 단순 200 응답 (`/api/system/live`) - K8s readiness: 의존성 포함 (`/api/system/ready`) -- 컨테이너 healthcheck 는 Spring 자체 `/actuator/health` 를 쓴다(docker-compose) — 이쪽은 - Actuator 종합이라 RabbitMQ 장애를 정상적으로 잡는다. +- **컨테이너 healthcheck 는 `/actuator/health/readiness`** 를 쓴다(docker-compose). + readiness 그룹은 `readinessState + db + rabbit` 로 명시돼 있다 — 백엔드가 자기 일을 하려면 + 반드시 필요한 것만. s3·aiServer 는 종합(`/actuator/health`)에만 들어간다: **AI 가 죽었다고 + 백엔드를 rotation 에서 빼면 정작 멀쩡한 로그인·히스토리까지 끊긴다.** +- 그룹 멤버십 검증(`validate-group-membership`)은 기본값 그대로 **켜 둔다**. 이름을 틀리면 + 부팅이 실패해 배포 게이트에서 잡힌다 — 조용히 UNKNOWN 이 되는 것보다 낫다(§실제 사례). ---