AWS CDKとProvider Frameworkを使うと簡単にAWS CloudFormation カスタムリソースが実装できました

AWS CloudFormationが対応していないリソースを作成するCustom Resource機能は便利ですが、色々と制約や結合部分の実装が必要といった手間がかかります。AWS CDKを使えば提供するミニフレームワークであるProvider Frameworkに従ってリソースを作成するLambda関数を作るだけで簡単にCustom Resouce機能が使えてCDKとの結合も簡単です。このブログではこれらのやり方や注意点を実際のコードを添えて説明します。
2020.12.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、加藤です。先日Application Load BalancerのターゲットグループがgRPCをサポートしたのでCloudFormationから作成して検証をしようとしたのですが、残念ながらまだCloudFormationは対応していませんでした。

事前にターゲットグループをCLIで作成しておいてARNを指定して...といった方法も、最初は思いつきましたがターゲットグループの作成にはVPCを指定する必要があり、CloudFormationでVPCも作成したかったので適合しませんでした。

今回は検証だったので存在は知っているが、使ったことの無かったCloudFormation カスタムリソースを使ってみるかと思い立ちCDKから使ってみたのですが、CDKにProvider Frameworkという仕組みがありこれに要求される形式でコードを書くだけで簡単にカスタムリソースが作成できたのでブログにまとめます。

CloudFormation カスタムリソースについて

CloudFormation カスタムリソースはCloudFormationのプロビジョニング機能を拡張するための仕組みです。要求されるリクエスト・レスポンス形式に従って作成・更新・削除の3パターンの処理を実装することでプロビジョニング機能を拡張することができます。

リクエストをSNSまたはLambda *1で受け取ることが可能で、レスポンスはS3バケットにJSONオブジェクトをアップロードする必要があります。Lambdaを使う場合にそのLambda関数がCloudFormationテンプレートにインラインで含まれていれば cfn-response というNode.jsおよびPython *2向けに提供されているライブラリを使うことでS3バケットを経由せずにレスポンスを返すことができます。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html#cfn-lambda-function-code-zipfile

AWS CloudFormation カスタムリソースと通信する関数を指定する場合、関数を呼び出したカスタムリソースに応答を送信するために独自の関数を作成する必要はありません。AWS CloudFormation は、応答の送信を簡素化する応答モジュール (cfn-response) を提供します。

カスタムリソースを利用する際は、自分でレスポンスの処理を書かなくて良いインラインLambda関数を使うのがファーストチョイスだと私は考えています。

SNS-backed パターン

Lambda-backed(Inline) パターン

CDKでカスタムリソースを使う方法

コードを確認しならが合わせて説明をします。今回のコードはこちらのリポジトリにPushしています。
intercept6/example-aws-cdk-custom-resource

CDKの場合は@aws-cdk/custom-resourceというモジュールがProvider Frameworkに従ってLambda関数を作ればインラインでなくても詳細なレスポンス処理を自分で行わずカスタムリソースを作成できます。

Provider Framework(AWS CDK) パターン

リソースのプロビジョニングを行うLambda関数を作成し、それをProviderに登録しカスタムリソースを作成する際に指定することでProvider Frameworkを使ってカスタムリソースを作成できます。

const onEventHandler = new SingletonFunction(this, 'control-target-group', {
      uuid: '4ddd3cf8-0a1b-43ee-994e-c15a2ffe1bd2',
      code: Code.fromAsset(resolve(__dirname, '..'), {...}),
      runtime: Runtime.NODEJS_12_X,
      handler: 'index.handler',
      memorySize: 512,
      timeout: Duration.minutes(14),
      initialPolicy: [
        new PolicyStatement({
          actions: ['elasticloadbalancing:*'],
          resources: ['*'],
        }),
      ],
    })

    const provider = new Provider(this, 'provider', { onEventHandler })

    const customResource = new CustomResource(this, 'custom-target-group', {
      serviceToken: provider.serviceToken,
      properties: {
        Name: 'grpc-tg',
        Port: 50051,
        Protocol: 'HTTP',
        ProtocolVersion: 'GRPC',
        VpcId: vpc.vpcId,
        TargetType: 'ip',
        HealthCheckEnabled: 'true',
        HealthCheckPath: '/Package.Service/Method',
        Matcher: {
          GrpcCode: '0-99',
        },
      } as TargetGroupProperties,
    })

プロビジョニング関数の作り方

TypeScriptでプロビジョニング関数を作る方法をより詳しくお伝えします。今回は冒頭で述べたようにカスタムリソースを使いgRPC用にターゲットグループを作成することが目的です。プロビジョニング関数とはProvider Frameworkに合わせて作るカスタムリソースをプロビジョニングするLambda関数を示しています。

