25
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

NRI OpenStandia Advent Calendar 2022

Day 6

AWS CDKとCDK Pipelinesでつくるサーバレスアプリケーション

Last updated at Posted at 2022-12-05

この記事について

AWS CDKv2でCDK PipelinesがGAになって大分たちますが、非常にラクにパイプラインが作成できて便利に感じました。
特に、サーバレスアプリケーションの場合は、Lambda関数を修正したのですぐに配備したい、というときに便利ですね。
この記事では、AWS CDKとCDK Pipelinesを用いて、以下のことを試してみます。

  1. AWS CDKを利用してAPI Gateway+Lambdaでサーバレスアプリケーションを配備します。
    合わせて「開発面」「ステージング面」・・・なども意識して、1つのコードから複数面の作成を行います。
  2. CDK Pipelinesを用いて、上記スタックをパイプライン化してみます。
  3. CDK Pipelinesのself-mutatingを試してみます。

すでにCDKでスタック作成している方も、これを参考にCDK Pipelinesにトライしてもらえると幸いです。

この記事の対象者

  • AWS CDKに興味がある方
  • CDK Pipelinesに興味がある方

CDK Pipelinesとは

  • マニュアル
    https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/cdk_pipeline.html
    CDK Pipelines are self-updating. If you add application stages or stacks, the pipeline automatically reconfigures itself to deploy those new stages or stacks.
    self-updatingとか、self-mutatingなどと表現されておりましたが、CodeCommitやGitHubにコードをpushすると、CDK Pipelinesがパイプラインそのものを変更してくれる、という動きをします。このあと、実際に試してみたいと思います。

事前準備

  1. あらかじめご準備いただきたいもの
    AWSアカウントと、IAMユーザはあらかじめ準備いただいている前提でお話いたします。
  2. CDK実行環境について
    一番手軽に準備ができますので、筆者はいつもCloudShell上でCDKを実行しております。
    CloudShellを起動して、sudo npm install -g aws-cdkを実行してください。
    CloudShellは放置しているとセッションが切れて、/homeディレクトリ以外のデータは削除されてしまいますのでご注意ください。(消えた場合はまたインストールすれば利用できます)
  3. cdk bootstrap実行
    AWSアカウント>リージョン毎に、CDKを初めて利用する際に1回だけ実行が必要になります。
    cdk bootstrap aws://ACCOUNT-NUMBER/REGION

本手順を通じて作成するもの

aws_arch2.JPG
API Gatewayと、その背後で動くLambdaを作成します。
作成を容易に行うために、API GatewayもLambdaもVPC外に作成しています。
面毎にパイプラインを作成するために、面に対応したブランチを作成します。

1段階目:API Gateway+Lambdaを作る

CDKにおける内部処理について

AWS CDKはコードからCloudFormationテンプレート(CFn Template)を生成し、それを用いてCloudFormationスタックを起動します。
CDKコードに環境に依存する情報(AWSアカウントIDやリージョン情報など)をハードコードしてしまうと再利用性が損なわれます。その時に役に立つのが「Context」という機能になります。Contextという機能により、外部の環境情報を読み込み、CDKコードと「合成」してCFnテンプレートを生成します。今回は、1つのコードから複数面を作成するために、この「Context」機能を応用してみました。環境を配備する際に環境面を指定したいので、コマンドライン引数でContext情報を指定します。

CDKを利用した開発の流れについて

以下の流れで開発を行います。

  1. 開発プロジェクトの作成
    cdk init app --language=typescript ※TypeScript、Python、Java、C#、Goが利用可能
  2. CDKコードの実装
    お好きなエディタを使ってCDKコードを実装してください。
  3. CDKコードと環境情報の合成(CFnテンプレートの作成)
    cdk synth
    Context情報は以下のように指定してください。(-cオプションで指定)
    cdk synth -c ENV=DEV1
  4. AWS環境への配備
    cdk deploy -c ENV=DEV1
    Context情報は以下のように指定してください。(-cオプションで指定)

実装前の準備

以下のコマンドを実行して、CDKの開発プロジェクトを作成します。
mkdir cp_sample && cd cp_sample && cdk init app --language=typescript
後で出てまいりますが、Node.jsを実行環境とするLambdaをCDKで配備する際にts-nodeが必要でしたのでここでインストールしておきます。
npm install ts-node --save-dev

API Gateway + Lambdaを配備するCDKを実装してみましょう

cdk initで作成したテンプレートに以下のコードを実装していきます。

