【AWS CDK】CodePipeline から Lambda を Blue-Green デプロイする

はじめに

テントの中から失礼します、IoT 事業部のてんとタカハシです!

Lambda のバージョニングとエイリアスを活用することで、Blue-Green デプロイの構成を実現することができます。実サービスの本番環境にリリースを行った際、何か問題が発生した場合に、元のバージョンへ切り戻せる構成になっていると非常に安心感があります。

今回は上記の記事を参考にして、GitHub リポジトリでのマージをトリガーに、CodePieline から Lambda を Blue-Green デプロイする構成について CDK で構築します。

尚、本記事で記載する全てのソースコードは、下記のリポジトリでも確認することができますので、必要に応じてご参照いただければと思います。

GitHub - iam326/lambda-blue-green-deploy-by-cdk

環境

$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.15.7
BuildVersion:	19H1824

$ aws --version
aws-cli/2.7.9 Python/3.9.11 Darwin/19.6.0 exe/x86_64 prompt/off

$ cdk --version
2.35.0 (build 5c23578)

AWS 構成

API Gateway から Lambda を Invoke する構成ですが、間に Lambda のエイリアスを挟むことで Blue-Green デプロイが可能な構成を作っています。

デプロイの流れとしては、GitHub リポジトリでのマージをトリガーに CodePipeline が起動され、CodeBuild 上から新しい Lambda のバージョンを発行します。

続いて、エイリアスの参照先を新しく発行したバージョンに切り替えます。

この構成により、新しく発行したバージョンで何か問題が発生した場合でも、エイリアスの参照先を元に戻すだけで切り戻しが可能になります。

環境とブランチ構成

今回は実案件を想定して、AWS アカウント上に本番(PROD)、検証(STG)、開発(DEV)の3環境を用意することを前提にします。そのため、GitHub リポジトリのブランチ構成についても、各環境に対応する main、release、develop の3つを用意します。

各ブランチでマージが発生した際に、CodePipeline が起動して、対応する環境へのデプロイが行われる流れとします。

GitHub コネクションを手動で作成

CodePipeline では「GitHub バージョン 2 アクション」により、ソースコードをダウンロードします。このアクションを使用するためには、GitHub へ接続するためのコネクションを用意しておく必要があるので、ここでは事前準備として作成していきます。

CodePipeline の左メニューから「設定」→「接続」を選択してから、画面右の「接続を作成」をクリックします。

プロバイダーを「GitHub」に選択して、接続名を入力した後、「GitHubに接続する」をクリックします。

「新しいアプリをインストールする」をクリックします。

GitHub の画面へ遷移された後、自アカウントをクリックします。

「Install」をクリックします。

AWS の画面に戻った後、「接続」をクリックします。

接続設定に記載されている「ARN」を後ほど使用するため、コピーしておいてください。

実装

スタック構成

スタックは下記の2つに分けています。

  • API スタック:[環境名]-blue-green-sample-api
  • CICD スタック:[環境名]-blue-green-sample-cicd

cdk.json

githubOwnerNamegithubRepositoryNameは各自の環境に合わせて変更してください。codestarConnectionArnには事前準備でコピーした ARN を貼り付けてください。

cdk.json

{
  "app": "npx ts-node --prefer-ts-exts bin/lambda-blue-green-deploy-by-cdk.ts",
  "watch": {
    ...
  },
  "context": {
    "projectName": "blue-green-sample",
    "githubOwnerName": "iam326",
    "githubRepositoryName": "lambda-blue-green-deploy-by-cdk",
    "codestarConnectionArn": "<codestarConnectionArn>",
    "dev": {
      "githubBranchName": "develop"
    },
    "stg": {
      "githubBranchName": "release"
    },
    "prod": {
      "githubBranchName": "main"
    },
    ...
  }
}

API スタック

このスタックでは主に下記のリソースを作成します。

  • Lambda 関数
  • 上記の新しいバージョン
  • 上記を参照するエイリアス
  • REST API
  • Lambda 関数と紐づく GET メソッド

バージョンの切り戻しを可能にするため、新しいバージョンが発行された場合でも、過去のバージョンは削除せずに保持します。ただし、開発環境では作業の度にデプロイが発生して、無駄なリソースが増え続けてしまうため、過去のバージョンを削除するようにしています。

また、Lambda の説明にコミットハッシュを載せることで、どの Lambda バージョンとコミットが紐付いているのかを後からでも確認できるようにしています。

lib/blue-green-sample-api-stack.ts

import * as path from 'path';

import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';

interface BlueGreenSampleApiStackProps extends StackProps {
  projectName: string;
  stageName: string;
  commitHash?: string;
}

