CodePipelineのInvokeアクションを利用してAWS Serverless Application Modelでバージョニングを有効化する

はじめに

こんにちは、中山です。

最近AWS Serverless Application Model(以下AWS SAM)をよく利用しています。基本的にCloudFormationを拡張したモデルという位置づけなので、シンプルで使いやすいところが気に入っています。Swaggerをネイティブな形でサポートしているのも嬉しいポイントです。こちらについては以下のエントリにまとめたので、よろしければ参照ください。

とても便利なモデルなのですが、執筆時点(2017/03/19)でLambda関数のバージョニングと(今回は触れない)エイリアスとの相性が少し悪い印象があります。それぞれ概要を説明すると、バージョニングとはアップロードしたLambda関数のコード毎にバージョン番号を付与する機能、エイリアスは特定のバージョンに対して別名を付ける機能です。より詳細な内容は以下のエントリにまとまっています。

CloudFormationではAWS::Lambda::Versionリソースでバージョニングを利用可能です。ただし、単純にこのリソースを使うだけでは期待した動作はしません。以下に解説します。

何故AWS SAMではバージョニングが難しいのか

この件については、こちらのイシューで議論が進んでいます。AWSの中の人であるsanathkrさんが何故AWS SAMでバージョニングを使うのが難しいのか、分かりやすく説明してくれているため引用します。

A Lambda version is an immutable resource which is created by AWS::Lambda::Version CFN resource type. To create a new Lambda version, you need to add a new AWS::Lambda::Version resource to your CFN template with a different resource name.This behavior makes it hard for SAM to automatically create versions for you. SAM template should somehow automatically store all earlier version resources

具体的なテンプレートで解説します。例えば以下のようなテンプレートがあったとします。

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

Resources:
  Func:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/func
      Handler: index.handler
      Runtime: python2.7

  FuncVersion1:
    DeletionPolicy: Retain
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !GetAtt Func.Arn

AWS::Lambda::Version リソースの FunctionName プロパティに、 AWS::Serverless::Function リソースで定義したLambda関数のARN(関数名でも可)を渡しています。このテンプレートからスタックを作成すると、最初の一度目は期待したとおりバージョン番号が付与されることが分かります。

$ aws lambda list-versions-by-function \
  --function-name test-Func-1318F6AZLTXJV \
  --query 'Versions[].Version'
[
    "$LATEST",
    "1"
]

ただし、Lambda関数を修正した後にスタックを更新しても期待した動作はしません。何故なら、先程引用したコメントに記載されているように、 AWS::Lambda::Version リソースの論理リソース名が同じなので、常に同じLambda関数を参照しているからです。つまり、 LambdaVersion1 を変更する必要があるというわけです。Lambda関数が1つであれば手動で変更すればよいですが、複数定義している場合は少し面倒です。

対抗フレームワークであるServerless Frameworkの場合、Lambda関数をデプロイする毎に自動的にバージョンを付与してくれます。仕組みを確認してみたのですが、どうやら .serverless ディレクトリ以下に出力されるCloudFormationテンプレートで、都度 AWS::Lambda::Version リソースを生成しているようでした。以下のようにLambda関数の変更前後で、論理リソース名を自動生成して新規のリソースを作成していることが分かります。

  • Lambda関数変更前
    "HelloLambdaVersionQIimobeZuA1DmP1rL7KrHtohCNCcmoUdMKKcEj83tQ": {
      "Type": "AWS::Lambda::Version",
      "DeletionPolicy": "Retain",
      "Properties": {
        "FunctionName": {
          "Ref": "HelloLambdaFunction"
        },
        "CodeSha256": "QIimobeZuA1Dm/P1rL7KrHtohCNCcmoUdMKKcEj83tQ="
      }
    }
  • Lambda関数変更後
    "HelloLambdaVersionWnVKUBrbvz10byODR19IjnzipK5qqXj47FLfdriE": {
      "Type": "AWS::Lambda::Version",
      "DeletionPolicy": "Retain",
      "Properties": {
        "FunctionName": {
          "Ref": "HelloLambdaFunction"
        },
        "CodeSha256": "W+nVKUBrbvz10byODR19IjnzipK+5qqXj+47FLfdriE="
      }
    }

一応この問題に対してAWS SAMでも対応を検討しているようですが、すぐに使いたい場合はワークアラウンドが必要です。方法はいろいろと考えられますが、今回はこちらのコメントで紹介されていた、CodePipelineのInvokeアクションで起動させたLambda関数によりバージョニングを有効化する方法をご紹介します。

ワークアラウンド

今回は以下のような構成を作ってみました。

aws

コードはGitHubにアップロードしておきました。ご自由にお使いください。

処理フローは単純です。GitHubからフェッチしてきたソースコードをもとに、CodePipelineによりテスト/承認/デプロイの後、InvokeアクションでLambda関数を起動させます。Lambda関数のコードは以下のリポジトリを参考に作っています。Node.js製だったので、深遠な理由によりPythonに変換しています。

CodePipeline/CodeBuildなどは以下のエントリで紹介したものを流用しています。

CodePipelineのInvokeアクション

