이전에 간단하게 ceph 클러스터를 구성했으니 spring 서버를 통해 파일을 업로드해 보자.
build.gradle
implementation platform('software.amazon.awssdk:bom:2.20.56')
implementation 'software.amazon.awssdk:s3' // 필요한 모듈만 추가
awssdk를 사용했다. s3 API를 사용하여 RGW에 요청을 보낼 것이기 때문에 s3도 추가했다.
RgwConfiguration
package com.woowacamp.storage.global.config;
import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
@Configuration
public class RgwConfiguration {
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.credentials.endpoint}")
private String endpoint;
@Bean
public S3Client s3Client() {
return S3Client.builder()
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.region(Region.AP_NORTHEAST_2)
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
@Bean
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.region(Region.AP_NORTHEAST_2)
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
}
파일에 직접 접근하여 삭제나 정보를 얻어오기 위한 S3Client Bean과 Presigned URL을 생성할 S3Presigner Bean을 등록했다.
이때 serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) 옵션은 bucket-name을 경로 형식으로 만드는 옵션이다. localhost:8080/bucket-name 이런 형식.
PresignedUrlService
package com.woowacamp.storage.domain.file.service;
import java.net.URL;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectResponse;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
@Service
@RequiredArgsConstructor
public class PresignedUrlService {
private final S3Client s3Client;
private final S3Presigner s3Presigner;
@Value("${cloud.aws.credentials.bucketName}")
private String bucketName;
@Value("${cloud.aws.credentials.duration}")
private int duration;
private PutObjectRequest getPutObjectRequest(String objectKey) {
return PutObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
}
public URL getPresignedUrl(String objectKey) {
// 객체 업로드를 위한 객체 정보 설정
PutObjectRequest objectRequest = getPutObjectRequest(objectKey);
// presigned url 설정
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(duration))
.putObjectRequest(objectRequest)
.build();
// presigned url 생성 및 url 반환
return s3Presigner.presignPutObject(presignRequest).url();
}
private GetObjectRequest getGetObjectRequest(String objectKey) {
return GetObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
}
public URL getDownloadUrl(String objectKey) {
// 파일이 존재하는지 먼저 확인을 진행
getFileMetadata(objectKey);
GetObjectRequest objectRequest = getGetObjectRequest(objectKey);
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(duration))
.getObjectRequest(objectRequest)
.build();
return s3Presigner.presignGetObject(presignRequest).url();
}
private HeadObjectRequest getHeadObjectRequest(String objectKey) {
return HeadObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
}
public HeadObjectResponse getFileMetadata(String objectKey) {
HeadObjectRequest headObjectRequest = getHeadObjectRequest(objectKey);
return s3Client.headObject(headObjectRequest);
}
private DeleteObjectRequest getDeleteObjectRequest(String objectKey) {
return DeleteObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
}
public DeleteObjectResponse deleteFile(String objectKey) {
DeleteObjectRequest deleteObjectRequest = getDeleteObjectRequest(objectKey);
return s3Client.deleteObject(deleteObjectRequest);
}
}
업로드와 다운로드 시에 사용할 Presigned URL을 생성하는 메서드와 파일의 메타데이터를 확인하고 지우는 메서드까지 전부 만들었다. 이 클래스를 통해 파일 처리를 하게 된다.
S3FileService
package com.woowacamp.storage.domain.file.service;
import java.net.URL;
import java.util.Objects;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.woowacamp.storage.domain.file.dto.request.FileUploadRequestDto;
import com.woowacamp.storage.domain.file.dto.response.FileUploadResponseDto;
import com.woowacamp.storage.domain.file.entity.FileMetadata;
import com.woowacamp.storage.domain.file.entity.FileMetadataFactory;
import com.woowacamp.storage.domain.file.repository.FileMetadataJpaRepository;
import com.woowacamp.storage.domain.folder.entity.FolderMetadata;
import com.woowacamp.storage.domain.folder.repository.FolderMetadataJpaRepository;
import com.woowacamp.storage.domain.folder.service.MetadataService;
import com.woowacamp.storage.domain.folder.service.RedisLockService;
import com.woowacamp.storage.global.constant.UploadStatus;
import com.woowacamp.storage.global.error.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import static com.woowacamp.storage.global.error.ErrorCode.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class S3FileService {
private final FileMetadataJpaRepository fileMetadataJpaRepository;
private final FolderMetadataJpaRepository folderMetadataJpaRepository;
private final RedisLockService redisLockService;
private final PresignedUrlService presignedUrlService;
private final MetadataService metadataService;
private final ValidationService validationService;
public FileUploadResponseDto createInitialMetadata(FileUploadRequestDto fileUploadRequestDto) {
String lockName = fileUploadRequestDto.parentFolderId() + "/" + fileUploadRequestDto.fileName();
return redisLockService.<FileUploadResponseDto>handleUserRequest(lockName, () ->
createFileMetadata(fileUploadRequestDto)
, FILE_NAME_DUPLICATE.baseException());
}
@Transactional
protected FileUploadResponseDto createFileMetadata(FileUploadRequestDto fileUploadRequestDto) {
FolderMetadata parentFolder = folderMetadataJpaRepository.findById(fileUploadRequestDto.parentFolderId())
.orElseThrow(FOLDER_NOT_FOUND::baseException);
// 파일 이름 검증
validationService.validateFile(fileUploadRequestDto);
validationService.validateFileSize(fileUploadRequestDto.fileSize(), parentFolder.getId());
// 1차 메타데이터 초기화
String uuidFileName = validationService.getUuidFileName();
String objectKey = uuidFileName;
if (fileUploadRequestDto.fileExtension() != null) {
objectKey += "." + fileUploadRequestDto.fileExtension();
}
FileMetadata fileMetadata = FileMetadataFactory.buildInitialMetadata(parentFolder, fileUploadRequestDto,
objectKey);
fileMetadataJpaRepository.save(fileMetadata);
metadataService.calculateSize(fileMetadata.getParentFolderId());
URL presignedUrl = presignedUrlService.getPresignedUrl(objectKey);
return new FileUploadResponseDto(fileMetadata.getId(), objectKey, presignedUrl);
}
...
}
파일 업로드가 시작되는 메서드는 createInitialMetadata이다. 해당 메서드는 redisLockService의 handleUserRequest 메서드를 호출하는데 해당 메서드는 Redis 분산락을 걸고 비즈니스 로직을 처리하는 메서드이다. 중복된 파일 이름이 업로드되는 것을 방지하기 위해 사용했다. 이때 주의할 점은 반드시 락을 걸고 트랜잭션을 시작하고 마무리까지 한 후에 락을 다시 해제해야 한다는 점이다. 그래서 실제 DB에 접근하여 저장하는 메서드는 createFileMetadata로 분리하고 @Transactional을 붙여주었다.
createFileMetadata 메서드는 validationService 클래스에서 제공하는 파일 검증 과정을 거친다. validateFile, validateFileSize는 각각 파일 이름이 적합한지, 이미 존재하지 않는지, 업로드가 가능한 사이즈인지 검증을 진행한다. 이후 1차로 메타데이터를 저장하고 calculateSize()를 호출하여 상위 폴더들에 대한 파일 사이즈를 재계산하도록 한다. 이후 presignedUrlService의 getPresignedUrl 메서드를 호출하여 Presigned URL을 생성하고 이를 응답 데이터에 포함시킨다.
public void createComplete(long fileId, long userId, String objectKey) {
FileMetadata fileMetadata = fileMetadataJpaRepository.findById(fileId)
.orElseThrow(FILE_NOT_FOUND::baseException);
// 자신이 생성한 파일이 아니면 완료 요청을 보낼 수 없다.
if (fileMetadata.getCreatorId() != userId) {
throw WRONG_PERMISSION_TYPE.baseException();
}
if (!Objects.equals(fileMetadata.getUuidFileName(), objectKey)) {
throw WRONG_OBJECT_KEY.baseException();
}
HeadObjectResponse rgwFileMetadata = presignedUrlService.getFileMetadata(fileMetadata.getUuidFileName());
// 파일 사이즈가 다르면 기존 요청과 다른 파일을 업로드 한 것으로 간주하고 상태를 실패로 변경
if (rgwFileMetadata.contentLength().longValue() != fileMetadata.getFileSize().longValue()) {
fileMetadata.updateFailUploadStatus();
fileMetadataJpaRepository.save(fileMetadata);
throw INVALID_FILE_SIZE.baseException();
}
fileMetadata.updateFinishUploadStatus();
fileMetadataJpaRepository.save(fileMetadata);
}
위 과정에서의 특이한 점은 1차로 메타데이터에 초기화를 한다는 점이다. 2차로 쓰는 과정은 createComplte 메서드에서 진행한다. 다만, createFileMetadata에서 호출하는 것이 아닌, 사용자가 Presigned URL로 파일을 업로드 후에 완료되었다는 요청을 보내야 한다.
이렇게 설계한 이유는 Presigned URL은 단순히 업로드를 위한 링크를 주었을 뿐이지, 다른 파일을 업로드해도 이를 알 수 없기 때문이다(실제로 해보면 아무 파일이나 올릴 수 있다). 그래서 사용자가 업로드 완료 요청을 보내면, 그 요청을 통해서 1차 메타데이터와 실제 저장한 파일을 비교하여 서로 일치한 지 확인을 해줘야 한다. 만약 이렇게 하지 않으면, 1차 메타데이터 초기화에서 작은 용량으로 속여서 보내고 실제 파일은 용량이 큰 파일을 업로드하여 용량 제한 없이 서비스를 이용할 수 있는 문제가 발생하게 된다. 그래서 createComplete 메서드는 이러한 문제를 방지하기 위해 2차 검증을 완료한 후에 최종적으로 업로드를 마무리하는 작업을 진행한다.
비교 과정이 presignedUrlService.getFileMetadata() 메서드로 진행한다. 이전에 PresignedUrlService 클래스에서 저장한 객체를 조회하는 메서드가 있었는데 저장한 객체 메타데이터를 조회하여 크기를 비교한다. 이때 동일한 크기의 파일이라면 메타데이터의 업로드 상태를 성공으로 바꾸고, 다르다면 실패로 바꾼다. 업로드에 실패한 파일은 별도의 스케줄러가 처리하도록 했다.
public URL getFileUrl(long fileId) {
FileMetadata fileMetadata = fileMetadataJpaRepository.findById(fileId)
.orElseThrow(FILE_NOT_FOUND::baseException);
if (!Objects.equals(fileMetadata.getUploadStatus(), UploadStatus.SUCCESS)) {
throw ErrorCode.FILE_NOT_FOUND.baseException();
}
return presignedUrlService.getDownloadUrl(fileMetadata.getUuidFileName());
}
마지막으로 파일 다운로드를 위해 URL을 요청하는 메서드이다. 파일 메타데이터를 통해 object key를 가져와서 이를 조회하여 리턴한다.
UploadFailManager
package com.woowacamp.storage.global.background;
import java.time.LocalDateTime;
import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.woowacamp.storage.domain.file.entity.FileMetadata;
import com.woowacamp.storage.domain.file.repository.FileMetadataRepository;
import com.woowacamp.storage.domain.file.service.PresignedUrlService;
import com.woowacamp.storage.domain.folder.utils.QueryExecuteTemplate;
import com.woowacamp.storage.global.constant.CommonConstant;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class UploadFailManager {
private final FileMetadataRepository fileMetadataRepository;
private final BackgroundJob backgroundJob;
private final PresignedUrlService presignedUrlService;
private final Executor deleteRgwFileThreadPoolExecutor;
private final static int DELETE_DELAY = 1000 * 60;
@Value("${constant.batchSize}")
private int pageSize;
@Scheduled(fixedDelay = DELETE_DELAY)
private void uploadFailureFileScheduler() {
QueryExecuteTemplate.<FileMetadata>selectFilesAndExecuteWithCursor(pageSize,
findFile -> fileMetadataRepository.findUploadFailureFileByLastId(
findFile == null ? null : findFile.getId(), pageSize),
findFileList -> findFileList.forEach(this::deleteRgwFile)
);
}
@Scheduled(fixedDelay = DELETE_DELAY)
private void tooMuchPendingFileScheduler() {
LocalDateTime timeLimit = LocalDateTime.now().minusMinutes(CommonConstant.maxPendingDuration);
QueryExecuteTemplate.<FileMetadata>selectFilesAndExecuteWithCursor(pageSize,
findFile -> fileMetadataRepository.findUploadPendingFileByLastId(
findFile == null ? null : findFile.getId(), pageSize, timeLimit),
findFileList -> findFileList.forEach(this::deleteRgwFile)
);
}
private void deleteRgwFile(FileMetadata fileMetadata) {
deleteRgwFileThreadPoolExecutor.execute(() -> {
presignedUrlService.deleteFile(fileMetadata.getUuidFileName());
backgroundJob.addForDeleteFile(fileMetadata);
});
}
}
스케줄러가 2개가 존재한다. 하나는 업로드가 실패한 경우 이를 삭제하는 메서드이고, 다른 하나는 pending 상태가 오래 지속되면 이는 업로드에 실패한 것으로 간주하고 파일을 삭제하는 메서드이다. 각각 상태에 해당하는 데이터를 커서 기반 페이징으로 조회하여 별도의 스케줄러를 통해 RGW에 객체 삭제 요청을 보내고 삭제를 위한 대기 큐에 삭제 요청을 보낸다.
public static <T> void selectFilesAndExecuteWithCursor(int limit, Function<T, List<T>> selectFunction,
Consumer<List<T>> resultConsumer) {
List<T> selectList = null;
do {
selectList = selectFunction.apply(selectList != null ? selectList.get(selectList.size() - 1) : null);
if (!selectList.isEmpty()) {
resultConsumer.accept(selectList);
}
} while (selectList.size() >= limit);
}
참고로 페이징 처리는 위와 같이 처리했다.
이렇게 생성한 비즈니스 로직을 컨트롤러에 연결하고 파일이 업로드되는지 확인을 해보자.
업로드 테스트
우선 현재 대시보드를 확인해 보면 모두 정상인 것을 확인할 수 있다.


