From a5e486b4d4dd04305731c700a68c133a57c4a98a Mon Sep 17 00:00:00 2001 From: yoon6yo Date: Mon, 7 Sep 2026 16:31:34 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B3=B8=EC=9D=B8=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EC=9E=91=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자를 거치지 않고 본인 컨테이너를 재시작할 수 있도록 POST /api/requests/{requestId}/reboot 를 추가했다. 재시작은 현재 노드를 후보로 고정한 마이그레이션(same_node=true)으로 구현한다. config-server의 /migrate는 새 Pod를 만들어 정상 확인한 뒤에야 기존 Pod를 지우므로, 삭제 후 재생성 방식과 달리 중간에 실패해도 사용자의 기존 컨테이너가 그대로 살아있다. - Status에 REBOOTING 추가. 재시작 중에도 자원을 점유하므로 activeStatuses에 포함해 "내 승인 완료 신청" 조회로 진행 상태를 폴링할 수 있게 했다. - 실제 처리는 approveRequest와 같은 방식으로 전용 executor에 넘기고 즉시 REBOOTING 상태를 응답한다. 일반 사용자 요청이 몰려도 관리자 승인 처리량이 굶지 않도록 approvalExecutor와 분리된 rebootExecutor를 사용한다. - 마이그레이션 호출 실패 시 기존 Pod는 그대로이므로 상태만 FULFILLED로 되돌리고, 결과 DB 반영에 실패하면 실제 인프라와 어긋나므로 REBOOTING을 유지한 채 알림만 보낸다. - 10분 넘게 REBOOTING에 갇힌 요청은 기존 재조정 스케줄러가 관리자에게 알린다. --- .../controller/RequestController.java | 13 + .../requests/controller/docs/RequestApi.java | 27 +- .../dto/response/SaveRequestResponseDTO.java | 2 +- .../domain/requests/entity/Request.java | 24 +- .../domain/requests/entity/Status.java | 8 +- .../domain/requests/entity/StatusFilter.java | 2 +- .../service/AdminRequestCommandService.java | 2 +- .../requests/service/PodMigrationService.java | 40 ++- .../requests/service/PodRebootService.java | 185 +++++++++++ .../domain/requests/service/PodService.java | 18 +- .../service/RequestCommandService.java | 2 +- .../scheduler/RequestSchedulerService.java | 24 +- .../users/service/AdminUserService.java | 3 +- .../DGU_AI_LAB/admin_be/error/ErrorCode.java | 2 + .../admin_be/global/config/AsyncConfig.java | 20 ++ src/main/resources/messages.properties | 3 + .../service/DashboardServiceTest.java | 4 +- .../service/PodRebootServiceTest.java | 298 ++++++++++++++++++ 18 files changed, 643 insertions(+), 34 deletions(-) create mode 100644 src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java create mode 100644 src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java index c4b5b0e5..f8622d86 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java @@ -5,6 +5,7 @@ import DGU_AI_LAB.admin_be.domain.requests.dto.request.SaveRequestRequestDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.ChangeRequestResponseDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.service.PodRebootService; import DGU_AI_LAB.admin_be.domain.requests.service.RequestCommandService; import DGU_AI_LAB.admin_be.domain.requests.service.RequestQueryService; import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails; @@ -24,6 +25,7 @@ public class RequestController implements RequestApi { private final RequestQueryService requestQueryService; private final RequestCommandService requestCommandService; + private final PodRebootService podRebootService; /** * 사용 신청 생성 @@ -59,6 +61,17 @@ public ResponseEntity> createChangeRequest(@AuthenticationPri return SuccessResponse.ok(null); } + /** + * 나의 컨테이너 재시작 (FULFILLED 상태만 가능) + */ + @PostMapping("/{requestId}/reboot") + public ResponseEntity> rebootPod(@AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable Long requestId + ) { + SaveRequestResponseDTO body = podRebootService.rebootPod(requestId, userId); + return SuccessResponse.ok(body); + } + /** * 나의 사용 신청 조회 */ diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java index 0531f7e8..3f78707a 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java @@ -67,12 +67,37 @@ ResponseEntity> createChangeRequest( @Valid SingleChangeRequestDTO dto ); + @Operation( + summary = "내 컨테이너 재시작", + description = "FULFILLED 상태인 나의 컨테이너를 같은 노드에서 재시작합니다. 새 컨테이너가 정상 확인된 뒤에야 " + + "기존 컨테이너가 정리되므로, 실패하더라도 기존 컨테이너는 그대로 유지됩니다. " + + "즉시 status=REBOOTING인 신청 정보를 반환하며, 실제 완료 여부는 '내 승인 완료 신청 목록 조회'를 " + + "폴링해 status가 FULFILLED로 돌아오는지로 확인합니다." + ) + @ApiResponse(responseCode = "200", description = "재시작 접수 성공 (status=REBOOTING)", + content = @Content(schema = @Schema(implementation = SaveRequestResponseDoc.class))) + @ApiResponse(responseCode = "400", description = "본인 소유의 신청이 아님", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = "404", description = "신청을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = "409", description = "FULFILLED 상태가 아니거나(이미 재시작/마이그레이션 진행 중) 배치된 노드 정보가 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = "429", description = "동시에 처리 중인 재시작 요청이 많음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @PostMapping("/{requestId}/reboot") + ResponseEntity> rebootPod( + @Parameter(hidden = true) Long userId, + @PathVariable @Parameter(description = "재시작할 신청 ID") Long requestId + ); + @Operation(summary = "내 신청 목록 조회", description = "로그인된 사용자의 모든 신청 내역(전체 상태 포함)을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = SaveRequestListResponseDoc.class))) ResponseEntity> getMyRequests(@Parameter(hidden = true) CustomUserDetails user); - @Operation(summary = "내 승인 완료 신청 목록 조회", description = "FULFILLED 상태인 신청 목록만 조회합니다.") + @Operation(summary = "내 승인 완료 신청 목록 조회", + description = "컨테이너가 살아있는 신청 목록(FULFILLED, 마이그레이션 중 MIGRATING, 재시작 중 REBOOTING)을 조회합니다. " + + "각 항목의 status로 재시작 진행 상태를 폴링할 수 있습니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = SaveRequestListResponseDoc.class))) ResponseEntity> getMyApprovedRequests(@Parameter(hidden = true) CustomUserDetails user); diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java index 6680f1d4..c840eb21 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java @@ -45,7 +45,7 @@ public record SaveRequestResponseDTO( @JsonRawValue String formAnswers, @Schema(description = "서버 만료 일시", example = "2026-03-02T06:17:29") LocalDateTime expiresAt, - @Schema(description = "처리 상태", example = "PENDING", allowableValues = {"PENDING", "FULFILLED", "DENIED", "MODIFICATION_REQUESTED", "MODIFICATION_APPROVED", "MODIFICATION_REJECTED"}) + @Schema(description = "처리 상태", example = "PENDING", allowableValues = {"PENDING", "PROCESSING", "DENIED", "FULFILLED", "MIGRATING", "REBOOTING", "DELETED"}) Status status, @Schema(description = "승인 일시", example = "2026-03-02T15:36:29", nullable = true) LocalDateTime approvedAt, diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java index 14a22e56..d8839fd4 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java @@ -209,6 +209,28 @@ public void endMigration() { this.status = Status.FULFILLED; } + /** + * 사용자 셀프 재시작 시작을 위해 FULFILLED -> REBOOTING으로 전환한다. + * beginMigration()과 같은 이유로 행 잠금 조회(findByIdForUpdate)와 같은 트랜잭션에서 + * 호출해야 동시에 들어온 두 번째 재시작 요청이 이 상태 검증에서 실제로 막힌다. + */ + public void beginReboot() { + if (this.status != Status.FULFILLED) { + throw new BusinessException("컨테이너가 실행 중일 때만 재시작할 수 있습니다. 이미 다른 작업이 진행 중입니다.", ErrorCode.INVALID_REQUEST_STATUS); + } + this.status = Status.REBOOTING; + } + + /** + * 재시작 시도가 끝나면(성공/실패 모두) REBOOTING -> FULFILLED로 되돌린다. + */ + public void endReboot() { + if (this.status != Status.REBOOTING) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_STATUS); + } + this.status = Status.FULFILLED; + } + public void assignUbuntuIds(Long ubuntuUid, Long ubuntuGid) { if (ubuntuUid == null || ubuntuGid == null || ubuntuUid <= 0 || ubuntuGid <= 0) { throw new BusinessException(ErrorCode.UID_ALLOCATION_FAILED); @@ -240,7 +262,7 @@ public void delete() { if (this.status == Status.DELETED) { throw new BusinessException("이미 삭제된 요청입니다.", ErrorCode.INVALID_REQUEST_STATUS); } - if (this.status == Status.FULFILLED || this.status == Status.MIGRATING) { + if (this.status == Status.FULFILLED || this.status == Status.MIGRATING || this.status == Status.REBOOTING) { throw new BusinessException("컨테이너가 실행 중입니다. 인프라 정리 후 삭제해주세요.", ErrorCode.INVALID_REQUEST_STATUS); } if (this.status == Status.PROCESSING) { diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Status.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Status.java index 72f3edea..02e08c9a 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Status.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Status.java @@ -3,14 +3,16 @@ import java.util.List; public enum Status { - PENDING, PROCESSING, DENIED, FULFILLED, MIGRATING, DELETED; + PENDING, PROCESSING, DENIED, FULFILLED, MIGRATING, REBOOTING, DELETED; /** * 실제 인프라(Pod/우분투 계정)가 살아있는 상태 집합. * "내 서버" 조회, 리소스 사용량 집계 등 FULFILLED를 기준으로 하던 조회 로직은 - * 마이그레이션 중에도 자원이 계속 점유돼 있으므로 이 집합을 사용해야 한다. + * 마이그레이션/재시작 중에도 자원이 계속 점유돼 있으므로 이 집합을 사용해야 한다. + * 특히 재시작은 사용자가 진행 상태를 "내 승인 완료 신청" 조회로 폴링하므로, + * REBOOTING이 빠지면 재시작 도중 자기 컨테이너가 목록에서 사라진다. */ public static List activeStatuses() { - return List.of(FULFILLED, MIGRATING); + return List.of(FULFILLED, MIGRATING, REBOOTING); } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/StatusFilter.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/StatusFilter.java index 660a7ae8..ea4caeee 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/StatusFilter.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/StatusFilter.java @@ -5,5 +5,5 @@ * ALL은 "모든 상태 조회" 의미의 sentinel 값입니다. */ public enum StatusFilter { - PENDING, PROCESSING, DENIED, FULFILLED, MIGRATING, DELETED, ALL + PENDING, PROCESSING, DENIED, FULFILLED, MIGRATING, REBOOTING, DELETED, ALL } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/AdminRequestCommandService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/AdminRequestCommandService.java index 695ad377..dc8c86ac 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/AdminRequestCommandService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/AdminRequestCommandService.java @@ -81,7 +81,7 @@ public class AdminRequestCommandService { // corePoolSize=maxPoolSize=3, queueCapacity=0(AsyncConfig 참고) — 이 이상 동시에 승인이 // 몰리면 큐잉하지 않고 즉시 TaskRejectedException으로 거부해, 관리자에게 명확한 에러로 // 실패시킨다 (기존 Semaphore(3) fail-fast 정책과 동일한 사용자 체감 동작 유지). - private final ThreadPoolTaskExecutor approvalExecutor; + private final @Qualifier("approvalExecutor") ThreadPoolTaskExecutor approvalExecutor; @Transactional(propagation = Propagation.NOT_SUPPORTED) public SaveRequestResponseDTO approveRequest(ApproveRequestDTO dto) { diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java index 6dc59a7d..e03131b8 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java @@ -42,7 +42,9 @@ public class PodMigrationService { // 무의미한 케이스). config-server 자체는 건드리지 않고 이미 있는 계약만 활용한다. // Double(래퍼)로 선언 — 삼항연산자에서 한쪽이 primitive double이면 다른 쪽 Double이 // null이어도 타입 프로모션 때문에 무조건 언박싱되어 NPE가 난다. - private static final Double FORCE_MIGRATION_RATIO = -1000.0; + // 셀프 재시작(PodRebootService)도 개선 비율 검사를 건너뛴 채 같은 노드로 재배치해야 하므로 + // 같은 패키지에서 재사용한다. + static final Double FORCE_MIGRATION_RATIO = -1000.0; public MigratePodResponseDTO migratePod(Long requestId, MigratePodRequestDTO dto) { TransactionTemplate tx = new TransactionTemplate(transactionManager); @@ -80,19 +82,7 @@ public MigratePodResponseDTO migratePod(Long requestId, MigratePodRequestDTO dto .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); if (response.isMigrated()) { - req.assignPodInfo(response.newPod(), response.to()); - - podExternalPortRepository.deleteByRequestRequestId(requestId); - if (response.ports() != null) { - for (CreatePodResponseDTO.PortInfo port : response.ports()) { - podExternalPortRepository.save(PodExternalPort.builder() - .request(req) - .internalPort(port.internalPort()) - .externalPort(port.externalPort()) - .usagePurpose(port.usagePurpose()) - .build()); - } - } + applyMigratedPodInfo(requestId, req, response); } req.endMigration(); return null; @@ -128,6 +118,28 @@ public MigratePodResponseDTO migratePod(Long requestId, MigratePodRequestDTO dto return response; } + /** + * config-server가 새로 만든 Pod의 위치와 포트 매핑을 Request에 반영한다. 기존 포트 행은 + * 새 Pod에서 더 이상 유효하지 않으므로 통째로 지우고 다시 심는다. + * 셀프 재시작(PodRebootService)도 같은 /migrate 응답을 받으므로 이 반영 로직을 공유한다. + * 호출자의 트랜잭션 안에서 실행되어야 하며, 상태 전환(endMigration/endReboot)은 호출자 책임이다. + */ + void applyMigratedPodInfo(Long requestId, Request req, MigratePodResponseDTO response) { + req.assignPodInfo(response.newPod(), response.to()); + + podExternalPortRepository.deleteByRequestRequestId(requestId); + if (response.ports() != null) { + for (CreatePodResponseDTO.PortInfo port : response.ports()) { + podExternalPortRepository.save(PodExternalPort.builder() + .request(req) + .internalPort(port.internalPort()) + .externalPort(port.externalPort()) + .usagePurpose(port.usagePurpose()) + .build()); + } + } + } + /** * 2단계(외부 호출) 또는 3단계(DB 반영) 실패 시 MIGRATING에 갇힌 요청을 FULFILLED로 되돌린다. * 이 복구 자체가 실패하면(예: 그 사이 상태가 다른 경로로 바뀐 경우) 수동 확인이 필요하므로 diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java new file mode 100644 index 00000000..6b13354e --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java @@ -0,0 +1,185 @@ +package DGU_AI_LAB.admin_be.domain.requests.service; + +import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.MigratePodResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import DGU_AI_LAB.admin_be.domain.requests.entity.Status; +import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +/** + * 사용자가 관리자를 거치지 않고 본인 컨테이너를 재시작하는 셀프 서비스. + * + * 재시작은 "현재 노드를 후보로 포함한 마이그레이션"(same_node=true)으로 구현한다. + * config-server의 /migrate는 새 Pod를 만들어 정상 동작을 확인한 뒤에야 기존 Pod를 지우므로, + * 삭제 후 재생성 방식과 달리 중간에 실패해도 사용자의 기존 컨테이너가 그대로 살아있다. + * + * approveRequest와 마찬가지로 실제 처리(수 분 소요)는 전용 executor로 넘기고 HTTP 요청은 + * 즉시 REBOOTING 상태를 반환한다. 진행 상황은 사용자가 GET /api/requests/my/approved를 + * 폴링해 status가 REBOOTING -> FULFILLED로 바뀌는지로 확인한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public class PodRebootService { + + private final RequestRepository requestRepository; + private final PodService podService; + private final PodMigrationService podMigrationService; + private final PlatformTransactionManager transactionManager; + private final AlarmService alarmService; + + // 일반 사용자가 아무 때나 누르는 요청이라 approvalExecutor와 공유하면 재시작이 몰렸을 때 + // 관리자 승인 처리까지 함께 막힌다. 풀이 가득 차면 큐잉 없이 즉시 거부된다(AsyncConfig 참고). + private final @Qualifier("rebootExecutor") ThreadPoolTaskExecutor rebootExecutor; + + public SaveRequestResponseDTO rebootPod(Long requestId, Long userId) { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + // 1. 소유자·상태 검증 + FULFILLED -> REBOOTING 전환 (짧은 트랜잭션, 이후 커넥션 반납) + // 행 잠금 조회와 상태 전환을 같은 트랜잭션에서 커밋해야, 사용자가 버튼을 연타해 + // 동시에 들어온 두 번째 호출이 beginReboot()의 상태 검증에서 실제로 막힌다. + final String[] usernameRef = {null}; + final String[] nodeRef = {null}; + final String[] podNameRef = {null}; + final SaveRequestResponseDTO[] responseRef = {null}; + tx.execute(status -> { + Request req = requestRepository.findByIdForUpdate(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + + if (!req.getUser().getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN_REQUEST); + } + // nodeName이 비어 있으면 재시작할 노드를 특정할 수 없다. 후보 목록을 비운 채 + // /migrate를 부르면 config-server가 임의의 다른 노드로 옮겨버릴 수 있어 막는다. + if (req.getNodeName() == null || req.getNodeName().isBlank()) { + throw new BusinessException(ErrorCode.POD_NODE_NOT_ASSIGNED); + } + + req.beginReboot(); + usernameRef[0] = req.getUbuntuUsername(); + nodeRef[0] = req.getNodeName(); + podNameRef[0] = req.getPodName(); + // 트랜잭션 종료 후 즉시 응답 DTO를 만들 때 쓰이는 lazy 연관 초기화 + req.getUser().getEmail(); + req.getContainerImage().getImageName(); + req.getResourceGroup().getServerName(); + req.getRequestGroups().size(); + responseRef[0] = SaveRequestResponseDTO.fromEntity(req); + return null; + }); + + String username = usernameRef[0]; + String currentNode = nodeRef[0]; + String oldPodName = podNameRef[0]; + + // 2. 실제 재시작(새 Pod 생성 → 확인 → 기존 Pod 삭제)은 수 분이 걸리므로 비동기로 넘긴다. + // 제출 자체는 동기 호출이라 풀이 가득 차면 여기서 바로 TaskRejectedException이 던져진다. + try { + rebootExecutor.execute(() -> processReboot(requestId, username, currentNode, oldPodName)); + } catch (TaskRejectedException e) { + log.warn("[동시 처리 한도 초과] 컨테이너 재시작 제출 거부 → 상태 복구 시작: requestId={}", requestId, e); + revertToFulfilled(requestId); + throw new BusinessException(ErrorCode.POD_REBOOT_CONCURRENCY_LIMIT); + } + + return responseRef[0]; + } + + /** + * rebootExecutor 스레드에서 실행되는 재시작 본체. 호출자(사용자 HTTP 요청)는 이미 응답을 + * 반환하고 떠난 상태라 예외를 던져봐야 아무도 받지 않으므로, 모든 실패를 여기서 처리하고 + * REBOOTING에 갇히지 않도록 상태를 되돌린다. + */ + private void processReboot(Long requestId, String username, String currentNode, String oldPodName) { + MigratePodResponseDTO response; + try { + // 후보 노드를 현재 노드 하나로 고정하고 개선 비율 검사를 무력화해, "같은 노드에 + // 새 Pod를 띄우고 기존 Pod를 정리"하는 재시작 동작으로 만든다. + response = podService.migratePod( + username, List.of(currentNode), PodMigrationService.FORCE_MIGRATION_RATIO, true); + } catch (Exception e) { + // config-server는 새 Pod가 정상 확인된 뒤에야 기존 Pod를 지운다 — 여기서 실패했다면 + // 사용자의 기존 컨테이너는 그대로 살아있으므로 상태 플래그만 되돌리면 된다. + log.warn("[컨테이너 재시작] 실패 → 기존 Pod 유지한 채 상태만 복구: requestId={}, username={}, node={}, oldPod={}", + requestId, username, currentNode, oldPodName, e); + revertToFulfilled(requestId); + return; + } + + try { + new TransactionTemplate(transactionManager).execute(status -> { + Request req = requestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + if (response.isMigrated()) { + podMigrationService.applyMigratedPodInfo(requestId, req, response); + } + req.endReboot(); + return null; + }); + } catch (RuntimeException e) { + // 이 시점엔 이미 새 Pod가 뜨고 기존 Pod가 지워진 뒤라, FULFILLED로 되돌리면 실제 + // Pod/포트와 DB가 어긋난 채 "정상"처럼 보인다. REBOOTING으로 남겨 beginReboot() + // 가드가 재시도를 막고, 관리자가 대조해 수동 정리하도록 알림만 남긴다. + String msg = String.format( + "[컨테이너 재시작] 결과 DB 반영 실패 - Pod/포트 상태 수동 확인 필요: requestId=%d, username=%s", + requestId, username); + log.error(msg, e); + sendAlertSafely(msg); + return; + } + + if (response.isMigrated()) { + log.info("컨테이너 재시작 완료: requestId={}, username={}, node={}, oldPod={}, newPod={}", + requestId, username, response.to(), oldPodName, response.newPod()); + } else { + // same_node=true인데도 config-server가 재배치를 건너뛴 경우(노드 자원 부족 등). + // 기존 Pod는 그대로 살아있으므로 사용자 입장에선 재시작이 일어나지 않은 것과 같다. + log.warn("컨테이너 재시작이 수행되지 않음: requestId={}, username={}, reason={}", + requestId, username, response.reason()); + } + } + + /** + * 재시작 실패로 REBOOTING에 갇힌 요청을 FULFILLED로 되돌린다. 이 복구 자체가 실패하면 + * 수동 확인이 필요하므로 알림만 남기고, 비동기 스레드에서는 더 전파할 곳이 없어 여기서 삼킨다. + */ + private void revertToFulfilled(Long requestId) { + try { + new TransactionTemplate(transactionManager).execute(status -> { + requestRepository.findById(requestId) + .filter(req -> req.getStatus() == Status.REBOOTING) + .ifPresent(Request::endReboot); + return null; + }); + } catch (Exception e) { + String msg = String.format( + "[컨테이너 재시작] REBOOTING 상태 복구 실패 - 수동 확인 필요: requestId=%d", requestId); + log.error(msg, e); + sendAlertSafely(msg); + } + } + + private void sendAlertSafely(String message) { + try { + alarmService.sendSlackAlert(message, null); + } catch (Exception ignored) { + // 알림 발송 실패가 원래 처리 흐름을 막으면 안 된다. + } + } +} diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodService.java index 96a96b97..86bb99da 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodService.java @@ -54,11 +54,14 @@ private record DeletePodRequest(@com.fasterxml.jackson.annotation.JsonProperty(" // config-server는 min_improvement_ratio 키가 아예 없어야 자체 기본값(0.2)을 쓴다. // null을 그대로 보내면 data.get(key, default)가 "키는 있지만 값이 None"이라 default가 // 적용되지 않고 그대로 None을 반환해 마이그레이션이 500으로 실패한다. + // same_node는 config-server에서 기본값 false이므로, 켜지 않은 호출은 키 자체를 보내지 않아 + // 기존 마이그레이션 동작을 그대로 유지한다(위 NON_NULL 설정으로 null이면 직렬화에서 빠진다). @JsonInclude(JsonInclude.Include.NON_NULL) private record MigratePodRequest( String username, List nodes, - @JsonProperty("min_improvement_ratio") Double minImprovementRatio + @JsonProperty("min_improvement_ratio") Double minImprovementRatio, + @JsonProperty("same_node") Boolean sameNode ) {} public CreatePodResponseDTO createPod(String username) { @@ -127,13 +130,22 @@ public void deletePod(String podName) { } public MigratePodResponseDTO migratePod(String username, List nodes, Double minImprovementRatio) { + return migratePod(username, nodes, minImprovementRatio, false); + } + + /** + * sameNode=true면 현재 Pod가 떠 있는 노드도 이동 대상 후보로 남는다. config-server가 + * 새 Pod를 만들어 정상 확인한 뒤에야 기존 Pod를 지우므로, "같은 노드로 마이그레이션"이 + * 곧 안전한 컨테이너 재시작이 된다(생성 실패 시 기존 Pod는 그대로 살아있다). + */ + public MigratePodResponseDTO migratePod(String username, List nodes, Double minImprovementRatio, boolean sameNode) { try { - log.info("Pod 마이그레이션 API 요청 시작: 사용자: {}, 후보 노드: {}", username, nodes); + log.info("Pod 마이그레이션 API 요청 시작: 사용자: {}, 후보 노드: {}, sameNode: {}", username, nodes, sameNode); MigratePodResponseDTO response = WebClientErrorHandler.onError( webClient.post() .uri("/migrate") - .bodyValue(new MigratePodRequest(username, nodes, minImprovementRatio)) + .bodyValue(new MigratePodRequest(username, nodes, minImprovementRatio, sameNode ? Boolean.TRUE : null)) .retrieve(), (status, body) -> new BusinessException("Pod 마이그레이션 실패: " + body, ErrorCode.POD_MIGRATION_FAILED) ) diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestCommandService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestCommandService.java index b9b9d42d..8e868d26 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestCommandService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestCommandService.java @@ -181,7 +181,7 @@ public SaveRequestResponseDTO createRequest(Long userId, SaveRequestRequestDTO d .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); if (requestRepository.existsByUbuntuUsernameAndStatusIn( - dto.ubuntuUsername(), List.of(Status.PENDING, Status.FULFILLED, Status.MIGRATING))) { + dto.ubuntuUsername(), List.of(Status.PENDING, Status.FULFILLED, Status.MIGRATING, Status.REBOOTING))) { throw new BusinessException(ErrorCode.DUPLICATE_USERNAME); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java index 833bf9a5..82b6bdae 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java @@ -51,12 +51,12 @@ public void runScheduler() { } /** - * 정지된(stale) PROCESSING/MIGRATING 요청 재조정(reconciliation). approveRequest/ - * PodMigrationService의 보상 트랜잭션은 전부 try/catch 안에서만 실행되므로, admin_be - * 프로세스 자체가 처리 도중 죽으면(강제 재배포, OOM 등) catch가 실행될 기회조차 없이 - * 그 요청은 PROCESSING/MIGRATING에 영구히 갇힌다. 5분마다 돌면서 임계치를 넘겨 방치된 + * 정지된(stale) PROCESSING/MIGRATING/REBOOTING 요청 재조정(reconciliation). approveRequest/ + * PodMigrationService/PodRebootService의 보상 트랜잭션은 전부 try/catch 안에서만 실행되므로, + * admin_be 프로세스 자체가 처리 도중 죽으면(강제 재배포, OOM 등) catch가 실행될 기회조차 없이 + * 그 요청은 해당 상태에 영구히 갇힌다. 5분마다 돌면서 임계치를 넘겨 방치된 * 요청을 찾아, PROCESSING은 안전하게 PENDING으로 되돌리고(재승인/재거절 가능하게), - * MIGRATING은 실제 Pod 생성/삭제가 걸려있어 자동 복구 대신 관리자 알림만 보낸다. + * MIGRATING/REBOOTING은 실제 Pod 생성/삭제가 걸려있어 자동 복구 대신 관리자 알림만 보낸다. */ @Scheduled(fixedRate = 5 * 60 * 1000) public void reconcileStaleInFlightRequests() { @@ -69,6 +69,9 @@ public void reconcileStaleInFlightRequests() { for (Request request : requestRepository.findAllByStatusAndUpdatedAtBefore(Status.MIGRATING, staleBefore)) { alertStaleMigrating(request); } + for (Request request : requestRepository.findAllByStatusAndUpdatedAtBefore(Status.REBOOTING, staleBefore)) { + alertStaleRebooting(request); + } } private void reconcileStaleProcessing(Request request) { @@ -96,6 +99,17 @@ private void alertStaleMigrating(Request request) { } catch (Exception ignored) {} } + private void alertStaleRebooting(Request request) { + log.error("🔧 [재조정] {}분 넘게 REBOOTING 상태로 방치된 요청 발견 — 실제 인프라 상태와 충돌할 수 있어 " + + "자동 복구하지 않고 알림만 발송: requestId={}", + STALE_IN_FLIGHT_THRESHOLD_MINUTES, request.getRequestId()); + try { + String msg = messageUtils.get("notification.admin.request.stale-rebooting", + request.getRequestId(), request.getUbuntuUsername(), STALE_IN_FLIGHT_THRESHOLD_MINUTES); + alarmService.sendSlackAlert(msg, null); + } catch (Exception ignored) {} + } + public void processExpiredRequests(LocalDateTime now) { List expiredRequests = requestRepository.findAllWithUserByExpiredDateBefore(now, Status.FULFILLED); if (expiredRequests.isEmpty()) return; diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java index f8d294a2..282a15c3 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java @@ -79,7 +79,8 @@ private void cleanupUserRequests(User user, String logPrefix) { // 사이에 요청이 delete()로 넘어가면, 그 승인이 나중에 완료될 때 이미 소유자가 정리된 // Request를 FULFILLED로 덮어써 정합성이 깨진다. boolean hasInFlightRequest = userRequests.stream() - .anyMatch(r -> r.getStatus() == Status.MIGRATING || r.getStatus() == Status.PROCESSING); + .anyMatch(r -> r.getStatus() == Status.MIGRATING || r.getStatus() == Status.PROCESSING + || r.getStatus() == Status.REBOOTING); if (hasInFlightRequest) { log.warn("[{}] userId={} 승인/마이그레이션 진행 중인 요청이 있어 정리를 거부합니다.", logPrefix, user.getUserId()); throw new ConflictException(ErrorCode.REQUEST_MIGRATION_IN_PROGRESS); diff --git a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java index 27b34451..5fbf4027 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java +++ b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java @@ -149,6 +149,8 @@ public enum ErrorCode { POD_DELETION_FAILED(HttpStatus.BAD_GATEWAY, "Pod 삭제 API 요청에 실패했습니다."), POD_CREATION_CONCURRENCY_LIMIT(HttpStatus.TOO_MANY_REQUESTS, "현재 동시에 처리 중인 Pod 생성 요청이 많습니다. 잠시 후 다시 시도해주세요."), POD_MIGRATION_FAILED(HttpStatus.BAD_GATEWAY, "Pod 마이그레이션 API 요청에 실패했습니다."), + POD_REBOOT_CONCURRENCY_LIMIT(HttpStatus.TOO_MANY_REQUESTS, "현재 동시에 처리 중인 컨테이너 재시작 요청이 많습니다. 잠시 후 다시 시도해주세요."), + POD_NODE_NOT_ASSIGNED(HttpStatus.CONFLICT, "컨테이너가 배치된 노드 정보가 없어 재시작할 수 없습니다. 관리자에게 문의해주세요."), /** * Message Template Error diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/config/AsyncConfig.java b/src/main/java/DGU_AI_LAB/admin_be/global/config/AsyncConfig.java index ed1b19c9..10e1edfb 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/global/config/AsyncConfig.java +++ b/src/main/java/DGU_AI_LAB/admin_be/global/config/AsyncConfig.java @@ -34,6 +34,26 @@ public ThreadPoolTaskExecutor approvalExecutor() { return executor; } + /** + * 사용자 셀프 컨테이너 재시작 전용 executor. approvalExecutor와 분리한 이유는 + * 재시작은 관리자가 아니라 일반 사용자가 아무 때나 누르는 요청이라, 같은 풀을 쓰면 + * 재시작이 몰렸을 때 관리자 승인 처리량까지 함께 굶어버리기 때문이다. + * 재시작 1건도 새 Pod 생성 대기(최대 10분)를 포함하므로 풀은 작게 잡고, + * 넘치면 큐잉 없이 AbortPolicy로 즉시 거부해 사용자에게 명확한 재시도 안내를 준다. + */ + @Bean + public ThreadPoolTaskExecutor rebootExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(0); + executor.setThreadNamePrefix("reboot-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.initialize(); + return executor; + } + private static class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index a476e046..3397dee9 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -47,6 +47,9 @@ notification.admin.request.stale-processing=\u26A0\uFE0F [\uC790\uB3D9 \uBCF5\uA # {0}: \uC694\uCCAD ID, {1}: \uC6B0\uBD84\uD22C \uACC4\uC815, {2}: \uBC29\uCE58 \uC2DC\uAC04(\uBD84) notification.admin.request.stale-migrating=\uD83D\uDEA8 [\uD655\uC778 \uD544\uC694] {2}\uBD84 \uB118\uAC8C MIGRATING \uC0C1\uD0DC\uB85C \uBC29\uCE58\uB41C \uC694\uCCAD\uC774 \uC788\uC2B5\uB2C8\uB2E4. \uC2E4\uC81C \uC778\uD504\uB77C \uC0C1\uD0DC\uC640 \uCDA9\uB3CC\uD560 \uC218 \uC788\uC5B4 \uC790\uB3D9 \uBCF5\uAD6C\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.\n- \uC694\uCCAD ID: {0}\n- \uACC4\uC815: {1}\n(\uC218\uB3D9\uC73C\uB85C Pod/\uB178\uB4DC \uC0C1\uD0DC\uB97C \uD655\uC778\uD55C \uB4A4 \uCC98\uB9AC\uD574\uC8FC\uC138\uC694) +# {0}: \uC694\uCCAD ID, {1}: \uC6B0\uBD84\uD22C \uACC4\uC815, {2}: \uBC29\uCE58 \uC2DC\uAC04(\uBD84) +notification.admin.request.stale-rebooting=\uD83D\uDEA8 [\uD655\uC778 \uD544\uC694] {2}\uBD84 \uB118\uAC8C REBOOTING \uC0C1\uD0DC\uB85C \uBC29\uCE58\uB41C \uC694\uCCAD\uC774 \uC788\uC2B5\uB2C8\uB2E4. \uC2E4\uC81C \uC778\uD504\uB77C \uC0C1\uD0DC\uC640 \uCDA9\uB3CC\uD560 \uC218 \uC788\uC5B4 \uC790\uB3D9 \uBCF5\uAD6C\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.\n- \uC694\uCCAD ID: {0}\n- \uACC4\uC815: {1}\n(\uC218\uB3D9\uC73C\uB85C Pod/\uD3EC\uD2B8 \uC0C1\uD0DC\uB97C \uD655\uC778\uD55C \uB4A4 \uCC98\uB9AC\uD574\uC8FC\uC138\uC694) + # [\uC2E0\uADDC \uC2E0\uCCAD \uC54C\uB9BC] # {0}: \uC2E0\uCCAD\uC790 \uC774\uB984, {1}: \uC11C\uBC84\uBA85 notification.admin.new-request=\uD83D\uDD14 \uC0C8\uB85C\uC6B4 \uC11C\uBC84 \uC0AC\uC6A9 \uC2E0\uCCAD! \uD83D\uDD14\n\u25B6 \uC2E0\uCCAD\uC790: {0}\n\u25B6 \uC11C\uBC84: {1}\n(\uAD00\uB9AC\uC790 \uD398\uC774\uC9C0 \uD655\uC778 \uC694\uB9DD) diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardServiceTest.java index 0bc71564..fd855011 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardServiceTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardServiceTest.java @@ -141,7 +141,7 @@ void status_containsOnlyValidDbValues() { .map(Enum::name) .collect(Collectors.toSet()); - assertThat(names).containsExactlyInAnyOrder("PENDING", "PROCESSING", "DENIED", "FULFILLED", "MIGRATING", "DELETED"); + assertThat(names).containsExactlyInAnyOrder("PENDING", "PROCESSING", "DENIED", "FULFILLED", "MIGRATING", "REBOOTING", "DELETED"); assertThat(names).doesNotContain("ALL"); } @@ -153,7 +153,7 @@ void statusFilter_containsAll() { .collect(Collectors.toSet()); assertThat(names).contains("ALL"); - assertThat(names).containsExactlyInAnyOrder("PENDING", "PROCESSING", "DENIED", "FULFILLED", "MIGRATING", "DELETED", "ALL"); + assertThat(names).containsExactlyInAnyOrder("PENDING", "PROCESSING", "DENIED", "FULFILLED", "MIGRATING", "REBOOTING", "DELETED", "ALL"); } } } diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java new file mode 100644 index 00000000..b38bb878 --- /dev/null +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java @@ -0,0 +1,298 @@ +package DGU_AI_LAB.admin_be.domain.requests.service; + +import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; +import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.CreatePodResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.MigratePodResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import DGU_AI_LAB.admin_be.domain.requests.entity.Status; +import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; +import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; +import DGU_AI_LAB.admin_be.domain.users.entity.User; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("PodRebootService") +class PodRebootServiceTest { + + private static final Long REQUEST_ID = 1L; + private static final Long OWNER_ID = 10L; + + @Mock private RequestRepository requestRepository; + @Mock private PodService podService; + @Mock private PodMigrationService podMigrationService; + @Mock private PlatformTransactionManager transactionManager; + @Mock private TransactionStatus transactionStatus; + @Mock private AlarmService alarmService; + @Mock private ThreadPoolTaskExecutor rebootExecutor; + + @Mock private Request mockRequest; + @Mock private User mockUser; + @Mock private ContainerImage mockImage; + @Mock private ResourceGroup mockResourceGroup; + + private PodRebootService service; + + @BeforeEach + void setUp() { + when(transactionManager.getTransaction(any())).thenReturn(transactionStatus); + service = new PodRebootService(requestRepository, podService, podMigrationService, + transactionManager, alarmService, rebootExecutor); + + // rebootExecutor는 제출된 작업을 그 자리에서 동기 실행한다 — 유닛 테스트에는 별도 + // 스레드가 필요 없고, 이렇게 해야 rebootPod() 호출 직후 바로 부수효과를 검증할 수 있다. + doAnswer(invocation -> { + Runnable task = invocation.getArgument(0); + task.run(); + return null; + }).when(rebootExecutor).execute(any()); + + when(mockUser.getUserId()).thenReturn(OWNER_ID); + when(mockUser.getEmail()).thenReturn("owner@dgu.ac.kr"); + when(mockUser.getName()).thenReturn("테스트유저"); + when(mockImage.getImageId()).thenReturn(1L); + when(mockImage.getImageName()).thenReturn("cuda"); + when(mockResourceGroup.getRsgroupId()).thenReturn(1); + when(mockResourceGroup.getServerName()).thenReturn("FARM"); + + when(mockRequest.getRequestId()).thenReturn(REQUEST_ID); + when(mockRequest.getUser()).thenReturn(mockUser); + when(mockRequest.getContainerImage()).thenReturn(mockImage); + when(mockRequest.getResourceGroup()).thenReturn(mockResourceGroup); + when(mockRequest.getRequestGroups()).thenReturn(new LinkedHashSet<>()); + when(mockRequest.getUbuntuUsername()).thenReturn("testuser"); + when(mockRequest.getNodeName()).thenReturn("farm1"); + when(mockRequest.getPodName()).thenReturn("ailab-testuser-1"); + when(requestRepository.findByIdForUpdate(REQUEST_ID)).thenReturn(Optional.of(mockRequest)); + when(requestRepository.findById(REQUEST_ID)).thenReturn(Optional.of(mockRequest)); + } + + /** + * mockRequest는 Mockito mock이라 beginReboot()의 실제 상태 검증이 실행되지 않으므로, + * FULFILLED가 아닌 상태를 재현할 때는 예외를 던지도록 명시적으로 스텁한다. + */ + private void stubStatus(Status status) { + when(mockRequest.getStatus()).thenReturn(status); + if (status != Status.FULFILLED) { + doThrow(new BusinessException("컨테이너가 실행 중일 때만 재시작할 수 있습니다. 이미 다른 작업이 진행 중입니다.", + ErrorCode.INVALID_REQUEST_STATUS)).when(mockRequest).beginReboot(); + } + } + + private MigratePodResponseDTO migratedResponse() { + return new MigratePodResponseDTO( + "migrated", null, "farm1", "farm1", "ailab-testuser-2", + List.of(new CreatePodResponseDTO.PortInfo("ssh", 22, 30099)), + null, null, null, null, null + ); + } + + @Nested + @DisplayName("정상 케이스") + class Success { + + @Test + @DisplayName("현재 노드를 후보로 고정하고 same_node=true로 마이그레이션을 호출한다") + void rebootPod_callsMigrateOnSameNode() { + stubStatus(Status.FULFILLED); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())).thenReturn(migratedResponse()); + + service.rebootPod(REQUEST_ID, OWNER_ID); + + verify(podService).migratePod("testuser", List.of("farm1"), + PodMigrationService.FORCE_MIGRATION_RATIO, true); + // 기존 Pod 삭제는 config-server가 /migrate 내부에서 처리한다 — 직접 지우면 안 된다. + verify(podService, never()).deletePod(anyString()); + } + + @Test + @DisplayName("성공하면 새 Pod 정보를 반영하고 REBOOTING을 FULFILLED로 되돌린다") + void rebootPod_success_appliesPodInfoAndEndsReboot() { + stubStatus(Status.FULFILLED); + MigratePodResponseDTO response = migratedResponse(); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())).thenReturn(response); + + service.rebootPod(REQUEST_ID, OWNER_ID); + + verify(mockRequest).beginReboot(); + verify(podMigrationService).applyMigratedPodInfo(REQUEST_ID, mockRequest, response); + verify(mockRequest).endReboot(); + } + + @Test + @DisplayName("즉시 응답 DTO는 REBOOTING 상태를 담아 반환된다") + void rebootPod_returnsRebootingSnapshot() { + // 즉시 응답은 beginReboot() 직후(같은 트랜잭션)에 만들어지므로 REBOOTING이 담긴다. + when(mockRequest.getStatus()).thenReturn(Status.REBOOTING); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())).thenReturn(migratedResponse()); + + SaveRequestResponseDTO result = service.rebootPod(REQUEST_ID, OWNER_ID); + + assertThat(result.requestId()).isEqualTo(REQUEST_ID); + assertThat(result.status()).isEqualTo(Status.REBOOTING); + assertThat(result.ubuntuUsername()).isEqualTo("testuser"); + } + + @Test + @DisplayName("config-server가 재배치를 건너뛰면 DB는 그대로 두고 상태만 되돌린다") + void rebootPod_skipped_doesNotTouchPodInfo() { + stubStatus(Status.FULFILLED); + MigratePodResponseDTO skipped = new MigratePodResponseDTO( + "skipped", "no_capacity", null, null, null, null, null, "farm1", 1.5, "farm1", 1.5); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())).thenReturn(skipped); + + service.rebootPod(REQUEST_ID, OWNER_ID); + + verify(podMigrationService, never()).applyMigratedPodInfo(any(), any(), any()); + verify(mockRequest).endReboot(); + } + } + + @Nested + @DisplayName("권한/상태 검증") + class Validation { + + @Test + @DisplayName("본인 소유가 아닌 신청이면 거부하고 아무 작업도 제출하지 않는다") + void rebootPod_notOwner_throwsForbidden() { + stubStatus(Status.FULFILLED); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, 999L)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FORBIDDEN_REQUEST); + + verify(mockRequest, never()).beginReboot(); + verify(rebootExecutor, never()).execute(any()); + } + + @Test + @DisplayName("FULFILLED가 아니면 executor 제출 전에 거부된다") + void rebootPod_notFulfilled_rejectedBeforeSubmit() { + stubStatus(Status.PENDING); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_REQUEST_STATUS); + + verify(rebootExecutor, never()).execute(any()); + verifyNoInteractions(podService); + } + + @Test + @DisplayName("이미 마이그레이션 중이면 재시작을 거부한다") + void rebootPod_alreadyMigrating_rejected() { + stubStatus(Status.MIGRATING); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_REQUEST_STATUS); + + verify(rebootExecutor, never()).execute(any()); + } + + @Test + @DisplayName("배치된 노드 정보가 없으면 임의 노드로 옮겨가지 않도록 거부한다") + void rebootPod_noNodeName_rejected() { + stubStatus(Status.FULFILLED); + when(mockRequest.getNodeName()).thenReturn(null); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.POD_NODE_NOT_ASSIGNED); + + verify(mockRequest, never()).beginReboot(); + verify(rebootExecutor, never()).execute(any()); + } + + @Test + @DisplayName("존재하지 않는 신청이면 404로 실패한다") + void rebootPod_notFound() { + when(requestRepository.findByIdForUpdate(REQUEST_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.RESOURCE_NOT_FOUND); + } + } + + @Nested + @DisplayName("실패 처리") + class Failure { + + @Test + @DisplayName("executor가 가득 차면 상태를 FULFILLED로 되돌리고 429로 실패한다") + void rebootPod_executorRejected_revertsAndThrows() { + stubStatus(Status.FULFILLED); + doThrow(new TaskRejectedException("pool full")).when(rebootExecutor).execute(any()); + // 복구 트랜잭션은 REBOOTING인 요청만 되돌린다 (제출 거부 시점의 실제 상태) + when(mockRequest.getStatus()).thenReturn(Status.REBOOTING); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.POD_REBOOT_CONCURRENCY_LIMIT); + + verify(mockRequest).endReboot(); + verifyNoInteractions(podService); + } + + @Test + @DisplayName("마이그레이션 호출이 실패하면 REBOOTING에 갇히지 않고 FULFILLED로 되돌린다") + void rebootPod_migrateFails_revertsToFulfilled() { + when(mockRequest.getStatus()).thenReturn(Status.FULFILLED, Status.REBOOTING); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())) + .thenThrow(new BusinessException(ErrorCode.POD_MIGRATION_FAILED)); + + // 비동기 본체의 실패는 호출자에게 전파되지 않는다 — 이미 즉시 응답을 반환한 뒤다. + SaveRequestResponseDTO result = service.rebootPod(REQUEST_ID, OWNER_ID); + + assertThat(result).isNotNull(); + verify(mockRequest).endReboot(); + // 기존 Pod는 config-server가 지우기 전이라 그대로 살아있다 — 보상 삭제를 하면 안 된다. + verify(podService, never()).deletePod(anyString()); + verify(podMigrationService, never()).applyMigratedPodInfo(any(), any(), any()); + } + + @Test + @DisplayName("결과 DB 반영이 실패하면 REBOOTING을 유지한 채 관리자 알림을 보낸다") + void rebootPod_dbApplyFails_keepsRebootingAndAlerts() { + stubStatus(Status.FULFILLED); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())).thenReturn(migratedResponse()); + doThrow(new IllegalStateException("db down")) + .when(podMigrationService).applyMigratedPodInfo(any(), any(), any()); + + service.rebootPod(REQUEST_ID, OWNER_ID); + + // 새 Pod는 이미 떴고 기존 Pod는 지워진 뒤라 FULFILLED로 되돌리면 DB와 실제가 어긋난다. + verify(mockRequest, never()).endReboot(); + verify(alarmService).sendSlackAlert(contains("결과 DB 반영 실패"), eq(null)); + } + } +} From 7783181e791e40701b1622cdaded28a9e7c09b69 Mon Sep 17 00:00:00 2001 From: yoon6yo Date: Mon, 7 Sep 2026 16:35:34 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=ED=9B=84=20=EA=B8=B0=EC=A1=B4=20Pod=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config-server가 새 Pod는 정상 생성했지만 기존 Pod 정리에 실패하면 노드에 Pod가 남아 자원을 계속 점유한다. 마이그레이션 경로와 동일하게 관리자 알림을 보내도록 했다. 정리 거부 사유 메시지도 마이그레이션뿐 아니라 승인/재시작 진행 중인 경우를 포함하도록 문구를 맞췄다. --- .../domain/requests/service/PodRebootService.java | 9 +++++++++ .../users/controller/docs/AdminUserApi.java | 2 +- .../domain/users/service/AdminUserService.java | 2 +- .../java/DGU_AI_LAB/admin_be/error/ErrorCode.java | 2 +- .../requests/service/PodRebootServiceTest.java | 15 +++++++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java index 6b13354e..cacbc0ab 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java @@ -147,6 +147,15 @@ private void processReboot(Long requestId, String username, String currentNode, if (response.isMigrated()) { log.info("컨테이너 재시작 완료: requestId={}, username={}, node={}, oldPod={}, newPod={}", requestId, username, response.to(), oldPodName, response.newPod()); + + if ("failed".equals(response.oldPodCleanup())) { + // 새 Pod는 정상 반영됐지만 기존 Pod가 노드에 남아 자원을 계속 점유한다. + String msg = String.format( + "[컨테이너 재시작] 새 Pod는 정상 반영됐지만 기존 Pod 정리 실패 - 수동 확인 필요: requestId=%d, username=%s, oldPod=%s, node=%s", + requestId, username, oldPodName, currentNode); + log.warn(msg); + sendAlertSafely(msg); + } } else { // same_node=true인데도 config-server가 재배치를 건너뛴 경우(노드 자원 부족 등). // 기존 Pod는 그대로 살아있으므로 사용자 입장에선 재시작이 일어나지 않은 것과 같다. diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AdminUserApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AdminUserApi.java index ed72e769..49707aaa 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AdminUserApi.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AdminUserApi.java @@ -70,7 +70,7 @@ ResponseEntity> reactivateUser( @ApiResponse(responseCode = "200", description = "성공") @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") @ApiResponse(responseCode = "409", description = "이미 비활성화된 사용자") - @ApiResponse(responseCode = "409", description = "마이그레이션이 진행 중인 요청이 있어 정리할 수 없음") + @ApiResponse(responseCode = "409", description = "승인/마이그레이션/재시작이 진행 중인 요청이 있어 정리할 수 없음") @PatchMapping("/{id}/deactivate") ResponseEntity> deactivateUser( @PathVariable @Parameter(description = "사용자 ID") Long id diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java index 282a15c3..2864030d 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/AdminUserService.java @@ -82,7 +82,7 @@ private void cleanupUserRequests(User user, String logPrefix) { .anyMatch(r -> r.getStatus() == Status.MIGRATING || r.getStatus() == Status.PROCESSING || r.getStatus() == Status.REBOOTING); if (hasInFlightRequest) { - log.warn("[{}] userId={} 승인/마이그레이션 진행 중인 요청이 있어 정리를 거부합니다.", logPrefix, user.getUserId()); + log.warn("[{}] userId={} 승인/마이그레이션/재시작 진행 중인 요청이 있어 정리를 거부합니다.", logPrefix, user.getUserId()); throw new ConflictException(ErrorCode.REQUEST_MIGRATION_IN_PROGRESS); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java index 5fbf4027..25eb51e0 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java +++ b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java @@ -132,7 +132,7 @@ public enum ErrorCode { INVALID_REQUEST_STATUS(HttpStatus.CONFLICT, "이미 처리된 신청입니다."), //FORBIDDEN_REQUEST(HttpStatus.BAD_REQUEST, "본인의 신청만 변경 신청할 수 있습니다."), UNSUPPORTED_CHANGE_TYPE(HttpStatus.BAD_REQUEST, "지원되지 않는 요청 타입(enum)입니다."), - REQUEST_MIGRATION_IN_PROGRESS(HttpStatus.CONFLICT, "마이그레이션이 진행 중인 요청이 있어 삭제할 수 없습니다."), + REQUEST_MIGRATION_IN_PROGRESS(HttpStatus.CONFLICT, "승인/마이그레이션/재시작이 진행 중인 요청이 있어 삭제할 수 없습니다."), USER_REQUEST_CLEANUP_PARTIALLY_FAILED(HttpStatus.BAD_GATEWAY, "일부 컨테이너/계정 정리에 실패했습니다. 관리자에게 알림이 전송되었으니 확인 후 다시 시도해주세요."), /** diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java index b38bb878..75c5315c 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java @@ -280,6 +280,21 @@ void rebootPod_migrateFails_revertsToFulfilled() { verify(podMigrationService, never()).applyMigratedPodInfo(any(), any(), any()); } + @Test + @DisplayName("기존 Pod 정리에 실패하면 재시작은 완료하되 관리자 알림을 보낸다") + void rebootPod_oldPodCleanupFailed_alertsAdmin() { + stubStatus(Status.FULFILLED); + MigratePodResponseDTO cleanupFailed = new MigratePodResponseDTO( + "migrated", null, "farm1", "farm1", "ailab-testuser-2", + List.of(), "failed", null, null, null, null); + when(podService.migratePod(anyString(), anyList(), any(), anyBoolean())).thenReturn(cleanupFailed); + + service.rebootPod(REQUEST_ID, OWNER_ID); + + verify(mockRequest).endReboot(); + verify(alarmService).sendSlackAlert(contains("기존 Pod 정리 실패"), eq(null)); + } + @Test @DisplayName("결과 DB 반영이 실패하면 REBOOTING을 유지한 채 관리자 알림을 보낸다") void rebootPod_dbApplyFails_keepsRebootingAndAlerts() { From dea9161b8ed2536d79d6c313c82e604fe5f5d7e2 Mon Sep 17 00:00:00 2001 From: yoon6yo Date: Mon, 7 Sep 2026 20:59:12 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= =?UTF-8?q?=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20=EC=BF=A8=EB=8B=A4=EC=9A=B4=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A7=88=EB=AC=B4=EB=A6=AC=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=96=89=20=EC=9E=A0=EA=B8=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재시작마다 config-server가 컨테이너 파일시스템 전체를 NFS에 다시 tar로 떠서 저장하므로, 연타로 인한 반복 실행을 막기 위해 마지막 재시작으로부터 10분 이내에는 재시도를 거부한다. 성공/실패 마무리 단계에서 Request를 다시 읽을 때 findById 대신 findByIdForUpdate를 사용해, 그 사이 다른 작업이 상태를 바꾼 경우를 approveRequest와 동일한 방식으로 방어한다. --- .../domain/requests/entity/Request.java | 21 ++++++ .../requests/service/PodRebootService.java | 4 +- .../DGU_AI_LAB/admin_be/error/ErrorCode.java | 1 + .../domain/requests/entity/RequestTest.java | 73 +++++++++++++++++++ .../service/PodRebootServiceTest.java | 15 ++++ 5 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java index d8839fd4..60df0df7 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java @@ -10,6 +10,7 @@ import jakarta.persistence.*; import lombok.*; +import java.time.Duration; import java.time.LocalDateTime; import java.util.LinkedHashSet; import java.util.Set; @@ -73,6 +74,9 @@ public class Request extends BaseTimeEntity { @Column(name = "node_name", length = 100) private String nodeName; + @Column(name = "last_rebooted_at") + private LocalDateTime lastRebootedAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "rsgroup_id", nullable = false) private ResourceGroup resourceGroup; @@ -209,6 +213,13 @@ public void endMigration() { this.status = Status.FULFILLED; } + /** + * 재시작 1회마다 config-server가 컨테이너 전체 파일시스템을 NFS에 tar로 떠서 저장하므로 + * (같은 파일을 덮어쓰긴 하지만) 매번 실질적인 I/O 비용이 든다. 연타로 인한 반복 실행을 + * 막기 위해 마지막 재시작 시도 이후 이 시간 동안은 재시도를 막는다. + */ + private static final long REBOOT_COOLDOWN_MINUTES = 10; + /** * 사용자 셀프 재시작 시작을 위해 FULFILLED -> REBOOTING으로 전환한다. * beginMigration()과 같은 이유로 행 잠금 조회(findByIdForUpdate)와 같은 트랜잭션에서 @@ -218,7 +229,17 @@ public void beginReboot() { if (this.status != Status.FULFILLED) { throw new BusinessException("컨테이너가 실행 중일 때만 재시작할 수 있습니다. 이미 다른 작업이 진행 중입니다.", ErrorCode.INVALID_REQUEST_STATUS); } + if (this.lastRebootedAt != null) { + LocalDateTime cooldownEnd = this.lastRebootedAt.plusMinutes(REBOOT_COOLDOWN_MINUTES); + if (cooldownEnd.isAfter(LocalDateTime.now())) { + long remainingMinutes = Duration.between(LocalDateTime.now(), cooldownEnd).toMinutes() + 1; + throw new BusinessException( + String.format("최근에 재시작한 컨테이너입니다. %d분 후 다시 시도해주세요.", remainingMinutes), + ErrorCode.POD_REBOOT_COOLDOWN); + } + } this.status = Status.REBOOTING; + this.lastRebootedAt = LocalDateTime.now(); } /** diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java index cacbc0ab..dfb6a23a 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootService.java @@ -124,7 +124,7 @@ private void processReboot(Long requestId, String username, String currentNode, try { new TransactionTemplate(transactionManager).execute(status -> { - Request req = requestRepository.findById(requestId) + Request req = requestRepository.findByIdForUpdate(requestId) .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); if (response.isMigrated()) { podMigrationService.applyMigratedPodInfo(requestId, req, response); @@ -171,7 +171,7 @@ private void processReboot(Long requestId, String username, String currentNode, private void revertToFulfilled(Long requestId) { try { new TransactionTemplate(transactionManager).execute(status -> { - requestRepository.findById(requestId) + requestRepository.findByIdForUpdate(requestId) .filter(req -> req.getStatus() == Status.REBOOTING) .ifPresent(Request::endReboot); return null; diff --git a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java index 25eb51e0..74eda9d8 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java +++ b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java @@ -151,6 +151,7 @@ public enum ErrorCode { POD_MIGRATION_FAILED(HttpStatus.BAD_GATEWAY, "Pod 마이그레이션 API 요청에 실패했습니다."), POD_REBOOT_CONCURRENCY_LIMIT(HttpStatus.TOO_MANY_REQUESTS, "현재 동시에 처리 중인 컨테이너 재시작 요청이 많습니다. 잠시 후 다시 시도해주세요."), POD_NODE_NOT_ASSIGNED(HttpStatus.CONFLICT, "컨테이너가 배치된 노드 정보가 없어 재시작할 수 없습니다. 관리자에게 문의해주세요."), + POD_REBOOT_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "최근에 재시작한 컨테이너입니다. 잠시 후 다시 시도해주세요."), /** * Message Template Error diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestTest.java index b1efd81b..d61f6d5d 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestTest.java @@ -8,11 +8,14 @@ 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 java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; import static org.mockito.Mockito.mock; class RequestTest { @@ -298,4 +301,74 @@ void assignUbuntuIds_throwsException_whenInvalid() { .isInstanceOf(BusinessException.class); } } + + @Nested + @DisplayName("beginReboot / endReboot") + class RebootLifecycle { + + @BeforeEach + void approveFirst() { + ContainerImage newImage = mock(ContainerImage.class); + ResourceGroup newRg = mock(ResourceGroup.class); + request.approve(newImage, newRg, "승인합니다"); + } + + @Test + @DisplayName("FULFILLED 상태에서 재시작을 시작하면 REBOOTING으로 바뀌고 마지막 재시작 시각이 기록된다") + void beginReboot_movesToRebooting_andRecordsTimestamp() { + request.beginReboot(); + + assertThat(request.getStatus()).isEqualTo(Status.REBOOTING); + assertThat(request.getLastRebootedAt()).isNotNull(); + assertThat(request.getLastRebootedAt()).isCloseTo(LocalDateTime.now(), within(5, ChronoUnit.SECONDS)); + } + + @Test + @DisplayName("FULFILLED 상태가 아니면 재시작을 거부한다") + void beginReboot_throwsException_whenNotFulfilled() { + request.beginReboot(); + + assertThatThrownBy(() -> request.beginReboot()) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("재시작이 끝나면 FULFILLED로 되돌아간다") + void endReboot_returnsToFulfilled() { + request.beginReboot(); + + request.endReboot(); + + assertThat(request.getStatus()).isEqualTo(Status.FULFILLED); + } + + @Test + @DisplayName("REBOOTING이 아닌 상태에서 endReboot을 호출하면 BusinessException을 던진다") + void endReboot_throwsException_whenNotRebooting() { + assertThatThrownBy(() -> request.endReboot()) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("직전 재시작으로부터 쿨다운 시간 이내면 재시작을 거부한다") + void beginReboot_throwsException_withinCooldown() { + request.beginReboot(); + request.endReboot(); + + assertThatThrownBy(() -> request.beginReboot()) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("쿨다운 시간이 지나면 다시 재시작할 수 있다") + void beginReboot_allowed_afterCooldownElapsed() { + request.beginReboot(); + request.endReboot(); + ReflectionTestUtils.setField(request, "lastRebootedAt", LocalDateTime.now().minusMinutes(11)); + + request.beginReboot(); + + assertThat(request.getStatus()).isEqualTo(Status.REBOOTING); + } + } } diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java index 75c5315c..0d1894ed 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodRebootServiceTest.java @@ -241,6 +241,21 @@ void rebootPod_notFound() { .isInstanceOf(BusinessException.class) .extracting("errorCode").isEqualTo(ErrorCode.RESOURCE_NOT_FOUND); } + + @Test + @DisplayName("쿨다운 시간 이내 재시작이면 executor 제출 전에 거부된다") + void rebootPod_withinCooldown_rejectedBeforeSubmit() { + when(mockRequest.getStatus()).thenReturn(Status.FULFILLED); + doThrow(new BusinessException("최근에 재시작한 컨테이너입니다. 3분 후 다시 시도해주세요.", + ErrorCode.POD_REBOOT_COOLDOWN)).when(mockRequest).beginReboot(); + + assertThatThrownBy(() -> service.rebootPod(REQUEST_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode").isEqualTo(ErrorCode.POD_REBOOT_COOLDOWN); + + verify(rebootExecutor, never()).execute(any()); + verifyNoInteractions(podService); + } } @Nested From ca89bef35259c76c64b698a193eec3f0b9144573 Mon Sep 17 00:00:00 2001 From: yoon6yo Date: Mon, 7 Sep 2026 21:06:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EC=85=80=ED=94=84=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EC=9E=91=20=EA=B8=B0=EB=8A=A5=20README?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index dccfa6b9..5638022f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ - 사용자는 원하는 GPU 용량, 기간, 이미지를 선택하여 신청. - 관리자 승인 시 **UsedId(UID/GID)** 자동 할당 및 **Ubuntu 계정 생성 API** 호출. +### 1-1. 셀프 서비스 컨테이너 재시작 +- 사용자가 관리자 개입 없이 본인의 실행 중인 컨테이너를 직접 재시작 가능 (`POST /api/requests/{requestId}/reboot`). +- 내부적으로는 "현재 노드를 후보에 포함한 마이그레이션"으로 동작 — 새 Pod가 정상 기동을 마친 뒤에야 기존 Pod를 정리하므로, 도중에 실패해도 기존 컨테이너가 그대로 유지됨. +- 컨테이너 파일시스템을 이미지로 커밋한 뒤 재생성하므로 설치한 패키지·파일은 유지되지만, 접속 포트 번호는 바뀔 수 있음. +- 반복 클릭으로 인한 과도한 재시작을 막기 위해 마지막 재시작으로부터 10분간 쿨다운 적용. + ### 2. 자동화된 스케줄러 (매일 10:00 실행) - **만료 예고:** 만료 전 정해진 날짜(7, 3, 1일 전)에 사용자에게 알림 발송. - **자동 회수:** 만료일 도래 시 Linux 계정 삭제, DB 데이터 정리(Cascade), UID 반납.