Serverless Application ModelのCodeUriプロパティとデプロイメントパッケージの関係を理解する

はじめに

こんにちは、中山です。

最近Serverless Application Model(以下AWS SAM)の動作を検証しています。このモデルではLambda関数をAWS::Serverless::Functionリソースで定義可能です。このリソースで定義可能な CodeUri プロパティの挙動がいまいち理解できていなかったので、本エントリでまとめたいと思います。結論を先に書くと このプロパティはアーティファクトをS3にアップロードする前の段階でAWS SAM用テンプレートに定義し、Lambda関数のコードはディレクトリを分けて管理した方がよい と考えています。以下で詳しく解説します。

なお、本エントリを執筆する上で検証に利用した主要な各種ツールのバージョンは以下の通りです。バージョンによって結果が変更される可能性があるので、その点ご了承ください。

  • AWS SAM: 2016-10-31
  • AWS CLI: aws-cli/1.11.47 Python/2.7.12 Darwin/16.4.0 botocore/1.5.10

解説

まずはじめにAWS SAM利用時のデプロイフローをご紹介します。基本的に以下のフローになります。

  1. AWS SAM用CloudFormationテンプレートを作成
  2. Lambda関数などを作成
  3. aws cloudformation package コマンドで各種アーティファクト(Lambda関数のデプロイメントパッケージなど)をS3にアップロード
  4. aws cloudformation deploy コマンドでCloudFormationスタックを作成/更新

ステップ1で作成したテンプレートはステップ3実行時にAWS SAM用テンプレートへ変換されます。変換後のテンプレートはデフォルトで標準出力に表示されますが、 --output-template-file <file> オプションでファイルに出力することが可能です。例えば以下のようなテンプレートがあったとします。

  • sam.yml
---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Test

Resources:
  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handler.hello
      Runtime: python2.7

このファイルを以下のコマンドで変換(と同時にアップロード)してみます。 <_YOUR_S3_BUCKET_> はご自身の環境に置き換えてください。

$ aws cloudformation package \
  --template-file sam.yml \
  --s3-bucket <_YOUR_S3_BUCKET_> \
  --output-template-file packaged.yml

変換後のファイルは以下のようになります。

  • packaged.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Test
Resources:
  Func1:
    Properties:
      CodeUri: s3://<_YOUR_S3_BUCKET_>/0dc56727e0ba0463637fdeea23edc24c
      Handler: handler.hello
      Runtime: python2.7
    Type: AWS::Serverless::Function
Transform: AWS::Serverless-2016-10-31

微妙に細かい部分が変わっていますが、一番大きな変化は AWS::Serverless::Function リソースに CodeUri プロパティが追加されていることです。先程リンクしたドキュメントに記載されているように、このプロパティはLambda関数のデプロイメントパッケージへのパスを指定します。該当の部分を引用します。

Property Name Type Description
CodeUri string Required. S3 Uri to the function code. The S3 object this Uri references MUST be a Lambda deployment package.

ただし、S3に保存されたデプロイメントパッケージを確認すると分かりますが、Lambda関数のコードのみアップロードされるわけではない という点に注意してください。今回の例では中身が以下のようになっています。

$ aws s3 cp s3://<_YOUR_S3_BUCKET_>/0dc56727e0ba0463637fdeea23edc24c - | bsdtar -tvf -
-rwxrwxrwx  0 0      0          54 Feb 12 14:21 handler.py
-rwxrwxrwx  0 0      0         225 Feb 12 14:25 sam.yml

デプロイメントパッケージに本来不要である sam.yml が含まれてしまっています。後述しますが、アップロードされるファイルは CodeUri プロパティを指定しない場合、 aws cloudformation package コマンドを実行したカレントディレクトリ以下のファイルになります。この挙動はどういった場合に問題になるのでしょうか。それは、 Lambda関数のコードに変更がないにも関わらず更新されてしまう場合がある という点です。例えば sam.yml を以下のように修正したとします。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Test

Resources:
  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handler.hello
      Runtime: python2.7

Outputs:
  Func1Name:
    Value: !Ref Func1

