OpsNow에서는 초기에 고객의 S3 버킷에서 빌링 데이터를 수집/처리하기 위해서 하나의 배치 프로그램을 구동했었습니다. 그 이후에 많은 AWS 서비스를 접하게 되면서 과연 어떻게 하면 비용도 줄이고, 성능도 최적화 할 수 있는지 많은 시도를 하게 되었습니다.

최종적으로 빌링 데이터의 수집 부분을 분리하고 이 부분에 대해서는 서버리스 (Serverless) 플랫폼을 이용하였고, 앞서 소개했던 AWS SAM으로 개발하기 글을 따라서 개발을 진행하게 되었습니다. 워크플로우 관리는 AWS Step Functions를 통해서 이루어지며, 그 안에서 각 단계에 해당하는 AWS Lambda가 동시에 여러 개씩 생성되어 빠르게 작업을 처리할 수 있습니다.

프로젝트 구성은 다음과 같습니다.

.
├── README.md
├── collector-aws-billing.code-workspace
├── lambda
│ ├── __init__.py
│ ├── __lambda__.py
│ ├── _common
│ │ ├── awsKms.py
│ │ ├── awsS3.py
│ │ ├── config.py
│ │ ├── database.py
│ │ ├── exception.py
│ │ ├── flag.py
│ │ └── query.py
│ ├── module
│ │ ├── addHistory.py
│ │ ├── addOperation.py
│ │ ├── createEmr.py
│ │ ├── errorHandler.py
│ │ ├── getCurFile.py
│ │ ├── getMetadata.py
│ │ ├── getOperation.py
│ │ ├── getResult.py
│ │ └── startCollector.py
│ └── requirements.txt
├── stepFunction
│ └── app.asl.json
└── template.yaml
Code language: Bash (bash)

Step Functions를 이용한 워크플로우 관리

서버리스 플랫폼에서 일련의 작업을 관리하기 위한 서비스로 Step Functions를 이용할 수 있습니다. 이름에서부터 Function들의 Step이라는 뜻을 통해 워크플로우 매니저라는 것을 명확히 하고 있습니다.

빌링 데이터를 수집하기 위해서 아래와 같은 예시 워크플로우를 작성합니다.

