들어가며
안녕하십니까. GS네오텍 최준승입니다.
클라우드의 기본 컨셉 중 "종량제"라는 개념이 있습니다. 사용자는 On-Demand 방식으로 필요할 때마다 자원을 원하는만큼 가져다 쓰고, 사용한 만큼만 비용을 지불합니다. 이런 식의 과금 모델을 만들고 설계하는건 물론 클라우드 벤더의 몫입니다. 먼저 충분한 양의 리소스 풀을 마련해 놓고, API나 콘솔을 통해 사용자 요청을 받으면, 샤샤샤샥 자원을 할당해서 개별 사용자에게 전달해 주는 것이 기본적인 흐름이 됩니다.
AWS 기본 교육에서 나올만한 고리타분한 얘기를 굳이 하는 이유는. 클라우드의 효율성이란 가치가 저 흐름에서 유래하기 때문입니다. 자원을 미리 할당하지 않고 "필요할 때" 할당해 주는 것. 다른 말로 "사용자가 필요없을때는 자원을 할당하지 않는 것"에서 전체 리소스 풀의 효율성이 극대화됩니다. 이런 "즉시 할당"을 얼마나 세련되고 seamless하게 하느냐는 벤더의 역량에 달려 있습니다만, 아무리 벤더의 역량이 훌륭하다고 해도 근본 설계상 어쩔 수 없는 "즉시 할당"의 부작용 또한 존재합니다. 그리고 이런 부작용들을 사용자가 슬기롭게 대처(?)할 수 있게 도와주는 것도 벤더의 역할입니다. ALB의 사전 Pre-warm 작업이나 EC2의 오토스케일링 기능이 그 예가 되겠네요.
Lambda 서비스의 경우엔 Cold Start라는 부작용이 있었습니다. 일순간 spiky하게 특정 함수의 동시 요청이 쏟아지면, 그 실행 환경을 Provisioning 하는 과정으로 인해 일부 요청의 응답이 늘어지는 현상이 있었죠. 이러한 지연이 비지니스 관점에서 허용 범위 내에 있다면 문제가 없겠지만, 범위 밖에 있는 경우에는 이를 방지하기 위한 번거로운 몇몇 작업들이 필요했습니다.
그리고 이번 리인벤트 행사에서 이런 번거로운 작업들을 일부 대체할 수 있는 새로운 옵션이 나왔습니다. Provisioned Concurrency 라는 개념인데요. 오늘 포스팅을 통해 어떤 컨셉의 옵션인지 살펴보도록 하겠습니다.
Lambda의 Concurrency 옵션 소개
현재 Lambda에는 Concurrency와 관련된 2가지 옵션이 있습니다. 하나는 Reserved Concurrency라는 개념으로 2017년에 나온 것이구요. 다른 하나는 이번 리인벤트에서 나온 Provisioned Concurrency라는 개념입니다. 두 개념은 서로 연관성이 있기 때문에, 겸사겸사 복습도 할겸 묶어서 함께 살펴보도록 하겠습니다.
시작하기 전에 Lambda에 기본적으로 설정되어 있는 Concurrency Limit 부터 알고 넘어갑시다. 3줄로 요약하면.
- 각 계정의 Region 단위로 기본 Concurrency 제한값이 있다
- 그 값은 리전별로 다르며, 필요시 별도 요청을 통해 상향(조정)할 수 있다
- 해당 수치를 초과하는 요청은 원칙적으로 Throttling Error(응답코드:429)가 발생한다
쉽게 말해 각 계정의 리전 단위로. (모든) Lambda 함수를 동시실행 할 수 있는 최대값(갑빠)이라고 보시면 됩니다. 일단 이 값을 바탕으로 하고, 그 범위 안에서 특정 함수(또는 특정 함수의 특정 버전)별로 다양한 Concurrency 값을 설정할 수 있게 되는 셈입니다.
▨ Reserved Concurrency
먼저 Reserved Concurrency에 대해 살펴보겠습니다. AWS 공식페이지의 그림을 그대로 따왔습니다.
해당 개념을 3줄로 요약하면.
특정 Function(모든 Version의 합) 단위로 설정하는 값이다
해당 함수는 위 설정값 이상의 동시 요청이 발생하면, 초과분은 수행하지 않는다
설정한 즉시 적용된다
예를 들어 제가 소유한 AWS 계정에 Seoul region에서 Lambda를 사용한다고 가정해 봅시다. 기본 capacity는 1000이라고 하겠습니다. 즉, 제 계정 내의 서울 리전에서는 모든 함수를 통틀어 1000개의 람다 함수를 동시에 수행할 수 있는 기본 한도를 갖고 있습니다. 그리고 이 한도 내에서 여러개의 람다 함수가 적당히 자원을 나눠 써가며 풀을 사용합니다. 물론 Serverless 기반이므로 개별적인 스케일링은 사용자가 신경쓰지 않습니다. 한도가 그렇다는 것이죠.
이때 A라는 함수(Function)에 100이라는 Reserved Concurrency 값을 설정해보겠습니다. 이제 A라는 함수는 동시에 100개 이상의 요청을 처리할 수 없습니다. 제한을 둔 것입니다. 제한은 왜 두는 것일까요? 두가지를 생각해 볼 수 있습니다.
해당 함수가 정해진 수치 이상으로 (동시) 실행되는 것을 방지하기 위해
다른 함수가 실행될 수 있는 풀(Unreserved)을 일정 수준 보장하기 위해
1번의 경우, 람다 내에서 다른 API를 호출하는 과정에서 해당 API에 대한 일정한 쓰로틀링이 필요할때 활용할 수 있습니다. 2번의 경우는 "A라는 함수에게 100이라는 값을 할당"한다는 측면보다는 "100 외의 여집합을 다른 함수에게 보장"한다는 개념으로 이해하는 것이 더 나은 해석입니다. 위에서 보여드렸던 그림을 다시 한번 보시면 이해가 가실지 모르겠네요.
▨ Provisioned Concurrency
이어 Provisioned Concurrency의 개념을 살펴보겠습니다. 먼저 3줄로 요약하면.
- 특정 Function의 특정 Version(또는 Alias) 단위로 설정하는 값이다
- 여기서 설정한 값만큼 미리 실행 환경(런타임 준비, 클래스 로딩 등)을 Provisioning 한다.
- 설정값이 반영되는데 일정 시간(몇분)이 소요된다
좀 억지스럽지만, 람다 함수가 실행되는 과정을 도로에서 차가 달리는 것에 비유해 보겠습니다. 기본적으로 차선은 1차선부터 1000차선까지 있습니다. 그리고 Reserved Concurrency의 경우에는 특정 함수가 n차선을 점유하는 개념입니다. 1000차선 중에 n차선은 A라는 함수만 달릴 수 있습니다. n차선 외의 다른 차선은 다른 함수를 위해 남겨둡니다.
이때, 해당 차선이 Reserved 되어 있든 되어있지 않든 간에, 처음 도로를 달릴때는 미리 길을 닦아서 길을 낸(환경을 Provisioning한) 후에야 달릴 수 있습니다. 이 과정이 스무스하게 진행되면 이상적이지만, 갑자기 차가 많이 들이닥치면(spike한 동시 요청) 이 Provisioning 과정에 지연이 생길 수 있습니다. 이걸 Lambda에서는 콜드 스타트(Cold Start)라고 부릅니다.
이번에 새로 나온 Provisioned Concurrency는 이 길을 미리 닦아놓겠다는 뜻입니다. 따라서 어떤 함수의 어떤 버전을 실행할 것인지 여부가 미리 확정되어 있어야 합니다. 미리 길을 닦아 놓았으니 코드가 실행되는 시간 외의 준비 시간은 필요하지 않습니다. 따라서 Cold Start가 발생하지 않으며, 이에 따라 사용자는 좀 더 다양한 경우의 수를 전제로 실행 환경을 조정할 수 있습니다.
Provisioned Concurrency 데모
이번 데모에서는 2가지 대조군을 설정하려고 합니다. 하나는 Provisioned Concurrency를 설정하지 않은 환경, 다른 하나는 Provisioned Concurrency를 미리 설정한 환경입니다. 같은 방식으로 동시 요청을 수행했을때 각 환경에서 어떤 패턴의 응답을 보여주는지를 확인하려고 합니다. 요청 툴은 hey를 사용했으며, 샘플 코드는 JAVA 11 버전으로 (결과 차이가 극대화될 수 있는 형태로) 작성되었습니다.
package example;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.time.Instant;
public class Hello implements RequestHandler { private static final Logger log = LogManager.getLogger(Hello.class);
static {
log.info("load start");
long s = System.currentTimeMillis();
while (System.currentTimeMillis() - s < 2000L) ;
log.info("load end in {} ms", System.currentTimeMillis() - s);
}
@Override
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent i, Context ctxt) {
return new APIGatewayProxyResponseEvent()
.withStatusCode(200)
.withBody("hello at " + Instant.now());
}
}
먼저 별도의 Provisioned Concurrency를 설정하지 않은 경우입니다. 람다의 앞단에는 API Gateway로 끝점을 만들었으며, 물론 API Gateway 계층에서는 별도의 쓰로틀링값을 설정하지 않았습니다. 누적 1000개의 요청을 하되, 동시요청수를 100으로 설정하였습니다.
Summary:
Total: 7.7821 secs
Slowest: 4.1637 secs
Fastest: 0.0128 secs
Average: 0.6195 secs
Requests/sec: 128.5008
Total data: 35994 bytes
Size/request: 35 bytes
Response time histogram:
0.013 [1] |
0.428 [835] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.843 [0] |
1.258 [0] |
1.673 [0] |
2.088 [0] |
2.503 [0] |
2.918 [0] |
3.333 [0] |
3.749 [139] |■■■■■■■
4.164 [25] |■
Latency distribution:
10% in 0.0155 secs
25% in 0.0166 secs
50% in 0.0182 secs
75% in 0.0287 secs
90% in 3.6441 secs
95% in 3.7328 secs
99% in 3.7743 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0095 secs, 0.0128 secs, 4.1637 secs
DNS-lookup: 0.0034 secs, 0.0000 secs, 0.0347 secs
req write: 0.0000 secs, 0.0000 secs, 0.0004 secs
resp wait: 0.6099 secs, 0.0127 secs, 4.0962 secs
resp read: 0.0000 secs, 0.0000 secs, 0.0002 secs
Status code distribution:
[200] 1000 responses
이번엔 Provisioned Concurrency의 값을 200으로 설정하고 동일한 요청을 수행해 보겠습니다. 앞서 말씀드렸듯이. 관련 환경이 프로비저닝될때까지(아래처럼 Ready 상태가 될때까지) 일정 시간 기다려 줘야 합니다.
(일정 시간 후)
API Gateway 끝점으로 동일한 형태의 요청을 해보겠습니다.
Summary:
Total: 1.5101 secs
Slowest: 0.7346 secs
Fastest: 0.0117 secs
Average: 0.1188 secs
Requests/sec: 662.2256
Total data: 35991 bytes
Size/request: 35 bytes
Response time histogram:
0.012 [1] |
0.084 [847] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.156 [0] |
0.229 [0] |
0.301 [0] |
0.373 [0] |
0.445 [0] |
0.518 [1] |
0.590 [49] |■■
0.662 [39] |■■
0.735 [63] |■■■
Latency distribution:
10% in 0.0168 secs
25% in 0.0202 secs
50% in 0.0276 secs
75% in 0.0383 secs
90% in 0.5974 secs
95% in 0.6743 secs
99% in 0.7029 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0095 secs, 0.0117 secs, 0.7346 secs
DNS-lookup: 0.0032 secs, 0.0000 secs, 0.0333 secs
req write: 0.0000 secs, 0.0000 secs, 0.0004 secs
resp wait: 0.1092 secs, 0.0116 secs, 0.6400 secs
resp read: 0.0000 secs, 0.0000 secs, 0.0004 secs
Status code distribution:
[200] 1000 responses
응답시간에 유의미한 차이가 발생하는 것이 보이시나요? 내부적으로 어떤 메커니즘으로 동작하는지는 알 수 없지만, 사용자 입장에서 Provisioned Concurrency를 미리 설정해 놓으면 그 범위 내에서 마치 캐싱한 것처럼 빠르게 함수를 실행시킬 수 있다. 라는 사실만 알고 있으면 됩니다.
Provisioned Concurrency 과금
아무리 6년근 홍삼이 몸에 좋아도 비싸서 먹지 못하면 의미가 없죠. AWS에서도 아무리 기능이 좋아도 비싸면(가격대비 효용이 떨어지면) 쓸 수가 없습니다. Provisioned Concurrency을 사용하게 되면, 기존 Lambda의 과금 모델이 조금 달라집니다. 이 부분도 한번 살펴보겠습니다.
기존 방식의 과금 단위는 다음과 같습니다. 서울 리전 기준입니다.
Provisioned Concurrency가 설정된 환경에서의 과금 모델입니다.
우선 Request 항목은 단가가 동일합니다. 그리고 Duration 항목에 대한 단가는 Provisioned Concurrency 환경이 오히려 저렴하네요. 대신 설정한 Provisioned Concurrency 값에 대한 추가 과금 항목이 있습니다. 단, 주의할 것은 이 항목은 실제 실행 요청이 발생하지 않아도 설정시 무조건 과금이 발생한다는 것입니다. 과금을 어떤 시간 단위로 하느냐 / 자원을 얼마나 할당하느냐에 따라 자잘한 오차가 있을 수 있지만, 대략적으로 보면 Provisioned Concurrency 값을 실제 요청에 맞게 완벽히(?) 최적화한 경우 기존과 비슷한 수준의 과금이 발생합니다. 반면 필요 이상으로 높은 Provisioning Concurrency 값을 설정했거나, 또는 Provisioning Concurrency 설정값에 비해 실제 요청이 과소하게 발생한 경우에는 저 과금 기준에 따라 상대적으로 높은 요금을 내야할 수도 있습니다.
마치며
이제 정리할 시간입니다.
제가 항상 칭찬하는 AWS의 미덕은 사용자에게 다양한 선택지를 준다는 점입니다. 물론 클라우드 벤더가 마치 궁예처럼 사용자의 마음을 처음부터 끝까지 읽어서 모든것을 알아서 해주면 좋겠지만, 그런 것은 현실에 존재하지 않습니다. 사용자에게 특정 부분의 제어 권한을 주는 행위는 그 제어권이 잘못 조정되었을때의 부작용까지 사용자가 감수해야 한다는 뜻입니다. 마치 양날의 검처럼 말이죠.
Lambda의 경우, 이번 Provisioned Concurrency 옵션을 통해 관련 자원을 미리 할당하는 것이 가능해 졌습니다. 여기에 따른 과금은 어떻게 될지, 얻는 이득에 비해 반대 급부가 무엇이 될지 사용자가 직접 판단해야 합니다. 그리고 효용이 있다고 판단되면 쓰면 되는 것이지요.
AWS는 친절하게도 Provisioned Concurrency 값을 상황에 따라 자동 스케일링할 수 있도록 Application Auto Scaling 서비스와 통합해 놓았습니다. 사용자가 외부에서 별도로 컨트롤 하지 않고도, 특정 시간 또는 특정 사용률(Utilization)에 따라 해당 값을 자동으로 조절할 수 있습니다.
이제 마칠 시간이네요. 포스팅 샘플에 사용된 람다 코드를 만들어 주신 도준호 과장님께 감사드리며, 글을 마치겠습니다. 끝!