CDKでLambdaのBlue/Greenデプロイをやってみた

2024.02.21

こんにちは、CX事業本部の高橋雄大です。

AWS CDKでLambdaのBlue/Greenデプロイをやってみたいと思います。今回の方法では、AWS CodePipelineは使わずにAWS CodeDeployを使うため、GitHub ActionsでCI/CDを構築している場合でも、既存のワークフローを崩さずに導入できます。

本記事のゴール

API Gatewayから呼び出されるLambdaを例に、Blue/Greenデプロイを実装します。

LambdaのBlue/Greenデプロイ

環境情報

項目 内容
OS macOS Sonoma 14.3.1(23D60)
Node.js 18.10.0
TypeScript 5.1.6
AWS CDK 2.92.0

サンプルのLambda関数を実装

CDKをあらかじめ準備しておき、Lambda関数のソースコードを配置します。

lambda/src/hello-world.ts

export const handler = async (event: unknown) => {
  return {
    body: 'Hello World!',
    statusCode: 200,
  };
};

ディレクトリ構成は以下のとおりです。

lambda-blue-green
 ├── bin
 │    └── lambda-blue-green.ts # CDKアプリケーション
 ├── lambda
 │    └── src
 │         └── hello-world.ts # Lambda関数ソースコード
 ├── lib
 │    └── lambda-blue-green-stack.ts # CDKスタック
〜〜〜〜〜
 ├── cdk.json
 ├── package-lock.json
 ├── package.json
 └── tsconfig.json

CDKでAPI GatewayとLambdaを定義

まずは普通にAPI GatewayとLambdaを定義して、RestAPIを実装してみます。

lib/lambda-blue-green-stack.ts

import {
  aws_apigateway as apigateway,
  aws_lambda_nodejs as lambdaNodejs,
  Stack,
  StackProps,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

const SAMPLE_PROJECT_NAME = 'lambda-blue-green';
const REST_API_STAGE_NAME = 'v1';

export class LambdaBlueGreenStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const helloWorldFunction = new lambdaNodejs.NodejsFunction(
      this,
      'HelloWorldFunction',
      {
        functionName: `${SAMPLE_PROJECT_NAME}-hello-world`,
        entry: 'lambda/src/hello-world.ts',
      },
    );

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `${SAMPLE_PROJECT_NAME}-rest-api`,
      deployOptions: {
        stageName: REST_API_STAGE_NAME,
      },
    });

    restApi.root
      .addResource('hello-world')
      .addMethod('GET', new apigateway.LambdaIntegration(helloWorldFunction));
  }
}

デプロイ後にCurlでAPIを叩いて動作確認をします。

$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
Hello World!

CDKでBlue/Greenデプロイを実装

LambdaのBlue/Greenデプロイのための実装を追加します。

lib/lambda-blue-green-stack.ts

import {
  aws_apigateway as apigateway,
  aws_cloudwatch as cloudwatch,
  aws_codedeploy as codedeploy,
  aws_lambda as lambda,
  aws_lambda_nodejs as lambdaNodejs,
  Duration,
  Stack,
  StackProps,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

const SAMPLE_PROJECT_NAME = 'lambda-blue-green';
const REST_API_STAGE_NAME = 'v1';

export class LambdaBlueGreenStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const deploymentConfig = new codedeploy.LambdaDeploymentConfig(
      this,
      'DeploymentConfig',
      {
        trafficRouting: new codedeploy.TimeBasedCanaryTrafficRouting({
          interval: Duration.minutes(1),
          percentage: 50,
        }),
      },
    );

    const application = new codedeploy.LambdaApplication(this, 'Application', {
      applicationName: `${SAMPLE_PROJECT_NAME}-lambda`,
    });

    const helloWorldFunction = new lambdaNodejs.NodejsFunction(
      this,
      'HelloWorldFunction',
      {
        functionName: `${SAMPLE_PROJECT_NAME}-hello-world`,
        entry: 'lambda/src/hello-world.ts',
      },
    );

    const helloWorldFunctionAlias = new lambda.Alias(this, 'Alias', {
      aliasName: 'blue',
      version: helloWorldFunction.currentVersion,
    });

    const helloWorldFunctionDeploymentGroup =
      new codedeploy.LambdaDeploymentGroup(
        this,
        'HelloWorldFunctionDeploymentGroup',
        {
          deploymentConfig: deploymentConfig,
          application: application,
          alias: helloWorldFunctionAlias,
        },
      );

    helloWorldFunctionDeploymentGroup.addAlarm(
      new cloudwatch.Alarm(this, 'ErrorAlarm', {
        comparisonOperator:
          cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
        threshold: 1,
        evaluationPeriods: 1,
        metric: helloWorldFunctionAlias.metricErrors(),
      }),
    );

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `${SAMPLE_PROJECT_NAME}-rest-api`,
      deployOptions: {
        stageName: REST_API_STAGE_NAME,
      },
    });

    restApi.root
      .addResource('hello-world')
      .addMethod(
        'GET',
        new apigateway.LambdaIntegration(helloWorldFunctionAlias),
      );
  }
}

