diff --git a/backend/api/build.gradle b/backend/api/build.gradle index 7290ca4b9..2b6f7b3bd 100644 --- a/backend/api/build.gradle +++ b/backend/api/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework:spring-webflux' implementation 'com.github.f4b6a3:uuid-creator:6.0.0' diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java index c27678635..494412362 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java @@ -8,6 +8,7 @@ import java.nio.ByteBuffer; import java.util.UUID; +import java.util.concurrent.Executor; import com.yat2.episode.global.constant.AttributeKeys; @@ -16,18 +17,37 @@ @Service public class CollaborationService { private final SessionRegistry sessionRegistry; - private final RedisStreamRepository redisStreamRepository; + private final RedisStreamStore redisStreamStore; + private final Executor redisExecutor; public void handleConnect(WebSocketSession session) { sessionRegistry.addSession(getMindmapId(session), session); } public void processMessage(WebSocketSession sender, BinaryMessage message) { - ByteBuffer buffer = message.getPayload(); - byte[] payload = new byte[buffer.remaining()]; - buffer.get(payload); + UUID roomId = getMindmapId(sender); + if (roomId == null) { + log.error("Mindmap Id is null."); + return; + } - sessionRegistry.broadcast(getMindmapId(sender), sender, payload); + byte[] payload = toByteArray(message.getPayload()); + + sessionRegistry.broadcast(roomId, sender, payload); + + if (YjsProtocolUtil.isUpdateFrame(payload)) { + try { + redisExecutor.execute(() -> { + try { + redisStreamStore.appendUpdate(roomId, payload); + } catch (Exception e) { + log.warn("Redis append failed. roomId={}", roomId, e); + } + }); + } catch (Exception e) { + log.error("Redis append failed. roomId={}", roomId, e); + } + } } public void handleDisconnect(WebSocketSession session) { @@ -38,4 +58,11 @@ public void handleDisconnect(WebSocketSession session) { private UUID getMindmapId(WebSocketSession session) { return (UUID) session.getAttributes().get(AttributeKeys.MINDMAP_ID); } + + private byte[] toByteArray(ByteBuffer buffer) { + ByteBuffer dup = buffer.duplicate(); + byte[] bytes = new byte[dup.remaining()]; + dup.get(bytes); + return bytes; + } } diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java deleted file mode 100644 index e80f92a7d..000000000 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.yat2.episode.collaboration; - -import org.springframework.stereotype.Repository; - -import java.util.UUID; - -@Repository -public class RedisStreamRepository { - public void append(UUID mindmapId, byte[] payload) { - //TODO - } -} diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java new file mode 100644 index 000000000..58775ee3e --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java @@ -0,0 +1,41 @@ +package com.yat2.episode.collaboration; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class RedisStreamStore { + + private final RedisTemplate redisBinaryTemplate; + + private static final String FIELD_UPDATE = "u"; + + private String streamKey(UUID roomId) { + return "collab:room:" + roomId; + } + + public RecordId appendUpdate(UUID roomId, byte[] update) { + String key = streamKey(roomId); + + StreamOperations ops = redisBinaryTemplate.opsForStream(); + + MapRecord record = + StreamRecords.newRecord().in(key).ofMap(Map.of(FIELD_UPDATE, update)); + + RecordId id = ops.add(record); + + redisBinaryTemplate.expire(key, Duration.ofDays(2)); + + return id; + } +} diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java b/backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java new file mode 100644 index 000000000..c380d7a8b --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java @@ -0,0 +1,20 @@ +package com.yat2.episode.collaboration; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class YjsProtocolUtil { + public static final int MSG_SYNC = 0; + + /* 추후 sync 라우팅을 위함 */ + public static final int SYNC_STEP_1 = 0; + public static final int SYNC_STEP_2 = 1; + public static final int SYNC_UPDATE = 2; + + public static boolean isUpdateFrame(byte[] payload) { + if (payload == null || payload.length < 3) { + return false; + } + return payload[0] == MSG_SYNC && payload[1] == SYNC_UPDATE; + } +} diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java b/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java new file mode 100644 index 000000000..c932578f6 --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java @@ -0,0 +1,46 @@ +package com.yat2.episode.collaboration.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.atomic.AtomicLong; + +@Slf4j +@Configuration +public class CollaborationAsyncConfig { + + @Bean(name = "redisExecutor") + public Executor redisExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setThreadNamePrefix("redis-append-"); + exec.setCorePoolSize(1); + exec.setMaxPoolSize(2); + exec.setQueueCapacity(10_000); + exec.setKeepAliveSeconds(30); + exec.setAllowCoreThreadTimeOut(true); + + exec.setRejectedExecutionHandler(dropAndLogError()); + exec.initialize(); + return exec; + } + + private RejectedExecutionHandler dropAndLogError() { + AtomicLong dropped = new AtomicLong(); + AtomicLong lastLogMs = new AtomicLong(0); + //TODO: Update가 drop 될 경우 Yjs Runner에게 알려 Sync 프로토콜로 복구 + return (r, executor) -> { + long n = dropped.incrementAndGet(); + + long now = System.currentTimeMillis(); + long prev = lastLogMs.get(); + if (now - prev >= 1000 && lastLogMs.compareAndSet(prev, now)) { + int qSize = executor.getQueue().size(); + log.error("Redis queue full. Dropping tasks. dropped={}, queueSize={}", n, qSize); + } + }; + } +} diff --git a/backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java b/backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java new file mode 100644 index 000000000..585a14967 --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java @@ -0,0 +1,31 @@ +package com.yat2.episode.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Slf4j +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisBinaryTemplate( + RedisConnectionFactory connectionFactory + ) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + template.setValueSerializer(RedisSerializer.byteArray()); + template.setHashValueSerializer(RedisSerializer.byteArray()); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/backend/api/src/main/resources/application.yml b/backend/api/src/main/resources/application.yml index cdae32b06..81da0937f 100644 --- a/backend/api/src/main/resources/application.yml +++ b/backend/api/src/main/resources/application.yml @@ -41,6 +41,12 @@ spring: resources: add-mappings: false + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + server: port: 8080 forward-headers-strategy: framework diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java new file mode 100644 index 000000000..0279a5bb5 --- /dev/null +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java @@ -0,0 +1,192 @@ +package com.yat2.episode.collaboration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executor; + +import com.yat2.episode.global.constant.AttributeKeys; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CollaborationService 단위 테스트") +class CollaborationServiceTest { + + @Mock + SessionRegistry sessionRegistry; + + @Mock + RedisStreamStore redisStreamStore; + + @Mock + Executor redisExecutor; + + CollaborationService service; + + @BeforeEach + void setUp() { + service = new CollaborationService(sessionRegistry, redisStreamStore, redisExecutor); + } + + @Nested + @DisplayName("세션 연결/해제") + class ConnectionTests { + + @Test + @DisplayName("연결 시 room에 세션을 등록한다") + void handleConnect_addsSessionToRoom() { + UUID roomId = UUID.randomUUID(); + WebSocketSession session = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(session.getAttributes()).thenReturn(attrs); + + service.handleConnect(session); + + verify(sessionRegistry).addSession(roomId, session); + verifyNoMoreInteractions(sessionRegistry); + } + + @Test + @DisplayName("해제 시 room에서 세션을 제거한다") + void handleDisconnect_removesSessionFromRoom() { + UUID roomId = UUID.randomUUID(); + WebSocketSession session = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(session.getAttributes()).thenReturn(attrs); + + service.handleDisconnect(session); + + verify(sessionRegistry).removeSession(roomId, session); + verifyNoMoreInteractions(sessionRegistry); + } + } + + @Nested + @DisplayName("메시지 처리") + class MessageTests { + + @Test + @DisplayName("항상 브로드캐스트한다") + void processMessage_alwaysBroadcasts() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 9, 9, 9 }; + BinaryMessage message = new BinaryMessage(frame); + + service.processMessage(sender, message); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(byte[].class); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), payloadCaptor.capture()); + + assertArrayEquals(frame, payloadCaptor.getValue()); + verifyNoMoreInteractions(sessionRegistry); + } + + @Test + @DisplayName("Update 프레임이면 Redis에 저장한다 (Executor에 task를 넣고, task 실행 시 append된다)") + void processMessage_whenUpdateFrame_appendsToRedis() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 0, 2, 1, 2, 3, 4 }; + BinaryMessage message = new BinaryMessage(frame); + + service.processMessage(sender, message); + + ArgumentCaptor broadcastCaptor = ArgumentCaptor.forClass(byte[].class); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), broadcastCaptor.capture()); + assertArrayEquals(frame, broadcastCaptor.getValue()); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(redisExecutor).execute(taskCaptor.capture()); + + taskCaptor.getValue().run(); + + ArgumentCaptor redisCaptor = ArgumentCaptor.forClass(byte[].class); + verify(redisStreamStore).appendUpdate(eq(roomId), redisCaptor.capture()); + assertArrayEquals(frame, redisCaptor.getValue()); + } + + @Test + @DisplayName("Update 프레임이 아니면 Redis에 저장하지 않는다") + void processMessage_whenNotUpdateFrame_doesNotAppendToRedis() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 0, 1, 9, 9 }; + BinaryMessage message = new BinaryMessage(frame); + + service.processMessage(sender, message); + + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + verifyNoInteractions(redisExecutor); + verifyNoInteractions(redisStreamStore); + } + + @Test + @DisplayName("Redis 저장 중 예외가 발생해도 처리 흐름이 죽지 않는다") + void processMessage_whenRedisThrows_doesNotCrash() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 0, 2, 1, 2, 3 }; + BinaryMessage message = new BinaryMessage(frame); + + doThrow(new RuntimeException("redis down")).when(redisStreamStore) + .appendUpdate(eq(roomId), any(byte[].class)); + + assertThatCode(() -> service.processMessage(sender, message)).doesNotThrowAnyException(); + + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(redisExecutor).execute(taskCaptor.capture()); + + assertThatCode(() -> taskCaptor.getValue().run()).doesNotThrowAnyException(); + + verify(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); + } + } +} diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java new file mode 100644 index 000000000..b356c866f --- /dev/null +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java @@ -0,0 +1,168 @@ +package com.yat2.episode.collaboration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.yat2.episode.collaboration.config.WebSocketProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("SessionRegistry 단위 테스트") +class SessionRegistryTest { + + SessionRegistry registry; + + WebSocketProperties wsProperties; + + @BeforeEach + void setUp() { + wsProperties = mock(WebSocketProperties.class); + when(wsProperties.sendTimeout()).thenReturn(1000); + when(wsProperties.bufferSize()).thenReturn(1024 * 1024); + + registry = new SessionRegistry(wsProperties); + } + + @SuppressWarnings("unchecked") + private Map> rooms() { + return (Map>) ReflectionTestUtils.getField(registry, "rooms"); + } + + @Nested + @DisplayName("세션 등록/제거") + class SessionManagementTests { + + @Test + @DisplayName("세션을 등록한다") + void addSession_addsDecoratedSession() { + UUID roomId = UUID.randomUUID(); + + WebSocketSession s1 = mock(WebSocketSession.class); + when(s1.getId()).thenReturn("s1"); + when(s1.isOpen()).thenReturn(true); + + registry.addSession(roomId, s1); + + assertThat(rooms().get(roomId)).hasSize(1); + } + + @Test + @DisplayName("세션 ID 기준으로 제거한다") + void removeSession_removesById() { + UUID roomId = UUID.randomUUID(); + + WebSocketSession s1 = mock(WebSocketSession.class); + when(s1.getId()).thenReturn("s1"); + when(s1.isOpen()).thenReturn(true); + + registry.addSession(roomId, s1); + assertThat(rooms().get(roomId)).hasSize(1); + + registry.removeSession(roomId, s1); + assertThat(rooms().get(roomId)).isNull(); + } + } + + @Nested + @DisplayName("브로드캐스트") + class BroadcastTests { + + @Test + @DisplayName("발신자를 제외한 세션에게 전송한다") + void broadcast_sendsToOthersButNotSender() throws Exception { + UUID roomId = UUID.randomUUID(); + + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); + + WebSocketSession r1 = mock(WebSocketSession.class); + when(r1.getId()).thenReturn("r1"); + when(r1.isOpen()).thenReturn(true); + + registry.addSession(roomId, sender); + registry.addSession(roomId, r1); + + byte[] payload = new byte[]{ 1, 2, 3, 4 }; + + registry.broadcast(roomId, sender, payload); + + verify(sender, never()).sendMessage(any(BinaryMessage.class)); + + verify(r1, times(1)).sendMessage(argThat((WebSocketMessage msg) -> { + if (!(msg instanceof BinaryMessage bm)) return false; + + ByteBuffer bb = bm.getPayload().duplicate(); + byte[] got = new byte[bb.remaining()]; + bb.get(got); + + return java.util.Arrays.equals(got, payload); + })); + } + + @Test + @DisplayName("닫힌 세션은 제거한다") + void broadcast_removesClosedSessions() { + UUID roomId = UUID.randomUUID(); + + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); + + WebSocketSession closed = mock(WebSocketSession.class); + when(closed.getId()).thenReturn("closed"); + when(closed.isOpen()).thenReturn(false); + + registry.addSession(roomId, sender); + registry.addSession(roomId, closed); + + registry.broadcast(roomId, sender, new byte[]{ 9 }); + + assertThat(rooms().get(roomId)).hasSize(1); + assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + } + + @Test + @DisplayName("전송 중 예외가 발생한 세션은 제거한다") + void broadcast_removesSessionsThatThrowOnSend() throws Exception { + UUID roomId = UUID.randomUUID(); + + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); + + WebSocketSession bad = mock(WebSocketSession.class); + when(bad.getId()).thenReturn("bad"); + when(bad.isOpen()).thenReturn(true); + doThrow(new IOException("boom")).when(bad).sendMessage(any(BinaryMessage.class)); + + registry.addSession(roomId, sender); + registry.addSession(roomId, bad); + + registry.broadcast(roomId, sender, new byte[]{ 1, 2 }); + + assertThat(rooms().get(roomId)).hasSize(1); + assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + } + } +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 3f966493c..1b834e09a 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -42,7 +42,18 @@ services: volumes: - minio-data:/data command: server /data --console-address ":9001" + + redis: + image: redis:7-alpine + container_name: episode-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: [ "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}" ] + volumes: mysql-data: minio-data: - + redis-data: diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml index a26dc4ee6..549024a16 100644 --- a/infra/prod/docker-compose.yml +++ b/infra/prod/docker-compose.yml @@ -28,6 +28,7 @@ services: ports: - "80:80" - "443:443" + - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data diff --git a/infra/stage/docker-compose.yml b/infra/stage/docker-compose.yml index 9fcb5e7df..6f16b5264 100644 --- a/infra/stage/docker-compose.yml +++ b/infra/stage/docker-compose.yml @@ -9,10 +9,15 @@ services: networks: - web healthcheck: - test: [ "CMD", "wget", "-qO-", "http://localhost:8080/actuator/health" ] + test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health" ] interval: 30s timeout: 5s retries: 3 + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy yjs: image: ghcr.io/softeerbootcamp-7th/episode-yjs-runner:stage @@ -69,6 +74,26 @@ services: volumes: - minio-data:/data command: server /data --console-address ":9001" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] + interval: 30s + timeout: 20s + retries: 3 + networks: + - web + + redis: + image: redis:7-alpine + container_name: episode-redis + restart: unless-stopped + volumes: + - redis-data:/data + command: [ "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}" ] + healthcheck: + test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 networks: - web @@ -81,3 +106,4 @@ volumes: caddy_config: mysql-data: minio-data: + redis-data: