개발을 하다보면 데이터베이스, 파일 저장소 등 인프라를 필연적으로 사용하게 된다. 하지만 로컬 환경, 테스트 환경에서도 실제 구동되고 있는 머신을 사용하는 것은 불필요한 자원들을 소모할 수 있다. 통합 테스트를 격리된 환경에서 진행할 수 없다는 단점도 있다. 이를 위해 나온 솔루션이 바로 LocalStack 이다!
aws의 서비스인 Lambda, S3, DynamoDB 등등 다양한 서비스를 로컬환경에서 구동할 수 있다. RDS, IAM 등 유료버전에서만 제공하는 기능, 서비스들도 있다. 로컬스택이 동작하는 방식이나 테라폼, 스프링 클라우드와 연동 등 더 다양한 설정이 궁금하다면 공식문서를 참조하자. 역시 공식 문서를 보는 것이 제일 정확하고 깔끔한 것 같다.
환경설정
다른 블로그를 보면 보통 localstack을 테스트 환경에서 사용하기 위해 testcontainer와 함께 쓴다. 하지만 나는 테스트 환경보단 로컬에서 직접 스프링을 구동하면서 실행시키고 싶어서 사용하지 않았다. 물론 테스트 코드를 작성할 땐 사용할 것이다.
aws cli 다운로드
$ curl "<https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip>" -o "awscliv2.zip"
$ unzip awscliv2.zip
$ sudo ./aws/install
$ ./aws/install -i /usr/local/aws-cli -b /usr/local/bin
$ aws --version
cli로 localstack을 관리하기 위해 다운받는다. (코드 상에서만 사용할 계획이라면 다운받지 않아도 되지만 이래저래 쓸 일이 많다.)
Docker Compose를 통해 LocalStack 실행
localstack:
container_name: aws-infra
image: localstack/localstack
ports:
- "4566:4566"
- "4572:4572"
environment:
- SERVICES=s3
- DEBUG=1
- DATA_DIR=/tmp/localstack/data
- DOCKER_SOCK=unix:///var/run/docker.sock
volumes:
- "./localstack:/tmp/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
docker-compose.yml에 localstack을 추가하자.
localstack의 리소스 관리는 4566포트를 통해서하므로 열어두고, 사용할 서비스 포트인 s3의 4572 포트를 열어두었다.
DATA_DIR은 localstack의 persistence mechanism을 위한 디렉토리인데, 이 디렉토리에 우리가 호출한 api call들을 저장하여 localstack을 다시 구동하여도 같은 상태로 구동되도록 한다. 요것 또한 유료버전(pro)와 무료버전(community)의 메커니즘이 다르다. 참고
DEBUG는 로깅 레벨등을 설정하기 위한 값이고 DOCKER_SOCK은 도커 소켓을 정의해준다. 구글링 하다보면 DOCKER_HOST로 정의되어 있는 곳도 있던데, 설정 필드가 바뀌었나보다. 공식 문서엔 DOCKER_SOCK으로 정의되어 있다.
S3 Bucket 만들기
$ export AWS_ACCESS_KEY_ID=foobar
$ export AWS_SECRET_ACCESS_KEY=foobar
$ aws --endpoint-url=http://localhost:4566 s3 mb s3://file-bucket
make_bucket: file-bucket
파일을 저장할 S3 Bucket을 만들어 준다. access key와 secret key는 localstack용이니 아무 문자열이나 넣어줘도 된다!
Bucket은 S3에서 디렉토리와 같은 개념이다. 버킷 단위로 지역, 권한 등을 설정할 수 있다.
file-bucket이라는 이름의 Bucket을 만들어주었다.
awslocal을 사용하면 endpoint를 수동으로 설정하지 않아도 되니 참고하면 좋을 것 같다.
/docker-entrypoint-initaws.d 디렉토리를 이용하면 쉘스크립트로 localstack 구동 시 수행할 명령어들을 저장할 수 있다. 참고
Gradle 의존성 추가
ext{
awsSdkVersion = "1.11.1000";
localStackVersion = "0.2.20";
}
dependencies {
implementation platform("com.amazonaws:aws-java-sdk-bom:${awsSdkVersion}")
implementation "org.springframework.boot:spring-boot-starter-web"
implementation("com.amazonaws:aws-java-sdk-s3")
implementation("cloud.localstack:localstack-utils:${localStackVersion}")
implementation("javax.xml.bind:jaxb-api")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
}
aws sdk를 사용하기 위해 sdk bom, sdk s3를 추가해주었다.
그리고 해당 모듈들에서 javax.xml.bind 관련 의존성이 해제되었는지 경고문이 발생해서 추가적으로 넣어주었다.
S3 Bean Override
spring:
main:
allow-bean-definition-overriding: true
이제 aws sdk에 정의된 s3Client bean을 재정의해야 하는데, bean-definition-overriding 옵션이 꺼져있으면 에러가 난다.
application.yml에 설정을 추가해주자.
@Configuration
public class AwsConfig {
public final String AWS_REGION = Regions.AP_NORTHEAST_2.getName();
public final String AWS_ENDPOINT = "http://127.0.0.1:4566";
public final String LOCAL_STACK_ACCESS_KEY = "foobar";
public final String LOCAL_STACK_SECRET_KEY = "foobar";
@Bean
public AmazonS3 amazonS3() {
AwsClientBuilder.EndpointConfiguration endpoint =
new AwsClientBuilder.EndpointConfiguration(AWS_ENDPOINT, AWS_REGION);
BasicAWSCredentials credentials
= new BasicAWSCredentials(LOCAL_STACK_ACCESS_KEY, LOCAL_STACK_SECRET_KEY);
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(endpoint)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.build();
}
}
s3 client가 amazon 서비스가 아닌 localstack을 바라보도록 endpoint를 설정해준다. (docker-compose 의 port)
access key와 secret key는 localstack 용이니 임시 문자열을 넣어두었는데, 실제 서비스에선 꼭꼭 @Value를 활용해서 코드레벨에서 관리하지 않도록 주의해야 한다.
File Upload/Download API
Controller
@RequiredArgsConstructor
@RequestMapping("/api/v1/files")
@RestController
public class FileController {
private final FileService fileService;
@GetMapping("/download")
public ResponseEntity<byte[]> download(final @RequestParam String fileName) {
return ApiResponseGenerator.of(fileService.download(fileName), fileName);
}
@PostMapping("/upload")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) {
return ApiResponseGenerator.of(fileService.upload(file));
}
}
우선 파일을 download, upload 할 controller를 추가해준다.
Service
@RequiredArgsConstructor
@Service
public class FileService {
private final AmazonS3 s3Client;
private final String BUCKET = "file-bucket";
public byte[] download(final String fileKey) {
S3Object fullObject = s3Client.getObject(BUCKET, fileKey);
if (fullObject == null) {
throw new UncaughtException("File Does Not Exist");
}
try {
return IOUtils.toByteArray(fullObject.getObjectContent());
} catch (IOException e) {
throw new UncaughtException(e);
}
}
public String upload(final MultipartFile file) {
if (file.isEmpty()) {
throw new UncaughtException("File is Empty");
}
String key = UUID.randomUUID() + "_" + file.getOriginalFilename();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
try {
PutObjectRequest request = new PutObjectRequest(BUCKET, key, file.getInputStream(), metadata);
PutObjectResult result = s3Client.putObject(request);
return key;
} catch (IOException e) {
throw new UncaughtException(e);
}
}
}
s3를 통한 파일 접근은 이전에 정의한 s3Client의 putObject, getObject 메소드를 통해 가능하다.
여기서 key는 s3 bucket에서 파일마다 가지고 있는 하나의 식별자같은 개념이다.
같은 파일이름이 중복되지 않도록 uuid를 사용하여 랜덤값을 추가해주었다.
여기서 파일을 저장할 때, ObjectMetadata라는 객체가 있는데,
{
"userMetadata": {},
"httpExpiresDate": null,
"expirationTime": null,
"expirationTimeRuleId": null,
"ongoingRestore": null,
"restoreExpirationTime": null,
"bucketKeyEnabled": null,
"lastModified": "2022-05-21T09:51:10.000+00:00",
"contentLength": 0,
"contentEncoding": null,
"contentType": "text/html; charset=utf-8",
"cacheControl": null,
"contentLanguage": null,
"contentDisposition": null,
"serverSideEncryption": null,
"ssecustomerKeyMd5": null,
"objectLockLegalHoldStatus": null,
"sseawsKmsEncryptionContext": null,
"ssecustomerAlgorithm": null,
"replicationStatus": null,
"objectLockRetainUntilDate": null,
"requesterCharged": false,
"etag": "43e9cfbcf1664d25b182859d8b7eb8ab",
"instanceLength": 0,
"ssealgorithm": null,
"versionId": null,
"contentMD5": null,
"rawMetadata": {
"Access-Control-Allow-Headers": "authorization,cache-control,content-length,content-md5,content-type,etag,location,x-amz-acl,x-amz-content-sha256,x-amz-date,x-amz-request-id,x-amz-security-token,x-amz-tagging,x-amz-target,x-amz-user-agent,x-amz-version-id,x-amzn-requestid,x-localstack-target,amz-sdk-invocation-id,amz-sdk-request",
"Access-Control-Allow-Methods": "HEAD,GET,PUT,POST,DELETE,OPTIONS,PATCH",
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "etag,x-amz-version-id",
"Content-Length": 0,
"Content-Type": "text/html; charset=utf-8",
"date": "Sat, 21 May 2022 09:51:10 GMT",
"ETag": "43e9cfbcf1664d25b182859d8b7eb8ab",
"last-modified": "2022-05-21T09:51:10.000+00:00",
"Location": "<http://file-bucket.s3.localhost.localstack.cloud:4566/>",
"server": "hypercorn-h11",
"x-amzn-requestid": "SYLIK7XFPG9KJ0JZIOV3L97U6ICOQNZ526U9ADGVEFZ6UVRLMCP6"
},
"storageClass": null,
"archiveStatus": null,
"sseawsKmsKeyId": null,
"partCount": null,
"objectLockMode": null,
"contentRange": null
}
다음과 같은 값들을 저장할 수 있다. cache control이나 expiration time 같은 값들도 metadata로 저장이 가능하다.
File Download Response
public static ResponseEntity<byte[]> of(final byte[] file, final String filename) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(file.length)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(file);
}
ResponseEntity를 통해 content-type, content-disposition 등을 설정할 수 있다.
content-disposition의 filename 옵션을 사용하면 해당 filename으로 다운로드 받을 수 있도록 설정해준다.
Request/Response
Upload
postman을 통해 API에 파일 업로드 요청을 전송한 후, 정상적으로 응답이 온 것을 확인할 수 있다.
Download
응답받은 파일 이름으로 다운로드 또한 정상적으로 이루어진다!
코드는 깃허브에 저장해두었다.
cache control이나 파일 크기, s3 bucket 권한 등 좀 더 고려해야 할 사항들을 찾아봐야겠다.
참고
https://github.com/localstack
https://litaro.tistory.com/entry/Localstack%EC%9C%BC%EB%A1%9C-AWS-%EC%84%9C%EB%B9%84%EC%8A%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0
https://codetinkering.com/localstack-s3-lambda-example-docker/
https://medium.com/@dudwls96/localstack-%ED%99%9C%EC%9A%A9%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-9b81ec51749c
https://techblog.woowahan.com/2638/
'Devops > CI' 카테고리의 다른 글
[Github Action] gradle을 이용한 java CI 구성하기 (0) | 2022.03.22 |
---|