動作確認

Lambda関数のコードを変更してエラーを返すようにします。

lambda/src/hello-world.ts

export const handler = async (event: unknown) => {
  throw new Error();
};

デプロイ後にコンソールからCodeDeployを確認します。

CodeDeploy

CurlでAPIを叩くと、50%くらいの確率でエラーが返ることがわかります。

$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
Hello World!
$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
{"message": "Internal server error"}
$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
Hello World!
$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
{"message": "Internal server error"}

少し時間をおいてから再度コンソールを確認するとロールバックされていることが確認できます。

CodeDeployロールバック

CurlでAPIを叩くと、エラーが発生しないことが確認できます。

$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
Hello World!
$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
Hello World!
$ curl -X "GET" "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/hello-world"
Hello World!

注意事項

CDK(CloudFormation)を使用したLambdaのBlue/Greenデプロイにおいて、エラーが設定した閾値を超えた際にロールバックするのは、デプロイが実行中のスタックだけです。IAMロールなどを別のスタックで管理している場合、IAMの変更が原因でエラーが発生しても、IAMロールの設定が含まれるスタックは自動でロールバックされないので、エラーが発生し続ける可能性があります。関連するリソースは1つのスタックにまとめるか、ある程度の妥協は必要かもしれません。

おまけ

独自のCDKコンストラクトを実装して抽象化しておくと使いやすいです。

lib/constructs/deploy-nodejs-function.ts

import {
  aws_cloudwatch as cloudwatch,
  aws_codedeploy as codedeploy,
  aws_lambda as lambda,
  aws_lambda_nodejs as lambdaNodejs,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

interface DeployNodejsFunctionProps extends lambdaNodejs.NodejsFunctionProps {
  deploymentConfig: codedeploy.ILambdaDeploymentConfig;
  application: codedeploy.ILambdaApplication;
}

export class DeployNodejsFunction extends lambdaNodejs.NodejsFunction {
  public readonly alias: lambda.Alias;

  constructor(scope: Construct, id: string, props: DeployNodejsFunctionProps) {
    super(scope, id, props);

    const alias = new lambda.Alias(this, `${id}Alias`, {
      aliasName: 'blue',
      version: this.currentVersion,
    });

    this.alias = alias;

    const deploymentGroup = new codedeploy.LambdaDeploymentGroup(
      this,
      `${id}LambdaDeploymentGroup`,
      {
        deploymentConfig: props.deploymentConfig,
        application: props.application,
        alias: alias,
      },
    );

    deploymentGroup.addAlarm(
      new cloudwatch.Alarm(this, `${id}ErrorAlarm`, {
        comparisonOperator:
          cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
        threshold: 1,
        evaluationPeriods: 1,
        metric: alias.metricErrors(),
      }),
    );
  }
}

実装したDeployNodejsFunctionNodejsFunctionの代わりに使います。

まとめ

思っていたよりも簡単にLambdaのBlue/Greenデプロイが実装できました。これまで、実装工数などの都合により、Blue/Greenデプロイの導入を見送っていたプロジェクトでも、是非導入してみてください。Blue/Greenデプロイを導入することで、万が一デプロイ後にエラーが発生した場合でも、影響範囲を最小限にとどめることができます。

CodeDeployのデプロイ設定における詳細な説明は省きましたが、トラフィックを移行する間隔や割合などを設定することができます。また、デプロイ開始前やデプロイ後に検証を実行することも可能です。

参考資料