本当にLambda Layersで幸せになれるのか? 旧式のデプロイ方式からLambda Layersを活用したデプロイ方式への移行を検討する
はじめに
サーバーレス開発部@大阪の岩田です。 re:Invent2018でLambda関連の新機能としてLambda Layersが発表されました。
【速報】【アップデート】Lambdaが複数のファンクションで共有するコードを持てるようになりました(Lambda Layer) #reinvent
この機能を使うことで複数のLambda Functionから共通利用するロジックやライブラリをLambda Function毎にデプロイする必要が無くなり、共通ロジックの管理が簡素化されるという待望の機能です!! ・・・本当にそうなんでしょうか??
マネジメントコンソールから直接コードを編集するようなレベルの小規模なLambda Functionであれば、Lambda Layersを使う事でライブラリをデプロイする手間が省けてハッピーになれそうです。 しかし、我々サーバーレス開発部における開発では1つのシステムを構築するために数10本のLambda Functionを作成します。 数10本のLambda Functionを効率的に管理するためにSAMやServerless Frameworkといったフレークワークを利用しますし、デプロイはCI/CDツールを活用して自動化しています。
個人的に引っかかったのが、このCI/CDツールとの連携部分です。 1つのLambda Functionを微修正しただけで新しいLayerがデプロイされてしまうと、Lambda FunctionとLambda Layersを分割したメリットが薄れます。 どのようにLambda FunctionとLambda Layersを分割してCI/CD環境に乗せればLambda Layersの恩恵を受けられるのでしょうか? この部分を検証しながら考察していきたいと思います。
検証用のアプリ
検証用に以下のような構成のアプリを使用します。 2つのLambda FunctionがぞれぞれAPI GWのバックエンドに指定される簡易なアプリです。 ライブラリのバージョン管理にはpipenvを、Lambda Functionの管理にはSAMを、CI/CDツールにはCircleCIを利用します。
後ほど検証で利用するので、requestsはpipenv install requests==2.20.0
でバージョン2.20.0を指定してインストールしておきます。
├── .circleci │ └── config.yml ├── Pipfile ├── Pipfile.lock ├── src │ ├── fuga.py │ ├── hoge.py └── template.yaml
Lambda Functionのソースコードです。
import json import requests def handler(event, context): ip = requests.get("http://checkip.amazonaws.com/") return { "statusCode": 200, "body": json.dumps( {"message": "hoge", "location": ip.text.replace("\n", "")} ), }
import json import requests def handler(event, context): ip = requests.get("http://checkip.amazonaws.com/") return { "statusCode": 200, "body": json.dumps( {"message": "fuga", "location": ip.text.replace("\n", "")} ), }
両方のLambda Functionでrequestsというライブラリを使用しており、requestsの管理をLambda Layersに移行することが目的です。
Lambda Layersが無かった頃
Lambda Layersが無かった頃は、下記のような設定を使用していました。 ※実際にはもっと色々やってますが、説明のために簡略化しています。
CircleCIの設定ファイルです
version: 2 references: pip_dependency_key: &pip_dependency_key v1-dependencies-{{ .Branch }}-{{ checksum "Pipfile" }}-{{ checksum "Pipfile.lock" }} pyenv_version_key: &pyenv_version_key 3.6.1 primary_container: &primary_container docker: - image: circleci/python:3.6 jobs: build: <<: *primary_container steps: - checkout - restore_cache: key: *pyenv_version_key - restore_cache: key: *pip_dependency_key - run: name: Install dependencies environment: PYENV_VIRTUALENV_CACHE_PATH: ~/.pyenv/cache PYTHON3_VERSION: 3.6.1 command: | set -x curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash export PATH="~/.pyenv/bin:$PATH" eval "$(pyenv init -)" eval "$(pyenv virtualenv-init -)" pyenv versions | grep -qF $PYTHON3_VERSION || pyenv install $PYTHON3_VERSION pyenv local $PYTHON3_VERSION python3 -m venv .venv source .venv/bin/activate pip install pipenv==11.10.0 awscli pipenv lock -r | pip install -r /dev/stdin -t src/vendor/ - save_cache: paths: - ~/.pyenv - .venv key: *pyenv_version_key - save_cache: paths: - src/vendor key: *pip_dependency_key deploy: <<: *primary_container steps: - checkout - restore_cache: key: *pyenv_version_key - restore_cache: key: *pip_dependency_key - run: name: deploy command: | set -x source .venv/bin/activate aws cloudformation package --template-file template.yaml --s3-bucket ${S3_BUCKET_FOR_DEPLOY} --output-template-file output.yaml aws cloudformation deploy --template-file output.yaml --stack-name blog --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --region ap-northeast-1 workflows: version: 2 deploy_workflow: jobs: - build - deploy: requires: - build
CircleCIのジョブbuild
の中でpipenv lock -r | pip install -r /dev/stdin -t src/vendor/
というコマンドを実行し、src/vendor
の中に必要なライブラリを詰め込んでいます。src/vendor
はv1-dependencies-{{ .Branch }}-{{ checksum "Pipfile" }}-{{ checksum "Pipfile.lock" }}
というキーでキャッシュしています。
同一ブランチで PipfileとPipfile.lockのハッシュが一致する場合はキャッシュを利用することでライブラリの導入がスキップされます。
次にSAMテンプレートです
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Globals: Function: Runtime: python3.6 Environment: Variables: PYTHONPATH: /var/runtime:/var/task/vendor Resources: HogeFunction: Type: AWS::Serverless::Function Properties: Handler: hoge.handler CodeUri: src Events: HelloWorld: Type: Api Properties: Path: /hoge Method: get FugaFunction: Type: AWS::Serverless::Function Properties: Handler: fuga.handler CodeUri: src Events: HelloWorld: Type: Api Properties: Path: /fuga Method: get Outputs: ApiEndpoint: Description: "API Gateway endpoint URL for Prod stage for Hello World function" Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
SAMテンプレートの方では環境変数PYTHONPATH
に/var/runtime:/var/task/vendor
を指定しており、build
ジョブの中で導入したライブラリがLambda Functionから利用できるようになっています。
Lambda Layersへの移行を検討する
ここからrequestsの管理をLambda Layersに移行していきます。 求める要件は下記の通りです。
- アプリが使用するライブラリに変更(ライブラリの追加、バージョンアップ等)があった場合に、自動的にLayerが更新されること
- 逆にライブラリに変更が無かった場合は、Lambda Functionがいくら更新されようがLayerは更新されないこと
どのように実現すれば良いのかを考えてみます。
SAMテンプレートを2つに分割し、ExportしたARNをFn::ImportValueで参照する
SAMテンプレートをLambda Layers用のテンプレートとLambda Function用のテンプレート2つに分割し、Lambda FunctionのテンプレートからはLambda LayersのテンプレートからExportされたARNをFn::ImportValue
で参照する方法です。
それぞれのSAMテンプレートはCircleCIの設定を利用し、別のライフサイクルでデプロイされるように管理します。
この方式だと、ライブラリに更新がかかった際にLambda Layersのスタックを更新しようとしても、別のスタックから参照されているというエラーが出てLambda Layersのスタックが更新できません。NGです。
SAMテンプレートを2つに分割し、Lambda Functionが利用するLayersのARNは手で指定する
ExportとFn::ImportValue
の組み合わせがダメだったので、いっそのこと手入力したらどうか?という発想です。自動化とは一体・・・ ということでNGです。
SAMテンプレートを2つに分割し、Lambda Functionが利用するLayersのARNはスクリプトで動的に指定する
CI/CDのジョブに一手間かましてLamba Layersを作成するスタックのOutPutからLambda Functionが利用するLayersのARNを取得し、Lambda Functionを作成するスタックのParametersに渡してやる方式です。 うまく行きそうな気もしますが、CI/CDツールを使ってLambda Functionをロールバックしたくなった場合に色々と考慮事項が多そうです。 Lambda Functionは旧バージョンに切り戻ったのに、Lambda Layersは新バージョンを参照しているといった不整合が懸念されます。
1つのSAMテンプレート内でLambda Layersを作成し、Lambda FunctionからはRefでARNを参照する
他のパターンも色々検討してみたのですが、テンプレートを分割するのはどうもうまく行かなさそうです。
という訳でテンプレートは1つのままで!Ref
を使ってLambd LayersとLambda Functionを連携させるのが良さそうです。
Lambda Layersへの移行に挑戦してみる
まずSAMテンプレートです。 ハイライト表示しているところが変更箇所です。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Globals: Function: Runtime: python3.6 Resources: HogeFunction: Type: AWS::Serverless::Function Properties: Handler: hoge.handler CodeUri: src Layers: - !Ref PythonModulesLayer Events: HelloWorld: Type: Api Properties: Path: /hoge Method: get FugaFunction: Type: AWS::Serverless::Function Properties: Handler: fuga.handler CodeUri: src Layers: - !Ref PythonModulesLayer Events: HelloWorld: Type: Api Properties: Path: /fuga Method: get PythonModulesLayer: Type: AWS::Serverless::LayerVersion Properties: Description: python modules Layer ContentUri: layer Outputs: ApiEndpoint: Description: "API Gateway endpoint URL for Prod stage for Hello World function" Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
AWS::Serverless::LayerVersion
でレイヤーを作成し、各Lambda Functionからは!Ref PythonModulesLayer
で作成したレイヤーを参照しています。
続いてCircleCIの設定ファイルです。
version: 2 references: pip_dependency_key: &pip_dependency_key v1-dependencies-{{ .Branch }}-{{ checksum "Pipfile" }}-{{ checksum "Pipfile.lock" }} pyenv_version_key: &pyenv_version_key 3.6.1 primary_container: &primary_container docker: - image: circleci/python:3.6 jobs: build: <<: *primary_container steps: - checkout - restore_cache: key: *pyenv_version_key - restore_cache: key: *pip_dependency_key - run: name: Install dependencies environment: PYENV_VIRTUALENV_CACHE_PATH: ~/.pyenv/cache PYTHON3_VERSION: 3.6.1 command: | set -x curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash export PATH="~/.pyenv/bin:$PATH" e###f##val "$(pyenv init -)" eval "$(pyenv virtualenv-init -)" pyenv versions | grep -qF $PYTHON3_VERSION || pyenv install $PYTHON3_VERSION pyenv local $PYTHON3_VERSION python3 -m venv .venv source .venv/bin/activate pip install pipenv==11.10.0 awscli pipenv lock -r | pip install -r /dev/stdin -t layer/python/lib/python3.6/site-packages/ - save_cache: paths: - ~/.pyenv - .venv key: *pyenv_version_key - save_cache: paths: - layer key: *pip_dependency_key deploy: <<: *primary_container steps: - checkout - restore_cache: key: *pyenv_version_key - restore_cache: key: *pip_dependency_key - run: name: deploy command: | set -x source .venv/bin/activate aws cloudformation package --template-file template.yaml --s3-bucket ${S3_BUCKET_FOR_DEPLOY} --output-template-file output.yaml aws cloudformation deploy --template-file output.yaml --stack-name blog --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --region ap-northeast-1 workflows: version: 2 deploy_workflow: jobs: - build - deploy: requires: - build
ライブラリの導入部分を
pipenv lock -r | pip install -r /dev/stdin -t layer/python/lib/python3.6/site-packages/
に変更しています。
SAMテンプレートの方ではLambda Layersのリソース定義でContentUri: layer
と指定しているので、CircleCIのジョブbuild
で準備したlayerというディレクトリがLambda Layersとしてデプロイされるという寸法です。
動作検証
まず普通にデプロイしてLambdaの動作を確認してみます。
OKです。 ちゃんとライブラリが読み込めています。
次にhoge.pyに空行を追加してコミット&プッシュしてみます。 Lambda Functionのみ更新され、Lambda Layersは更新されないのが期待値です。
Lambda Layersまで更新されてしまいました。。。これだとLayerを分けた意味が無いです。
動作を調べてみる
なぜhoge.pyを更新しただけでLambda Layersまで更新されたのか、少し深掘りしていきます。
まず、aws cloudformation package ... --output-template-file output.yaml
で生成されたYAMLファイルを確認してみます。
このコマンドではざっくり下記のような処理を行なっています。
ContentUri
とCodeUri
で指定されたフォルダをZIPに圧縮してS3にUPするContentUri
とCodeUri
をS3のパスに書き換えたoutput.yamlを出力する
出力されたoutput.yamlです。
....略 PythonModulesLayer: Properties: ContentUri: s3://<MY-S3BUCKET>/f573f400ab30de1b47217c0f329b0836 Description: python modules Layer Type: AWS::Serverless::LayerVersion
ContentUri
が指定したS3バケット+ハッシュ値という形式になっています。
試しに何度か手動デプロイを試したところ、このContentUri
が前回デプロイ時と同一であればLambda Layersは特に更新されませんでした。
アプリが使用するライブラリに変更が無い場合はContentUri
に変更が発生しないようにすれば良さそうです。
ContentUriの変換処理について調べる
ContentUriをどのように生成しているかAWS CLIのソースを確認してみます。 なおAWS CLIのバージョンは投稿時の最新版である1.16.81を使用しています。
...略 if local_path is None: # Build the root directory and upload to S3 local_path = parent_dir if is_s3_url(local_path): # A valid CloudFormation template will specify artifacts as S3 URLs. # This check is supporting the case where your resource does not # refer to local artifacts # Nothing to do if property value is an S3 URL LOG.debug("Property {0} of {1} is already a S3 URL" .format(property_name, resource_id)) return local_path local_path = make_abs_path(parent_dir, local_path) # Or, pointing to a folder. Zip the folder and upload if is_local_folder(local_path): return zip_and_upload(local_path, uploader) # Path could be pointing to a file. Upload the file elif is_local_file(local_path): return uploader.upload_with_dedup(local_path) ...略
ContentUri
に指定された値を判定してuploader.upload_with_dedup
もしくはzip_and_upload
を呼び出しています。
zip_and_upload
の定義を確認してみます
def zip_and_upload(local_path, uploader): with zip_folder(local_path) as zipfile: return uploader.upload_with_dedup(zipfile)
ZIPファイルを作成した後、uploader.upload_with_dedup
を呼び出しています。
ということでuploader.upload_with_dedup
を確認すれば良さそうです。
upload_with_dedup
の定義を確認してみます
def upload_with_dedup(self, file_name, extension=None): """ Makes and returns name of the S3 object based on the file's MD5 sum :param file_name: file to upload :param extension: String of file extension to append to the object :return: S3 URL of the uploaded object """ # This construction of remote_path is critical to preventing duplicate # uploads of same object. Uploader will check if the file exists in S3 # and re-upload only if necessary. So the template points to same file # in multiple places, this will upload only once filemd5 = self.file_checksum(file_name) remote_path = filemd5 if extension: remote_path = remote_path + "." + extension return self.upload(file_name, remote_path)
ZIPファイルのハッシュ値を取得してS3オブジェクトのキーとして利用しているようです。 よって、「ライブラリに変更が無い場合は、レイヤー用ZIPファイルのハッシュ値が同一になるようにCI/CDのパイプラインを構築する」とういうことが実現できれば目標を達成できそうです。
Lambda Layersへの移行に挑戦してみる(再)
調査結果を踏まえてテンプレートを修正していきます。まずSAMテンプレートです。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Globals: Function: Runtime: python3.6 Resources: HogeFunction: Type: AWS::Serverless::Function Properties: Handler: hoge.handler CodeUri: src Layers: - !Ref PythonModulesLayer Events: HelloWorld: Type: Api Properties: Path: /hoge Method: get FugaFunction: Type: AWS::Serverless::Function Properties: Handler: fuga.handler CodeUri: src Layers: - !Ref PythonModulesLayer Events: HelloWorld: Type: Api Properties: Path: /fuga Method: get PythonModulesLayer: Type: AWS::Serverless::LayerVersion Properties: Description: python modules Layer ContentUri: layer.zip Outputs: ApiEndpoint: Description: "API Gateway endpoint URL for Prod stage for Hello World function" Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
レイヤーの定義を一部修正し、ContentUri: layer.zip
としました。
CircleCI側でlayer.zipを管理し、必要な場合のみlayer.zipを作成することで、ライブラリに変更が無い場合はlayer.zipのハッシュ値が同一になるように制御します。
CircleCIの設定ファイルです。
version: 2 references: pip_dependency_key: &pip_dependency_key v1-dependencies-{{ .Branch }}-{{ checksum "Pipfile" }}-{{ checksum "Pipfile.lock" }} pyenv_version_key: &pyenv_version_key 3.6.1 primary_container: &primary_container docker: - image: circleci/python:3.6 jobs: build: <<: *primary_container steps: - checkout - restore_cache: key: *pyenv_version_key - restore_cache: key: *pip_dependency_key - run: name: Install dependencies environment: PYENV_VIRTUALENV_CACHE_PATH: ~/.pyenv/cache PYTHON3_VERSION: 3.6.1 command: | set -x curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash export PATH="~/.pyenv/bin:$PATH" eval "$(pyenv init -)" eval "$(pyenv virtualenv-init -)" pyenv versions | grep -qF $PYTHON3_VERSION || pyenv install $PYTHON3_VERSION pyenv local $PYTHON3_VERSION python3 -m venv .venv source .venv/bin/activate pip install pipenv==11.10.0 awscli pipenv lock -r | pip install -r /dev/stdin -t layer/python/lib/python3.6/site-packages/ cd layer sh zip.sh - save_cache: paths: - ~/.pyenv - .venv key: *pyenv_version_key - save_cache: paths: - layer - layer.zip key: *pip_dependency_key deploy: <<: *primary_container steps: - checkout - restore_cache: key: *pyenv_version_key - restore_cache: key: *pip_dependency_key - run: name: deploy command: | set -x source .venv/bin/activate aws cloudformation package --template-file template.yaml --s3-bucket ${S3_BUCKET_FOR_DEPLOY} --output-template-file output.yaml aws cloudformation deploy --template-file output.yaml --stack-name blog --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --region ap-northeast-1 workflows: version: 2 deploy_workflow: jobs: - build - deploy: requires: - build
新たに追加したシェルスクリプトzip.sh
でlayer.zipを作成しつつ、作成されたlayer.zipをキャッシュ対象に追加しています。
layer.zipを作成するシェルスクリプトです。
#!/bin/sh zip -r -u ../layer.zip python rc=$? if [ $rc -eq 12 ]; then exit 0 fi exit $rc
大したことはやっていないのですが、zipコマンドの完了コードが12(nothing to do.)の場合は完了コードに0を返すようにラッパーとして作成しています。 zipコマンドのオプションに-uを指定しつつlayerの中身はキャッシュを使って各ビルド間で持ち回しているので、利用するライブラリに変更が発生しない限り2回目以後のlayer.zip作成処理は特に何も実行されずに完了コード12で終了します。完了コード12をそのまま返してしまうと、CircleCIのジョブが失敗扱いにあるので一手間咬ましている訳です。
動作検証
まず普通にデプロイしてみます。
出来ました。 Lambdaの動作を確認してみます。
ちゃんとライブラリが読み込めています。
次にhoge.pyに空行を追加してコミット&プッシュしてみます。 Lambda Functionのみ更新され、Lambda Layersは更新されないのが期待値です。
今度はLambda Functionのみデプロイされています。
最後にrequestsのバージョンを変えてデプロイしてみます。
ローカル環境でpipenv install requests==2.21.0
を実行してPipfileとPipfile.lockを更新した後、コミット&プッシュしてみます。
ちゃんとレイヤーが更新されました。 期待通り動作してそうです。
まとめ
実業務においてLambda Layersを活用するためにはCI/CDをどのように変更すべきなのか考察してみました。
結論としては
- ContentUriの指定はローカルのZIPファイルを指定するように変える
- ContentUriで指定されたZIPファイルはCI/CDツールのジョブで作成する
- 作成したZIPファイルはCI/CDツールのキャッシュ機能を使い、ライブラリに変更が発生しない限りはビルド間で共有し再作成しない
という考え方に沿ってCI/CDのジョブ定義を修正していくのが良さそうです。 誰かの参考になれば幸いです。