AWS SAMを通してCodeDeployを利用したLambda関数のデプロイを理解する – ClassmethodサーバーレスAdvent Calendar 2017 #serverless #adventcalendar #reinvent

はじめに

こんにちは、中山です。

このエントリはServerless Advent Calendar 2017 7日目の記事です。

以前、3日目のエントリでAWS SAMのアップデートをご紹介しました。

AWS SAMがめちゃめちゃアップデートされてる件 – ClassmethodサーバーレスAdvent Calendar 2017 #serverless #adventcalendar #reinvent

本エントリではこのアップデートの内、安全なLambda関数のデプロイについてより詳細にご紹介したいと思います。

目次

基本的な機能の紹介

「安全なLambda関数のデプロイ」とは、要するにre:Invent 2017中に発表されたCodeDeployの機能を利用したデプロイのことです。この機能を利用することで以下のような柔軟なデプロイが可能となります。

  • エイリアスが付与された新しいLambda関数に対して段階的にトラフィックを流す
  • CloudWatch Alarmを設定し、デプロイ中にしきい値を超過した場合以前のLambda関数にロールバックさせる
  • デプロイ前後にLambda関数を実行して任意のロジック(例えばE2Eテストなど)を組み込む

このアップデートはAWS SAMからも利用可能です。 AWS::Serverless::Lambda リソースに DeploymentPreference プロパティを設定することで使えます。現在サポートされている設定は以下3つです。

  • Type : どういった方法で新しいLambda関数にトラフィックを流すか
  • Alarms : デプロイ中、しきい値を超過した場合に以前のLambda関数へロールバックさせたいCloudWatch Alarm
  • Hooks : デプロイの前後で実行させたいLambda関数

基本機能を解説したところで、より詳細な内容を以下で解説していきます。

DeploymentPreferenceプロパティを有効化すると何が作成されるのか

AWS SAMとは要するにCloudFormationの拡張機能です。AWS SAM特有のリソースタイプが複数存在していますが、これらを使ったとしても最終的に素のCloudFormationがパース可能なリソースタイプに変換されます。例えば、 AWS::Serverless::Function リソースを利用してスタックを作成すると、実際には AWS::Lambda::Function が作られることが分かるでしょう。

では AWS::Serverless:Function リソースに DeploymentGroup プロパティを付与するとどのようなリソースが作成されるのか、確認してみましょう。ドキュメントにまとまっていますが、より詳細に見ていきたいと思います。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Safe Lambda Deployment Samples - Basic

Resources:
  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      Handler: index.handler
      AutoPublishAlias: dev
      CodeUri: src/handlers/func1
      DeploymentPreference:
        Enabled: true
        Type: AllAtOnce

スタック作成後、 該当のプロパティによってどのようなリソースが作られたのか確認してみます。

$ aws cloudformation describe-stack-resources \
  --stack-name basic-dev-v1 \
  --query 'StackResources[?contains(LogicalResourceId,`Deploy`)].[ResourceType,LogicalResourceId,PhysicalResourceId]' \
  --output text
AWS::IAM::Role  CodeDeployServiceRole   basic-dev-v1-CodeDeployServiceRole-1U9ZZRVD66DOA
AWS::CodeDeploy::DeploymentGroup        Func1DeploymentGroup    basic-dev-v1-Func1DeploymentGroup-1HBVC4XH7PXRG
AWS::CodeDeploy::Application    ServerlessDeploymentApplication basic-dev-v1-ServerlessDeploymentApplication-1GDVKHZYTTA0N

出力結果から以下の点が確認できます。

  • CodeDeployのService Role用IAM Roleが作成される
  • CodeDeployのDeployment Groupが作成される
  • CodeDeployのApplicationが作成される
  • 各物理リソースIDはスタック名をベースにランダムな文字列になる
  • Deployment Groupの論理リソースIDは <AWS::Serverless::Functionリソースの論理リソースID>DeploymentGroup という形式になる
    • ドキュメントに記載されているようにDeployment Groupが AWS::Serverless::Function リソース毎に作成される
  • 逆に、 AWS::IAM::RoleAWS::CodeDeploy::Application リソースの論理リソースIDは一意になるような命名になっている
    • つまり、上記2リソースはスタック毎に1つだけ作成されることが分かる

更に詳細を眺めてみます。まずService Role用IAM Roleです。どのようなポリシーがアタッチされているか見てみると、マネージドポリシーである AWSCodeDeployRoleForLambda ポリシーのみ付与されていることが分かります。