アウトプットでLambda関数名を表示させているだけです。この修正後、テンプレートを変換すると以下のようにこのプロパティの値が書き換わることが確認できます。

AWSTemplateFormatVersion: 2010-09-09
Description: Test
Outputs:
  Func1Name:
    Value:
      Ref: Func1
Resources:
  Func1:
    Properties:
      CodeUri: s3://<_YOUR_S3_BUCKET_>/ce0dac5704bc958cb1e79046ccfab1b3
      Handler: handler.hello
      Runtime: python2.7
    Type: AWS::Serverless::Function
Transform: AWS::Serverless-2016-10-31

当然ですが、この状態で aws cloudformation deploy を実行すると、デプロイメントパッケージの内容が変更されたと判断されるので、以下のようにLambda関数が更新されます。

$ aws cloudformation describe-stack-events \
  --stack-name test
{
    "StackEvents": [
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "12cd83c0-f0ee-11e6-add6-50a68668d04a",
            "ResourceStatus": "UPDATE_COMPLETE",
            "ResourceType": "AWS::CloudFormation::Stack",
            "Timestamp": "2017-02-12T06:39:58.913Z",
            "StackName": "test",
            "PhysicalResourceId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "LogicalResourceId": "test"
        },
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "11faf180-f0ee-11e6-add6-50a68668d04a",
            "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
            "ResourceType": "AWS::CloudFormation::Stack",
            "Timestamp": "2017-02-12T06:39:57.518Z",
            "StackName": "test",
            "PhysicalResourceId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "LogicalResourceId": "test"
        },
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "Func1-UPDATE_COMPLETE-2017-02-12T06:39:54.441Z",
            "ResourceStatus": "UPDATE_COMPLETE",
            "ResourceType": "AWS::Lambda::Function",
            "Timestamp": "2017-02-12T06:39:54.441Z",
            "StackName": "test",
            "ResourceProperties": "{\"Role\":\"arn:aws:iam::************:role/test-Func1Role-1SA58HG9XM3BI\",\"Runtime\":\"python2.7\",\"Handler\":\"handler.hello\",\"Code\":{\"S3Bucket\":\"<_YOUR_S3_BUCKET_>\",\"S3Key\":\"ce0dac5704bc958cb1e79046ccfab1b3\"}}\n",
            "PhysicalResourceId": "test-Func1-LRSDPIWVG2J8",
            "LogicalResourceId": "Func1"
        },
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "Func1-UPDATE_IN_PROGRESS-2017-02-12T06:39:53.823Z",
            "ResourceStatus": "UPDATE_IN_PROGRESS",
            "ResourceType": "AWS::Lambda::Function",
            "Timestamp": "2017-02-12T06:39:53.823Z",
            "StackName": "test",
            "ResourceProperties": "{\"Role\":\"arn:aws:iam::************:role/test-Func1Role-1SA58HG9XM3BI\",\"Runtime\":\"python2.7\",\"Handler\":\"handler.hello\",\"Code\":{\"S3Bucket\":\"<_YOUR_S3_BUCKET_>\",\"S3Key\":\"ce0dac5704bc958cb1e79046ccfab1b3\"}}\n",
            "PhysicalResourceId": "test-Func1-LRSDPIWVG2J8",
            "LogicalResourceId": "Func1"
        },
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "0c6a8910-f0ee-11e6-b5ad-50d5ca9ff42a",
            "ResourceStatus": "UPDATE_IN_PROGRESS",
            "ResourceType": "AWS::CloudFormation::Stack",
            "Timestamp": "2017-02-12T06:39:48.163Z",
            "ResourceStatusReason": "User Initiated",
            "StackName": "test",
            "PhysicalResourceId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "LogicalResourceId": "test"
        },
<snip>

Lambda関数が更新された場合、どういった挙動になるのか検証する必要がありますが、多くの方にとってこの動作は望ましくないはずです。コードに変更がないのであれば、更新処理も実施して欲しくありません。また、スタックの状態が完了に遷移するまで時間が伸びる点も懸念されます。この問題に対する対処方法としては、冒頭にも記載したように以下2つの方法が考えられます。

  1. CodeUri プロパティへデプロイメントパッケージに含ませたいLambda関数への相対パスを指定する
  2. Lambda関数毎にディレクトリを分ける

