Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface ContainerImageRepository extends JpaRepository<ContainerImage, Long> {
boolean existsByImageNameAndImageVersion(String imageName, String imageVersion);

Optional<ContainerImage> findByImageNameAndImageVersion(String imageName, String imageVersion);
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ record ContainerListResponseDoc(int status, String message, List<ContainerInfoDT

@Operation(summary = "컨테이너 재시작", description = "현재 노드에서 컨테이너를 다시 만드는 작업을 등록하고 바로 202로 돌아옵니다(신청은 MIGRATING). "
+ "새 컨테이너는 노드의 현재 GPU 목록을 받습니다. keepChanges(기본 true)면 설치한 패키지 등 컨테이너 변경분을 유지하고, "
+ "false면 기본 이미지로 초기화합니다. 실행 중이던 프로세스는 이어지지 않습니다. 결과는 마지막 마이그레이션 결과로 확인합니다.")
+ "false면 기본 이미지로 초기화합니다. imageId를 주면 신청의 이미지를 그 이미지로 바꿔 다시 만들며(변경분은 이어 줄 수 없어 keepChanges는 false), 작업이 성공하면 신청의 이미지도 바뀝니다. 실행 중이던 프로세스는 이어지지 않습니다. 결과는 마지막 마이그레이션 결과로 확인합니다.")
@ApiResponse(responseCode = "202", description = "작업 등록됨")
@ApiResponse(responseCode = "404", description = "신청을 찾을 수 없음", content = @Content)
@ApiResponse(responseCode = "400", description = "imageId와 keepChanges=true를 함께 줌", content = @Content)
@ApiResponse(responseCode = "404", description = "신청 또는 이미지를 찾을 수 없음", content = @Content)
@ApiResponse(responseCode = "409", description = "FULFILLED 상태가 아니거나 이미 진행 중", content = @Content)
@ApiResponse(responseCode = "502", description = "config-server 작업 등록 실패", content = @Content)
ResponseEntity<SuccessResponse<?>> startRestart(Long requestId, RestartPodRequestDTO dto);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,29 @@ public record MigrateRegisterRequestDTO(
Boolean force,
Boolean recreate,
@JsonProperty("keep_changes") Boolean keepChanges,
@JsonProperty("access_blocked") Boolean accessBlocked
@JsonProperty("access_blocked") Boolean accessBlocked,
String image
) {
/** 다른 노드로 옮기는 작업. */
public static MigrateRegisterRequestDTO move(Long requestId, String podName, String username, List<String> nodes,
Double minImprovementRatio, Boolean force) {
return new MigrateRegisterRequestDTO(requestId, podName, username, nodes, minImprovementRatio, force, null, null, null);
return new MigrateRegisterRequestDTO(requestId, podName, username, nodes, minImprovementRatio, force, null, null, null, null);
}

/** 현재 노드에서 다시 만드는 작업. keepChanges면 컨테이너 변경분을 그 노드에 구워 새 Pod에 이어 준다. */
public static MigrateRegisterRequestDTO restart(Long requestId, String podName, String username, boolean keepChanges) {
return new MigrateRegisterRequestDTO(requestId, podName, username, null, null, null, true, keepChanges, null);
return restart(requestId, podName, username, keepChanges, null);
}

/** image(name:version)를 주면 새 Pod를 그 이미지로 만든다. 구운 변경분은 옛 이미지 위라 keepChanges와 함께 쓸 수 없다. */
public static MigrateRegisterRequestDTO restart(Long requestId, String podName, String username, boolean keepChanges,
String image) {
return new MigrateRegisterRequestDTO(requestId, podName, username, null, null, null, true, keepChanges, null, image);
}

/** 접속이 막혀 있어야 하는 사용자의 작업이면 그 표시를 단다. 아니면 키를 뺀다. */
public MigrateRegisterRequestDTO accessBlocked(boolean blocked) {
return new MigrateRegisterRequestDTO(requestId, podName, username, nodes, minImprovementRatio, force, recreate,
keepChanges, blocked ? Boolean.TRUE : null);
keepChanges, blocked ? Boolean.TRUE : null, image);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

@Schema(description = "컨테이너 재시작 요청 DTO")
public record RestartPodRequestDTO(
@Schema(description = "컨테이너 안에 설치한 패키지 등 변경분을 유지할지 (생략 시 true). false면 기본 이미지로 초기화", example = "true")
Boolean keepChanges
@Schema(description = "컨테이너 안에 설치한 패키지 등 변경분을 유지할지 (생략 시 true, imageId를 주면 false). false면 기본 이미지로 초기화", example = "true")
Boolean keepChanges,
@Schema(description = "관리자만: 이 이미지로 다시 만든다(생략하면 신청에 묶인 이미지). 기존 변경분은 이어 줄 수 없어 keepChanges를 true로 줄 수 없다", example = "3")
Long imageId
) {
public boolean keepsChanges() {
return !Boolean.FALSE.equals(keepChanges);
return keepChanges == null ? imageId == null : keepChanges;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,27 @@ public record Result(
@JsonProperty("old_pod_cleanup") String oldPodCleanup,
// 생성 작업만: 계정에 넣은 보조 그룹과 그 gid. 승인 대기 그룹(gid 없이 보낸 그룹)은 작업이 발급한 gid 를
// 여기서 받아 채운다.
List<GroupResult> groups
List<GroupResult> groups,
// 마이그레이션 작업만: 이미지를 바꿔 다시 만들었다면 새 Pod가 쓴 이미지(name:version)
String image
) {
/** 생성 작업 결과. */
public Result(Long uid, Long gid, String podName, String node, List<CreatePodResponseDTO.PortInfo> ports) {
this(uid, gid, podName, node, ports, null, null, null, null, null, null, null);
this(uid, gid, podName, node, ports, null, null, null, null, null, null, null, null);
}

/** 그룹 결과가 없는 작업 결과(마이그레이션 등). */
public Result(Long uid, Long gid, String podName, String node, List<CreatePodResponseDTO.PortInfo> ports,
String status, String reason, String fromNode, String toNode, String oldPodName,
String oldPodCleanup) {
this(uid, gid, podName, node, ports, status, reason, fromNode, toNode, oldPodName, oldPodCleanup, null);
this(uid, gid, podName, node, ports, status, reason, fromNode, toNode, oldPodName, oldPodCleanup, null, null);
}

/** 이미지 없이 그룹 결과까지 있는 작업 결과(생성). */
public Result(Long uid, Long gid, String podName, String node, List<CreatePodResponseDTO.PortInfo> ports,
String status, String reason, String fromNode, String toNode, String oldPodName,
String oldPodCleanup, List<GroupResult> groups) {
this(uid, gid, podName, node, ports, status, reason, fromNode, toNode, oldPodName, oldPodCleanup, groups, null);
}

public boolean isMigrated() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ public void prepareAsyncApproval(ContainerImage image, ResourceGroup resourceGro
}
}

/** 이미지를 바꿔 다시 만든 작업이 성공했을 때 신청의 이미지를 새 Pod가 쓰는 이미지로 맞춘다. */
public void changeContainerImage(ContainerImage image) {
this.containerImage = image;
}

/** 등록해 둔 생성 작업이 성공했을 때 승인을 확정한다. 관리자가 고른 값은 이미 반영돼 있다. */
public void completeApproval() {
transitionTo(Status.FULFILLED, "처리 중인 신청만 승인을 확정할 수 있습니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import DGU_AI_LAB.admin_be.domain.requests.job.JobResults;
import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService;
import DGU_AI_LAB.admin_be.domain.alarm.service.UserGuideMailer;
import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage;
import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository;
import DGU_AI_LAB.admin_be.domain.nodes.entity.Node;
import DGU_AI_LAB.admin_be.domain.nodes.repository.NodeRepository;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.MigratePodRequestDTO;
Expand Down Expand Up @@ -60,6 +62,7 @@ public class PodMigrationService {
private final SuspensionGuard suspensionGuard;
private final PodExternalPortRepository podExternalPortRepository;
private final NodeRepository nodeRepository;
private final ContainerImageRepository containerImageRepository;
private final JobClient jobClient;
private final PlatformTransactionManager transactionManager;
private final AlarmService alarmService;
Expand Down Expand Up @@ -112,16 +115,35 @@ public void startMigration(Long requestId, MigratePodRequestDTO dto) {
dto.nodes(), dto.minImprovementRatio(), dto.force()));
}

/** 관리자가 현재 노드에서 다시 만든다(GPU 목록 갱신 등). */
/** 관리자가 현재 노드에서 다시 만든다(GPU 목록 갱신 등). imageId를 주면 그 이미지로 바꿔 만든다. */
public void startRestart(Long requestId, RestartPodRequestDTO dto) {
start(requestId, req -> { }, req -> { }, req -> restartJob(req, dto));
// 이미지 확인은 행 잠금·상태 검증 뒤(job 안)에서 한다 — 상태 오류(409)가 이미지 오류보다 먼저여야 한다.
start(requestId, req -> { }, req -> { }, req -> restartJob(req, dto, imageRefOf(dto)));
}

/** 상태 전환과 같은 트랜잭션 안에서 확인해(거절하면 롤백) name:version으로 돌려준다. 이미지를 고르지 않았으면 null. */
private String imageRefOf(RestartPodRequestDTO dto) {
if (dto == null || dto.imageId() == null) {
return null;
}
if (dto.keepsChanges()) {
throw new BusinessException("이미지를 바꾸면 컨테이너 변경분을 이어 줄 수 없습니다. keepChanges를 false로 주세요.",
ErrorCode.INVALID_INPUT_VALUE);
}
ContainerImage image = containerImageRepository.findById(dto.imageId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND));
return image.getImageName() + ":" + image.getImageVersion();
}

/**
* 사용자가 본인 컨테이너를 다시 만든다. 남의 신청이면 상태를 드러내지 않도록 상태 전환보다 먼저 거절하고,
* 횟수는 실제로 시작할 수 있는 요청만 센다(상태 검증에 막힌 요청이 횟수를 쓰지 않게 전환 뒤에 센다).
*/
public void startOwnRestart(Long userId, Long requestId, RestartPodRequestDTO dto) {
// 이미지 선택은 관리자 권한이다. 조용히 무시하면 사용자는 이미지가 바뀐 줄 안다.
if (dto != null && dto.imageId() != null) {
throw new BusinessException(ErrorCode.FORBIDDEN_REQUEST);
}
// start()는 먼저 끝난 작업 결과를 당겨 반영한다. 남의 신청에는 그것도 일어나지 않게 여기서 한 번 거른다
// (잠금 안에서 다시 확인한다).
requireOwner(requestRepository.findById(requestId)
Expand All @@ -132,8 +154,12 @@ public void startOwnRestart(Long userId, Long requestId, RestartPodRequestDTO dt
}

private static MigrateRegisterRequestDTO restartJob(Request req, RestartPodRequestDTO dto) {
return restartJob(req, dto, null);
}

private static MigrateRegisterRequestDTO restartJob(Request req, RestartPodRequestDTO dto, String image) {
return MigrateRegisterRequestDTO.restart(req.getRequestId(), req.getPodName(), req.getUbuntuUsername(),
dto == null || dto.keepsChanges());
dto == null || dto.keepsChanges(), image);
}

private static void requireOwner(Request req, Long userId) {
Expand Down Expand Up @@ -283,6 +309,7 @@ public void completeMigrationJob(Long requestId, JobResultResponseDTO.Result mad
final boolean[] applied = {false};
final Long[] ownerId = {null};
final Request[] portsChangedRequest = {null};
final boolean[] imageUnmatched = {false};
try {
new TransactionTemplate(transactionManager).execute(status -> {
Request req = requestRepository.findByIdForUpdate(requestId)
Expand All @@ -294,6 +321,10 @@ public void completeMigrationJob(Long requestId, JobResultResponseDTO.Result mad
if (made != null && made.isMigrated()) {
Set<String> oldPorts = portKeys(podExternalPortRepository.findByRequestRequestId(requestId));
req.assignPodInfo(made.podName(), made.node());
if (made.image() != null) {
// 새 Pod는 이미 이 이미지로 떠 있다. 이미지가 그 사이 지워졌어도 반영은 이어 가고 알린다.
imageUnmatched[0] = !applyImage(req, made.image());
}
podExternalPortRepository.deleteByRequestRequestId(requestId);
if (made.ports() != null) {
for (CreatePodResponseDTO.PortInfo port : made.ports()) {
Expand Down Expand Up @@ -335,11 +366,30 @@ public void completeMigrationJob(Long requestId, JobResultResponseDTO.Result mad
if (portsChangedRequest[0] != null) {
notifyPortsChanged(portsChangedRequest[0]);
}
if (imageUnmatched[0]) {
if (alertDeduplicator.firstOccurrence("migration-image-unmatched:" + requestId + ":" + made.image())) {
alert(null, "notification.admin.migration.image-unmatched", requestId, made.image());
}
}
} else {
log.info("Pod 마이그레이션 건너뜀: requestId={}, reason={}", requestId, made == null ? null : made.reason());
}
}

/** name:version을 마지막 콜론에서 갈라 신청의 이미지를 바꾼다(이름에 레지스트리 포트의 콜론이 올 수 있다). 못 찾으면 false. */
private boolean applyImage(Request req, String ref) {
int colon = ref.lastIndexOf(':');
if (colon <= 0 || colon == ref.length() - 1) {
return false;
}
return containerImageRepository.findByImageNameAndImageVersion(ref.substring(0, colon), ref.substring(colon + 1))
.map(image -> {
req.changeContainerImage(image);
return true;
})
.orElse(false);
}

/**
* 작업이 실패로 끝났다. 제어기가 새 Pod를 정리했고 기존 Pod는 그대로이므로 FULFILLED로 되돌리고 처리 기록(noti)으로
* 남긴다. 변경분을 굽느라 멈춘 기존 컨테이너를 다시 풀지 못한 실패는 "기존 컨테이너 유지"가 사실이 아니므로 문구만 달리한다.
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ notification.admin.approval.apply-failed=[승인 확인 필요] 생성 작업
notification.admin.approval.revert-failed=[보상 트랜잭션 실패] 요청 상태 복구 실패 - 수동 확인 필요: server={0}, requestId={1}
notification.admin.migration.register-unconfirmed=[마이그레이션 확인 필요] 작업 등록 결과를 확인하지 못해 MIGRATING으로 두었습니다: requestId={0}, error={1}
notification.admin.migration.result-missing=[마이그레이션 확인 필요] 작업은 성공했으나 결과 정보를 받지 못해 신청에 반영하지 못했습니다: requestId={0}
notification.admin.migration.image-unmatched=[마이그레이션 확인 필요] 이미지를 바꿔 다시 만들었지만 그 이미지를 DB에서 찾지 못해 신청의 이미지는 그대로입니다: requestId={0}, image={1}
notification.admin.migration.apply-failed=[마이그레이션] 결과 DB 반영 실패 - Pod/포트 상태 수동 확인 필요: requestId={0}
notification.admin.migration.old-pod-cleanup-failed=[마이그레이션] 새 Pod는 정상 반영됐지만 기존 Pod 정리 실패 - 수동 확인 필요: requestId={0}, oldPod={1}, oldNode={2}
notification.admin.migration.job-failed=[마이그레이션 실패] 기존 컨테이너를 유지합니다: requestId={0}, error={1}
Expand Down
Loading
Loading