ディレクトリ構成
bin
└── cp_sample.ts            CDKエントリポイント
env
└── cp-env.ts               複数面作成するために細工した環境変数を保持するインタフェース
lambda
└── hello-world.ts          CDK内で配備するLambda関数
lib
└── apigateway-stack.ts     API Gateway+Lambdaを配備するスタック
bin/cp_sample.ts
  #!/usr/bin/env node
  import 'source-map-support/register';
  import * as cdk from 'aws-cdk-lib';
  import ApiGatewayStack from '../lib/apigateway-stack';
  import * as cp from '../env/cp-env';
  
  const app = new cdk.App();
  const env = cp.getEnv(app.node);
  new ApiGatewayStack(app, `ApiGatewayStack-${env.side}`, {});

作成する「面」の情報を取得するためにcp.getEnv(app.node)を実行していますが、これはcp-env.tsの関数として実装しています。
ApiGatewayStack()の第二引数がCFnスタックの名前になります。この名前に「面」の情報を連結して、面毎に名前が変わるようにしてあります。

env/cp-env.ts
  import { Node } from 'constructs';
  
  export interface IEnvironment {
      side: string,
      message: string,
  }
  
  export interface CPEnvironment {
      [env: string]: IEnvironment,
  };
  
  // 環境「面」を追加したい場合は、以下のインデックスを追加する。
  export const env: CPEnvironment = {
      ['DEV1']: {
          side: 'DEV1',
          message: 'I am DEV1.',
      },
      ['DEV2']: {
          side: 'DEV2',
          message: 'I am DEV2.',
      },
  }
  
  export function getEnv(node: Node): IEnvironment{
      // cdk deploy -c ENV=DEV1 といった形で-cで指定された値を取得する。
      const envContext = node.tryGetContext('ENV');
      if(!envContext){
          throw new Error('ENV context is required.');
      } else if (!(envContext in env)){
          throw new Error(`The specified value ${envContext} does not exist in the environment variables.`);
      }
      return env[envContext];
  }

cdk実行時にc- ENV=DEV1といった形で指定する想定で上記実装をしています。
-cの指定を忘れた場合や、想定外の値が指定されたことも踏まえてチェック処理も入れてみました。
環境毎に設定を変更したいものをCPEnvironmentに追加していく使い方です。

lib/apigateway-stack.ts
  import * as cdk from 'aws-cdk-lib';
  import * as lambda from 'aws-cdk-lib/aws-lambda';
  import * as lambdaNodeJS from 'aws-cdk-lib/aws-lambda-nodejs';
  import * as apigateway from 'aws-cdk-lib/aws-apigateway';
  import { Construct } from 'constructs';
  import * as cp from '../env/cp-env';
  
  export interface ApiGatewayProps extends cdk.StackProps {
  }
  
  export default class ApiGatewayStack extends cdk.Stack {
      public readonly url: cdk.CfnOutput;
      constructor(scope: Construct, id: string, props: ApiGatewayProps) {
          super(scope, id, props);
  
          const env = cp.getEnv(this.node);
  
          const helloWorldLambda = new lambdaNodeJS.NodejsFunction(this, `MyLambda-${env.side}`, {
              entry: 'lambda/hello-world.ts',
              handler: 'handler',
              runtime: lambda.Runtime.NODEJS_16_X,
              functionName: `HelloWorldLambda-${env.side}`,
              environment: { "MY_MESSAGE": env.message },
          });
  
          const helloWorldGateway = new apigateway.LambdaRestApi(this, `MyApiGateway-${env.side}`, {
              handler: helloWorldLambda,
              restApiName: `HelloWorldGateway-${env.side}`,
              proxy: false,
          });
          const hoge = helloWorldGateway.root.addResource('hoge');
          hoge.addMethod('GET', new apigateway.LambdaIntegration(helloWorldLambda));
          const fuga = helloWorldGateway.root.addResource('fuga');
          fuga.addMethod('GET', new apigateway.LambdaIntegration(helloWorldLambda));
          const bar = helloWorldGateway.root.addResource('bar');
          bar.addMethod('GET', new apigateway.LambdaIntegration(helloWorldLambda));
          
          this.url = new cdk.CfnOutput(this, "API_GATEWAY_ADDRESS", {value: helloWorldGateway.url});
      }
  }

本コードの中で、Lambda関数の配備と、API Gatewayの配備を行っています。
ここでもgetEnv()から「面」情報を取り出して、各リソースの名前組み立てに使っています。
面毎にふるまいを変更する使い方をイメージして、Lambda関数の環境変数に面毎に切り替えたい値をいれてみました。
3段階目の実装時に利用するので、API GatewayのURLをCfnOutputしてあります。