CodePipelineはInvokeアクションでLambda関数を起動し、前後のステージと連携させることが可能です。詳細なドキュメントはこちらです。今回はCloudFormationで作っています。テンプレートは以下の通りです。

        - Name: Invoke
          Actions:
            - Name: publish-version
              ActionTypeId:
                Category: Invoke
                Owner: AWS
                Version: 1
                Provider: Lambda
              Configuration:
                FunctionName: !Ref FunctionName
              InputArtifacts:
                - Name: DeployOutput

内容は単純です。 FunctionName に起動したいLambda関数名を指定し、前段のステージ(デプロイ)から渡されたアウトプットを InputArtifacts に指定しています。

CodePipelineにアタッチするIAM Roleのポリシーに注意してください。Lambda関数の起動、起動させるLambda関数の表示を内部的に行っているので、以下のようなパーミッションが必要です。

              - Sid: LambdaAccess
                Effect: Allow
                Action:
                  - lambda:InvokeFunction
                  - lambda:ListFunctions
                Resource: "*"

バージョン番号を付与するLambda関数

from __future__ import print_function
from zipfile import ZipFile
from botocore.client import ClientError, Config
import boto3
import json

OUTPUT_ZIP_PATH = '/tmp/deploy-output.zip'
OUTPUT_JSON_PATH = 'deploy-output.json'
codepipeline = boto3.client('codepipeline')


def extract_file():
    with ZipFile(OUTPUT_ZIP_PATH) as zip:
        with zip.open(OUTPUT_JSON_PATH) as f:
            return json.loads(f.read())


def send_fail(job_id, message):
    codepipeline.put_job_failure_result(
            jobId=job_id,
            failureDetails={'type': 'JobFailed', 'message': message}
            )


def handler(event, context):
    print(event)

    job_id = event['CodePipeline.job']['id']
    bucket_name = event['CodePipeline.job']['data']['inputArtifacts'][0]['location']['s3Location']['bucketName']
    object_key = event['CodePipeline.job']['data']['inputArtifacts'][0]['location']['s3Location']['objectKey']
    try:
        boto3.resource('s3', config=Config(signature_version='s3v4')).meta.client.download_file(bucket_name, object_key, OUTPUT_ZIP_PATH)
    except ClientError as e:
        send_fail(job_id, e.response['Error'])
        return

    deploy_output = extract_file()
    for func_arn in deploy_output.keys():
        try:
            boto3.client('lambda').publish_version(FunctionName=deploy_output[func_arn])
        except ClientError as e:
            send_fail(job_id, e.response['Error'])
            return
    codepipeline.put_job_success_result(jobId=job_id)
    return event
  • 32行: インプットで渡されるデータを /tmp 以下にダウンロードしています
    • ファイルはアーティファクトのアップロード先バケットに保存され、 event にパスが記述されています
    • ファイル自体はZipで固められていて、JSONファイルが入っています
    • 保存するファイル名はCodePipeline側で指定可能です
    • 今回は以下のようにLambda関数のARN(CloudFormationのアウトプットの内容)が入ってます
{
  "Func2Arn": "arn:aws:lambda:us-east-1:************:function:aws-sam-simple-Func2-BTFIE1WXMZG5",
  "Func1Arn": "arn:aws:lambda:us-east-1:************:function:aws-sam-simple-Func1-15KJYJ3NJERWZ"
}
  • 40行: Boto3のpublish_versionメソッドを利用してバージョンを付与しています

CodePipelineからInvokeされた場合には、Lambda関数の処理が正常に終了したのか、そうでないのかをCodePipeline側に伝える必要があります。その処理には以下のメソッドを利用します。

動作確認

動作確認用にLambda関数を2つ定義しているリポジトリを作成しておきました。

まずこのコードにpushすると、CI/CDパイプラインが走って最終的にバージョンが付与されることが確認できます。

$ aws lambda list-versions-by-function \
  --function-name aws-sam-simple-Func1-15KJYJ3NJERWZ \
  --query 'Versions[].Version'
[
    "$LATEST",
    "1"
]
$ aws lambda list-versions-by-function \
  --function-name aws-sam-simple-Func2-BTFIE1WXMZG5 \
  --query 'Versions[].Version'
[
    "$LATEST",
    "1"
]

Lambda関数の修正後、コードをpushすると都度バージョンが付いていることが確認できます。

$ aws lambda list-versions-by-function \
  --function-name aws-sam-simple-Func1-15KJYJ3NJERWZ \
  --query 'Versions[].Version'
[
    "$LATEST",
    "1",
    "2"
]
$ aws lambda list-versions-by-function \
  --function-name aws-sam-simple-Func2-BTFIE1WXMZG5 \
  --query 'Versions[].Version'
[
    "$LATEST",
    "1",
    "2"
]

まとめ

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

CodePipelineのInvokeアクションを利用して、AWS SAMで定義しているLambda関数に自動でバージョンを付与する仕組みをご紹介しました。とりあえず「動くもの」を作ってみたレベルなので、この仕組がサービスに投入できるレベルなのか、利用する上で注意すべき内容などを詳細に調査していない点は注意してください。「こういった仕組みもありますよ」と理解いただければ幸いです。

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