Lambda Extensionsについて
AWSのblogで丁寧な説明がありますので、Extensionsそのものの詳細な説明は本稿からは割愛します。それでも、話の流れで必要な部分は少しずつ紹介しながら進めます。
AWS blog
仕組み
AWS Lambda Extensions (プレビュー) のご紹介でも書かれている通り、Extensionsはinternalとexternal二つのモードで実行することができます。
Internal extensionsは、利用者のコードと同じランタイムプロセスの一部として実行されます。ランタイムプロセスの起動を言語固有の環境変数とラッパースクリプトを用いて変更できます。Internal extensionsを用いて、コードの自動計測などのユースケースを実現することができます。
External extensionsは、対象のLambda関数のランタイムプロセスと同じ実行環境内の、別のプロセスで動作させることができます。External extensionsは、呼び出し前にシークレットを取得したり、関数呼び出しの外にいるカスタムな送信先へと測定情報を送信したりするようなユースケースで使用できます。こういったExtensionsはLambda関数に付随するプロセスとして実行されます。
そして、AWSの各種ブログではExternal extensionsの説明に 多くの項を割いていて、Internal Extensionsはほんの少ししか登場しない。
有名なこの図もExternal Extensionsのものになります。
External Extensionsはもちろん自作もできますが、AWSのMonitoringやSecurity系パートナーが提供するLambda Layerを組み込むだけですぐにLambdaを拡張することが可能になっています。(blogの中で、ローンチ時に利用可能なAWS Lambda Readyパートナーextensionとして紹介がありますのでご参考ください)
非常に便利ですね。
しかし本稿では、このExternal Extensionsではなく、あえてInternal Extensionsに光を当てて、その使い道を探っていきます。とくにAWS Lambdaのチューニングについてこんな使い方が出来ますよというご紹介にしていきたいと思います。
Internal Extensions
External Extensionsがサイドカー的に、Lambda実行環境にプロセスをアドオンしていたのに対し、Internal ExtensionsはHandlerが動くプロセスと同じプロセスで実行されるため、主としてRuntimeの拡張用途で使われることが多いです。
※AWSの公式Docにもランタイム環境の変更の方法が詳しく書かれているので合わせてそちらも是非お読みください。
言語固有の環境変数
Lambdaは、以下のように言語固有の環境変数を通じて、関数の初期化中に変化を加えることができるような設定をサポートしています。
言語 | 環境変数 | 説明 |
---|---|---|
Java | JAVA_TOOL_OPTIONS | Java 11 および Java 8 (java8.al2) では、Lambda はこの環境変数をサポートし、Lambda で追加のコマンドライン変数を設定します。この環境変数では、ツールの初期化を指定できます。具体的には、agentlib または javaagent オプションを使用して、ネイティブまたは Java プログラミング言語エージェントの起動を指定できます。 |
Node.js | NODE_OPTIONS | Node.js 10x 以降では、Lambda はこの環境変数をサポートします。 |
.NET | DOTNET_STARTUP_HOOKS | .NET Core 3.1 以降では、Lambda はこの環境変数によって提供されるアセンブリ (dll) へのパスを使用できます。 |
Lambdaのチューニング
AWS Lambda 関数を使用するためのベストプラクティスにも詳しく書かれていますが、
Lambdaのチューニングにはそのスコープごとに3つの視点でみて行く必要があります。
- 他のAWSサービスとの統合方法やシステム要件などの俯瞰したアーキテクチャ的な視点
- SQSを用いた非同期実装
- EventBridgeを用いたイベントドリブン実装
- StepFunctionsを用いたワークフロー構築
- 継続的改善のための可観測性向上、X-Ray, CloudWatch Metrics(Logs), External Extensions
- フロントキャッシュ
- etc.
- AWS Lambdaサービス機能としてのチューニング
- Provisioned Concurrencyによる暖機
- Memory設定
- VPC設定
- Containerの利用
- EFSの利用
- etc.
- プログラミングコードとしてのチューニング
- ランタイム言語ごとのプラクティス
- Handler外のキャッシュ、再利用
- 遅延が懸念される3rd Party APIの同期呼び出しを避ける
- Lazy Loading (Pythonの場合、Lazy Importing)
- etc.
このチューニングの中で、特に今回よく使われる技法として、PythonのLazy Importingについて着目し、Lambdaとの関わり方や、どのようにチューニングするかをみていきます。
Python での Lazy Importing
従来、Lazy Importingを行うには2つの方法がありました。
- ローカルインポートによる対処
- LazyLoaderによる対処
最初の方法で、まず思いつく方法は、グローバルインポートではなくローカルインポートを実行する方法です。(つまり、モジュールのグローバル部分ではなくHandler関数内で必要な場合にだけインポート)。これは、インポートするモジュールを実際に必要とするコードを実行するまでインポートを遅延するように機能します。しかし、同じインポートステートメントを何度も作成しなければならないという欠点があります。
また、個々のモジュール内でローカルインポートを行うと(あるいは開発者が複数の場合)、グローバールインポートを回避しようとしていたライブラリが何かを忘れて、不本意にグローバルにインポートしてしまうこともよくあります。
したがって、このアプローチは機能しますが、実装者に注意が必要です。(実装者の責務)
2つ目が、LazyLoaderによる対処方法です。
このLazyLoaderクラスを使うと、Importingテスト実施のオーバーヘッドを最小限に抑えることができます。ローカルインポートに対するLazyLoaderの利点は、モジュールが属性にアクセスするまで、モジュールのローディングを延期してくれます。(LazyLoaderの責務)
なるほど。これらの方法でLazy Loadingが実装できたので"よかったよかった"なのですが本当にLazy Loadingされているかを確認する方法ってどうしたら良いでしょうか。
課題感
- Pythonコードの中でグローバルインポート/ローカルインポートしている部分をログから追跡/確認したい
- 実際にimportされている部分のロード時間を計測したい
この課題に対処するアプローチがPythonのruntimeオプションである implementation-specific optionになります。
話しがここまでたどり着くのに長くなってしまったのですが、これまでのLambdaはこのRuntime Optionに触れることが難しかったのですが、Internal Extensionsを利用すると簡単に設定できるようになります。(本稿で言いたいのはこの部分でした。)
Python の implementation-specific option
さまざまな実装固有のオプションとして、以下のように定義されています。 (CPythonでは、次の可能な値を定義)
-X faulthandler: enable faulthandler
-X showrefcount: output the total reference count and number of used
memory blocks when the program finishes or after each statement in the
interactive interpreter. This only works on debug builds
-X tracemalloc: start tracing Python memory allocations using the
tracemalloc module. By default, only the most recent frame is stored in a
traceback of a trace. Use -X tracemalloc=NFRAME to start tracing with a
traceback limit of NFRAME frames
-X showalloccount: output the total count of allocated objects for each
type when the program finishes. This only works when Python was built with
COUNT_ALLOCS defined
-X importtime: show how long each import takes. It shows module name,
cumulative time (including nested imports) and self time (excluding
nested imports). Note that its output may be broken in multi-threaded
application. Typical usage is python3 -X importtime -c 'import asyncio'
-X dev: enable CPython’s “development mode”, introducing additional runtime
checks which are too expensive to be enabled by default. Effect of the
developer mode:
* Add default warning filter, as -W default
* Install debug hooks on memory allocators: see the PyMem_SetupDebugHooks() C function
* Enable the faulthandler module to dump the Python traceback on a crash
* Enable asyncio debug mode
* Set the dev_mode attribute of sys.flags to True
* io.IOBase destructor logs close() exceptions
-X utf8: enable UTF-8 mode for operating system interfaces, overriding the default
locale-aware mode. -X utf8=0 explicitly disables UTF-8 mode (even when it would
otherwise activate automatically)
-X pycache_prefix=PATH: enable writing .pyc files to a parallel tree rooted at the
given directory instead of to the code tree
この中で今回利用するのは、-X importtime になります。
各インポートにかかる時間を示します。モジュール名が表示され、累積ロード時間(ネストされたインポートを含む)と自己ロード時間(ネストされたインポート時間は除外)。
※計測時間出力の精度はマルチスレッド計測なので、不正値も含まれることに注意してください。
Python 3.8 でのラッパースクリプトの作成と使用
Python 3.8 でのラッパースクリプトの作成と使用について、公式Docにのも掲載されていますので、合わせてご覧ください。
利用手順はいたってシンプルです。
- SAM templateを用意
- メインのLambda関数実装
- LayerとしてWrapper Scriptを用意
- Lambda関数のDeploy
今回、Layerの中にimport用のモジュールの定義しました。
実装の説明
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
python-wrapper
Globals:
Function:
Timeout: 20
Resources:
PythonWrapperFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: app.lambda_handler
Runtime: python3.8
Layers:
- !Ref PythonWrapperLayer
Environment:
Variables:
AWS_LAMBDA_EXEC_WRAPPER: "/opt/importtime_wrapper" #スクリプトのファイルシステムパス
PythonWrapperLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: python-wrapper-layer
Description: Dependencies for the Function
ContentUri: layer/
CompatibleRuntimes:
- python3.8
LicenseInfo: 'MIT'
RetentionPolicy: Retain
```
基本的なSAMの構文説明は割愛しますが、Internal Extensionsスクリプトを指定するには、実行可能バイナリまたはスクリプトのファイルシステムパスとして AWS_LAMBDA_EXEC_WRAPPER 環境変数の値を設定します。
全体的な階層は以下の通り
```bash
├── layer
│ ├── importtime_wrapper
│ └── python
│ └── util
│ ├── __init__.py
│ ├── inside.py
│ └── outside.py
├── src
│ ├── __init__.py
│ ├── app.py
│ └── requirements.txt
└── template.yaml
```
inside.pyとoutside.pyはそれぞれローカルとグローバルインポートする予定のモジュールです。(本来はInternal Extensionsと別のLayerで管理すべきですが簡単のために同居させています。)
LayerのそれぞれのPythonモジュールはechoするだけの機能しか持っていません。
注意点としてimporttime_wrapperには**chmod +x**しておくのを忘れないようにしましょう。
importtime_wrapperの実装は以下のとおり。
```bash
#!/bin/bash
# the path to the interpreter and all of the originally intended arguments
args=("$@")
# the extra options to pass to the interpreter
extra_args=("-X" "importtime")
# insert the extra options
args=("${args[@]:0:$#-1}" "${extra_args[@]}" "${args[@]: -1}")
# start the runtime with the extra options
exec "${args[@]}"
```
**extra_args=("-X" "importtime")** にて、implementation-specific optionを設定しています。
ここを他のさまざまなオプションに切り替えることも出来ますので、是非試してみてください。
Handler関数は、グローバルおよびローカルでimportを実装しています。
```python
import json
from util import outside
outside.echo('this is loaded outside')
def lambda_handler(event, context):
from util import inside
inside.echo('this is loaded inside')
return {
"statusCode": 200,
"body": json.dumps({
"message": "hello world"
}),
}
```
##### 実行してみる
デプロイしたLambdaをTest実行してみました。
```bash
START RequestId: e949850e-1435-473b-a852-a368dc23a358 Version: $LATEST
import time: self [us] | cumulative | imported package
import time: 749 | 749 | _frozen_importlib_external
import time: 293 | 293 | time
import time: 342 | 635 | zipimport
import time: 82 | 82 | _codecs
import time: 1121 | 1203 | codecs
import time: 637 | 637 | encodings.aliases
import time: 1464 | 3302 | encodings
import time: 297 | 297 | encodings.utf_8
import time: 191 | 191 | _signal
import time: 374 | 374 | encodings.latin_1
import time: 55 | 55 | _abc
import time: 413 | 467 | abc
import time: 577 | 1043 | io
import time: 79 | 79 | _stat
import time: 621 | 699 | stat
import time: 1420 | 1420 | _collections_abc
import time: 246 | 246 | genericpath
import time: 422 | 668 | posixpath
import time: 913 | 3699 | os
import time: 287 | 287 | _sitebuiltins
import time: 52 | 52 | pwd
import time: 307 | 307 | sitecustomize
import time: 104 | 104 | usercustomize
import time: 1146 | 5593 | site
import time: 388 | 388 | types
import time: 868 | 1255 | enum
import time: 69 | 69 | _sre
import time: 368 | 368 | sre_constants
import time: 635 | 1003 | sre_parse
import time: 442 | 1513 | sre_compile
import time: 75 | 75 | _operator
import time: 422 | 497 | operator
import time: 298 | 298 | keyword
import time: 341 | 341 | _heapq
import time: 315 | 655 | heapq
import time: 220 | 220 | itertools
import time: 309 | 309 | reprlib
import time: 106 | 106 | _collections
import time: 1991 | 4073 | collections
import time: 76 | 76 | _functools
import time: 924 | 5071 | functools
import time: 225 | 225 | _locale
import time: 275 | 275 | copyreg
import time: 961 | 9298 | re
import time: 415 | 415 | _json
import time: 714 | 1128 | json.scanner
import time: 628 | 11053 | json.decoder
import time: 679 | 679 | json.encoder
import time: 898 | 12629 | json
import time: 256 | 256 | token
import time: 1217 | 1472 | tokenize
import time: 347 | 1819 | linecache
import time: 488 | 2307 | traceback
import time: 462 | 462 | warnings
import time: 353 | 353 | _weakrefset
import time: 803 | 1156 | weakref
import time: 292 | 292 | collections.abc
import time: 49 | 49 | _string
import time: 1005 | 1054 | string
import time: 845 | 845 | threading
import time: 49 | 49 | atexit
import time: 3321 | 9483 | logging
import time: 1196 | 1196 | http
import time: 438 | 438 | email
import time: 569 | 569 | email.errors
import time: 671 | 671 | binascii
import time: 451 | 451 | email.quoprimime
import time: 434 | 434 | _struct
import time: 260 | 693 | struct
import time: 880 | 1572 | base64
import time: 329 | 1901 | email.base64mime
import time: 233 | 233 | quopri
import time: 207 | 439 | email.encoders
import time: 387 | 825 | email.charset
import time: 1101 | 4947 | email.header
import time: 453 | 453 | math
import time: 283 | 283 | _bisect
import time: 297 | 580 | bisect
import time: 414 | 414 | _sha512
import time: 407 | 407 | _random
import time: 1435 | 3287 | random
import time: 652 | 652 | _socket
import time: 422 | 422 | select
import time: 866 | 1287 | selectors
import time: 118 | 118 | errno
import time: 2574 | 4629 | socket
import time: 595 | 595 | _datetime
import time: 1333 | 1928 | datetime
import time: 543 | 543 | urllib
import time: 1276 | 1818 | urllib.parse
import time: 1128 | 1128 | locale
import time: 1660 | 2788 | calendar
import time: 465 | 3252 | email._parseaddr
import time: 840 | 15752 | email.utils
import time: 509 | 21207 | email._policybase
import time: 879 | 22654 | email.feedparser
import time: 445 | 23536 | email.parser
import time: 324 | 324 | uu
import time: 465 | 465 | email._encoded_words
import time: 246 | 246 | email.iterators
import time: 1005 | 2039 | email.message
import time: 10632 | 10632 | _ssl
import time: 4103 | 14735 | ssl
import time: 1423 | 42928 | http.client
import time: 3408 | 3408 | rapid_client
import time: 746 | 746 | lambda_internal
import time: 260 | 260 | __future__
import time: 534 | 534 | numbers
import time: 1405 | 1939 | _decimal
import time: 315 | 2253 | decimal
import time: 344 | 344 | lambda_internal.simplejson.errors
import time: 225 | 225 | lambda_internal.simplejson.raw_json
import time: 373 | 373 | importlib
import time: 203 | 576 | lambda_internal.simplejson.compat
import time: 89 | 89 | simplejson
import time: 119 | 208 | simplejson.raw_json
import time: 482 | 689 | lambda_internal.simplejson._speedups
import time: 346 | 1034 | lambda_internal.simplejson.scanner
import time: 71 | 71 | simplejson
import time: 21 | 92 | simplejson.raw_json
import time: 130 | 221 | lambda_internal.simplejson._speedups
import time: 445 | 2275 | lambda_internal.simplejson.decoder
import time: 74 | 74 | simplejson
import time: 26 | 99 | simplejson.raw_json
import time: 106 | 204 | lambda_internal.simplejson._speedups
import time: 760 | 964 | lambda_internal.simplejson.encoder
import time: 645 | 7708 | lambda_internal.simplejson
import time: 464 | 464 | lambda_runtime_exception
import time: 900 | 9071 | lambda_runtime_marshaller
import time: 2398 | 57804 | lambda_runtime_client
import time: 258 | 258 | importlib.machinery
import time: 629 | 629 | importlib.abc
import time: 841 | 841 | contextlib
import time: 494 | 1963 | importlib.util
import time: 488 | 2708 | imp
import time: 266 | 266 | util
import time: 271 | 271 | util.outside
this is loaded outside
import time: 234 | 234 | util.inside
this is loaded inside
END RequestId: e949850e-1435-473b-a852-a368dc23a358
REPORT RequestId: e949850e-1435-473b-a852-a368dc23a358 Duration: 1.21 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 51 MB Init Duration: 124.62 ms
```
ネストされたロード時間がマイクロ秒単位でロードモジュールごとに列挙されています。自身のロード時間(μs)そして累計のネストロード時間(μs)が表形式で出力されています。また、import packageのカラムには適切なインデントが施されているためネストが見やすくなっています。
最後の方の出力で
```bash
(snip)
import time: 266 | 266 | util
import time: 271 | 271 | util.outside
this is loaded outside
import time: 234 | 234 | util.inside
this is loaded inside
END RequestId: e949850e-1435-473b-a852-a368dc23a358
(snip)
```
`this is loaded outside` より上がグローバルにロードされたモジュールで、そこから`this is loaded inside`までがローカルに(Handler内で)ロードされたモジュールです。
モジュールごとのロード時間とネストの深さ、累計のロード時間がグローバルおよびローカルに分類されて計測されているためPythonコードのチューニングポイントがさらに分かりやすくなったのではないでしょうか?
# Provisioned Concurrency と Lazy Loading の関係について
いったんここまででInternal Extensionsの話は終わりですが、ちょっと補足としてProvisioned ConcurrencyとLazy Loadingの関係について説明しておきます。
Provisioned ConcurrencyはLambda関数インスタンスのColdStart対策など(他にも効用はありますが)で用いられる暖機機能です。
※Provisioned Concurrency自体は別のblogに [LambdaのProvisioned Concurrencyと1年付き合ってみて思ったこと](https://qiita.com/_kensh/items/2c8c884d709e429d0993)という内容で説明を書きましたので参照ください。
Provisioned Concurrencyは事前にLambda関数を暖機することができるので、あえてLazy Loadingにしなくても Provisioningフェーズでimport仕切ってしまえばよいという実装パターンがよく用いられます。
[![1st](https://files.speakerdeck.com/presentations/5eb45ff4ea8b4db79162a9e8a65e5bcd/preview_slide_52.jpg)](https://speakerdeck.com/_kensh/aws-lambda-provisioned-concurrency-dive-deep-and-practice?slide=53)
こちらの資料でも、importはHandler外で実装することがプラクティスとして挙げられています。このようにProvisioned ConcurrencyのCapacityを超えない(spill overしない)リクエスト量であったり、もしくはApplication Auto ScalingでLambdaへのリクエスト増加に応じてProvisioned Concurrencyが適切にスケールするような設計をしている場合は、あえてLazy Loadingしないという考え方も取れるということに注意ください。
# まとめ
本稿では、あまり脚光を浴びていなかった Lambda Internal Extensions をピックアップしその利用価値について説明しました。
Layerで導入できることから、チューニングフェーズのみ取り込んでみて、あとから外しておくこともできます。とても便利な機能ですのでぜひ皆さんのサービス開発にご活用ください。