lambda/hello-world.ts
  import * as lambda from 'aws-lambda';
  
  export const handler = async (event: lambda.APIGatewayProxyEventV2, context: lambda.APIGatewayEventRequestContextV2): Promise<lambda.APIGatewayProxyResultV2> => {
  
      const message = process.env.MY_MESSAGE;
      const responseBody = { message: `Hello! ${message}`};
      return {
          statusCode: 200,
          headers: { "Content-Type": "application/json"},
          body: JSON.stringify(responseBody),
      }
  
  }

Lambda環境変数から値を受け取り、それをJSONとして応答しています。

配備してみましょう

npx cdk deploy -c ENV=DEV1
npx cdk deploy -c ENV=DEV2
上記コマンドを実行すると、2面分作成できます。

  • CloudFormation画面
    cfn1.JPG
  • API Gateway画面
    apigateway1.JPG
  • Lambda画面
    lambda1.JPG
    API Gateway>どちらかの面のAPIを選択>ステージから任意のURLにアクセスしていただき、
    {"message":"Hello! I am DEV1."}が表示されれば成功です。(messageの内容は面により変わります)
    確認ができたら、削除しておきましょう。以下のコマンドで削除できます。
    npx cdk destroy -c ENV=DEV1
    npx cdk destroy -c ENV=DEV2

2段階目:CDK Pipelines化してみる

実装前の準備

パイプラインを作成しますので、パイプライン起動の起点となるgitリポジトリとブランチが必要です。
今回は、CodeCommitに「CPSample」という名前のリポジトリを準備します。(AWSマネジメントコンソール上で、手で作成してください)  
開発面毎にパイプラインを動かすことを模擬して、以下の形でブランチを作成します。

  • DEV1 -> DEV1面向けブランチ
  • DEV2 -> DEV2面向けブランチ

API Gateway + LambdaのCDKコードをパイプライン化してみましょう

前に作成したAPI GatewayやLambda作成のプロジェクトをそのまま利用します。
lib/pipeline-stack.tsを新規で実装し、bin/cp_sample.tsを修正します。
API Gateway+Lambdaを作成するlib/apigateway-stack.tsは修正不要です。

ディレクトリ構成
bin
└── cp_sample.ts            CDKエントリポイント ★修正★
env
└── cp-env.ts               複数面作成するために細工した環境変数を保持するインタフェース
lambda
└── hello-world.ts          CDK内で配備するLambda関数
lib
└── apigateway-stack.ts     API Gateway+Lambdaを配備するスタック
└── pipeline-stack.ts       CDK Pipelinesを配備するスタック ★追加★
lib/pipeline-stack.ts
  import * as cdk from 'aws-cdk-lib';
  import * as pipelines from 'aws-cdk-lib/pipelines';
  import * as codecommit from 'aws-cdk-lib/aws-codecommit';
  import { Construct } from 'constructs';
  import * as cp from '../env/cp-env';
  import ApiGatewayStack from '../lib/apigateway-stack';
  
  export default class MyPipelineStack extends cdk.Stack {
  
      constructor(scope: Construct, id: string, props?: cdk.StackProps) {
          super(scope, id, props);
  
          const env = cp.getEnv(this.node);
  
          // ポイント1
          const pipeline = new pipelines.CodePipeline(this, `MyPipeline-${env.side}`, {
              pipelineName: `MyPipeline-${env.side}`,
              synth: new pipelines.ShellStep('Synth', {
                  input: pipelines.CodePipelineSource.codeCommit(
                      codecommit.Repository.fromRepositoryName(this, `MyCodeCommit-${env.side}`, 'CPSample'),
                      `${env.side}`, {}),
                  commands: [
                      'npm ci',
                      'npm run build',
                      `npx cdk synth -c ENV=${env.side}`,  // ポイント3
                  ],
              }),
          });
  
          // ポイント2
          const myApp = new MyApplication(this, `MyAppStage-${env.side}`, {});
          const apiGatewayStage = pipeline.addStage(myApp);
      }
  
  }
  
  export interface MyAppProps extends cdk.StackProps {
  }
  
  // ポイント2
  class MyApplication extends cdk.Stage {
      constructor(scope: Construct, id: string, props: MyAppProps) {
          super(scope, id, props);
          const env = cp.getEnv(this.node);
          const apiGatewayStack = new ApiGatewayStack(this, `MyGateway-${env.side}`, {});
      }
  }

