AWS SAM (AWS Serverless Application Model) で実現するカナリアリリース!

おつかれさまです。サーバーレス開発部の新井です。

先日、弊社の記事でこんなのを見つけて、

[レポート] AWS Lambda と Amazon API Gateway で安全なデプロイを行うためのベストプラクティス #awssummit

「そういえば、サーバーレスでカナリアリリースって話は結構前から聞くけど、実際のプロダクション運用で何処まで現実的なんだろう?」

と疑問に思ったので、サーバーレス構成 (API Gateway + Lambda)でのカナリアリリースを、AWS SAMで試してみました。

そもそもカナリアリリース(Canary Release)とは

わかる方は飛ばしてください。

カナリアリリースとは、新機能やサービスを一部のユーザーに公開して、問題がないことを確認しながら段階的に全体に向けて展開していくデプロイ手法のことです。

新バージョンのソフトウェアをすべての人が利用する前に、小さなサブセットをゆっくりと公開することで、問題がおこった際のリスクを減らすことができます。また、潜在的な問題に対するの早期発見の機会を提供してくれます。

かつて鉱山労働者が無臭の有毒ガスの漏れる炭鉱にカナリアを運んでいたのが名前の由来らしいです。

また、開発者のみアクセスできるバージョンを本番環境にリリースする「ダークカナリアリリース」とういうデプロイ手法もあるそうです。

サーバーレスアーキテクチャで実現するカナリアリリースパターン

こちらのスライドが参考になりました。大きく分けて2パターンあるそうです。

※ 以下、API Gateway Canary パターン、Lambda Canary パターン とします。

API Gateway Canary パターン

Lambda Canary パターン

AWS SAM でためしていきます。

API Gateway Canary パターン

ドキュメントを調べてみると、こちらCanarySettingなる設定項目を発見しました。

これをベースに早速テンプレートをいじっていこうと思います。

GitHubにコード上げているので参考にして下さい。 ソースコード

まずは、AWS SAMでお馴染みのコマンドは省略しましす。 今回言語はNode.js 8.10をつかいます。(4月1日にNode.js 6.10のサポートが終了するとのことなので)

$ sam --version
SAM CLI, version 0.13.0
$ sam init -r nodejs8.10
$ cd sam-app

template.yamlにCanarySettingを追記しつつ、全体を編集しました。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-app

  Sample SAM Template for sam-app

Parameters:
  StageName:
    Type: String
    Default: prod
Globals:
  Function:
    Timeout: 3
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs8.10
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello-world
            Method: get
            RestApiId: !Ref HelloWorldApi
  HelloWorldApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref StageName
      Variables:
        sv0: "sv0"
        sv1: "sv1"
        stage: !Ref StageName
        version: 1
      CanarySetting:
        PercentTraffic: 30
        StageVariableOverrides:
          sv1: "sv1_new"
          sv2: "sv2_new"
          alias: !Ref StageName
          version: 1
        UseStageCache: false
      DefinitionBody:
        swagger: 2.0
        schemes:
          - https
        info:
          title: APIG-Canary-API
        produces:
          - application/json
        paths:
          "/hello-world":
            get:
              responses:
                "200":
                  description: OK
                  schema:
                    type: string
                    example: Hello World
              x-amazon-apigateway-integration:
                httpMethod: POST
                type: aws_proxy
                uri:
                  Fn::Sub:
                    - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${FunctionArn}/invocations
                    - { FunctionArn: !GetAtt HelloWorldFunction.Arn }

設定できたので、デプロイしてみます。 ※ バケットは事前に作成しておきます。

#ビルド
sam build 
#デプロイパッケージ作成
sam package --template template.yaml --s3-bucket <your-bucket> --output-template-file packaged.yml
#デプロイ実行
sam deploy --template-file packaged.yml --stack-name <your-stack-name> --capabilities CAPABILITY_NAMED_IAM 