1点目について。ドキュメントを見ると一件 CodeUri プロパティにはS3のパスしか指定できないように読めますが、これはあくまでスタックを作成/更新する時( aws cloudformation deploy を実行する時)の話です。テンプレートの変換前に指定した CodeUri プロパティは、変換後デプロイメントパッケージへのパスに書き換えてくれます。 aws cloudformation package のヘルプを見るとこのプロパティにローカルファイルへのパスが指定できると記載されています。少し長いですが該当の部分を引用します。

this command can upload local artifacts specified by following properties of a resource:

o BodyS3Location property for the AWS::ApiGateway::RestApi resource

o Code property for the AWS::Lambda::Function resource

o CodeUri property for the AWS::Serverless::Function resource

o DefinitionUri property for the AWS::Serverless::Api resource

o SourceBundle property for the AWS::ElasticBeanstalk::Application Version resource

o TemplateURL property for the AWS::CloudFormation::Stack resource

to specify a local artifact in your template, specify a path to a local file or folder, as either an absolute or relative path. The relative path is a location that is relative to your template's location.

for example, if your AWS Lambda function source code is in the /home/user/code/lambdafunction/ folder, specify CodeUri: /home/user/code/lambdafunction for the AWS::Serverless::Function resource. The command returns a template and replaces the local path with the S3 location: CodeUri: s3://mybucket/lambdafunction.zip.

if you specify a file, the command directly uploads it to the S3 bucket. If you specify a folder, the command zips the folder and then uploads the .zip file. For most resources, if you don't specify a path, the command zips and uploads the current working directory. The exception is AWS::ApiGateway::RestApi; if you don't specify a bodyS3Location, this command will not upload an artifact to S3.

少し話がそれますが、直接ファイルを指定するかディレクトリを指定するかどちらの方がよいのでしょうか。私は基本的にディレクトリの方がよいと思っています。非標準の外部モジュールをデプロイメントパッケージに含ませたい場合にそちらの方が都合がいいからです。例えばLambda関数と同じディレクトリ上に vendored というディレクトリを作成し、そこに外部モジュールを設置しておけばLambda関数から簡単に呼び出し可能です。Pythonの例ですが、以下のようにインポートできます。

import sys
import os
sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'vendored'))
import some-external-module

2点目について。例えばディレクトリ構成が以下のようになっているとします。

$ tree .
.
├── dir1
│   └── test1.txt
├── dir2
│   └── test2.txt
├── handler.py
└── sam.yml

2 directories, 4 files

この状態でいくら CodeUri プロパティを指定しても(というか指定するとしたら CodeUri: ./ になってこの用途には意味ないですが)、カレントディレクトリ以下がデプロイメントパッケージに含まれてしまうので、余計なファイルが入ります。ディレクトリの分け方はいろいろと考えられますが、例えば以下のようにLambda関数毎にディレクトリを分けたとします。

$ tree .
.
├── sam.yml
└── src
    └── handlers
        └── func1
            └── handler.py

3 directories, 2 files

この状態でテンプレートを以下のようにしておけば、特定のファイルのみデプロイメントパッケージに含ませることが可能です。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Test

Resources:
  Func1:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/func1
      Handler: handler.hello
      Runtime: python2.7

Outputs:
  Func1Name:
    Value: !Ref Func1

aws cloudformation package 実行後、S3にアップロードされたデプロイメントパッケージを確認すると期待した動作になっていることが確認できます。

$ aws s3 cp s3://<_YOUR_S3_BUCKET_>/a88a4814bf124443b1fd4617581e025f - | bsdtar -tvf -
-rwxrwxrwx  0 0      0          54 Feb 12 16:43 handler.py

デプロイメントパッケージに含まれるファイル以外で各種ファイルを修正しても、変換されたテンプレートの CodeUri プロパティは変更されません。

