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
Original file line number Diff line number Diff line change
@@ -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 서버 생존 여부 — **작업 큐의 컨슈머 수**로 판단한다.
*
* <p>Core 는 AI 서버를 HTTP 로 호출하지 않는다(아키텍처 §4.1: RabbitMQ 경유). 헬스체크 하나
* 때문에 Core→AI HTTP 의존을 새로 만들 이유가 없고, 컨슈머 수는 오히려 더 정확한 신호다 —
* 프로세스가 살아 있는 것보다 <b>큐를 실제로 구독하고 있는지</b>가 중요하다.
* 컨슈머가 0이면 질문 생성·꼬리질문·피드백이 전부 큐에 쌓이기만 한다.
*
* <p>빈 이름이 곧 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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) 도달성.
*
* <p>빈 이름이 곧 Actuator 컴포넌트 키가 된다 — {@code s3HealthIndicator} → {@code "s3"}.
* `SystemHealthService` 가 그 키로 조회하므로 이름을 바꾸면 UNKNOWN 으로 돌아간다.
*
* <p>스토리지가 죽으면 이력서 업로드·음성 답변·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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ public interface ObjectStorageClient {
void delete(String key);

URI createPresignedGetUrl(String key, Duration ttl);

/**
* 스토리지에 도달 가능한지 확인한다(헬스체크 전용). 실패하면 {@link StorageException}.
* 키를 모르고도 확인할 수 있어야 해서 별도 메서드로 둔다 — get/put 은 대상 키가 필요하다.
*/
void verifyAvailable();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ public enum StorageErrorType {
UPLOAD_FAILED,
DOWNLOAD_FAILED,
DELETE_FAILED,
PRESIGNED_URL_FAILED
PRESIGNED_URL_FAILED,
// 헬스체크: 엔드포인트·자격증명·버킷 도달 실패.
UNAVAILABLE
}
10 changes: 10 additions & 0 deletions backend/src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# 테스트 컨텍스트는 DataSource 를 제외하므로 db 컨트리뷰터가 없다.
# 그룹 멤버십 검증은 **켜둔 채로**(운영에서 이름 오타가 조용히 무시되지 않게 — 실제로
# rabbitmq 키 오타가 헬스체크를 무력화한 적이 있다) 테스트에서만 그룹을 축소한다.
management:
endpoint:
health:
group:
readiness:
include: readinessState

spring:
autoconfigure:
exclude:
Expand Down
6 changes: 6 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 이 되는 것보다 낫다(§실제 사례).

---

Expand Down
Loading