ハンドラー関数で作成・更新・削除どのイベントかを判断しそれぞれの関数に渡します。更新でif文を書いているのはターゲットグループでは一部のパラメーターは変更できず再作成の必要があるためです。

またProvider Framework(@aws-cdk/custom-resouces)が提供している型が内部で @types/aws-lambda を使用しているのですが、devDependenciesに定義されているため、合わせてインストールがされません。インストールする必要があります。

  • npm npm install --save-dev @types/aws-lambda
  • yarn yarn add -D @types/aws-lambda
export const handler = async (
  event: OnEventRequest
): Promise<OnEventResponse> => {
  console.log('event: ', event)

  switch (event.RequestType) {
    case 'Create': {
      return createTargetGroup(event)
    }
    case 'Update': {
      const targetGroupProps = event.ResourceProperties as TargetGroupProperties
      const oldTargetGroupProps = event.OldResourceProperties as TargetGroupProperties

      if (targetGroupProps.Name !== oldTargetGroupProps.Name) {
        await deleteTargetGroup(event)
        return createTargetGroup(event)
      }
      if (targetGroupProps.Port !== oldTargetGroupProps.Port) {
        await deleteTargetGroup(event)
        return createTargetGroup(event)
      }
			// snip

      return updateTargetGroup(event)
    }
    case 'Delete': {
      return deleteTargetGroup(event)
    }
  }
}

作成を行う関数です。イベントから指定されたリソースのプロパティを取得しそれに基づいてAWS SDKでターゲットグループを作成します。気をつけて欲しいのがプロパティの型です。型定義としては [key: string]: any となっているので必要に応じて自分で型定義をする必要があります。また実際の値の型としてstring, numberは使えましたがbooleanは使用できませんでした。"true" または "false" という文字列で伝わってきます。なので今回は型定義では HealthCheckEnabled: 'true' | 'false' としてSDKを利用する際にboolean型に変換を行っています。

レスポンスは PhysicalResourceId にはARNを指定しました、これはCloudFormationテンプレートからRefで参照が可能です。DataにはターゲットグループのCloudFormation定義を参考に3つのパラメーターを設定しました。この値はGetAttで参照が可能です。項目名を定数で指定しているのはCDKのテストコードを参考に作ったCDK側から参照する際に項目名を迷わなくて済むための工夫ですが、もっと良い方法があれば差し替えたいです。

const createTargetGroup = async (
  event: OnEventRequest
): Promise<OnEventResponse> => {
  const {
    Name,
		// snip
  } = event.ResourceProperties as TargetGroupProperties

  if (!Name) {
    throw new Error(`Name is required`)
  }
  // snip

  const tg = await elbv2
    .createTargetGroup({
      Name,
      Port,
      Protocol,
      ProtocolVersion,
      VpcId,
      TargetType,
      HealthCheckEnabled: HealthCheckEnabled === 'true',
      // snip
    })
    .promise()

  console.log('create response: ', tg.TargetGroups![0])

  return {
    PhysicalResourceId: tg.TargetGroups![0].TargetGroupArn!,
    Data: {
      [ATTR_LOAD_BALANCER_ARNS]: tg.TargetGroups![0].LoadBalancerArns,
      [ATTR_TARGET_GROUP_NAME]: tg.TargetGroups![0].TargetGroupName,
      [ATTR_TARGET_GROUP_FULL_NAME]: tg.TargetGroups![0].TargetGroupArn!.split(
        ':'
      )[5],
    },
  }
}

更新を行う関数です。コードには反映されていないのですがAWS SDKを使ってリソースを変更する際に注意しなくてはいけないことに付いてお伝えします。それはデフォルト値の挙動です。CloudFormationで作成する多くのリソースにはデフォルト値が設定されており、ターゲットグループであればデフォルトのヘルスチェック間隔がN秒というよなものです。今回のプロビジョニング関数では一度デフォルト値から値を変更した後に、その設定項目を削除してもデフォルト値には戻りません。これはmodifyTargetGroup関数は単純に指定された値に変更するだけなので仕方の無いことです。なのでデフォルト値の挙動をさせたければ自分で処理を実装する必要があります。ターゲットグループはオリジンがLambdaかInstance, IPかによってデフォルト値が異なり今回の目的を思い返すとそこまで頑張る必要は無いなと判断して省略しました。