OSD의 사용 용량이 매우 적은데 파일을 별로 안 올리기도 했고 하나의 파일아 하나의 OSD에 온전히 저장되는 것은 아니라서 그렇다.
단일 파일 업로드

요청을 보내면 1차 메타데이터로 생성한 파일의 id와 객체 저장소에 저장할 객체의 objectKey, Presigned URL이 응답으로 온다.
요청 body에는 여러 데이터가 있는데, 현재 프론트를 구현하지 않은 상태라서 우선은 저렇게 했다. 저기서 보내는 파일 사이즈가 업로드 완료 후의 파일 사이즈와 일치하지 않으면 예외를 발생시킨다.

이제 해당 링크를 통해 파일을 업로드하자. 이때 HTTP 메서드는 반드시 PUT이어야 한다. 그리고 보내는 파일을 binary로 보내야 한다. 이렇게 파일을 전송하여 성공하면 200 응답이 올 것이고, 이를 확인했다면 2차로 응답 완료가 되었다는 요청을 서버에 보내야 한다.

이렇게 첫 업로드 요청에서 받은 파일의 id, objectKey와 함께 요청을 보내면 2차로 검증 후 파일 업로드 과정이 마무리된다.

파일 id로 파일의 다운로드 URL을 받아서 확인을 해보면

