ちょっと話題の記事

本当にLambda Layersで幸せになれるのか? 旧式のデプロイ方式からLambda Layersを活用したデプロイ方式への移行を検討する

実業務においてLambda Layersを活用するためにはCI/CDをどのように変更すべきなのか考察してみました
2018.12.28

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

サーバーレス開発部@大阪の岩田です。 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のソースコードです。

hoge.py

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", "")}
        ),
    }

fuga.py

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の設定ファイルです

.circleci/config.yml

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/vendorv1-dependencies-{{ .Branch }}-{{ checksum "Pipfile" }}-{{ checksum "Pipfile.lock" }}というキーでキャッシュしています。 同一ブランチで PipfileとPipfile.lockのハッシュが一致する場合はキャッシュを利用することでライブラリの導入がスキップされます。

次にSAMテンプレートです

template.yaml

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テンプレートです。 ハイライト表示しているところが変更箇所です。

template.yaml

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の設定ファイルです。

.circleci/config.yml

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ファイルを確認してみます。 このコマンドではざっくり下記のような処理を行なっています。

  • ContentUriCodeUriで指定されたフォルダをZIPに圧縮してS3にUPする
  • ContentUriCodeUriをS3のパスに書き換えたoutput.yamlを出力する

出力された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を使用しています。

aws-cli/awscli/customizations/cloudformation/artifact_exporter.py

...略
    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の定義を確認してみます

aws-cli/awscli/customizations/cloudformation/artifact_exporter.py

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の定義を確認してみます

aws-cli/awscli/customizations/s3uploader.py

    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テンプレートです。

template.yaml

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の設定ファイルです。

.circleci/config.yml

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を作成するシェルスクリプトです。

zip.sh

#!/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のジョブ定義を修正していくのが良さそうです。 誰かの参考になれば幸いです。