export class BlueGreenSampleApiStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: BlueGreenSampleApiStackProps
  ) {
    super(scope, id, props);

    const { projectName, stageName, commitHash } = props;

    const lambdaFunction = new lambdaNodejs.NodejsFunction(
      this,
      'LambdaFunction',
      {
        functionName: `${stageName}-${projectName}-lambda`,
        description: commitHash ? `Commit Hash: ${commitHash}` : '',
        runtime: lambda.Runtime.NODEJS_14_X,
        entry: path.join(__dirname, '../src/lambda/index.ts'),
        handler: 'handler',
        currentVersionOptions: {
          removalPolicy:
            stageName === 'dev' ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN,
        },
      }
    );
    const lambdaAlias = lambdaFunction.currentVersion.addAlias('alias');

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `${stageName}-${projectName}-api`,
    });
    restApi.root.addMethod(
      'GET',
      new apigateway.LambdaIntegration(lambdaAlias)
    );
  }
}

Lambda のソースコードは下記の通りです。

src/lambda/index.ts

export const handler = async () => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      hello: 'world',
    }),
  };
};

CICD スタック

このスタックでは主に下記のリソースを作成しています。

  • CodePipeline
  • デプロイ用の CodeBuild

CodePipeline が起動すると、GitHub からソースコードをダウンロードして、CodeBuild 上で buildspec.yml を実行します。

lib/blue-green-sample-cicd-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as codeBuild from 'aws-cdk-lib/aws-codebuild';
import * as codePipeline from 'aws-cdk-lib/aws-codepipeline';
import * as codePipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as iam from 'aws-cdk-lib/aws-iam';

export interface BlueGreenSampleCicdStackProps extends StackProps {
  projectName: string;
  stageName: string;
  githubOwnerName: string;
  githubRepositoryName: string;
  githubBranchName: string;
  codestarConnectionArn: string;
}

export class BlueGreenSampleCicdStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: BlueGreenSampleCicdStackProps
  ) {
    super(scope, id, props);

    const {
      projectName,
      stageName,
      githubOwnerName,
      githubRepositoryName,
      githubBranchName,
      codestarConnectionArn,
    } = props;

    const sourceArtifact = new codePipeline.Artifact();
    const sourceAction =
      new codePipelineActions.CodeStarConnectionsSourceAction({
        actionName: 'source',
        owner: githubOwnerName,
        repo: githubRepositoryName,
        branch: githubBranchName,
        connectionArn: codestarConnectionArn,
        output: sourceArtifact,
      });

    const codeBuildServiceRole = new iam.Role(this, 'CodeBuildServiceRole', {
      assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'),
      path: '/',
      inlinePolicies: {
        codeBuildServicePolicies: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['cloudformation:*'],
              resources: [
                `arn:aws:cloudformation:${this.region}:${this.account}:stack/*`,
              ],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['ssm:GetParameter'],
              resources: [
                `arn:aws:ssm:${this.region}:${this.account}:parameter/cdk-bootstrap/*`,
              ],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['s3:*'],
              resources: [
                `arn:aws:s3:::cdk-*-assets-${this.account}-${this.region}`,
                `arn:aws:s3:::cdk-*-assets-${this.account}-${this.region}/*`,
                'arn:aws:s3:::cdktoolkit-stagingbucket-*',
              ],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['iam:PassRole'],
              resources: [
                `arn:aws:iam::${this.account}:role/cdk-*-role-${this.account}-${this.region}`,
              ],
            }),
          ],
        }),
      },
    });

    const codeBuildDeployProject = new codeBuild.PipelineProject(
      this,
      'CodeBuildDeployProject',
      {
        projectName: `${stageName}-${projectName}-deploy-project`,
        buildSpec: codeBuild.BuildSpec.fromSourceFilename('./buildspec.yml'),
        role: codeBuildServiceRole,
        environment: {
          buildImage: codeBuild.LinuxBuildImage.STANDARD_5_0,
          computeType: codeBuild.ComputeType.SMALL,
          privileged: true,
          environmentVariables: {
            STAGE_NAME: {
              type: codeBuild.BuildEnvironmentVariableType.PLAINTEXT,
              value: stageName,
            },
          },
        },
      }
    );

    const deployAction = new codePipelineActions.CodeBuildAction({
      actionName: 'deploy',
      project: codeBuildDeployProject,
      input: sourceArtifact,
    });

    new codePipeline.Pipeline(this, 'DeployPipeline', {
      pipelineName: `${stageName}-${projectName}-deploy-pipeline`,
      stages: [
        {
          stageName: 'source',
          actions: [sourceAction],
        },
        {
          stageName: 'deploy',
          actions: [deployAction],
        },
      ],
    });
  }
}

buildspec.yml の実装は下記の通りです。CodePipeline から CodeBuild を起動すると、環境変数にコミットハッシュが格納されるのでcdk deploy時に渡してあげています。

buildspec.yml

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 14
    commands:
      - yarn install
  build:
    commands:
      - yarn deploy $STAGE_NAME-blue-green-sample-api -c stageName=$STAGE_NAME -c commitHash=$CODEBUILD_RESOLVED_SOURCE_VERSION --require-approval never