上記コード内コメントの「ポイント」と合わせて解説します。

  • ポイント1
    パイプラインを作成します。パイプラインの起点となるCodeCommitリポジトリ(CPSample)を指定しています。
    プロパティとして指定している「synth」は必須です。ここでCDKコードをsynthして、CFnテンプレートを作成するイメージです。
  • ポイント2
    1段階目で作成したAPI GatewayスタックをStageとしてpipelineに登録します。
    前にも記載しましたが、既存のコード(ApiGatewayStack)に手を加える必要はないので便利ですね。
  • ポイント3
    環境「面」毎にパイプラインを作成するポイントになります。
    DEV1ブランチにコードがpushされると、それに対応したパイプラインが稼働します。
    内部で作成されるAPI GatewayスタックにContext情報を伝えるため、-c ENV=DEV1となるようにここでオプション指定を行っています。
bin/cp_sample.ts
  #!/usr/bin/env node
  import 'source-map-support/register';
  import * as cdk from 'aws-cdk-lib';
- import ApiGatewayStack from '../lib/apigateway-stack';
+ import MyPipelineStack from '../lib/pipeline-stack';
  import * as cp from '../env/cp-env';
  
  const app = new cdk.App();
  const env = cp.getEnv(app.node);
- new ApiGatewayStack(app, `ApiGatewayStack-${env.side}`, {});
+ new MyPipelineStack(app, `MyPipelineStack-${env.side}`, {});

MyPipelineStackを実行するように修正します。ApiGatewayStackの実行は不要になりますので削除しましょう。

配備してみましょう

1段階目と同様、下記コマンドで配備することができます。
npx cdk deploy -c ENV=DEV1 ※DEV1ブランチをcheckoutした状態で実行
npx cdk deploy -c ENV=DEV2 ※DEV2ブランチをcheckoutした状態で実行
1つのパイプラインが終わるまで10分程度かかるので少々お待ちください。
実行が終わると以下のような形で、CFnスタックがそれぞれ2つずつ作成されます。

  • CloudFormation画面
    cfn2.JPG
    MyPipelineStack-DEV1はCDK Pipelinesを配備した際に起動されるスタックです。
    MyAppStage-DEV1-MyGateway-DEV1はCDK Pipelinesが作成したスタックです。
  • CodePipeline画面
    codepipeline1.JPG

3段階目:self-updatingを体験してみる

パイプラインの最後に、API Gatewayがきちんと配備できたかどうかを確認するために、テストを行うコードを追加してみましょう。

lib/pipeline-stack.ts
  import * as cdk from 'aws-cdk-lib';
  import * as pipelines from 'aws-cdk-lib/pipelines';
  import * as codecommit from 'aws-cdk-lib/aws-codecommit';
  import { Construct } from 'constructs';
  import * as cp from '../env/cp-env';
  import ApiGatewayStack from '../lib/apigateway-stack';
  
  export default class MyPipelineStack extends cdk.Stack {
  
      constructor(scope: Construct, id: string, props?: cdk.StackProps) {
          super(scope, id, props);
  
          const env = cp.getEnv(this.node);
  
          // ポイント1
          const pipeline = new pipelines.CodePipeline(this, `MyPipeline-${env.side}`, {
              pipelineName: `MyPipeline-${env.side}`,
              synth: new pipelines.ShellStep('Synth', {
                  input: pipelines.CodePipelineSource.codeCommit(
                      codecommit.Repository.fromRepositoryName(this, `MyCodeCommit-${env.side}`, 'CPSample'),
                      `${env.side}`, {}),
                  commands: [
                      'npm ci',
                      'npm run build',
                      `npx cdk synth -c ENV=${env.side}`,  // ポイント3
                  ],
              }),
          });
  
          // ポイント2
          const myApp = new MyApplication(this, `MyAppStage-${env.side}`, {});
          const apiGatewayStage = pipeline.addStage(myApp);
  
+         // ポイント4
+        apiGatewayStage.addPost(new pipelines.ShellStep(`GET-TEST-${env.side}`, {
+             envFromCfnOutputs: {
+                 URL: myApp.apiGatewayAddress,
+             },
+             commands: [`curl -Ssf $URL/bar`],
+        }));
      }
  
  }
  
  export interface MyAppProps extends cdk.StackProps {
  }
  
  // ポイント2
  class MyApplication extends cdk.Stage {
+     public readonly apiGatewayAddress: cdk.CfnOutput;  // ★ポイント4★
      constructor(scope: Construct, id: string, props: MyAppProps) {
          super(scope, id, props);
          const env = cp.getEnv(this.node);
          const apiGatewayStack = new ApiGatewayStack(this, `MyGateway-${env.side}`, {});
+         this.apiGatewayAddress = apiGatewayStack.url;  // ★ポイント4★
      }
  }