しばらくすると、Successfully created/updated stackと表示されて、デプロイが成功しました。

API GatewayのCanarySettingを確認して、設定が反映されていることを確認。

この時は初回デプロイなので、カナリアリリースは行われていません。CanarySettingが反映されているだけです。

一応、APIの状態を確認しておきます。

$ aws apigateway get-stage --rest-api-id <api_id> --stage-name prod
{
    "deploymentId": "n812ne",
    "stageName": "prod",
    "cacheClusterEnabled": false,
    "cacheClusterStatus": "NOT_AVAILABLE",
    "methodSettings": {},
    "variables": {
        "stage": "prod",
        "sv0": "sv0",
        "sv1": "sv1",
        "version": "1"
    },
    "canarySettings": {
        "percentTraffic": 30.0,
        "deploymentId": "n812ne",
        "stageVariableOverrides": {
            "alias": "prod",
            "sv1": "sv1_new",
            "sv2": "sv2_new",
            "version": "1"
        },
        "useStageCache": false
    },
    "tracingEnabled": false,
    "createdDate": 1553557744,
    "lastUpdatedDate": 1553557745
}

同じdeploymentId n812ne を指していますね。

カナリアリリース開始していきます

Lambdaのコードを少しいじって、 message部分をhello world new.と変更して、再デプロイします。

デプロイ成功後、API Gatewayにリクエストを送信して確認します。

今のテンプレートの設定なら、10回リクエスト送信して、hello worldhello world newがだいたい7:3の割合で返ってくる想定です。

が、しかし...

for i in {1..10}; do curl -w '\n' https://<api>.execute-api.ap-northeast-1.amazonaws.com/prod/hello-world ;done
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}

あれ?全部新しいLambdaにふりわけられている?

ここで少しハマる

API Gatewayの状態を確認してみると、両方ともn812neを指してたままでした。謎???

$ aws apigateway get-stage --rest-api-id <api_id> --stage-name prod
{
    "deploymentId": "n812ne",
    "stageName": "prod",
    "cacheClusterEnabled": false,
    "cacheClusterStatus": "NOT_AVAILABLE",
    "methodSettings": {},
    "variables": {
        "stage": "prod",
        "sv0": "sv0",
        "sv1": "sv1",
        "version": "1"
    },
    "canarySettings": {
        "percentTraffic": 30.0,
        "deploymentId": "n812ne",
        "stageVariableOverrides": {
            "alias": "prod",
            "sv1": "sv1_new",
            "sv2": "sv2_new",
            "version": "1"
        },
        "useStageCache": false
    },
    "tracingEnabled": false,
    "createdDate": 1553557744,
    "lastUpdatedDate": 1553557745
}

しばらくして、CloudFormationのデプロイスタックを確認してようやく気づきました。

そうです。 CanarySettingはあくまでAPI Gatewayの設定なので、API Gateway上の変化がないとデプロイされないのです。 もう少し厳密に言うと、 API Gatewayの設定に修正がないと、CloudFormation側でChange Setsとして扱ってもらえないのです。

今回行ったLambdaだけの修正では、API Gatewayはデプロイメント対象外となるのでカナリアリリースは実施できないとわかりました。

そこで、あたらしくLambdaを追加してAPI Gatewayエンドポイント先を変更することを考えていきます。

template.yamlを編集して、HelloWorld2という関数を追加して、関数のmessage部分をhello world 2.に変更します。

...
  HelloWorldFunction2:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler2
      Runtime: nodejs8.10
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello-world
            Method: get
            RestApiId: !Ref HelloWorldApi
Fn::Sub:
  - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${FunctionArn}/invocations
  # - { FunctionArn: !GetAtt HelloWorldFunction.Arn }
  - { FunctionArn: !GetAtt HelloWorldFunction2.Arn }

デプロイが成功したので、リクエストを送信して確認します。

