Skip to content

Commit a3099fb

Browse files
authored
Merge pull request #184 from Team-StackUp/feature/s3-ai-health-indicators
feat(backend): s3·aiServer 헬스 indicator + 컨테이너 healthcheck 를 readiness 로
2 parents 3627e04 + ba2c651 commit a3099fb

11 files changed

Lines changed: 299 additions & 6 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.stackup.stackup.common.health;
2+
3+
import com.stackup.stackup.common.config.properties.RabbitMqProperties;
4+
import org.springframework.amqp.core.AmqpAdmin;
5+
import org.springframework.amqp.core.QueueInformation;
6+
import org.springframework.boot.health.contributor.Health;
7+
import org.springframework.boot.health.contributor.HealthIndicator;
8+
import org.springframework.stereotype.Component;
9+
10+
/**
11+
* AI 서버 생존 여부 — **작업 큐의 컨슈머 수**로 판단한다.
12+
*
13+
* <p>Core 는 AI 서버를 HTTP 로 호출하지 않는다(아키텍처 §4.1: RabbitMQ 경유). 헬스체크 하나
14+
* 때문에 Core→AI HTTP 의존을 새로 만들 이유가 없고, 컨슈머 수는 오히려 더 정확한 신호다 —
15+
* 프로세스가 살아 있는 것보다 <b>큐를 실제로 구독하고 있는지</b>가 중요하다.
16+
* 컨슈머가 0이면 질문 생성·꼬리질문·피드백이 전부 큐에 쌓이기만 한다.
17+
*
18+
* <p>빈 이름이 곧 Actuator 컴포넌트 키다 — {@code aiServerHealthIndicator} → {@code "aiServer"}.
19+
*/
20+
@Component
21+
public class AiServerHealthIndicator implements HealthIndicator {
22+
23+
private final AmqpAdmin amqpAdmin;
24+
private final RabbitMqProperties properties;
25+
26+
public AiServerHealthIndicator(AmqpAdmin amqpAdmin, RabbitMqProperties properties) {
27+
this.amqpAdmin = amqpAdmin;
28+
this.properties = properties;
29+
}
30+
31+
@Override
32+
public Health health() {
33+
// 대표 큐 하나로 판단한다. 이 큐에 컨슈머가 없으면 면접 자체가 시작되지 않는다.
34+
String queue = properties.queues().names().aiGenerateQuestions();
35+
try {
36+
QueueInformation info = amqpAdmin.getQueueInfo(queue);
37+
if (info == null) {
38+
return Health.down()
39+
.withDetail("queue", queue)
40+
.withDetail("reason", "queue not found")
41+
.build();
42+
}
43+
int consumers = info.getConsumerCount();
44+
Health.Builder builder = consumers > 0 ? Health.up() : Health.down();
45+
return builder
46+
.withDetail("queue", queue)
47+
.withDetail("consumers", consumers)
48+
.withDetail("pendingMessages", info.getMessageCount())
49+
.build();
50+
} catch (RuntimeException e) {
51+
// 브로커 자체가 죽었으면 rabbitmq 컴포넌트가 따로 알려준다. 여기선 판단 불가로 둔다.
52+
return Health.unknown()
53+
.withDetail("queue", queue)
54+
.withDetail("reason", e.getMessage())
55+
.build();
56+
}
57+
}
58+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.stackup.stackup.common.health;
2+
3+
import com.stackup.stackup.common.config.properties.S3Properties;
4+
import com.stackup.stackup.common.storage.ObjectStorageClient;
5+
import org.springframework.boot.health.contributor.Health;
6+
import org.springframework.boot.health.contributor.HealthIndicator;
7+
import org.springframework.stereotype.Component;
8+
9+
/**
10+
* 객체 스토리지(S3/MinIO) 도달성.
11+
*
12+
* <p>빈 이름이 곧 Actuator 컴포넌트 키가 된다 — {@code s3HealthIndicator} → {@code "s3"}.
13+
* `SystemHealthService` 가 그 키로 조회하므로 이름을 바꾸면 UNKNOWN 으로 돌아간다.
14+
*
15+
* <p>스토리지가 죽으면 이력서 업로드·음성 답변·TTS 재생이 전부 실패한다.
16+
*/
17+
@Component
18+
public class S3HealthIndicator implements HealthIndicator {
19+
20+
private final ObjectStorageClient storage;
21+
private final S3Properties properties;
22+
23+
public S3HealthIndicator(ObjectStorageClient storage, S3Properties properties) {
24+
this.storage = storage;
25+
this.properties = properties;
26+
}
27+
28+
@Override
29+
public Health health() {
30+
try {
31+
storage.verifyAvailable();
32+
return Health.up().withDetail("bucket", properties.bucket()).build();
33+
} catch (RuntimeException e) {
34+
return Health.down()
35+
.withDetail("bucket", properties.bucket())
36+
.withDetail("reason", e.getMessage())
37+
.build();
38+
}
39+
}
40+
}

backend/src/main/java/com/stackup/stackup/common/storage/ObjectStorageClient.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,10 @@ public interface ObjectStorageClient {
1313
void delete(String key);
1414

1515
URI createPresignedGetUrl(String key, Duration ttl);
16+
17+
/**
18+
* 스토리지에 도달 가능한지 확인한다(헬스체크 전용). 실패하면 {@link StorageException}.
19+
* 키를 모르고도 확인할 수 있어야 해서 별도 메서드로 둔다 — get/put 은 대상 키가 필요하다.
20+
*/
21+
void verifyAvailable();
1622
}

backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ public S3ObjectStorageClient(S3Properties properties) {
5454
.build();
5555
}
5656

57+
// 버킷 존재·자격증명·엔드포인트 도달성을 한 번에 확인하는 가장 싼 호출.
58+
@Override
59+
public void verifyAvailable() {
60+
try {
61+
s3Client.headBucket(b -> b.bucket(properties.bucket()));
62+
} catch (RuntimeException e) {
63+
throw new StorageException(StorageErrorType.UNAVAILABLE,
64+
"object storage is not reachable: " + e.getMessage(), e);
65+
}
66+
}
67+
5768
@Override
5869
public StoredObject put(String key, InputStream content, long size, String contentType) {
5970
requireKey(key);

backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ public enum StorageErrorType {
55
UPLOAD_FAILED,
66
DOWNLOAD_FAILED,
77
DELETE_FAILED,
8-
PRESIGNED_URL_FAILED
8+
PRESIGNED_URL_FAILED,
9+
// 헬스체크: 엔드포인트·자격증명·버킷 도달 실패.
10+
UNAVAILABLE
911
}

backend/src/main/resources/application-test.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
# 테스트 컨텍스트는 DataSource 를 제외하므로 db 컨트리뷰터가 없다.
2+
# 그룹 멤버십 검증은 **켜둔 채로**(운영에서 이름 오타가 조용히 무시되지 않게 — 실제로
3+
# rabbitmq 키 오타가 헬스체크를 무력화한 적이 있다) 테스트에서만 그룹을 축소한다.
4+
management:
5+
endpoint:
6+
health:
7+
group:
8+
readiness:
9+
include: readinessState
10+
111
spring:
212
autoconfigure:
313
exclude:

backend/src/main/resources/application.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ management:
2727
health:
2828
probes:
2929
enabled: true
30+
# 컨테이너 healthcheck 는 이 그룹을 본다(docker-compose). 백엔드가 **자기 일을 하려면
31+
# 반드시 필요한 것**만 넣는다 — DB·RabbitMQ. s3/aiServer 는 종합(/actuator/health)에만
32+
# 들어간다: AI 가 죽었다고 백엔드를 rotation 에서 빼면 로그인·히스토리까지 못 쓰게 된다.
33+
group:
34+
readiness:
35+
include: readinessState, db, rabbit
3036

3137
springdoc:
3238
api-docs:
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.stackup.stackup.common.health;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.Mockito.when;
5+
6+
import com.stackup.stackup.common.config.properties.RabbitMqProperties;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.junit.jupiter.api.extension.ExtendWith;
10+
import org.mockito.Mock;
11+
import org.mockito.junit.jupiter.MockitoExtension;
12+
import org.springframework.amqp.core.AmqpAdmin;
13+
import org.springframework.amqp.core.QueueInformation;
14+
import org.springframework.boot.health.contributor.Health;
15+
import org.springframework.boot.health.contributor.Status;
16+
17+
/**
18+
* AI 생존을 HTTP 가 아니라 큐 컨슈머 수로 판단한다 — Core→AI HTTP 의존을 만들지 않기 위해서다
19+
* (아키텍처 §4.1). 컨슈머 0 은 "프로세스는 떠 있지만 일을 안 받는" 상태까지 잡아낸다.
20+
*/
21+
@ExtendWith(MockitoExtension.class)
22+
class AiServerHealthIndicatorTest {
23+
24+
private static final String QUEUE = "ai.generate.questions";
25+
26+
@Mock AmqpAdmin amqpAdmin;
27+
28+
AiServerHealthIndicator indicator;
29+
30+
@BeforeEach
31+
void setUp() {
32+
indicator = new AiServerHealthIndicator(amqpAdmin, propertiesWithQueue(QUEUE));
33+
}
34+
35+
@Test
36+
void up_whenQueueHasConsumers() {
37+
when(amqpAdmin.getQueueInfo(QUEUE)).thenReturn(new QueueInformation(QUEUE, 3, 2));
38+
39+
Health health = indicator.health();
40+
41+
assertThat(health.getStatus()).isEqualTo(Status.UP);
42+
assertThat(health.getDetails()).containsEntry("consumers", 2);
43+
assertThat(health.getDetails()).containsEntry("pendingMessages", 3L);
44+
}
45+
46+
// 큐는 있는데 아무도 안 먹고 있으면 면접이 시작되지 않는다 — UP 으로 볼 수 없다.
47+
@Test
48+
void down_whenNoConsumers() {
49+
when(amqpAdmin.getQueueInfo(QUEUE)).thenReturn(new QueueInformation(QUEUE, 12, 0));
50+
51+
Health health = indicator.health();
52+
53+
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
54+
assertThat(health.getDetails()).containsEntry("consumers", 0);
55+
// 쌓인 메시지 수가 함께 보여야 얼마나 밀렸는지 판단할 수 있다.
56+
assertThat(health.getDetails()).containsEntry("pendingMessages", 12L);
57+
}
58+
59+
@Test
60+
void down_whenQueueMissing() {
61+
when(amqpAdmin.getQueueInfo(QUEUE)).thenReturn(null);
62+
63+
assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN);
64+
}
65+
66+
// 브로커가 죽은 경우는 rabbitmq 컴포넌트가 따로 알려준다. 여기서 DOWN 을 겹쳐 내면
67+
// "AI 가 죽었다" 로 오독된다 — 판단 불가로 남긴다.
68+
@Test
69+
void unknown_whenBrokerUnreachable() {
70+
when(amqpAdmin.getQueueInfo(QUEUE)).thenThrow(new IllegalStateException("connection refused"));
71+
72+
Health health = indicator.health();
73+
74+
assertThat(health.getStatus()).isEqualTo(Status.UNKNOWN);
75+
assertThat(health.getDetails()).containsEntry("queue", QUEUE);
76+
}
77+
78+
private RabbitMqProperties propertiesWithQueue(String generateQuestions) {
79+
return new RabbitMqProperties(
80+
"core", "1",
81+
new RabbitMqProperties.Message("application/json", "UTF-8", "X-Trace-Id"),
82+
new RabbitMqProperties.Template(true),
83+
new RabbitMqProperties.Exchanges(true, false,
84+
new RabbitMqProperties.Exchanges.Names("core.ai", "ai.core", "realtime")),
85+
new RabbitMqProperties.Queues(true,
86+
new RabbitMqProperties.Queues.Names(
87+
"ai.analyze.resume", "ai.analyze.repository", "ai.analyze.web",
88+
"ai.analyze.cover_letter", generateQuestions, "ai.generate.followup",
89+
"ai.generate.feedback", "ai.analyze.voice", "ai.generate.tts",
90+
"core.callback.analysis", "core.callback.questions", "core.callback.feedback",
91+
"core.callback.voice", "core.callback.tts")),
92+
new RabbitMqProperties.RoutingKeyProperties(
93+
"analyze.resume", "analyze.repository", "analyze.web", "analyze.cover_letter",
94+
"generate.questions", "generate.followup", "generate.feedback", "analyze.voice",
95+
"generate.tts", "callback.analysis", "callback.questions", "callback.feedback",
96+
"callback.voice", "callback.tts", "session.notify", "realtime.user.notify",
97+
"realtime.document.notify"),
98+
new RabbitMqProperties.DeadLetter("dlx", "dlq."),
99+
new RabbitMqProperties.Retry(3, java.time.Duration.ofSeconds(1), 2.0,
100+
java.time.Duration.ofSeconds(10))
101+
);
102+
}
103+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.stackup.stackup.common.health;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.Mockito.doThrow;
5+
6+
import com.stackup.stackup.common.config.properties.S3Properties;
7+
import com.stackup.stackup.common.storage.ObjectStorageClient;
8+
import com.stackup.stackup.common.storage.StorageErrorType;
9+
import com.stackup.stackup.common.storage.StorageException;
10+
import java.net.URI;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.extension.ExtendWith;
13+
import org.mockito.Mock;
14+
import org.mockito.junit.jupiter.MockitoExtension;
15+
import org.springframework.boot.health.contributor.Health;
16+
import org.springframework.boot.health.contributor.Status;
17+
18+
@ExtendWith(MockitoExtension.class)
19+
class S3HealthIndicatorTest {
20+
21+
@Mock ObjectStorageClient storage;
22+
23+
@Test
24+
void up_whenStorageIsReachable() {
25+
Health health = new S3HealthIndicator(storage, properties()).health();
26+
27+
assertThat(health.getStatus()).isEqualTo(Status.UP);
28+
assertThat(health.getDetails()).containsEntry("bucket", "stackup");
29+
}
30+
31+
// 스토리지가 죽으면 이력서 업로드·음성 답변·TTS 재생이 전부 실패한다 — 조용히 UP 이면 안 된다.
32+
@Test
33+
void down_whenStorageIsUnreachable() {
34+
doThrow(new StorageException(StorageErrorType.UNAVAILABLE, "connection refused"))
35+
.when(storage).verifyAvailable();
36+
37+
Health health = new S3HealthIndicator(storage, properties()).health();
38+
39+
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
40+
assertThat(health.getDetails().get("reason").toString()).contains("connection refused");
41+
}
42+
43+
private S3Properties properties() {
44+
// record 순서: endpoint, accessKey, secretKey, bucket, region, pathStyle
45+
return new S3Properties(
46+
URI.create("http://localhost:9000"), "key", "secret", "stackup", "us-east-1", true);
47+
}
48+
}

docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ services:
194194
minio:
195195
condition: service_healthy
196196
healthcheck:
197-
test: ["CMD-SHELL", "curl -sf http://localhost:38010/actuator/health >/dev/null || exit 1"]
197+
# 종합(/actuator/health)이 아니라 readiness 그룹을 본다 — s3/aiServer 장애로
198+
# 백엔드 컨테이너가 unhealthy 가 되면 정작 멀쩡한 로그인·히스토리까지 끊긴다.
199+
test: ["CMD-SHELL", "curl -sf http://localhost:38010/actuator/health/readiness >/dev/null || exit 1"]
198200
interval: 10s
199201
timeout: 5s
200202
retries: 15

0 commit comments

Comments
 (0)