VPC Lambdaのコールドスタートにお悩みの方へ捧ぐコールドスタート予防のハック Lambdaを定期実行するならメモリの割り当ては1600Mがオススメ?!

2019/5/31追記

先日のBlack BeltでLambdaの内部構造について一部解説がありました。※スライド公開され次第更新します

これまでWorkerというコンポーネントはMicroVMを指しているという理解だったのですが、実際にはMicroVMをホストするEC2インスタンスのレイヤーがWorkerに相当するようです。以後の「Worker」という表記は基本的に「MicroVM」に置き換えて読んで頂くようお願いします。

【AWS Black Belt Online Seminar】AWS Lambda Part4より AWS Lambdaのアイソレーション

はじめに

サーバーレス開発部@大阪の岩田です。

先日のブログでLambdaのコールドスタートの裏側について考察しました。

これまでの常識は間違っていた?!Lambdaのコールドスタート対策にはメモリ割り当てを減らすという選択肢が有効に働く場面も

本ブログでは前回の考察をベースにLambdaのコールドスタート回避に関するテクニックについて深掘りしていきます。なお、以後の記述は全てコールドスタートの影響が分かりやすいVPC Lambdaを前提とします。

以後記載している内容はあくまでも私の主観に基づく考察です。実際の仕様とは異なる可能性があることをあらかじめご了承ください。

VPC Lambdaのコールドスタート回避テクニックについて

Lambdaのコールドスタートを回避するテクニックとして、定期的にLambda関数を実行することでコンテナの破棄を避けるテクニックが知られています。

この方法は、常に定期的にLambda関数を実行することで、以下のような環境を維持していると考えられます。

1つのWorkerに1つのサンドボックス環境がプロビジョニングされた状態

この状態で新たにLambda関数実行のリクエストがあった場合、アクセス数があまり多くなければ

  • プロビジョニング済みのサンドボックス環境で処理を行う
  • ENIアタッチ済みのWorker上に新たにサンドボックス環境を構築して処理を行う

のいずれかになり、Lambda関数実行の遅延を最小限に抑えられるはずです。しかし、多数のLambda関数実行リクエストが発生すると、ENIアタッチ済みのWorker内に十分な数のサンドボックス環境を準備できず、新たにWorkerの割り当てとENIの作成&アタッチ処理が発生すると考えられます。この辺りの動作について検証していきます。

検証環境の構築

VPC Lambda & API Gatewayの作成

まずLambda関数のコードを用意しておきます。正直コードはどうでも良いです。

def handler(event, context):
  return {
    'statusCode': 200,
    'body': 'hello'
  }

このコードをSAMテンプレートのCodeUriから参照してAPI Gatewayのバックエンドとしてデプロイします。

SAMテンプレートです。 API GWのパス/test/ping1配下にメモリ割り当て128MのLambda関数を、/ping2~/ping6配下にメモリ割り当て1600MのLambda関数を作成します。

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: for blog
Globals:
  Function:
    Runtime: python3.7
    Handler: index.handler
    CodeUri: ./
    MemorySize: 1600
    VpcConfig:
      SecurityGroupIds:
        - <適当なセキュリティグループのID>
      SubnetIds:
        - <適当なサブネットのID>
Resources:
  LambdaExecuteRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - 'lambda.amazonaws.com'
          Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaENIManagementAccess
  Test:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn
      MemorySize: 128
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /test
            Method: get
  Ping1:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn
      MemorySize: 128
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /ping1
            Method: get
  Ping2:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /ping2
            Method: get
  Ping3:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /ping3
            Method: get
  Ping4:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn      
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /ping4
            Method: get
  Ping5:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn      
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /ping5
            Method: get
  Ping6:
    Type: AWS::Serverless::Function
    Properties:
      Role: !GetAtt LambdaExecuteRole.Arn      
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /ping6
            Method: get

メモリ割り当て1600MのLambda関数は以下のような環境を作成することを目的としています。

1つのWorkerに1つの大きなサンドボックス環境がプロビジョニングされた状態

AWSのドキュメントによると

Lambda 関数で VPC にアクセスする場合は、Lambda 関数でのスケーリング要件をサポートできる充分な ENI キャパシティーが VPC にあることを確認します。次の式を使用すると、ENI 要件を概算できます Projected peak concurrent executions * (Memory in GB / 3GB)

次のとおりです。

  • Projected peak concurrent execution (投射されたピーク時の同時実行) – この値を決定するには、「同時実行数の管理」の情報を使用します。
  • メモリ – Lambda 関数用に設定したメモリの容量。

VPC 対応の Lambda 関数をセットアップするためのガイドライン

とのことなので、メモリ1600MのLambda関数を作成すれば、Workerを1つ確保しつつ、1424M分のメモリを余剰リソースとしてプールしておけるのではないか?という推測です。

テスト用クライアント

次にテスト用クライアントの環境を構築します。 今回利用した環境は以下の通りです。

  • OS: Amazon Linux2(ami-00d101850e971728d) m5.large
  • テスト用ツール: hey v0.1.2

上記のAMIを指定したEC2を立ち上げ、heyをインストールします

sudo amazon-linux-extras install golang1.11
go get -u github.com/rakyll/hey

インストールできたらbash_profileに以下の記述を追加して、パスを通します。

PATH=$PATH:$HOME/go/bin

準備ができたら早速検証していきます。

検証実施

テストコマンド

以後の検証では、全て以下のコマンドを利用しています。

hey -n 500 -c 50 https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Stage/test

API Gatewayに対して50並列で合計500リクエストを発行し、/testというパスにぶら下がるメモリ128MのVPC Lambdaを起動します。50並列で実行しているのでLambdaのサンドボックス環境1つでは処理できず、サンドボックス環境がスケールアウトしていくはずです。

検証1.特に小細工無し

まずは特に小細工無しで普通にテストを流してみます。事前にVPC Lambdaから利用可能なENIが存在しないことを確認しておきます。

検証1.実施前のENIの状況

EC2に紐付くENIしか存在しない状況です。 この状態でテストを実行すると確実にコールドスタートが発生するはずです。 また、Lambdaのメモリ割り当ては128Mかつテストは50並列で流すので、計算式としては 128M * 50クライアント / 3G ≒ 2.08となり、ENIが3つ必要になる想定です。

実行結果です。

Summary:
  Total:	13.4614 secs
  Slowest:	13.0460 secs
  Fastest:	0.0194 secs
  Average:	1.2528 secs
  Requests/sec:	37.1433

  Total data:	2500 bytes
  Size/request:	5 bytes

Response time histogram:
  0.019 [1]	|
  1.322 [448]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  2.625 [1]	|
  3.927 [0]	|
  5.230 [0]	|
  6.533 [0]	|
  7.835 [0]	|
  9.138 [0]	|
  10.441 [4]	|
  11.743 [9]	|■
  13.046 [37]	|■■■


Latency distribution:
  10% in 0.0329 secs
  25% in 0.0388 secs
  50% in 0.0454 secs
  75% in 0.0613 secs
  90% in 10.1785 secs
  95% in 12.2642 secs
  99% in 12.8836 secs

Details (average, fastest, slowest):
  DNS+dialup:	0.0087 secs, 0.0194 secs, 13.0460 secs
  DNS-lookup:	0.0051 secs, 0.0000 secs, 0.0520 secs
  req write:	0.0000 secs, 0.0000 secs, 0.0009 secs
  resp wait:	1.2440 secs, 0.0193 secs, 12.9546 secs
  resp read:	0.0000 secs, 0.0000 secs, 0.0007 secs

Status code distribution:
  [200]	500 responses

10秒超えのリクエストが合計50回発生しています。ENIの状況を確認してみましょう。

検証1.実施直後のENIの状況

Lambda用にENIが2つ生成されていることが分かります。ENIは3つ作成されると見積もりましたが、50並列の負荷掛けクライアントが完全に同時にアクセスする訳では無いので、まあ妥当な線だと思います。

検証2.メモリ128MのLambda関数を暖機しておく

次にLambdaを定期実行することでコールドスタートを抑制しつつ、再度テストしてみます。 以下のPythonのスクリプトを実行し、無限ループの中で2分に1回VPC Lambdaを起動します。 ※5分に1回程度のペースが推奨されていましたが、確実にWarmサンドボックスをキープしたかったので、実行間隔を短くしています。

import requests
from time import sleep

API_URL_BASE = 'https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/Stage/'

def main():
  
    while True:
      requests.get(f'{API_URL_BASE}ping1')
      sleep(120)
    

if __name__ == '__main__':
  main()

メモリを128M割り当てたLambdaを定期実行しているので、Lambda実行環境は以下のような状態をキープしている想定です。

1つのWorkerに1つのサンドボックス環境がプロビジョニングされた状態

ENIの状況です。

検証2.実施前のENIの状況

LambaにアタッチされたENIは1つなので、意図通りになっていそうです。この状態でテストを流すと以下のような結果になりました。

  Summary:
  Total:	13.4155 secs
  Slowest:	13.0072 secs
  Fastest:	0.0185 secs
  Average:	0.8371 secs
  Requests/sec:	37.2702

  Total data:	2500 bytes
  Size/request:	5 bytes

Response time histogram:
  0.018 [1]	|
  1.317 [446]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  2.616 [13]	|■
  3.915 [12]	|■
  5.214 [0]	|
  6.513 [0]	|
  7.812 [0]	|
  9.111 [0]	|
  10.409 [2]	|
  11.708 [8]	|■
  13.007 [18]	|■■


Latency distribution:
  10% in 0.0316 secs
  25% in 0.0370 secs
  50% in 0.0424 secs
  75% in 0.0587 secs
  90% in 1.8128 secs
  95% in 10.6099 secs
  99% in 12.9119 secs

Details (average, fastest, slowest):
  DNS+dialup:	0.0086 secs, 0.0185 secs, 13.0072 secs
  DNS-lookup:	0.0050 secs, 0.0000 secs, 0.0504 secs
  req write:	0.0000 secs, 0.0000 secs, 0.0044 secs
  resp wait:	0.8285 secs, 0.0184 secs, 12.9303 secs
  resp read:	0.0000 secs, 0.0000 secs, 0.0001 secs

Status code distribution:
  [200]	500 responses

10秒超えのリクエストは合計28回と、検証1に比べるとレスポンスが改善しています。ただし、28回のリクエストについてはENI作成の影響を受けて非常にレスポンスが遅くなっているようです。50並列同時に実行したことでWorker1つでプロビジョニング可能な数以上にサンドボックス環境が必要になり、新たにWorkerの割り当てとENI作成&アタッチ処理が実行されたと考えられます。ENIの状況を確認すると、検証1.実施後と同様Lambda用にENIが2つ作成された状態でした。

検証3.メモリ1600MのLambda関数を5つ暖機しておく

先ほどのping用のPythonスクリプトを修正し、メモリ割り当て1600MのLambda5つに対して定期的にリクエストを送ります。

requests.get(f'{API_URL_BASE}ping1')

の部分を

requests.get(f'{API_URL_BASE}ping2')
requests.get(f'{API_URL_BASE}ping3')
requests.get(f'{API_URL_BASE}ping4')
requests.get(f'{API_URL_BASE}ping5')
requests.get(f'{API_URL_BASE}ping6')

に変更して実行します。 Lambda実行環境は以下のような状態をキープしている想定です。

複数のWorkerにメモリ1600Mのサンドボックス環境が1つづつプロジョニングされた状態

Worker5台分の空き領域を合計すると50並列ぐらいは確保済みのリソースで捌けそうですよね? こんな感じで割り当て済みのWorker内に全てのサンドボックス環境がプロビジョニングされる想定です。

複数のWorkerにメモリ1600Mのサンドボックス環境が1つ、メモリ128Mのサンドボックス環境が複数プロジョニングされた状態

ENIの状況です。

検証3.実施前のENIの状況

LambaにアタッチされたENIは5つなので、意図通りになっていそうです。この状態でテストを流すと以下のような結果になりました。

Summary:
  Total:	2.9398 secs
  Slowest:	2.4377 secs
  Fastest:	0.0206 secs
  Average:	0.1540 secs
  Requests/sec:	170.0795

  Total data:	2500 bytes
  Size/request:	5 bytes

Response time histogram:
  0.021 [1]	|
  0.262 [466]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.504 [3]	|
  0.746 [2]	|
  0.987 [2]	|
  1.229 [1]	|
  1.471 [2]	|
  1.713 [4]	|
  1.954 [7]	|■
  2.196 [4]	|
  2.438 [8]	|■


Latency distribution:
  10% in 0.0300 secs
  25% in 0.0364 secs
  50% in 0.0423 secs
  75% in 0.0578 secs
  90% in 0.1562 secs
  95% in 1.3603 secs
  99% in 2.2589 secs

Details (average, fastest, slowest):
  DNS+dialup:	0.0084 secs, 0.0206 secs, 2.4377 secs
  DNS-lookup:	0.0050 secs, 0.0000 secs, 0.0503 secs
  req write:	0.0000 secs, 0.0000 secs, 0.0007 secs
  resp wait:	0.1456 secs, 0.0205 secs, 2.4377 secs
  resp read:	0.0000 secs, 0.0000 secs, 0.0003 secs

Status code distribution:
  [200]	500 responses

一番遅いリクエストでも約2.4秒、10秒超えのリクエストは0件という結果でした。想定通りコールドスタートは発生しなかったと言えそうです!!

まとめ

今回試した検証パターンの範囲ではVPC Lambaのコールドスタート対策にはメモリを1600M割り当てたLambda関数を複数用意し、定期的に実行しておくのが効果的という結論になりました。

もしLambdaのバックエンドが考察の通りになっていれば、Workerを確保しつつ1G以上のメモリを遊ばせておくのはAWS側からすると傍迷惑なWorkerの使い方です。このまま調子に乗って定期実行するLambdaの数を増やしていくと、また違った挙動を見せてくれるのかもしれません。また色々なパターンを増やして検証してみたいと思います。