내가 업로드 한 이미지를 확인할 수 있다!
Ceph의 상태 변화


이전보다 사용량이 늘어난 것을 볼 수 있는데, 파일 사이즈만큼 OSD 사용량이 증가하지 않았다. 이는 여러 PG로 쪼개져서 각 PG에 매핑된 OSD에 저장되기 때문이다.
그래서 파일을 읽을 때도 흩어진 PG를 모아서 저장하는 것으로 알고 있다.
OSD를 하나씩 다운시키면 어떻게 될까?
이전에 pool size를 2로 설정했고, 이는 하나의 파일을 복제하여 두 곳에 저장하는 것이라고 했다. 그렇다면 3개의 OSD 서버 중 2개만 운영되고 있을 때 내가 업로드 한 파일을 문제없이 다운로드할 수 있어야 한다.
OSD1부터 죽여보자

프로세스를 직접 죽이는 방법으로 OSD 서버를 다운시켰다.


1만 다운된 상태일 때는 문제없이 다운로드 URL을 받을 수 있었다.
2도 함께 죽여보았다.


타임아웃을 따로 설정하지 않아서 무한 대기를 하게 되었다. OSD1, OSD2가 죽었을 때는 정상적으로 파일을 받아볼 수 없었다.
OSD2를 살리고 OSD3을 죽여보자