{
"Comment": "Check the available accounts, check the billing files and download it if the billing files are updated.",
"StartAt": "Add the operations",
"States": {
"Add the operations": {
"Type": "Task",
"Resource": "${AwsBillingCollectorFunction}",
"InputPath": "$",
"Parameters": {
"mode.$": "$.mode",
"job": "addOperation"
},
"Retry": [
{
"ErrorEquals": [ "States.ALL" ],
"IntervalSeconds": 3,
"MaxAttempts": 2,
"BackoffRate": 2
}
],
"Catch": [
{
"ErrorEquals": [ "States.ALL" ],
"Next": "Error handler"
}
],
"Next": "Get the operations"
},
"Get the operations": {

...
Confidential!!!
...

"Get the billing files": {
"Type": "Map",
"MaxConcurrency": 10,
"Next": "Add history",
"InputPath": "$",
"ItemsPath": "$.operation",
"Parameters": {
"id.$": "$.id",
"mode.$": "$.mode",
"operation.$": "$$.Map.Item.Value",
"job": "getMetadata"
},

...
Confidential!!!
...

},
"Error handler": {
"Type": "Task",
"Resource": "${AwsBillingCollectorFunction}",
"InputPath": "$",
"Parameters": {
"Error.$": "$.Error",
"job": "errorHandler"
},
"End": true
},
"Finished successfully": {
"Type": "Succeed"
}
}
}
Code language: JSON / JSON with Comments (json)

위 코드는 단순한 JSON이 아닌 .asl.json 이라는 확장자의 파일입니다. 이것은 AWS에서 Step Functions를 위해서 별도로 제공하는 개발 언어인 ASL (Amazon States Language) 입니다. 기본적으로 JSON 형태와 동일한데, 내부적으로 워크플로우 구축을 위한 몇 가지 Validation 체크가 추가된 것으로 보입니다. 예를 들어, Step A에서 다음 스텝을 B라고 명시했는데, B가 없거나 C가 나오면 오류를 알려줍니다.

SAM을 이용하면 워크플로우 안에서 실행할 Lambda 함수를 CloudFormation에서 생성할 Lambda의 논리적 ID로 지정할 수 있습니다. 소스 안에서 ${AwsBillingCollectorFunction} 와 같이 정의되어 있습니다. 반대로 CloudFormation에서도 DefinitionSubstitutions 에 대한 정의가 필요합니다.

# Step Functions
AwsBillingCollectorSteps:
Type: AWS::Serverless::StateMachine
Properties:
DefinitionUri: stepFunction/app.asl.json
DefinitionSubstitutions:
AwsBillingCollectorFunction: !Join
- ':'
- - !GetAtt AwsBillingCollectorFunction.Arn
- Worked
Role: !GetAtt AwsBillingCollectorStepsRole.Arn
Code language: YAML (yaml)

그리고 VS Code에서 AWS Toolkit을 사용하면 아래처럼 워크플로우를 생성할 수 있습니다! 소스 상단에 Render graph를 눌러 확인합니다.

두터운 부분은 Map 상태로 제공된 List의 아이템을 동시에 실행하는 단계입니다.

워크플로우는 이미지로 보시는 바와 같이 단순합니다. 수집 대상을 만들고 -> 확인하고 -> 수집 대상별로 빌링 데이터를 확인하고 -> 업데이트 되었으면 수집하고 -> 결과 취합하여 -> 결과 상태를 기록하고 -> 데이터 처리를 위해서 수집 결과를 EMR로 전달합니다. 실제로 소스가 배포되고 콘솔에서 실행된 결과는 아래와 같습니다.

비록 개발계이긴 하지만, 시작과 종료 시간이 90초도 차이가 나지 않습니다!

개별 파일에 대해서 각각의 Lambda 함수가 병렬로 실행되기 때문에 굉장히 빠른 속도로 파일 수집이 가능합니다. 운영 환경에서도 3분 내에 모든 고객의 데이터를 수집할 수 있습니다. 심지어, 비용을 절감하기 위해서 Lambda 함수의 메모리와 동시성 (Concurrency)를 제한해둔 상태인데, 이 값들을 추가로 제공하면 1분 이내 전체 수집도 가능합니다.

Lambda 함수와 동시성, 통계

본 프로젝트는 단일 Lambda 함수로 구성되어 있습니다. 각각의 단계에서 사용할 Lambda 함수가 거의 동일한 구성을 가지고 있기 때문입니다. 빌링 데이터 다운로드 함수에서도 Chunk와 스트리밍을 도입하여 메모리 사용량을 최소화 하였습니다. 그래서 내부 모듈 전체가 Lambda의 최저 메모리 설정인 128MB로 동작 가능하고, 이에 따라서 Lambda 환경을 통일할 수 있었습니다. 코드에서 일부 설정은 삭제하였습니다.

# Function
AwsBillingCollectorFunction:
Type: AWS::Serverless::Function
Properties:
AutoPublishAlias: Worked
CodeUri: lambda/
Handler: __lambda__.lambda_handler
MemorySize: 128
Role: !GetAtt AwsBillingCollectorFunctionRole.Arn
Runtime: python3.8
Timeout: 600
VpcConfig:
SecurityGroupIds:
- !Ref AwsBillingCollectorFunctionSecurityGroup
SubnetIds: !FindInMap [StageMap, !Ref TemplateStage, Subnets]
Code language: YAML (yaml)

각각의 단계에서는 작업 아이디를 제공하고, Lambda에서는 작업 아이디를 통해서 필요한 함수를 호출하게 됩니다. 앞서 언급한 바와 같이, 메모리는 고정이고 작업에 따라 수행 시간만 다르게 됩니다.

동시 실행되는 Lambda에 대해서는 CloudWatch의 Logs Insights에서 실행 이력을 확인할 수 있습니다.

filter @type = "REPORT"

| fields @timestamp as Timestamp, @requestId as RequestID, @logStream as LogStream, @duration as DurationInMS, @billedDuration as BilledDurationInMS, @memorySize/1000000 as MemorySetInMB, @maxMemoryUsed/1000000 as MemoryUsedInMB
| sort Timestamp desc
Code language: JavaScript (javascript)

쿼리 결과는 다음과 같이 나타납니다.

개별 함수에 대한 데이터와 전체 통계를 확인할 수 있습니다.

Lambda 함수 자체의 모니터링을 통해서도 여러 지표에 대한 확인이 가능합니다. 저희 개발계에서 1회 시행에 342회의 Lambda가 실행되었으며 최대 동시 실행은 29개로 나타났습니다. 평균 실행 시간은 5.2초 가량으로 나타났습니다.

Lambda의 모니터링 대시보드.

예상 비용

이제, 이렇게 확인된 통계를 이용하여 한 달 비용을 예상해볼 수 있습니다.

  • 운영계 기준 한 달 호출 15,000회로 가정
  • 평균 실행시간은 6초로 가정
  • 메모리는 128MB
  • Step Functions에서 전환은 호출 횟수의 2배인 30,000회로 가정

그러면 AWS에서 서울 리전에 대해 계산된 예상 가격은 아래와 같습니다.

월 $0.89…?!

보시는 바와 같이, 가격이 기존 EC2로는 도달하기 어려울 정도로 낮은 수준입니다. 물론, 빌링 데이터의 수집 주기를 변경함에 따라서 호출 횟수도 달라지기 때문에 비용도 늘어날 수 있습니다. 그럼에도 불구하고 서버리스 플랫폼은 온디맨드 사용에 최적화되어 있으며 어마어마한 비용 절감의 기회를 제공합니다.

이렇게, OpsNow에서는 비용도 절감하고 빌링 데이터의 수집부를 분리하였으며 서버리스 플랫폼을 성공적으로 도입하였습니다!