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 world
とhello 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 }
CanarySetting
のdeploymentId
が変わってない。
しかし、ベースバージョンの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のデプロイの違いは、
- CanarySettingのdeploymentIDが更新される
- 新しい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でビルドし、初回デプロイまで行います。
次に、message
をhello world
からhellow world canary
に変更して2回目のデプロイを行います。この時、DeploymentPreference
のType
は、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 パターンは本番利用も十分検討できると思います。
以上、かなり長くなってしまいましたが、お付き合い頂き有難うございます。どなたかの役に立てば幸いです。