$ aws iam list-attached-role-policies \
  --role-name basic-dev-v1-CodeDeployServiceRole-1U9ZZRVD66DOA
{
    "AttachedPolicies": [
        {
            "PolicyName": "AWSCodeDeployRoleForLambda",
            "PolicyArn": "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambda"
        }
    ]
}

Applicationの設定内容を確認してみます。compute platformがLambdaになっている/Deployment Groupが存在していることが分かります。

$ aws deploy get-application \
  --application-name basic-dev-v1-ServerlessDeploymentApplication-1GDVKHZYTTA0N
{
    "application": {
        "applicationId": "1b646ab6-8e84-49bd-b4a0-8a7f8ace269a",
        "applicationName": "basic-dev-v1-ServerlessDeploymentApplication-1GDVKHZYTTA0N",
        "createTime": 1512243240.848,
        "linkedToGitHub": false,
        "computePlatform": "Lambda"
    }
}
$ aws deploy list-deployment-groups \
  --application-name basic-dev-v1-ServerlessDeploymentApplication-1GDVKHZYTTA0N
{
    "deploymentGroups": [
        "basic-dev-v1-Func1DeploymentGroup-1HBVC4XH7PXRG"
    ]
}

Deployment Groupの設定は以下のようになっています。デフォルトでロールバックが有効化されていることが分かります。

$ aws deploy get-deployment-group \
  --application-name basic-dev-v1-ServerlessDeploymentApplication-1GDVKHZYTTA0N \
  --deployment-group-name basic-dev-v1-Func1DeploymentGroup-1HBVC4XH7PXRG
{
    "deploymentGroupInfo": {
        "applicationName": "basic-dev-v1-ServerlessDeploymentApplication-1GDVKHZYTTA0N",
        "deploymentGroupId": "65a8398d-66ea-452b-ba57-0eb11ffa9dee",
        "deploymentGroupName": "basic-dev-v1-Func1DeploymentGroup-1HBVC4XH7PXRG",
        "deploymentConfigName": "CodeDeployDefault.LambdaAllAtOnce",
        "ec2TagFilters": [],
        "onPremisesInstanceTagFilters": [],
        "autoScalingGroups": [],
        "serviceRoleArn": "arn:aws:iam::************:role/basic-dev-v1-CodeDeployServiceRole-1U9ZZRVD66DOA",
        "triggerConfigurations": [],
        "autoRollbackConfiguration": {
            "enabled": true,
            "events": [
                "DEPLOYMENT_STOP_ON_REQUEST",
                "DEPLOYMENT_STOP_ON_ALARM",
                "DEPLOYMENT_FAILURE"
            ]
        },
        "deploymentStyle": {
            "deploymentType": "BLUE_GREEN",
            "deploymentOption": "WITH_TRAFFIC_CONTROL"
        },
        "computePlatform": "Lambda"
    }
}

ドキュメントにも記載されていますが、素のCloudFormationに変換されたテンプレートを見ると AWS::Lambda::Alias リソースに UpdatePolicy が設定されていることが分かります。この設定がされると、Lambda関数のエイリアスに変化が発生した時にCodeDeployを利用したデプロイが利用可能になります。 AWS::Lambda::AliasUpdatePolicy の使い方はこちらのドキュメントにまとまっています。今回は特にHookの設定を行っていないためApplication/Deployment Groupのみ設定されています。

