서버리스 사진첩 만들기
어플리케이션의 기능
- 이미지가 업로드되면, 원본과 별도로 썸네일을 생성하고, 이를 별도의 버킷에 저장해야 합니다.
- 썸네일 이미지는 가로 200px의 크기를 가집니다.
- 썸네일을 저장할 별도의 버킷은 람다 함수의 환경 설정으로 구성되어야 합니다.
- 썸네일 생성이 완료되면, 메일로 해당 썸네일 URL과 함께 전송이 되어야 합니다.
- Amazon SNS를 활용합니다.
- S3 이벤트가 SQS로 전송되게 만들고, SQS로부터 이벤트를 받아 람다가 실행하게 만들어봅시다.
- S3의 Pre-signed URL 기능을 이용하여, 업로드 전용 URL을 획득하고, 이를 통해 이미지를 S3 업로드할 수 있게 만들어봅시다.
아키텍쳐 구성
- 서버리스 애플리케이션을 구성할 것이므로, AWS 람다를 사용한다.
- 람다의 트리거 기능을 사용하여 s3에 이미지가 업로드되면 그것을 람다에서 처리하여 썸네일로 바꾸는 로직을 구현한다.
- 람다에서 바꾼 로직을 새로운 s3에 저장한다.
- s3 이벤트에 SQS도 연결해본다.
람다 개발환경 구축
서버리스 애플리케이션을 만들기 위해 SAM을 사용할 것이다.
sam init
// Quick Start Template -> Standalone function
만들어진 템플릿을 활용해 hello-from-lambda라는 간단한 람다 함수를 만든다.
AWS 콘솔을 이용하여 s3 트리거를 람다함수에 붙인다.
s3 트리거가 실행되었을때, 이벤트가 어떻게 오는지 보기 위해 다음과 같이 람다함수를 만든다.
exports.helloFromLambdaHandler = async (event, context) => {
console.log(JSON.stringify(event))
console.log(context)
return 'Hello from Lambda!';
}
이때 JSON.stringify()를 사용해 event를 직렬화하였는데, 이는 이벤트로그를 함축없이 그대로 보기 위함이다.
추가적인 내용은 MDN에 있다.
이벤트 로그 확인하기
{
"Records": [
{
"eventVersion": "2.1",
"eventSource": "aws:s3",
"awsRegion": "ap-northeast-2",
"eventTime": "2022-04-14T02:00:02.711Z",
"eventName": "ObjectCreated:Put",
"userIdentity": {
"principalId": "A13AH02OYMXZFP"
},
"requestParameters": {
"sourceIPAddress": "1.225.252.19"
},
"responseElements": {
"x-amz-request-id": "WK2QM7H17P1ZBATK",
"x-amz-id-2": "ogaO2JQLY7DXur0daWdtoXp41jkjVQ1ATtdmOXZW2q+aDcSJSswF4mnZ5LsM2znHHCd0sAX9OfAHMBWK1wEdAwLBgrHTq7pZ"
},
"s3": {
"s3SchemaVersion": "1.0",
"configurationId": "42155288-e8db-449e-8f61-ce397524eed1",
"bucket": {
"name": "image-source-mason",
"ownerIdentity": {
"principalId": "A13AH02OYMXZFP"
},
"arn": "arn:aws:s3:::image-source-mason"
},
"object": {
"key": "test.jpg",
"size": 15812,
"eTag": "994181f579bb1306071d5f846bedc87b",
"sequencer": "0062578022A9077886"
}
}
}
]
}
s3로 트리거된 이벤트 로그를 잘 본다. 이는 cloudwatch에서 확인할 수 있다.
s3에 이미지를 올리면 람다 함수에 이벤트가 위와 같이 오는 것을 알 수 있다.
여기에서 내가 필요한 버킷의 이름과 올린 사진 파일 이름을 뽑아 내야하는데 어떻게 해야할지 고민한다.
이벤트 로그에서 필요한 것 뽑아내기
exports.helloFromLambdaHandler = async (event, context) => {
const s3SourceBucket = event.Records[0].s3.bucket.name
const s3SourceKey = event.Records[0].s3.object.key
console.log(s3SourceBucket) //콘솔찍어서 지속적으로 원하는 값이 오는지 확인
console.log(s3SourceKey)
return 'hello from Lambda!';
}
모르면 콘솔찍어서 확인한다. 원하는 값을 찾아서 변수에 넣어주어야 한다.
sam build
sam local invoke -e ./s3-event-log.json
코드 수정 후 sam local invoke 명령을 통해 충분히 로컬에서 테스트해야한다.
sam local의 문법에 관해서는 공식문서에 있다.
람다함수 코드 입력하기
// 원본 버킷으로부터 파일 읽기
const s3Object = await s3.getObject({
Bucket: 원본_버킷_이름,
Key: 업로드한_파일명
}).promise()
// 이미지 리사이즈, sharp 라이브러리가 필요합니다.
const data = await sharp(s3Object.Body)
.resize(200)
.jpeg({ mozjpeg: true })
.toBuffer()
// 대상 버킷으로 파일 쓰기
const result = await s3.putObject({
Bucket: 대상_버킷_이름,
Key: 업로드한_파일명과_동일,
ContentType: 'image/jpeg',
Body: data,
ACL: 'public-read'
}).promise()
원본_버킷_이름에 s3SourceBucket,
업로드한_파일명에 s3SourceKey,
대상_버킷_이름은 이벤트 로그에서 받아올 수 없었기에 하드코딩한다.
이렇게 해서 돌려보면 실행이 안된다. 에러를 보며 차근차근 해결해보자.
❯ sam local invoke -e ./s3-event-log.json
Invoking src/handlers/hello-from-lambda.helloFromLambdaHandler (nodejs14.x)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-nodejs14.x:rapid-1.46.0-x86_64.
Mounting /Users/masonjar/cs-sprint/image-resizer/.aws-sam/build/helloFromLambdaFunction as /var/task:ro,delegated inside runtime container
START RequestId: 531bafcc-da53-4c0f-a970-fa151fbc5f2e Version: $LATEST
2022-04-14T03:29:22.660Z 531bafcc-da53-4c0f-a970-fa151fbc5f2e INFO test.jpg
2022-04-14T03:29:22.688Z 531bafcc-da53-4c0f-a970-fa151fbc5f2e ERROR Invoke Error {"errorType":"ReferenceError","errorMessage":"s3 is not defined","stack":["ReferenceError: s3 is not defined"," at Runtime.exports.helloFromLambdaHandler [as handler] (/var/task/src/handlers/hello-from-lambda.js:10:22)"," at Runtime.handleOnceNonStreaming (/var/runtime/Runtime.js:73:25)"]}
END RequestId: 531bafcc-da53-4c0f-a970-fa151fbc5f2e
REPORT RequestId: 531bafcc-da53-4c0f-a970-fa151fbc5f2e Init Duration: 1.21 ms Duration: 1062.50 ms Billed Duration: 1063 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"errorType":"ReferenceError","errorMessage":"s3 is not defined","trace":["ReferenceError: s3 is not defined"," at Runtime.exports.helloFromLambdaHandler [as handler] (/var/task/src/handlers/hello-from-lambda.js:10:22)"," at Runtime.handleOnceNonStreaming (/var/runtime/Runtime.js:73:25)"]}%
s3가 정의되지 않았단다.
const AWS = require('aws-sdk')
AWS.config.update({region: 'ap-northeast-2'});
const s3 = new AWS.S3({apiVersion: '2006-03-01'})
위와 같이 코드를 작성한다. 공식문서를 보면 S3 버킷 생성 및 사용에 관해 자세히 나온다.
현재 나는 m1 mac os를 사용중이므로 sdk v2를 사용한다. 최근에 sdk v3도 나왔다고 한다.
sdk는 Software Development Kit으로 소프트웨어 개발에 필요한 도구라고 할 수 있다. 즉 aws-sdk는 아마존 서비스 소프트웨어 개발에 필요한 도구들이라고 생각하면 된다.
s3를 잘 정의해주면 다른 에러가 뜬다.
❯ sam local invoke -e ./s3-event-log.json
Invoking src/handlers/hello-from-lambda.helloFromLambdaHandler (nodejs14.x)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-nodejs14.x:rapid-1.46.0-x86_64.
Mounting /Users/masonjar/cs-sprint/image-resizer/.aws-sam/build/helloFromLambdaFunction as /var/task:ro,delegated inside runtime container
START RequestId: c6094358-55ff-4a79-829c-aeb7b8452305 Version: $LATEST
2022-04-14T04:23:42.916Z c6094358-55ff-4a79-829c-aeb7b8452305 ERROR Invoke Error {"errorType":"ReferenceError","errorMessage":"sharp is not defined","stack":["ReferenceError: sharp is not defined"," at Runtime.exports.helloFromLambdaHandler [as handler] (/var/task/src/handlers/hello-from-lambda.js:20:18)"," at processTicksAndRejections (internal/process/task_queues.js:95:5)"]}
END RequestId: c6094358-55ff-4a79-829c-aeb7b8452305
REPORT RequestId: c6094358-55ff-4a79-829c-aeb7b8452305 Init Duration: 0.91 ms Duration: 4362.02 ms Billed Duration: 4363 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"errorType":"ReferenceError","errorMessage":"sharp is not defined","trace":["ReferenceError: sharp is not defined"," at Runtime.exports.helloFromLambdaHandler [as handler] (/var/task/src/handlers/hello-from-lambda.js:20:18)"," at processTicksAndRejections (internal/process/task_queues.js:95:5)"]}%
sharp이 정의되지 않았단다. sharp은 이미지를 잘라주는 라이브러리로 npm을 통해 설치할 수 있다.
npm install sharp
람다 함수에서 sharp를 사용할 수 있게 불러온다.
const sharp = require('sharp')
그러고 실행을 하면 또 다른 에러가 나온다.
❯ sam local invoke -e ./s3-event-log.json
Invoking src/handlers/hello-from-lambda.helloFromLambdaHandler (nodejs14.x)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-nodejs14.x:rapid-1.46.0-x86_64.
Mounting /Users/masonjar/cs-sprint/image-resizer/.aws-sam/build/helloFromLambdaFunction as /var/task:ro,delegated inside runtime container
START RequestId: e22cfe50-7b83-435a-b45f-3da014a4156c Version: $LATEST
2022-04-14T04:56:46.799Z e22cfe50-7b83-435a-b45f-3da014a4156c ERROR Invoke Error {"errorType":"AccessControlListNotSupported","errorMessage":"The bucket does not allow ACLs","code":"AccessControlListNotSupported","message":"The bucket does not allow ACLs","region":null,"time":"2022-04-14T04:56:46.783Z","requestId":"ZTMF090PZH7KG4NT","extendedRequestId":"LgVpNLHEMNgEq1Nf/DCAVDXrc3/Zqi++SlwgBTOvTAcUoJCKRtC2M2R4TGIZNq1AKlo2ZNi6XrM=","statusCode":400,"retryable":false,"retryDelay":1.2689592969733265,"stack":["AccessControlListNotSupported: The bucket does not allow ACLs"," at Request.extractError (/var/task/node_modules/aws-sdk/lib/services/s3.js:711:35)"," at Request.callListeners (/var/task/node_modules/aws-sdk/lib/sequential_executor.js:106:20)"," at Request.emit (/var/task/node_modules/aws-sdk/lib/sequential_executor.js:78:10)"," at Request.emit (/var/task/node_modules/aws-sdk/lib/request.js:686:14)"," at Request.transition (/var/task/node_modules/aws-sdk/lib/request.js:22:10)"," at AcceptorStateMachine.runTo (/var/task/node_modules/aws-sdk/lib/state_machine.js:14:12)"," at /var/task/node_modules/aws-sdk/lib/state_machine.js:26:10"," at Request.<anonymous> (/var/task/node_modules/aws-sdk/lib/request.js:38:9)"," at Request.<anonymous> (/var/task/node_modules/aws-sdk/lib/request.js:688:12)"," at Request.callListeners (/var/task/node_modules/aws-sdk/lib/sequential_executor.js:116:18)"]}
END RequestId: e22cfe50-7b83-435a-b45f-3da014a4156c
REPORT RequestId: e22cfe50-7b83-435a-b45f-3da014a4156c Init Duration: 1.04 ms Duration: 6347.97 ms Billed Duration: 6348 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"errorType":"AccessControlListNotSupported","errorMessage":"The bucket does not allow ACLs","trace":["AccessControlListNotSupported: The bucket does not allow ACLs"," at Request.extractError (/var/task/node_modules/aws-sdk/lib/services/s3.js:711:35)"," at Request.callListeners (/var/task/node_modules/aws-sdk/lib/sequential_executor.js:106:20)"," at Request.emit (/var/task/node_modules/aws-sdk/lib/sequential_executor.js:78:10)"," at Request.emit (/var/task/node_modules/aws-sdk/lib/request.js:686:14)"," at Request.transition (/var/task/node_modules/aws-sdk/lib/request.js:22:10)"," at AcceptorStateMachine.runTo (/var/task/node_modules/aws-sdk/lib/state_machine.js:14:12)"," at /var/task/node_modules/aws-sdk/lib/state_machine.js:26:10"," at Request.<anonymous> (/var/task/node_modules/aws-sdk/lib/request.js:38:9)"," at Request.<anonymous> (/var/task/node_modules/aws-sdk/lib/request.js:688:12)"," at Request.callListeners (/var/task/node_modules/aws-sdk/lib/sequential_executor.js:116:18)"]}%
람다가 버킷에 대한 ACL이 없단다. AWS에서 권한 부여해주러 가보자.
sam을 이용해 람다를 만들때, IAM 생성할건지 물어봤다. 그것을 찾아서 s3에 대한 권한을 주고, s3에서도 ACL 권한을 열어주면 될 것이다.
드디어 에러없이 "hello from Lambda!"가 떴다! 그렇다면 배포하여 프로덕션 환경에서도 테스트해보자.
프로덕션에도 성공적으로 되었다면, s3에 jpg파일을 올리면 image-resized-mason에 용량이 작아진 jpg파일이 자동으로 올라갈 것이다. cloudwatch에서도 로그를 확인할 수 있다.
SNS에 연결하기
sns의 주제를 생성하고 나의 이메일로 구독 설정을 한다.
이메일에서 구독 설정 확인을 하면, 확인됨으로 표시된다.
이 sns를 s3에 연결할 것인데 그 전에, 액세스 정책을 수정한다.
{
"Version": "2012-10-17",
"Id": "example-ID",
"Statement": [
{
"Sid": "Example SNS topic policy",
"Effect": "Allow",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"SNS:Publish"
],
"Resource": "SNS-topic-ARN",
"Condition": {
"ArnLike": {
"aws:SourceArn": "arn:aws:s3:*:*:bucket-name"
},
"StringEquals": {
"aws:SourceAccount": "bucket-owner-account-id"
}
}
}
]
}
이렇게 하면 SNS가 s3 이벤트 생성에 관여할 수 있다.
생성된 썸네일이 저장되는 s3에 가서 속성, 이벤트 알림에 해당 SNS 서비스를 연결하면 끝!
근데 이미지의 url만을 전달해주고 싶은데, 아직 방법을 모르겠다.
이 방법은 이벤트를 이쁘게 바꿔주는 람다를 하나 더 만들면 될 것이다.
SQS를 트리거로 설정하기
위에서는 람다의 트리거를 바로 s3의 객체 생성이벤트로 했었는데, 마이크로 서비스의 fault tolerance를 높이기 위해 중간에 SQS라는 메시지 큐 서비스를 넣어보겠다.
간단하다. SQS를 생성하고 s3에서 이벤트로 SQS를 연결한다.
SQS에서 위에서 만든 람다함수를 트리거로 연결한다.
당연히 위와 같은 아키텍처에서 SQS는 s3에 대한 권한이 필요할 것이고, 람다는 SQS에 대한 권한이 필요할 것이다. 필요에 맞게 수정해준다.
연결이 다되었다면, s3에 객체를 올려보자.
분명 에러가 뜨고 무한으로 람다함수가 돌아가고 있는 것을 cloudwatch에서 확인할 수 있다.
무슨일인지 살펴보자.
2022-04-14T13:44:14.886Z 1011c8d6-fa33-5049-9df0-9204dd54b506 INFO {
"Records": [
{
"messageId": "ae51233e-d5b3-46e1-b577-f151f87d67dd",
"receiptHandle": "AQEBf7dF2krfHVyQWM8nxyk9xsAj49QTax2Cva8zUQ6pZ1hOvUJRdrJ4+1EdpA2VLFfYwikAXOyC9wtz0SCM6WPIn5Bh3ZE91kpFZwG3dCN4dMwynFsGPs1w1aeQajQMMBlCjLqUb2ZPz1SycOMTQa2mZUW/WSC5kGsZdLL/2X22O12q4yEcv7kdZlnAiE/tO2YufPngumLovIWJH5TCLypAvhplvsKGELZCN5Odf6c7zwvRvU7DGavk/emti+a2gxKpeuBrQgyFt9WQNO7o5LXqkilHn0SbPlIoeO25RPCPqE039hnC2R0NO9Z0VgJjLj1nt/CMhOAC5jidKqKzERJ3vva1dXWw/en4PSoufU+ICCRfo51p3+QCXItEm1iiLPTE2RtiQDYvO4UEjcv6vSKd1A==",
"body": "{\"Service\":\"Amazon S3\",\"Event\":\"s3:TestEvent\",\"Time\":\"2022-04-14T13:44:13.677Z\",\"Bucket\":\"image-source-mason\",\"RequestId\":\"P740MTADR2PCTM09\",\"HostId\":\"uDCR4f9DTGtdr+2Yw1DSucNSZR2YYohxuMg7FL6UTtMJ0Z6V9KxUSnYAcR1O8XhA524YlbUMLcs=\"}",
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1649943853707",
"SenderId": "AIDAJCPXNKW2CHJMM46DC",
"ApproximateFirstReceiveTimestamp": "1649943853711"
},
"messageAttributes": {},
"md5OfBody": "5a0d58f78b5fbcb51c4ea2530ff4903f",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:ap-northeast-2:523139768306:image-resizer-qeue",
"awsRegion": "ap-northeast-2"
}
]
}
//테스트 이벤트로그 이것은 최초연결시 테스트로 보낸것 같다.
//이것을 무시하는 로직도 짜주자
2022-04-14T13:44:34.564Z afed5f49-df39-5deb-8d41-059bf847ec19 INFO {
"Records": [
{
"messageId": "b8c2fe0a-ee4e-420a-bc5c-a8c8418e527c",
"receiptHandle": "AQEBMWM6HtIKsgxVrcaFuDhprIm/Ip1uRZUb6wTF4Amd5aIXgY1ZjYPJfQeg5BLUxzOUnp83jSEK30MZskYlT3lFfnKACyvZUvLwjLwqwhpD2tRuD98Y1WZQ8cA2AjjxilCO7xMV0o+pTUOwhFdlGnagjBg+o/wj+orTuMH7SgSjHP/Khmg+SoNxbb3ke7AoYNgfx29WuCvUwfh4k0bzWcSyt0XJnvhWR7F/81QDu9d39Jv/D+/Hh7fCuulYfOkD2qU/YEAcEBkp9+jFhleAomyvo2lFZQT4GvFisCHNEeep0YGeRDojPa17MhhF//IybVHWDwqu0u2OitakWNQ/lWvS0S7zTObH9BnhYtBMiYA96lE9Vz6G5x5GVea6kzAP6BGFieRv/qvffc18dnnM0Zbw7w==",
"body": "{\"Records\":[{\"eventVersion\":\"2.1\",\"eventSource\":\"aws:s3\",\"awsRegion\":\"ap-northeast-2\",\"eventTime\":\"2022-04-14T13:44:32.465Z\",\"eventName\":\"ObjectCreated:Put\",\"userIdentity\":{\"principalId\":\"A13AH02OYMXZFP\"},\"requestParameters\":{\"sourceIPAddress\":\"1.225.252.19\"},\"responseElements\":{\"x-amz-request-id\":\"G8WTB5VTFQ2TWQ0N\",\"x-amz-id-2\":\"Lx/rsQ5m1kpa/exqAFfHAo5fYAEum2aYZ/Omidq90bvqgXaEhRVqfih+YURzgd6TeIRxhOZEqxXj7p4u9Vzkq66oBaBsOws3\"},\"s3\":{\"s3SchemaVersion\":\"1.0\",\"configurationId\":\"creation-image\",\"bucket\":{\"name\":\"image-source-mason\",\"ownerIdentity\":{\"principalId\":\"A13AH02OYMXZFP\"},\"arn\":\"arn:aws:s3:::image-source-mason\"},\"object\":{\"key\":\"dog.jpg\",\"size\":41978,\"eTag\":\"cfb90e42d6494c246c6ef1d02a70608f\",\"sequencer\":\"0062582540651DE6A5\"}}}]}",
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1649943874335",
"SenderId": "AIDAJCPXNKW2CHJMM46DC",
"ApproximateFirstReceiveTimestamp": "1649943874340"
},
"messageAttributes": {},
"md5OfBody": "4dd807240c8cedaf039e3e8bf912f515",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:ap-northeast-2:523139768306:image-resizer-qeue",
"awsRegion": "ap-northeast-2"
}
]
}
//이벤트로그 body에 필요한 정보 있는 것 확인!
2022-04-14T13:44:14.925Z 1011c8d6-fa33-5049-9df0-9204dd54b506 ERROR Invoke Error {
"errorType": "TypeError",
"errorMessage": "Cannot read property 'bucket' of undefined",
"stack": [
"TypeError: Cannot read property 'bucket' of undefined",
" at Runtime.exports.helloFromLambdaHandler [as handler] (/var/task/src/handlers/hello-from-lambda.js:12:48)",
" at Runtime.handleOnce (/var/runtime/Runtime.js:66:25)"
]
}
//에러 로그
자바스크립트 에러이다. 66번째 줄에서 undefined라는 것의 'bucket'이라는 속성을 읽어올 수 없단다.
그래서 다시 이벤트를 살펴보니, 내가 작성한 람다코드는 위와 같은 이벤트를 처리할 수 없다!
이전에 봤던 이벤트와 뭔가 다르다. 내가 봤던 이벤트는 Records[0].body에 string 형태로 저장되어 있다.
JSON.parse를 통해 string에서 JSON파일로 바꾸어주자.
하나하나 콘솔에 찍어보며 이벤트로그와 친해지도록 한다.
이벤트를 바탕으로 람다 코드를 수정한다.
/**
* A Lambda function that returns a static string
*/
const AWS = require('aws-sdk')
AWS.config.update({region: 'ap-northeast-2'});
const s3 = new AWS.S3({apiVersion: '2006-03-01'})
const sharp = require('sharp')
exports.helloFromLambdaHandler = async (event, context) => {
console.log(JSON.stringify(event));
const parsedSQSevent = JSON.parse(event.Records[0].body)
if (parsedSQSevent.Event === 's3:TestEvent'){ //테스트 이벤트를 무시하는 코드
return ;
}
const s3SourceBucket = parsedSQSevent.Records[0].s3.bucket.name
const s3SourceKey = parsedSQSevent.Records[0].s3.object.key
const s3DestinationBucket = 'image-resized-mason'
// 원본 버킷으로부터 파일 읽기
const s3Object = await s3.getObject({
Bucket: s3SourceBucket,
Key: s3SourceKey
}).promise()
// 이미지 리사이즈, sharp 라이브러리가 필요합니다.
const data = await sharp(s3Object.Body)
.resize(200)
.jpeg({ mozjpeg: true })
.toBuffer()
// 대상 버킷으로 파일 쓰기
const result = await s3.putObject({
Bucket: s3DestinationBucket,
Key: s3SourceKey,
ContentType: 'image/jpeg',
Body: data,
ACL: 'public-read'
}).promise()
return 'hello from Lambda!';
}
에러없이 잘 실행되고, s3에 이미지가 잘 저장된 것을 확인하고, SNS를 통해 알림도 받았다.
최종적으로 완성된 아키텍처이다.
이를 바탕으로 서비리스 서비스인 람다에 대해 조금 더 이해해볼 수 있는 계기가 되었고, 서버리스의 편리함과 강력함을 느끼게 되었다.