bin

各スタックを配置します。

bin/lambda-blue-green-deploy-by-cdk.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { BlueGreenSampleApiStack } from '../lib/blue-green-sample-api-stack';
import { BlueGreenSampleCicdStack } from '../lib/blue-green-sample-cicd-stack';

interface Environment {
  projectName: string;
  stageName: string;
  githubBranchName: string;
}

const app = new cdk.App();

const projectName = app.node.tryGetContext('projectName');
const stageName = app.node.tryGetContext('stageName');
const env: Environment = app.node.tryGetContext(stageName);
const githubOwnerName = app.node.tryGetContext('githubOwnerName');
const githubRepositoryName = app.node.tryGetContext('githubRepositoryName');
const codestarConnectionArn = app.node.tryGetContext('codestarConnectionArn');
const commitHash = app.node.tryGetContext('commitHash');

env.projectName = projectName;
env.stageName = stageName;

new BlueGreenSampleCicdStack(app, `${stageName}-${projectName}-cicd`, {
  ...env,
  githubOwnerName,
  githubRepositoryName,
  codestarConnectionArn,
});

new BlueGreenSampleApiStack(app, `${stageName}-${projectName}-api`, {
  ...env,
  commitHash,
});

デモ

本構成により Blue-Green デプロイする流れをデモします。

初回デプロイ

まずは各環境へ CICD スタックを手動でデプロイします。

$ cdk deploy dev-blue-green-sample-cicd -c stageName=dev
$ cdk deploy stg-blue-green-sample-cicd -c stageName=stg
$ cdk deploy prod-blue-green-sample-cicd -c stageName=prod

CICD スタックのデプロイが完了すると、自動で CodePipeline が起動します。

CodePipeline が起動した結果として、API スタックがデプロイされます。

この状態で各環境の API へアクセスすると全て同じ値が返ってきます。

% curl https://<DEV 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world"}

% curl https://<STG 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world"}
                                                                                         
% curl https://<PROD 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world"}

Lambda のバージョン一覧を確認すると、バージョン「1」のみ存在します。

Lambda のエイリアスを確認すると、バージョン「1」を参照しています。

DEV デプロイ

Lambda のソースコードを変更して、DEV 環境にデプロイする流れを確認します。下記のように feature ブランチを develop ブランチへマージするための PR を作成します。

マージをすると DEV 環境の CodePipeline が起動します。

デプロイが完了すると、Lambda のバージョン「2」が新しく発行されます。DEV 環境なので過去バージョンは削除されます。

Lambda のエイリアスを確認すると、新しく発行されたバージョン「2」を参照しています。

DEV 環境の API へアクセスすると、レスポンスが変わっていることを確認できます。

% curl https://<DEV 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world version 2"}

STG デプロイ

続いて、STG 環境へデプロイする流れを確認します。下記のように develop ブランチを release ブランチへマージするための PR を作成します。

マージをすると STG 環境の CodePipeline が起動します。

デプロイが完了すると、DEV 環境と同様に Lambda のバージョン「2」が新しく発行されます。こちらの環境では過去バージョンが保持されたままです。

Lambda のエイリアスを確認すると、新しく発行されたバージョン「2」を参照しています。

STG 環境の API へアクセスすると、レスポンスが変わっていることを確認できます。

% curl https://<STG 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world version 2"}

PROD デプロイ

最後に、PROD 環境へデプロイする流れを確認します。下記のように release ブランチを main ブランチへマージするための PR を作成します。

マージをすると PROD 環境の CodePipeline が起動します。

デプロイが完了すると、これまでと同様に Lambda のバージョン「2」が新しく発行されます。こちらも過去バージョンが保持されたままです。

Lambda のエイリアスを確認すると、新しく発行されたバージョン「2」を参照しています。

PROD 環境の API へアクセスすると、レスポンスが変わっていることを確認できます。

% curl https://<PROD 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world version 2"}

切り戻し

PROD 環境へデプロイを行った後、何か問題が発生したことにより、元のバージョンへ戻す必要がある場合は、下記の操作で簡単に切り戻しを完了することができます。

エイリアスを選択した後、「編集」をクリックします。

バージョン「1」を選択した後、「保存」をクリックします。

再度 PROD 環境の API へアクセスすると、レスポンスが元に戻っていることを確認できます。

% curl https://<PROD 環境の API Gateway デフォルトエンドポイント>/prod
{"hello":"world"}

おわりに

本番環境へのリリースは精神的負荷が高いものです。何か失敗が発生した場合でも簡単に切り戻しが可能な構成にしておくと、安心してリリース作業に臨むことができます。

今回は CICD の構成として CodePipeline を使用しましたが、同じようなことは CircleCI や GitHub Actions でも可能だと思いますので、プロジェクトに合わせて活用できると良いのかなと思います。

今回は以上になります。最後まで読んで頂きありがとうございました!