"Func1Aliasv1": {
  "Type": "AWS::Lambda::Alias",
  "UpdatePolicy": {
    "CodeDeployLambdaAliasUpdate": {
      "ApplicationName": {
        "Ref": "ServerlessDeploymentApplication"
      },
      "DeploymentGroupName": {
        "Ref": "Func1DeploymentGroup"
      }
    }
  },

まとめます。 DeploymentPreference プロパティを有効化するとCodeDeploy関連のリソースが自動的に作成されることが分かりました。もちろんAWS SAMを利用せず素のCloudFormationでこれらのリソースを定義することも可能ですが、プロパティを設定するだけで自動的に作成してくれるのは便利ですね。

トラフィックはどのように流れていくのか

Type に「トラフィックの流し方」を指定することでどのように新しいLambda関数へトラフィックを流したいか指定可能です。該当のドキュメントはこちらです。細かい設定が可能になっています。大きく分けて3パターンあることが分かります。

  • LinearXPercentYMinutes
    • Y分毎にXパーセントずつ新しいLambda関数へトラフィックを流し、100%になるまでこれを継続する
  • CanaryXPercentYMinutes
    • XパーセントのトラフィックをY分間新しいLambda関数へ流し、その後一気に100パーセントのトラフィックを流す
  • AllAtOnce
    • 即時で100%のトラフィックを新しいLambda関数に流す
    • Hooks などを使える以外は既存のデプロイ方法と同じ

それぞれどういった動きになるのか、各パターンのうち1つをピックアップして検証してみましょう。なお、検証にはAPI GatewayのバックエンドにLambda関数が存在し、GETメソッドでInvoke、レスポンスに現在のバージョンを返すという動作にしています。

補足すると、CodeDeployはデプロイメントコンフィギュレーションをカスタマイズ可能なため、事前に用意されているものでは要件に合わない場合に自分で作成することも可能です。

LinearXPercentYMinutes

事前に用意されている設定は以下の通りです。

  • Linear10PercentEvery1Minute
  • Linear10PercentEvery2Minutes
  • Linear10PercentEvery3Minutes
  • Linear10PercentEvery10Minutes

今回は Linear10PercentEvery1Minute を試してみます。サンプルとなるテンプレートは以下の通りです。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Safe Lambda Deployment Samples - Linear10PercentEvery1Minute

Parameters:
  Stage:
    Type: String
    Default: dev
  ArtifactBucket:
    Type: String

Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: !Sub s3://${ArtifactBucket}/swagger.yml
      Variables:
        Stage: !Ref Stage

  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      Handler: index.handler
      AutoPublishAlias: !Ref Stage
      CodeUri: src/handlers/func1
      DeploymentPreference:
        Enabled: true
        Type: Linear10PercentEvery1Minute
      Events:
        Func1Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
            RestApiId: !Ref Api

Outputs:
  ApiEndpoint:
    Value: !Sub https://${Api}.execute-api.${AWS::Region}.amazonaws.com/${Stage}
  • Lambda関数
import json


def handler(event, context):
    message = json.dumps({'message': 'v1'})
    response = {'statusCode': 200,
                'headers': {'Content-Type': 'application/json'},
                'body': message}

    return response

Lambda関数にバージョンの番号を出力させるようにしてあるのでそれを修正後、デプロイしてみます。すると以下のように徐々に新しいLambda関数へトラフィックが流れている点、さらにトラフィックの流量も徐々に増えている点が確認できます。また、100パーセントになるまでおおよそ10分(10%/count x 1min)かかることが分かります。

$ while true; do printf "$(date): $(curl -s 'https://n6k1g307mg.execute-api.ap-northeast-1.amazonaws.com/dev')\n"; sleep 1; done
# デプロイ開始(最初は10パーセント程度)
Sun Dec  3 11:25:22 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:23 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:24 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:26 JST 2017: {"message": "v2"}
Sun Dec  3 11:25:27 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:29 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:30 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:32 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:33 JST 2017: {"message": "v1"}
Sun Dec  3 11:25:35 JST 2017: {"message": "v1"}
<snip>
# 50パーセント
Sun Dec  3 11:29:17 JST 2017: {"message": "v2"}
Sun Dec  3 11:29:18 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:20 JST 2017: {"message": "v2"}
Sun Dec  3 11:29:21 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:23 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:24 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:25 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:27 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:28 JST 2017: {"message": "v2"}
Sun Dec  3 11:29:30 JST 2017: {"message": "v2"}
Sun Dec  3 11:29:31 JST 2017: {"message": "v1"}
Sun Dec  3 11:29:33 JST 2017: {"message": "v2"}
Sun Dec  3 11:29:34 JST 2017: {"message": "v1"}
# 100パーセント
Sun Dec  3 11:34:20 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:22 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:23 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:25 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:26 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:28 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:29 JST 2017: {"message": "v2"}
Sun Dec  3 11:34:31 JST 2017: {"message": "v2"}

CanaryXPercentYMinutes

事前に用意されている設定は以下の通りです。

  • Canary10Percent5Minutes
  • Canary10Percent10Minutes
  • Canary10Percent15Minutes
  • Canary10Percent30Minutes

今回は Canary10Percent5Minutes を試してみます。サンプルとなるテンプレートは以下の通りです。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Safe Lambda Deployment Samples - Canary10Percent5Minutes

Parameters:
  Stage:
    Type: String
    Default: dev
  ArtifactBucket:
    Type: String

Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: !Sub s3://${ArtifactBucket}/swagger.yml
      Variables:
        Stage: !Ref Stage

  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      Handler: index.handler
      AutoPublishAlias: !Ref Stage
      CodeUri: src/handlers/func1
      DeploymentPreference:
        Enabled: true
        Type: Canary10Percent5Minutes
      Events:
        Func1Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
            RestApiId: !Ref Api

Outputs:
  ApiEndpoint:
    Value: !Sub https://${Api}.execute-api.${AWS::Region}.amazonaws.com/${Stage}

先程と同じようにLambda関数を修正後デプロイしてみます。すると以下のようにおおよそ5分経過すると新しいLambda関数へトラフィックが完全に切り替わることが分かります。

$ while true; do printf "$(date): $(curl -s 'https://7p3plkeoze.execute-api.ap-northeast-1.amazonaws.com/dev')\n"; sleep 1; done
# デプロイ開始(少しだけトラフィックが流れている)
Sun Dec  3 11:01:11 JST 2017: {"message": "v1"}
Sun Dec  3 11:02:52 JST 2017: {"message": "v2"}
Sun Dec  3 11:02:53 JST 2017: {"message": "v1"}
Sun Dec  3 11:02:54 JST 2017: {"message": "v1"}
Sun Dec  3 11:02:56 JST 2017: {"message": "v1"}
<snip>
# おおよそ5分経過すると完全に切り替わる
Sun Dec  3 11:07:09 JST 2017: {"message": "v1"}
Sun Dec  3 11:07:11 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:12 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:14 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:15 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:16 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:18 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:19 JST 2017: {"message": "v2"}
Sun Dec  3 11:07:20 JST 2017: {"message": "v2"}

AllAtOnce

このパターンで指定可能なのは1つだけです。

  • AllAtOnce

サンプルとなるテンプレートは以下の通りです。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Safe Lambda Deployment Samples - AllAtOnce

Parameters:
  Stage:
    Type: String
    Default: dev
  ArtifactBucket:
    Type: String

Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: !Sub s3://${ArtifactBucket}/swagger.yml
      Variables:
        Stage: !Ref Stage

  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      Handler: index.handler
      AutoPublishAlias: !Ref Stage
      CodeUri: src/handlers/func1
      DeploymentPreference:
        Enabled: true
        Type: AllAtOnce
      Events:
        Func1Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
            RestApiId: !Ref Api

Outputs:
  ApiEndpoint:
    Value: !Sub https://${Api}.execute-api.${AWS::Region}.amazonaws.com/${Stage}

再度Lambda関数を修正後デプロイしてみます。すると以下のようにデプロイが開始するとすぐに切り替わることが分かります。

$ while true; do printf "$(date): $(curl -s 'https://5ttwzagmqf.execute-api.ap-northeast-1.amazonaws.com/dev')\n"; sleep 1; done
# デプロイが始まるとすぐ切り替わる
Sun Dec  3 11:33:22 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:24 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:25 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:27 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:28 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:30 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:32 JST 2017: {"message": "v1"}
Sun Dec  3 11:33:34 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:36 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:37 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:38 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:40 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:41 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:43 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:44 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:46 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:48 JST 2017: {"message": "v2"}
Sun Dec  3 11:33:49 JST 2017: {"message": "v2"}

各トラフィックの振り分け方法を確認すると、以下のようなユースケースで選択するとよいかと思います。

  • LinearXPercentYMinutes
    • デプロイの影響範囲を最小限にしたい場合(例えば大きなメジャーアップデートなど)
    • また、両バージョンの混在時間/デプロイ時間が一番長いのでそれが許容できるかもポイントになります
  • CanaryXPercentYMinutes
    • 軽微な修正の場合
  • AllAtOnce
    • CodeDeployの便利な機能( HooksAlarms )を利用したいが、デプロイ時間は短縮したい場合

ちなみに Type は途中から変更可能です。デプロイしたい内容によって適宜変更するとよいでしょう。

アラームを設定するとどのような動作になるのか

Alarms にCloudWatch Alarm名を指定することで、デプロイ中意図しない動作をした場合に以前のLambda関数へロールバックさせることが可能です。例えば、Lambda関数のError rate用メトリクスからアラームを設定しておき、しきい値を超過した場合に元に戻すといった用途が考えられます。

サンプルとなるテンプレートは以下の通りです。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Safe Lambda Deployment Samples - Alarm

Resources:
  Func1Alarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      Namespace: AWS/Lambda
      Dimensions:
        - Name: FunctionName
          Value: !Ref Func1
      MetricName: Errors
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Statistic: Sum
      Period: 60
      EvaluationPeriods: 1
      Threshold: 1

  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      CodeUri: src/handlers/func1
      Handler: index.handler
      AutoPublishAlias: dev
      DeploymentPreference:
        Enabled: true
        Type: Linear10PercentEvery10Minutes
        Alarms:
          - !Ref Func1Alarm

当初Lambda関数を以下のようにしてデプロイしておきます。

  • バージョン1
def handler(event, context):
    print(event)

続いて、ロールバックの動作を確認したいのでこのLambda関数をわざと失敗させるため以下のように例外を出力させます。

  • バージョン2
def handler(event, context):
    raise Exception('Exception occured')

「バージョン2」のLambda関数をデプロイさせしばらく待つと、デプロイに失敗しロールバックすることが分かります。

$ aws deploy get-deployment \
  --deployment-id d-QBP1NHUCP \
  --query 'deploymentInfo.[deploymentOverview,errorInformation,rollbackInfo]'
[
    {
        "Pending": 0,
        "InProgress": 0,
        "Succeeded": 0,
        "Failed": 1,
        "Skipped": 0,
        "Ready": 0
    },
    {
        "code": "ALARM_ACTIVE",
        "message": "the deployment is cancelled due to an abort request"
    },
    {
        "rollbackDeploymentId": "d-A9LOSCYCP",
        "rollbackMessage": "Deployment d-QBP1NHUCP terminated. Automatic rollback is triggered with a DeploymentId d-A9LOSCYCP."
    }
]

今回「わざとらしく」アラーム機能の動作を確認してみましたが、注視しておきたいメトリクス(例えばDead Letter Queue)に対しこの設定をしておくと心の平穏が訪れそうです。

フックを使ってみる

Hooks を使うことでデプロイの前後にLambda関数を実行し、ロジックを組み込むことが可能です。現在以下2つのポイントにフックさせることができます。

  • BeforeAllowTraffic : 新しいLambda関数にトラフィックを流す前
    • AWS SAMでは PreTraffic
  • AfterAllowTraffic : 新しいLambda関数にトラフィックが流れた後
    • AWS SAMでは PostTraffic

ポイントは BeforeAllowTraffic の時点で新しいLambda関数がデプロイされているが、トラフィックはまだ流れてないという点です。つまりシステムを利用しているユーザに影響をあたえること無くロジックを挟むことが可能です。例えば、 BeforeAllowTraffic でインテグレーションテスト、 AfterAllowTraffic E2Eテストといったことができるでしょう。夢がひろがりングですね。

早速試してみます。今回は BeforeAllowTraffic でテストさせたいLambdaをInvokeしレスポンスをチェック、 AfterAllowTraffic でHTTPエンドポイントにアクセスしレスポンスをチェック、ということをやってみます。

---
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Safe Lambda Deployment Samples - Hooks

Parameters:
  Stage:
    Type: String
    Default: dev
  ArtifactBucket:
    Type: String

Resources:
  PreTrafficFunc:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: CodeDeployHook_PreTraffic
      Runtime: python3.6
      Handler: index.handler
      CodeUri: src/handlers/pre-traffic-func
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action:
                - codedeploy:PutLifecycleEventHookExecutionStatus
              Resource: "*"
            - Effect: Allow
              Action:
                - lambda:InvokeFunction
              Resource: !GetAtt Func1.Arn
      DeploymentPreference:
        Enabled: false
      Environment:
        Variables:
          FUNC1_NAME: !Ref Func1
          FUNC1_ARN_VERSION: !Ref Func1.Version

  PostTrafficFunc:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: CodeDeployHook_PostTraffic
      Runtime: python3.6
      Handler: index.handler
      CodeUri: src/handlers/post-traffic-func
      Policies:
        - CloudFormationDescribeStacksPolicy: {}
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action:
                - codedeploy:PutLifecycleEventHookExecutionStatus
              Resource: "*"
      DeploymentPreference:
        Enabled: false
      Environment:
        Variables:
          STACK_NAME: !Ref AWS::StackName
          FUNC1_ARN_VERSION: !Ref Func1.Version

  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: !Sub s3://${ArtifactBucket}/swagger.yml
      Variables:
        Stage: !Ref Stage

  Func1:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      Handler: index.handler
      AutoPublishAlias: !Ref Stage
      CodeUri: src/handlers/func1
      DeploymentPreference:
        Enabled: true
        Type: AllAtOnce
        Hooks:
          PreTraffic: !Ref PreTrafficFunc
          PostTraffic: !Ref PostTrafficFunc
      Events:
        Func1Api:
          Type: Api
          Properties:
            Path: /
            Method: GET
            RestApiId: !Ref Api

Outputs:
  ApiEndpoint:
    Value: !Sub https://${Api}.execute-api.${AWS::Region}.amazonaws.com/${Stage}

それぞれのLambda関数は以下のようにしました。フックで呼び出されるLambda関数は呼び出し元となるCodeDeployに結果を返す必要があるので、PutLifecycleEventHookExecutionStatus APIを利用しています。

  • BeforeAllowTraffic のLambda関数
import os
import json

import boto3

CODE_DEPLOY = boto3.client('codedeploy')
AWS_LAMBDA = boto3.client('lambda')
FUNC1_VERSION = os.getenv('FUNC1_ARN_VERSION').split(':')[-1]


def invoke_aws_lambda():
    func1_name = os.getenv('FUNC1_NAME')

    return AWS_LAMBDA.invoke(FunctionName=func1_name,
                             InvocationType='RequestResponse',
                             LogType='Tail',
                             Qualifier=FUNC1_VERSION)


def notify_execution_status(event, status):
    deployment_id = event.get('DeploymentId')
    execution_id = event.get('LifecycleEventHookExecutionId')

    return CODE_DEPLOY.put_lifecycle_event_hook_execution_status(deploymentId=deployment_id,
                                                                 lifecycleEventHookExecutionId=execution_id,
                                                                 status=status)


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

    status = 'Succeeded'

    try:
        aws_lambda_log = invoke_aws_lambda()
        payload = json.loads(aws_lambda_log.get('Payload').read())
        assert json.loads(payload.get('body')).get('message') == FUNC1_VERSION
        notify_response = notify_execution_status(event, status)
    except Exception as e:
        print(e)
        status = 'Failed'
        notify_execution_status(event, status)
    else:
        print(notify_response)
  • AfterAllowTraffic のLambda関数
import os

import boto3
import requests

CODE_DEPLOY = boto3.client('codedeploy')
CLOUDFORMATION = boto3.client('cloudformation')


def get_api_endpoint():
    stack_name = os.getenv('STACK_NAME')

    stack_outputs = CLOUDFORMATION.describe_stacks(StackName=stack_name).get('Stacks')[0].get('Outputs')

    for stack_output in stack_outputs:
        for output_value in stack_output.values():
            if output_value == 'ApiEndpoint':
                return stack_output.get('OutputValue')


def get_api_response():
    api_endpoint = get_api_endpoint()

    return requests.get(api_endpoint)


def notify_execution_status(event, status):
    deployment_id = event.get('DeploymentId')
    execution_id = event.get('LifecycleEventHookExecutionId')

    return CODE_DEPLOY.put_lifecycle_event_hook_execution_status(deploymentId=deployment_id,
                                                                 lifecycleEventHookExecutionId=execution_id,
                                                                 status=status)


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

    status = 'Succeeded'
    func1_version = os.getenv('FUNC1_ARN_VERSION').split(':')[-1]

    try:
        api_response = get_api_response()
        assert api_response.status_code == 200
        assert api_response.json().get('message') == func1_version
        notify_response = notify_execution_status(event, status)
    except Exception as e:
        print(e)
        status = 'Failed'
        notify_execution_status(event, status)
    else:
        print(notify_response)
  • テスト対象となるLambda関数
import json


def handler(event, context):
    message = json.dumps({'message': context.function_version})
    response = {'statusCode': 200,
                'headers': {'Content-Type': 'application/json'},
                'body': message}

    return response

テスト対象となるLambda関数を適当に修正した後スタックをアップデートするとCodeDeployのデプロイが実行され、フックに登録したLambda関数がInvokeされることがわかると思います。

今回Lambda自体でインテグレーションテスト、E2Eテストをするといった方法にしてみました。ただ本格的に使うには例えばCIとの連携を考えた方が良いかもしれません。例えば BeforeAllowTraffic からCircleCIを呼び出してテストするとかできたらおもしろそうですね。今後ももっと使い倒してご紹介できればと思います。

まとめ

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

CodeDeployの機能を利用したより安全なLambda関数のデプロイ方法についてご紹介しました。デプロイ戦略が増えたことは1ユーザとして嬉しい限りです。今回ご紹介した以外のユースケースもありそうな程奥深い機能なので、今後便利なユースケースが見つかったらまたご紹介できればと思います。

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