마찬가지로 되지 않는다.
OSD2 만 죽여보자


OSD2 만 죽였을 때는 문제없이 잘 된다.
OSD2, OSD3을 죽여보자


여전히 2대를 죽였을 때는 동작하지 않는다.
무한 대기 중에 OSD2를 살려보았다.


응답이 왔다.
이 결과로 유추할 수 있는 것은 pool size를 2로 설정해서 3대 중 하나의 OSD만 죽었을 땐 언제든 내가 저장한 파일을 볼 수 있다는 점이다. 또한 OSD 클러스터에 속한 노드가 과반수가 되지 않으면 정상작동을 하지 않는다. 이건 찾아보니 ceph 클러스터는 과반수가 활성 상태가 되어야 클라이언트 요청을 처리할 수 있다고 한다. 1대로도 처리할 수 있는 옵션이 있는데, 이는 안정성을 포기하고 설정하는 것이다. 내 생각에도 OSD 서버가 죽으면 빠르게 개발자에게 알림을 주어서 복구를 하는 것이 맞는 것 같다. 그리고 1대만 사용해서 요청을 처리하게 되면 결국 하나의 서버에만 파일을 저장하게 되므로, 나중에 서버가 복구가 되었을 때 그러한 파일만 찾아서 복제하는 것도 쉬운 일은 아닐 것 같다.
과반수가 되어야 하는 이유가 뭘까?
정확히는 Quorum(정족수)라고 한다. 정족수는 어떠한 의사 결정을 내리기 위한 최소 노드의 수를 의미한다.
이게 왜 필요한가 생각을 해보자. 우선 클러스터 구성이 아닌, Master - slave 구조를 가지고 있다고 가정하자. 사실 SPOF 문제를 해결하기 위해서는 이 방법도 사용할 수 있을 것이다. 이 경우 master가 다운되었을 때, slave가 이를 인지하고 master 역할을 대체할 때까지의 시간이 필요하다. 또한 master가 실시간으로 처리하던 데이터는 slave가 인지할 방법이 없을 것이다.
그렇다면 OSD 노드가 3개인 경우는 무엇이 다를까? 보통 Quorum의 수는 보통 (N/2)+1로 결정한다. 즉, 노드가 3개인 경우 정족수는 2이다. OSD는 요청이 들어오면 해당 요청을 처리하기 위해 다른 노드들의 동의를 구할 것이고, 이때 정족수를 만족하면 해당 요청을 처리하고 다른 OSD 노드들도 이러한 과정을 알게 될 것이다.
MON 노드도 마찬가지이다. 어떠한 요청이 오면, cluster map, pg map 등의 정보를 동기화하기 위해 다른 MON 노드에게 요청을 보내서 정족수를 만족하면 해당 요청을 처리하고, 정보를 업데이트를 하고, 이러한 변경 사항을 동기화할 것이다.
결국 정족수를 만족하는 방법을 사용하는 이유는 실시간으로 계속 요청을 처리할 수 있고, 노드들 간 일관성을 최대한 보장하기 위함이다. 그래서 2대의 OSD 노드가 다운된 경우는 정족수를 만족하지 못해 일관성이 크게 깨질 수 있으니 요청을 처리하지 않는 것이다. 그리고 정족수라는 개념이 나왔다는 것은 클러스터 구성을 했다는 것이니 부하 분산이 되어 Master - slave 구조보다 안정적으로 트래픽에 대응이 가능할 것이다.
단점도 있다. 3개의 노드가 있는 상황에서 각 노드에 각각의 요청이 왔다고 가정하자. 그럼 각 노드는 다른 노드들에게 요청을 보내서 정족수를 만족하는지 확인할 것이다. 그렇다면 하나의 요청에 대해 2개의 추가 요청을 주고받기 때문에 추가 6개의 요청을 처리하게 된다. 이는 네트워크 사용량도 증가하고, 정족수 확인 과정을 위한 시간이 발생하니 상대적으로 느려질 수 있다.
그리고 클러스터를 구성했다고 항상 문제가 없는 것은 아니다. 장애가 발생한 노드를 복구할 때 최신 데이터를 제대로 동기화하지 못하면 데이터가 누락되어 요청을 잘못 처리할 수도 있고, 모든 노드가 동시에 문제가 생길 수도 있다. 정확히 기억나지는 않지만, [데이터 중심 애플리케이션 설계]라는 책에서는 컴퓨터마다 시간 정보가 조금씩 달라서 최신 데이터를 가지고 있지 않음에도 동의를 얻는 문제가 발생할 수 있다는 내용이 있었던 것 같다.
결국 이러한 방법은 큰 문제가 발생하지 않도록 최소한의 안전장치들을 하는 것이라는 생각이 들었다. 그리고 실시간성이 중요하지 않고, 일관성도 크게 중요하지 않다면 master - slave 구조로 만들어서 비용을 절약하는 게 더 좋은 것 같다. 하지만 나처럼 사용자가 업로드한 데이터를 잃지 않는 것이 중요한 클라우드 스토리지의 경우 클러스터를 구성하는 게 더 낫다는 생각이 들었다.
알림 설정하기
위의 OSD를 down 하며 들었던 생각이 서버가 다운되는 상황이 발생할 수 있고, 이런 경우 빠르게 복구하기 위해 알림 설정이 필요함을 느꼈다.
ceph는 smtp로 메일을 보낼 수 있는 기능이 있어서 간단히 이를 사용해 보았다. 사용법은 간단했고, 공식 문서에 잘 나와 있다. https://docs.ceph.com/en/quincy/mgr/alerts/
ceph mgr module enable alerts
이메일을 사용할 수 있도록 활성화시킨다.
ceph config set mgr mgr/alerts/smtp_host *<smtp-server>*
ceph config set mgr mgr/alerts/smtp_destination *<email-address-to-send-to>*
ceph config set mgr mgr/alerts/smtp_sender *<from-email-address>*
ceph config set mgr mgr/alerts/smtp_ssl false # if not SSL
ceph config set mgr mgr/alerts/smtp_port *<port-number>* # if not 465
ssl 사용하지 않거나 포트를 변경하려면 위와 같은 명령어를 사용하면 된다.
smtp 서버를 인증하려면 사용자와 비밀번호를 설정해야 한다. smtp를 사용할 거니까 smtp 서버에서 사용할 인증 정보를 입력하면 된다. gmail은 앱 비밀번호 생성을 해서 해당 비밀번호를 넣고, 계정은 자신의 계정을 넣으면 해당 계정으로 메일을 전송한다.
참고로 gmail의 경우 16자리 비밀번호를 생성해서 줄텐데 띄어쓰기를 제외하고 비밀번호를 입력하면 된다.
ceph config set mgr mgr/alerts/smtp_user *<username>*
ceph config set mgr mgr/alerts/smtp_password *<password>*
제목도 변경하려면 아래와 같은 명령어를 입력한다.
ceph config set mgr mgr/alerts/smtp_from_name 'Ceph Cluster Foo'
기본적으로 모듈은 1분에 한 번씩 클러스터 상태를 확인하고 변경사항이 있으면 메시지를 보낸다. 빈도를 변경하려면 아래와 같은 명령어를 사용한다.
ceph config set mgr mgr/alerts/interval *<interval>* # e.g., "5m" for 5 minutes
알림을 바로 보내려면 아래와 같이 입력한다.
ceph alerts send