$ for i in {1..10}; do curl -w '\n' https://<api_id>.execute-api.ap-northeast-1.amazonaws.com/prod/hello-world ;done
{"message":"hello world 2."}
{"message":"hello world 2."}
{"message":"hello world new."}
{"message":"hello world 2."}
{"message":"hello world 2."}
{"message":"hello world 2."}
{"message":"hello world 2."}
{"message":"hello world 2."}
{"message":"hello world new."}
{"message":"hello world new."}

おぉ! カナリアリリース成功した! いい感じにリクエストが振り分けられてる!

...と、この時は思っていました。

でも、冷静によく見てみると

「デプロイした新規のHelloWorldFunction2に リクエスト振り分けられすぎじゃね?新規は30%に設定したはずなのに。。。」

ここでさらにハマる

先ほど同様に、API Gatewayの状態を確認してみました。

すると、

$ aws apigateway get-stage --rest-api-id <api_id> --stage-name prod
{
    "deploymentId": "1qv112",
    "stageName": "prod",
    "cacheClusterEnabled": false,
    "cacheClusterStatus": "NOT_AVAILABLE",
    "methodSettings": {},
    "variables": {
        "stage": "prod",
        "sv0": "sv0",
        "sv1": "sv1",
        "version": "1"
    },
    "canarySettings": {
        "percentTraffic": 30.0,
        "deploymentId": "n812ne",
        "stageVariableOverrides": {
            "alias": "prod",
            "sv1": "sv1_new",
            "sv2": "sv2_new",
            "version": "1"
        },
        "useStageCache": false
    },
    "tracingEnabled": false,
    "createdDate": 1553557744,
    "lastUpdatedDate": 1553562279
}

CanarySettingdeploymentIdが変わってない。 しかし、ベースバージョンのdeploymentIdは変わっている。

よくわからないままに、percentTrafficを10.0に変更して再デプロイしてみるたところ、 、しばらくしてFailed to create/update the stack.と表示されデプロイは失敗しました。(※この原因は後で判明します。)

コンソール操作の挙動と比較してみる

挙動が意味不明だったため、 コンソールで同様の手順を行い、比較してみました。

こちらが参考になりました。

コンソールでAPIG-Canary-API-2というAPIを作成していきます。

先ほど作成した、HellowWorldFunctionをメソッドのエンドポイントに指定して、初回デプロイを実施します。

デプロイ後に、ステージの設定から、Canaryを設定していきます。

CanarySettingを有効化した時点でのDeploymentIdを確認します。

$ aws apigateway get-stage --rest-api-id 28gct0a7wc --stage-name prod
{
    "deploymentId": "f3e9gn",
    "stageName": "prod",
    "cacheClusterEnabled": false,
    "cacheClusterStatus": "NOT_AVAILABLE",
    "methodSettings": {},
    "canarySettings": {
        "percentTraffic": 30.0,
        "deploymentId": "f3e9gn",
        "useStageCache": false
    },
    "tracingEnabled": false,
    "createdDate": 1553645029,
    "lastUpdatedDate": 1553645097
}

最後に、メソッドのエンドポイントをHellowWorldFunction2に切り替えて、カナリアプロイを実施します。

CanaryDeployを実行した後のDeploymentIdを確認します。

$ aws apigateway get-stage --rest-api-id 28gct0a7wc --stage-name prod
{
    "deploymentId": "f3e9gn",
    "stageName": "prod",
    "cacheClusterEnabled": false,
    "cacheClusterStatus": "NOT_AVAILABLE",
    "methodSettings": {},
    "canarySettings": {
        "percentTraffic": 30.0,
        "deploymentId": "uzkagk",
        "useStageCache": false
    },
    "tracingEnabled": false,
    "createdDate": 1553645029,
    "lastUpdatedDate": 1553645352
}

SAMのデプロイとは異なり、canarySettings側のdeploymentIdが更新されていますね。 ここらへんでなんとなく挙動の違いが見えてきました。

