/ #cdk #lambda 

【CDK/Python】CodeDeployを使ってLambdaのカナリアリリースする

もくじ

本番環境で動作中のサービスを更新するときって緊張しますよね。

新バージョンでエラーが発生したら即座に旧バージョンに戻せる、Bluew/Greenデプロイ的なことをしてればいくぶん心が楽ですが、 さてそれってLambdaだとどうやって実装すればいいんでしょう?

ということで、今回はCDKでLambdaをカナリアリリースしてみたいと思います。

先にカナリアリリース(カナリアデプロイ?)とはなんぞや?という話ですが

古いバージョンから新しいバージョンにアプリをバージョンアップするときに、一気にすべてのトラフィックを新バージョンに流すのではなくて、全体の数%を新バージョンへ流しそれ以外は旧バージョンに流して動作確認をし、そこで問題なさそうだったら新旧のバージョンを一気に切り替え、問題あったら旧バージョンに巻き戻すというデプロイ戦略です。

余談ですが、 初めは少しづつ新しいバージョンにトラフィックを流し、だんだん量を増やしていき最終的に旧バージョンのトラフィックをなくす、、、というデプロイ戦略をAWSだと線形デプロイと呼ぶっぽいです。

はい。では今回CDKで作ったらこんな感じの構成ができました

はい、わけわからないですね。

構成のざっくりイメージ

今回作るもののざっくりとしてイメージですが、

ApiGatewayにLambdaのエイリアスを登録してデプロイします。構成図でいうと一番右の1列です。

そしてこのエイリアスをカナリアリリースするつもりです。また一番右の1列以外はすべてカナリアリリースするためのリソースです(笑)

実はprehookとかposthookのLambdaはオプションなので、なくても全然いいのですが、こんなこともできるんだねー、という紹介がてらおいてあります。

では早速コード

そのまえに今回のリポジトリはこちら

https://github.com/sisi100/cdk_lambda_canaria_demo

ちょっと説明の関係上エイリアスでDeployする動作確認用のLambdaから見ます。

import os

from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent
from aws_lambda_powertools.utilities.typing import LambdaContext

app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)


@app.get("/error")
def get_error():
    raise Exception("Error!!")


@app.get("/hello")
def get_hello_world():
    return {"message": "hello world", "version": os.getenv("AWS_LAMBDA_FUNCTION_VERSION")}


def handler(event: APIGatewayProxyEvent, context: LambdaContext):
    return app.resolve(event, context)

こんな感じになってます。

ApiGatewayと統合する実装になっていて LambdaPowertools でルーティングしてます。

動作確認のために途中でエラーを出させたいので、パスを変えることでエラーが出せるようになってます。

あとはLambdaのバージョンを返すようにしてるので、カナリアリリース中に今どっちのLambdaが動いたかがわかるようになってます!

次にそれを踏まえてCDKの構成です↓

import os

import aws_cdk as cdk
from aws_cdk import Duration, aws_apigateway, aws_cloudwatch, aws_codedeploy, aws_lambda

app = cdk.App()
stack = cdk.Stack(app, "test")

# 0. 変数的なもの

# LambdaPowertools
LAMBDA_POWERTOOLS_LAYER_ARN = (
    f"arn:aws:lambda:{os.getenv('CDK_DEFAULT_REGION')}:017000801446:layer:AWSLambdaPowertoolsPython:3"
)
# Lambdaの共有設定
FN_PARAM = dict(
    code=aws_lambda.Code.from_asset("lambdas"),
    runtime=aws_lambda.Runtime.PYTHON_3_9,
)

# 1. Lambda関数を作成する

powertools_layer = aws_lambda.LayerVersion.from_layer_version_arn(
    stack, "powertoolsLayer", LAMBDA_POWERTOOLS_LAYER_ARN
)
function = aws_lambda.Function(stack, "Function", handler="api.handler", layers=[powertools_layer], **FN_PARAM)

# 2. Lambdaのエイリアスをホストする

version = function.current_version
alias = aws_lambda.Alias(stack, "Alias", alias_name="hogehoge", version=version)
aws_apigateway.LambdaRestApi(stack, "Api", handler=alias)

# 3. エイリアスをカナリアデプロイさせる

# 3-1. CodeDeployを作成する
# アプリケーションを作る
application = aws_codedeploy.LambdaApplication(stack, "Application")
# 動作確認でなる早でデプロイが終わるように設定した(`$ code deploy`したときにターミナルが返ってこなくなるから)
config = aws_codedeploy.CustomLambdaDeploymentConfig(
    stack,
    "Config",
    type=aws_codedeploy.CustomLambdaDeploymentConfigType.CANARY,
    interval=Duration.minutes(1),
    percentage=50,
)
# hook用のLambdaをつくる(オプション)
pre_hook_fn = aws_lambda.Function(stack, "PreHook", handler="hooks.pre_hook_handler", **FN_PARAM)
post_hook_fn = aws_lambda.Function(stack, "PostHook", handler="hooks.post_hook_handler", **FN_PARAM)
# デプロイメントグループを作る
deployment_group = aws_codedeploy.LambdaDeploymentGroup(
    stack,
    "CanariaDeploy",
    application=application,
    alias=alias,
    deployment_config=config,
    pre_hook=pre_hook_fn,
    post_hook=post_hook_fn,
)