alerts를 보내니 위처럼 현재 상태를 메일로 보내는 것을 알 수 있다.
이제 OSD 하나를 다운시켜 보자


어떤 OSD가 다운되었는지, 그래서 PG는 어떻게 됐는지 그냥 전체를 알려준다. 흠 뭔가 형식을 변경할 수 있다면 간결하게 하는 것도 좋을 것 같다.


실시간으로 문제가 계속 업데이트되니까 새로운 메일이 또 왔다. 생각보다 민감하게 바로 반응하는 것은 좋은 것 같다. 대시보드에서 비교해 보니 대시보드에서 알려주는 내용을 그대로 전달해 주는 것으로 보인다. 추가로 상태가 변화하지 않으니 별도의 메일이 전송되지는 않았다.
다만 5분 간격으로 다운되었다는 메일이 전송되었다. 1분인 줄 알았는데 5분인가 보다.
정상적으로 복구를 하니 이후에는 아무 메일도 받지 못했다.
마지막으로 혹시 메일이 안 왔다면 smtp 설정이 켜져 있는지 확인을 해보자.
'Java > My-Storage 프로젝트' 카테고리의 다른 글
[My-Storage 개선하기] 로컬 환경에서 Docker로 Ceph 설치하기 (0) | 2025.01.04 |
---|---|
[My-Storage 개선하기] 데드락 해결, DB lock 사용 줄이기(2) - 테스트 (0) | 2024.12.19 |
[My-Storage 개선하기] 데드락 해결, DB lock 사용 줄이기(1) (0) | 2024.12.19 |
[우아한 테크 캠프 팀 프로젝트] 파일 이동 및 삭제 My-Storage(3) (0) | 2024.11.18 |
[우아한 테크 캠프 팀 프로젝트]동기 처리 vs 비동기 처리, 비동기 처리에서 발생한 OOM 문제 My-Storage(2) (0) | 2024.09.09 |