const updateTargetGroup = async (
  event: OnEventRequest
): Promise<OnEventResponse> => {
  if (!event.PhysicalResourceId) {
    throw new Error('PhysicalResourceId(TargetGroupArn) is required')
  }

  const {
    HealthCheckEnabled,
		// snip
  } = event.ResourceProperties as TargetGroupProperties

  const tg = await elbv2
    .modifyTargetGroup({
      TargetGroupArn: event.PhysicalResourceId,
      HealthCheckEnabled: HealthCheckEnabled === 'true',
      HealthCheckIntervalSeconds,
			// snip
    })
    .promise()

  console.log('update response: ', tg.TargetGroups![0])

  return {
    PhysicalResourceId: tg.TargetGroups![0].TargetGroupArn,
    Data: {
      [ATTR_LOAD_BALANCER_ARNS]: tg.TargetGroups![0].LoadBalancerArns,
      [ATTR_TARGET_GROUP_NAME]: tg.TargetGroups![0].TargetGroupName,
      [ATTR_TARGET_GROUP_FULL_NAME]: tg.TargetGroups![0].TargetGroupArn!.split(
        ':'
      )[5],
    },
  }
}

削除を行う関数です。リソースを削除してしまうのでPhysicalResourceIdも返す必要がありません。

const deleteTargetGroup = async (
  event: OnEventRequest
): Promise<OnEventResponse> => {
  await elbv2
    .deleteTargetGroup({ TargetGroupArn: event.PhysicalResourceId! })
    .promise()

  return {}
}

カスタムリソースで作成されたリソースをCDKで扱う

前述の通りカスタムリソースで作成したリソースのプロパティはRefやGetAttで取得することができます。しかし、CDKを使う上では単純にstring型で取得できるだけでは不十分なことが多いです。今回のように作成したリソースがAWSリソースでありCDKにインポート関数が定義されていれば取り込んで他のリソースと関連付けることなどが行なえます。 *3

const customResource = new CustomResource(this, 'custom-target-group', {
      serviceToken: provider.serviceToken,
      properties: {
        Name: 'grpc-tg',
        Port: 50051,
        Protocol: 'HTTP',
        ProtocolVersion: 'GRPC',
        VpcId: vpc.vpcId,
        TargetType: 'ip',
        HealthCheckEnabled: 'true',
        HealthCheckPath: '/Package.Service/Method',
        Matcher: {
          GrpcCode: '0-99',
        },
      } as TargetGroupProperties,
    })
    const grpcTargetGroup = ApplicationTargetGroup.fromTargetGroupAttributes(
      this,
      'grpc-target-group',
      {
        targetGroupArn: customResource.ref,
      }
    )

    const certificate = Certificate.fromCertificateArn(
      this,
      'certificate',
      props.certificateArn
    )

    new ApplicationLoadBalancer(this, 'alb', { vpc }).addListener(
      'grpc-listener',
      {
        protocol: ApplicationProtocol.HTTPS,
        port: 50051,
        defaultTargetGroups: [grpcTargetGroup],
        certificates: [certificate],
      }
    )

エラー処理

プロビジョニング関数内でリカバー不可能なエラーが発生した場合は、例外を投げる事でそれがCloudFormationに伝わりイベントログに表示されエラーの特定に役立ちます。

下記の様に変更して強制的に例外を投げてみました。

export const handler = async (
  event: OnEventRequest
): Promise<OnEventResponse> => {
  console.log('event: ', event)

  throw new Error(`Demo Error, event: ${JSON.stringify(event)}`)
  // snip
}

CloudFormationスタックのイベントタブとCDKを実行したターミナルからエラーメッセージを確認できます。このメッセージには文字数制限があるようなので、データをすべてダンプするといった使い方はできません。より多くの情報を記録したいなら標準出力経由でCloudwatch Logsに記録しましょう。

今回のコードはAWS SDKは自前でエラーハンドリングしていないので、何かエラーが発生したらエラーメッセージがそのまま確認できます。このおかげでboolean型でデータが渡っていないことがエラーメッセージからすぐ判断でき助かりました。

あとがき

CloudFormationで作成できないリソースを作成できるというのは便利ですが、運用する人により多くの知識を求め負担も増えることになってしまうので本番環境で利用する際はメンバーのスキルセットや本当にカスタムリソースを使うことで楽になるのかをしっかりと検討しましょう。

しかし、検証する場合や適した場所であればCloudFormationを柔軟に拡張できるため非常に便利です。今後も必要になる機会があれば活用して行きたいです。

脚注

  1. 機能リリース当時はSNSのみ対応していた。
  2. Pythonの場合、モジュール名はcfnresponseです。
  3. CDK以外で作成したリソースを取り込んだ場合は機能が制限される場合があります。ご注意ください。