追加した箇所について解説します。コードを追加したのはlib/pipeline-stack.tsのみ。

  • ポイント4
    MyAppStage-${env.side}の配備が完了した後にステップを1つ追加しています(addPost()が該当)。
    ShellStepはパイプラインを作成する際にも登場しておりますが、CodeBuild上でシェルコマンドを実行する仕組みです。今回は、curlでAPI GatewayのURLにGETアクセスしています。curlの宛先URLが$URLとなっておりますが、これは、envFromCfnOutputsプロパティで指定しています。CfnOutputの値を、CodeBuild内で環境変数として引き継いでくれる仕組みです。CfnOutputの値そのものは、MyApplicationクラス内で引継ぎを行っています。(// ★ポイント4★ の箇所)

パイプラインを動かしてself-updatingを体感してみましょう

実装が終わったらCodeCommitにpushしてみましょう。数十秒ほどでpushした方のブランチに紐づくCodePipelineが動き始めます。
自明ですが、npx cdk deploy・・・は実行不要です。  
最初に配備した時のパイプラインは以下のイメージですが、
codepipeline2.JPG  
DEV1リポジトリにコードをpushした後のパイプラインはこのような形になります。GET-TEST-DEV1ステップを追加できたことが分かるかと思います。
codepipeline3.JPG
なお、GET-TEST-DEV1の「詳細」をクリックするとCodeBuildのログが確認できます。

・・・(略)・・・
[Container] 2022/11/27 07:27:41 Entering phase BUILD
[Container] 2022/11/27 07:27:41 Running command curl -Ssf $URL/bar
{"message":"Hello! I am DEV1."}
[Container] 2022/11/27 07:27:43 Phase complete: BUILD State: SUCCEEDED
[Container] 2022/11/27 07:27:43 Phase context status code:  Message: 
・・・(略)・・・

テストも成功していますね。なお、$URLの値はCodeBuil画面の「環境変数」タブで確認できます。

確認が終わったら削除しましょう

以下のコマンドで削除できます。
npx cdk destroy -c ENV=DEV1 ※DEV1ブランチをcheckoutした状態で実行
npx cdk destroy -c ENV=DEV2 ※DEV2ブランチをcheckoutした状態で実行
CDK Pipelines内で作成されたスタックは上記コマンドでは削除されないようです。こちらについては、CloudFormation画面から削除するようにしてください。

補足:CDKコマンドの実行方法について

cdkコマンドを実行する際に、npxを利用しておりました。こちらについて補足しておきます。
CDK開発プロジェクトを作成する際(cdk init app --language=typescript)は、グローバル環境にインストールされたcdkコマンドを実行しております。
その後、CDK開発プロジェクトの中でcdkコマンドを発行するときには、一貫してnpx cdk deployといった形でnpx~で実行しています。
実際の運用では、CDK開発プロジェクトごとgitで管理することになると思いますが、npxでcdkコマンドを実行することにより、gitでコードを共有された開発者がみな同じバージョンのcdkコマンドを実行することができます。(cdk initで開発プロジェクトを作成した際に、node_module内にcdkもインストールされます。)
CDKは非常にバージョンアップの速度が速いため(体感的には1か月程度でマイナーバージョンが上がっていく)、運用上もこの点は意識しておいたほうがよいと思います。

まとめ

  • AWS CDKを用いて、API GatewayとLambda関数を配備してみました。
  • CDKのContextを活用して、同一のコードから複数面作成することができました。
  • API GatewayとLambda関数を作成するコードをそのまま利用し、CDK Pipelinesを用いてパイプライン化してみました。
  • パイプライン化する際にも、環境面を意識して、ブランチとパイプラインの紐づけ実装ができることを確認しました。

Lambdaをはじめとするサーバレスアプリケーションを配備する際、コードをpushするだけで実行環境に配備までしてくれるのでだいぶ便利に感じました。
また、同様の仕組みをCodeBuildとCodePipelineと連携させて自作ことも可能ですが、同じCDKで実装するにしても、こちらのほうがラクできるのではないでしょうか。

参考

25
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?