Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
4
Help us understand the problem. What are the problem?

posted at

Lambda Internal Extensions で Lambdaのチューニングに役立つ情報を取得する

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のものになります。

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つの視点でみて行く必要があります。

  1. 他のAWSサービスとの統合方法やシステム要件などの俯瞰したアーキテクチャ的な視点
    • SQSを用いた非同期実装
    • EventBridgeを用いたイベントドリブン実装
    • StepFunctionsを用いたワークフロー構築
    • 継続的改善のための可観測性向上、X-Ray, CloudWatch Metrics(Logs), External Extensions
    • フロントキャッシュ
    • etc.
  2. AWS Lambdaサービス機能としてのチューニング
    • Provisioned Concurrencyによる暖機
    • Memory設定
    • VPC設定
    • Containerの利用
    • EFSの利用
    • etc.
  3. プログラミングコードとしてのチューニング
    • ランタイム言語ごとのプラクティス
    • Handler外のキャッシュ、再利用
    • 遅延が懸念される3rd Party APIの同期呼び出しを避ける
    • Lazy Loading (Pythonの場合、Lazy Importing)
    • etc.

このチューニングの中で、特に今回よく使われる技法として、PythonのLazy Importingについて着目し、Lambdaとの関わり方や、どのようにチューニングするかをみていきます。

Python での Lazy Importing

従来、Lazy Importingを行うには2つの方法がありました。

  1. ローカルインポートによる対処
  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にのも掲載されていますので、合わせてご覧ください。

利用手順はいたってシンプルです。

  1. SAM templateを用意
  2. メインのLambda関数実装
  3. LayerとしてWrapper Scriptを用意
  4. 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 環境変数の値を設定します。

全体的な階層は以下の通り

├── 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の実装は以下のとおり。

  #!/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を実装しています。

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実行してみました。

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のカラムには適切なインデントが施されているためネストが見やすくなっています。

最後の方の出力で


(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年付き合ってみて思ったことという内容で説明を書きましたので参照ください。

Provisioned Concurrencyは事前にLambda関数を暖機することができるので、あえてLazy Loadingにしなくても Provisioningフェーズでimport仕切ってしまえばよいという実装パターンがよく用いられます。

1st

こちらの資料でも、importはHandler外で実装することがプラクティスとして挙げられています。このようにProvisioned ConcurrencyのCapacityを超えない(spill overしない)リクエスト量であったり、もしくはApplication Auto ScalingでLambdaへのリクエスト増加に応じてProvisioned Concurrencyが適切にスケールするような設計をしている場合は、あえてLazy Loadingしないという考え方も取れるということに注意ください。

まとめ

本稿では、あまり脚光を浴びていなかった Lambda Internal Extensions をピックアップしその利用価値について説明しました。
Layerで導入できることから、チューニングフェーズのみ取り込んでみて、あとから外しておくこともできます。とても便利な機能ですのでぜひ皆さんのサービス開発にご活用ください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
4
Help us understand the problem. What are the problem?