リクエストを送信して確認してみる。

$ for i in {1..10}; do curl -w '\n' https://28gct0a7wc.execute-api.ap-northeast-1.amazonaws.com/prod/hello-world ;done
{"message":"hello world 2 new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world 2 new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world new."}
{"message":"hello world 2 new."}
{"message":"hello world new."}
{"message":"hello world new."}

期待通りに動作していますね。 コンソールからの操作ではうまくできるようです。

そしてSAMのデプロイの違いは、

  1. CanarySettingのdeploymentIDが更新される
  2. 新しいLambda(HelloWor2)へリクエストが3割程振り分けられている

といった点です。

動きを理解する

ここまできてなんとなくSAMのデプロイメントの挙動を理解しました。

両者の挙動を比較すると、以下の表の様な感じです。

CanarySettingの設定直後 HelloWorld2に切替えて、デプロイした後 
SAM {Canary-Version:1, prod-Version:1} {Canary-Version:2, prod-Version:1}
コンソール {Canary-Version:1, prod-Version:1} {Canary-Version:1, prod-Version:2}

どうやらSAMのデプロイメントでは、常にベースバージョン(prod)にデプロイされるようです。

で、先程3回目のSAMのデプロイメントがエラーになった理由ですが、こちらのドキュメントの最後の方に理由が記載されています。

Canary がステージで有効になっていると、デプロイは Canary リリースのデプロイになります。Canary 設定がステージから削除されるまで、ステージは非 Canary のデプロイに関連付けることはできません。

悲しい結末

結論として、

API Gateway Canary パターン をSAMのデプロイフローで利用するのは無理っぽいです。。。

理由は、

  • AWS SAMでCanarySettingは設定はできるが、カナリアバージョンへのデプロイが実質機能しない
  • 仮に上記が可能だとしても、、、
    • API Gatewayの設定が変更されている必要がある ( = Lambda側の修正だけでは、カナリアデプロイは不可能)
    • 仮に毎回API Gatewayの設定変更が行われるような仕組みをつくったとしても、管理が大変そう (エンドポイントのLambdaのAliasをタイムスタンプとかにして、毎回エンドポイント切り替えるみたいな運用ができれば可能かも。。。)

ここまでたどり着くのにだいぶ時間がかかりました。 ここまで調べて結果使えなさそうというのは残念ですが、同じ部分でハマる人の助けになればと思います。

一方、ドキュメントにチュートリアルが記載されていますので、

  • コンソールからの操作
  • AWS CLI
  • API呼び出し

を利用する場合はうまく行くと思います。また、CloudFormationのテンプレートを書いている場合もおそらく可能かと思います。

AWS Lambda Canary パターン

本当は、API Gateway Canary パターンだけサクっとやってしまおうと思ってたのですが、このままだと終われないのでこちらも試します。

どうやら内部的には、CodeDeployを使って段階的なトラフィックシフトを行っているようです。 テンプレートの設定は、LambdaのDeploymentPreferenceで行うようです。

こちらのブログでも同じようなことが紹介されています。

参考にしたドキュメント

  • https://awslabs.github.io/serverless-application-model/safe_lambda_deployments.html
  • https://github.com/awslabs/serverless-application-model/blob/master/docs/safe_lambda_deployments.rst#traffic-shifting-using-codedeploy
  • https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/automating-updates-to-serverless-apps.html

カナリアリリース開始していきます

こちらのサンプルを参考にしました。

最終更新日も古くそのままでは動かなかったので、同じような仕組みを自分で作ってみました。GitHubにソースコード上げているので参考にして下さい。今回もNode.js 8.10を使います。ソースコード

まずは、AWS SAMでビルドし、初回デプロイまで行います。

次に、messagehello worldからhellow world canaryに変更して2回目のデプロイを行います。この時、DeploymentPreferenceTypeは、Canary10Percent5Minutesに設定しあるので、5分毎に10%づつトラフィックが新バージョンにシフトしていく想定です。