AWSTemplateFormatVersion: 2010-09-09
Description: Test
Resources:
  Func1:
    Properties:
      CodeUri: s3://<_YOUR_S3_BUCKET_>/a88a4814bf124443b1fd4617581e025f
      Handler: handler.hello
      Runtime: python2.7
    Type: AWS::Serverless::Function
Transform: AWS::Serverless-2016-10-31

当然 aws cloudformation deploy でスタックを更新しても、デプロイメントパッケージは変更されてないのでLambda関数も更新されません。

$ aws cloudformation describe-stack-events \
  --stack-name test
{
    "StackEvents": [
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "bf114ff0-f0f7-11e6-a153-50a68656dc62",
            "ResourceStatus": "UPDATE_COMPLETE",
            "ResourceType": "AWS::CloudFormation::Stack",
            "Timestamp": "2017-02-12T07:49:13.385Z",
            "StackName": "test",
            "PhysicalResourceId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "LogicalResourceId": "test"
        },
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "be3f59f0-f0f7-11e6-aa77-50a6866998c6",
            "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
            "ResourceType": "AWS::CloudFormation::Stack",
            "Timestamp": "2017-02-12T07:49:12.011Z",
            "StackName": "test",
            "PhysicalResourceId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "LogicalResourceId": "test"
        },
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "EventId": "ba6e54c0-f0f7-11e6-97e5-50d5ca9ff462",
            "ResourceStatus": "UPDATE_IN_PROGRESS",
            "ResourceType": "AWS::CloudFormation::Stack",
            "Timestamp": "2017-02-12T07:49:05.597Z",
            "ResourceStatusReason": "User Initiated",
            "StackName": "test",
            "PhysicalResourceId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/3d6ca860-f006-11e6-af5b-500c44f24ce6",
            "LogicalResourceId": "test"
        },
<snip>

複数のLambda関数を定義した場合どうなるのか

解説した方法であれば対応可能です。例えば以下のような複数のLambda関数を定義したテンプレートがあったとします。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Test

Resources:
  Func1:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/func1
      Handler: func1.hello
      Runtime: python2.7
  Func2:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/func2
      Handler: func2.hello
      Runtime: python2.7

ディレクトリは以下の構成です。

$ tree .
.
├── sam.yml
└── src
    └── handlers
        ├── func1
        │   └── func1.py
        └── func2
            └── func2.py

4 directories, 3 files

この状態で func2.py を更新後、テンプレートを変換してみます。すると以下のように更新したLambda関数のみ CodeUri プロパティが変更されていること(関数毎にプロパティの値が変わっていること)が確認できます。

AWSTemplateFormatVersion: 2010-09-09
Description: Test
Resources:
  Func1:
    Properties:
      CodeUri: s3://<_YOUR_S3_BUCKET_>/bfd5cbc1d484c4901175e3d180159bfe
      Handler: func1.hello
      Runtime: python2.7
    Type: AWS::Serverless::Function
  Func2:
    Properties:
      CodeUri: s3://<_YOUR_S3_BUCKET_>/3e0ecc95dc3ada08a3d0bfbbe72aebab
      Handler: func2.hello
      Runtime: python2.7
    Type: AWS::Serverless::Function
Transform: AWS::Serverless-2016-10-31

S3上のアーティファクトを確認すると、特定のコードのみがデプロイメントパッケージに含まれていることが確認できます。

$ aws s3 cp s3://<_YOUR_S3_BUCKET_>/bfd5cbc1d484c4901175e3d180159bfe - | bsdtar -tvf -
-rwxrwxrwx  0 0      0          54 Feb 12 17:03 func1.py
$ aws s3 cp s3://<_YOUR_S3_BUCKET_>/3e0ecc95dc3ada08a3d0bfbbe72aebab - | bsdtar -tvf -
-rwxrwxrwx  0 0      0          54 Feb 12 17:03 func2.py

まとめ

いかがだったでしょうか。

AWS SAMのCodeUriプロパティとデプロイメントパッケージの関係についてご紹介しました。この機能は昨年11月頃に発表された比較的新しい機能です。そのためか、まだWeb上の情報が少ない印象があります。今後もブログで検証した内容などをご紹介できればと思っています。

本エントリがみなさんの参考になれば幸いに思います。