# 3-2. デプロイ中にエラーが発生した場合にロールバックさせる
alarm = aws_cloudwatch.Alarm(
    stack,
    "Alarm",
    comparison_operator=aws_cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
    threshold=1,
    evaluation_periods=1,
    metric=alias.metric_errors(),
)
deployment_group.add_alarm(alarm)

app.synth()

#0. #1. #2. くらいのところは説明がなくて良いかなと思います。

もしこのあたりが気になる場合はLambdaPowertoolsだとこれとかこれ、統合に関してはこの辺がもうちょい詳しくかいてるかもです。

なので #3. から見ていきます。

まず使うサービスはCodeDeployです。CodeDeployはLambdaとかEC2とかECSとかをいい感じにデプロイしてくれるサービスです。

利用するためにCodeDeploy内にアプリケーションを作成して、そのなかにデプロイメントグループ(デプロイの対象やデプロイ方法とかの定義)を作成する必要があります。

コードだとアプリケーションを作る、はここ↓

application = aws_codedeploy.LambdaApplication(stack, "Application")

そしてここ↓でどんな風にデプロイするかを定義してます

config = aws_codedeploy.CustomLambdaDeploymentConfig(
    stack,
    "Config",
    type=aws_codedeploy.CustomLambdaDeploymentConfigType.CANARY,
    interval=Duration.minutes(1),
    percentage=50,
)

この設定はカナリアtypeのデプロイで50%のトラフィックを新バージョンに流す、1分間問題がなかったらバージョンを切り替える、という設定です。

50%は多いですね!というツッコミはなしです。あとintervalの値を大きくすると$ cdk deployしたときにターミナルが返って来なくなって時間かかるので短くしてます!!

あとtypeですがカナリアの他に線形デプロイを設定して、徐々にトラフィックを増やすこともできます。

次にhook用のLambdaですが、デプロイの前後でLambdaを呼び出してなにか必要なタスクがあれば実行できます。

次にデプロイメントグループですが、こんな感じでつくれます!くらいですかね

最後にロールバックですが、LambdaDeploymentGroupはaramを設定することができて、このaramがデプロイ中になると自動的にロールバックしてくれます。(設定でロールバックしないようにもできる。需要があるかはわからないけれども)

今回はLambdaのエイリアスで1つ以上エラーが発生した場合に、アラームが鳴るように設定しました。新旧どっちのアラームにも反応します(笑)

動作確認してみます。

デプロイします

cdk -a "python3 app.py" deploy

そしてデプロイを完了するまで待ちます。

完了するとapi gatewayのエンドポイントが出力されるのでとりあえずcurlで叩いてみます。

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"1"}

バージョン1が返ってきました!

ここでCodeDeployのデプロイ履歴を見るとー

見事に空です。

初回は動きません(笑)

なのでちょっとだけLambdaに差分を作ってデプロイし直します。Lambdaのコードのどこかに改行でも入れてからDeployしましょう(笑)

cdk -a "python3 app.py" deploy

...省略
xxxxxxx| UPDATE_IN_PROGRESS   | AWS::Lambda::Alias               | Alias

デプロイコマンドを叩いてしばらく待ってると↑のようにAliasがデプロイ中になります。

このタイミングでデプロイが完了するまえにcurlコマンドを叩きまくりましょう

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"1"}

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"2"}

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"1"}

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"2"}

こんな感じでバージョン1と2が交互に現れました!

ちゃんとトラフィックが50%で分散されてますね!

先程からだったデプロイ履歴も確認してみるとしっかりデプロイ成功!でレコードが増えてます!

次にロールバックさせてみましょう。 まずカナリアリリースの動作確認期間1分だと絶対間に合わないので5分くらいに設定しておきましょう

config = aws_codedeploy.CustomLambdaDeploymentConfig(
    stack,
    "Config",
    type=aws_codedeploy.CustomLambdaDeploymentConfigType.CANARY,
    interval=Duration.minutes(5), # ←ここ
    percentage=50,
)

そして再びLambdaのどこかに改行を入れてデプロイします。そして Arias がデプロイ中になるまで待ちます。 そしてバージョン2と3が返ってくるなーというのを確認したら即座にerrorのエンドポイントを叩きます

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"2"}

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"3"}

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/error
>{"message": "Internal server error"}

ちなみにアラームは上がるまで誤差が結構あるのでerrorを出したら、$ cdk deployが失敗するのをのんびり待ちます。

しばらくするとこんな感じでアップデートが失敗してロールバックしたよ!というログがでてきます。

xxxxxxx | UPDATE_FAILED        | AWS::Lambda::Alias               | Alias
Rollback successful

このあとにcurlを叩いても無事バージョン2です

curl https://91v5w4wy01.execute-api.ap-northeast-1.amazonaws.com/prod/hello
>{"message":"hello world","version":"2"}

CodeDeployのデプロイ履歴をみてもCodeDeploy ロールバックというイベントのレコードが追加され、無事ロールバックされました。

ちなみにCloudFormation側もロールバックするのでインフラ側も戻ります。

というわけで動作確認しましたのでStackを削除します。

cdk -a "python3 app.py" destroy

さて、以上です!最後まで読んでくださってありがとうございました!

今回のリポジトリはこちら

https://github.com/sisi100/cdk_lambda_canaria_demo

(追加)hookのLambda忘れてた

hookのLambdaについて説明忘れていたので、新しく記事を書いてみました。もしよろければごらんください!

Author

Sisii

インフラが好きなエンジニアぶってるなにか