2回目のデプロイを実施すると、Waiting for stack create/update to completeと表示されて待たされます。

ここで、コンソールからCodeDeployの画面を確認してみます。

トラフィックがシフトしていってるのが視覚的に見れます。

このタイミングでリクエストを送信してみます。

for i in {1..10}; do curl -w '\n' https://<api_id>.execute-api.ap-northeast-1.amazonaws.
com/prod/hello; done
{"message":"hello world canary"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world"}
{"message":"hello world canary"}

想定通りのうごきですね。

デプロイが完了しました。

リクエストを送信してみます。

for i in {1..10}; do curl -w '\n' https://<api_id>.execute-api.ap-northeast-1.amazonaws.
com/prod/hello; done
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}
{"message":"hello world canary"}

はい。想定通りのうごきです。

動きを理解する

内部的には、CodeDeployを使って段階的にトラフィックシフトしてるらしいです。

テンプレートのDeploymentPreferenceを指定することで、CodeDeployがトラフィックを古いバージョン(現在のprodエイリアスがアタッチされているバージョン)から新しいバージョン(Latest)にシフトする作業を引き継いでいます。

  • Hooksを設定すると、トラフィックのシフトが行われる前後にLambdaで任意の処理を実行できるそうです。
    • PreTrafficLambdaFunctionはトラフィックシフトが始まる前に起動されます。この関数は、PutLifecycleEventHookExecutionStatusを介して、成功/失敗をCodeDeployにコールバックする必要があります。失敗すると、CodeDeployは処理を中断して、CloudFormationに失敗を通知します。成功すると、CodeDeployは指定されたトラフィックシフトを続行します。
    • PostTrafficLambdaFunctionはトラフィックシフトが完了した後に起動されます。PreTrafficLambdaFunctionと同様に、成功/失敗をCodeDeployにコールバックする必要があります。この関数では、結合テストなどを実行するのに利用できます。

また、Alarmsを設定していると、CloudWatch Alarmsがアラーム状態になると、ロールバックされます。(CodeDeployはエイリアスを古いバージョンに戻し、CloudFormationに失敗を通知するらしい。)

すべてうまくいけば、最終的に新しいバージョン(Latest)にprodエイリアスがアタッチされます。このprodエイリアスのアタッチは、AutoPublishAliasで設定しています。

注意点としては、タイプ:Linear10PercentEvery10Minutesだと、新しいバージョンに10%のトラフィックで開始し、10分ごとに10%を追加するので、合計100分をかかるという点です。

嬉しい結末

結論として、

Lambda Canary パターン はAWS SAMのデプロイフローで利用できそうです!しかも柔軟な設定変更が可能です。

嬉しい点としては、

  • また、API Gateway Canary パターンみたいにAPI Gateway上の設定変更がないのでカナリアデプロイされてないということはないです。
  • トラフィックシフトの方法がいろいろあるので、用途ごとで使い分けられる
  • 何かあった際にすぐ自動で、ロールバックできる
  • トラフィックシフトの前後に処理を追加できる ( ex: 後処理にE2Eテストなどを実施して、失敗した際にロールバックすること可能)

一方、指定したデプロイメントタイプによっては、完了まで結構待たされるので、その分時間が消費される点に注意したいです。CircleCIなどのでデプロイを行っている場合は、もったいなので。

まとめ

今回のネタを書くにあたってネットで軽く調べてみましたが、日本語の情報は少なかったです。機能がローンチされてから結構年月が立つものの、プロダクション利用はあまりされていないのかもしれません。

一方、英語の記事やカンファレンスでは結構目にする事があるので、海外では利用されてるシーンは多いのかと思います。今回の利用した感覚では、Lambda Canary パターンは本番利用も十分検討できると思います。

以上、かなり長くなってしまいましたが、お付き合い頂き有難うございます。どなたかの役に立てば幸いです。