🎉

AWS CDKでデプロイ時の値を使ってフロントエンドとバックエンドを一発でデプロイできるようになったので試してみる

2022/02/18に公開
3

はじめに

最近はmermaidがGitHubで利用できるようになった話題でもちきりですが、個人的にAWS CDKのアップデートもかなり熱いものだと思っています。その機能がv2.11.0aws_s3_deploymentに追加されたデプロイ時の値を持つデータをS3バケットへデプロイする機能です。

CognitoのユーザープールID等、バックエンドリソースの情報をフロントエンドで使用することはよくあると思います。環境数が固定されているプロジェクトではバックエンドリソースを先に作成し、フロントエンドにバックエンドの情報を設定してデプロイする~なんてことも可能ですが、例えば評価用としてバックエンドとフロントエンドをプルリクエスト毎に自動構築しているプロジェクトではどうでしょうか。両方を一つのリポジトリで管理しているプロジェクトであってもフロントエンドを構築するにはバックエンドを先に構築して情報を取得する必要があります。少なくともフロントエンドとバックエンドでスタックを分割してデプロイする手間が必要でしたが、v2.11.0で追加された機能によりこれらを同時にデプロイできるようになりました。

今回はその機能を利用してこの構成をcdk deployコマンド1発でデプロイできるようにしていきます。
構築する環境の構成図

そもそもAWS CDKとは

AWS CDKはInfrastructure as Codeの一種で、任意の言語を使用してAWS環境を定義できるAWSの公式ツールです。次に例を示します。

example-stack.ts
import {
  Stack,
  StackProps,
  aws_cloudfront as cloudfront,
  aws_cloudfront_origins as origins,
  aws_s3 as s3,
  aws_s3_deployment as s3deployment
} from 'aws-cdk-lib'
import { Construct } from 'constructs'

export class ExampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)
    const bucket = new s3.Bucket(this, 'Bucket')
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultRootObject: 'index.html',
      defaultBehavior: {
        origin: new origins.S3Origin(bucket),
      },
    })
    new s3deployment.BucketDeployment(this, 'BucketDeployment', {
      destinationBucket: bucket,
      distribution,
      sources: [
        s3deployment.Source.data('/index.html', '<html><body>Hello World</body></html>'),
      ],
    })
    console.log(distribution.domainName)
  }
}

このスタックを利用してデプロイすると以下のリソース作成、及びindex.htmlをデプロイしてWEBで閲覧できるようになります。

  • S3バケット
  • CloudFrontディストリビューション
  • S3バケット用のOAI

簡単にWEBサイトが公開できますね。これだけならVercelGitHub Pagesを利用した方が楽なのですが、AWS CDKでは上記以外のRDSやLambdaの様な豊富なAWSリソースを定義できるのが圧倒的な強みです。

デプロイ時の値とは

AWS CDKのスタック内で定義したリソースのデプロイ時に解決されるトークン値のことです。これだけではAWS CDKを利用したことのない方や、初学者の方はピンとこないと思われるため補足を入れます。

先ほど例にあげたコードの末尾付近にCloudFrontのドメイン名を出力するような記述を入れています。

example-stack.ts
console.log(distribution.domainName)

ドメイン名が出力されると思いましたか?
残念ながらこの記述ではCloudFrontのドメイン名は出力されません。
上記CDKスタックをデプロイしようと実行するとCloudFrontのドメイン名は出力されず、代わりにこのようなトークンと呼ばれる文字列が出力されます。

terminal
${Token[TOKEN.228]}

なぜこのような出力になるかというと、AWS CDKはコードを実行したタイミングでAWSリソースを作成しているのではなく、コードを実行した結果を用いてCloudFormationテンプレートを作成しているためです。つまり、コード実行している間は定義したリソースが実在しないためCloudFrontのドメイン名を始めとした値がどんな値になるか分かりません。ただし、作成するリソースのプロパティに別のリソースの値を利用できないのでは実用性に乏しく使えたものではありません。そのため、AWS CDKではリソースのデプロイが完了するまで確定しない値をトークンとして扱い開発者がコード中で利用できるようにしているのです。

例えば先ほどのCloudFrontのドメイン名を完全なURLとして出力するにはCfnOutputを利用します。

example-stack.ts
new CfnOutput(this, 'URL', {
  value: `https://${distribution.domainName}`,
})

デプロイしてみるとこのようなログが取得でき、プログラム上で記述した期待通りの値が出力されています。

terminal
 ✅  ExampleStack

✨  Deployment time: 109.48s

Outputs:
ExampleStack.URL = https://xxxxxxxxx.cloudfront.net

実際にはCloudFormationの組み込み関数によって実現されています。

cdk.out/ExampleStack.template.json
{
  ...省略,
  "Outputs": {
    "URL": {
      "Value": {
        "Fn::Join": [
          "",
          [
            "https://",
            {
              "Fn::GetAtt": [
                "Distribution830FAC52",
                "DomainName"
              ]
            }
          ]
        ]
      }
    }
  }
}

これらの説明から分かるようにAWS CDKではデプロイ時の値をトークンとして表現しているため、ランダムに決定される値(バケット名を指定しなかったS3バケットのバケット名やCognitoのユーザープールIDなど)をフロントエンドから参照しようとすると、バックエンドのスタックとフロントエンドのスタックを分離して一つ一つデプロイする必要がありました。具体的には次のような手順です。

  1. バックエンドのスタックとフロントエンドのスタックを分離する
  2. バックエンドのスタックのみをデプロイし、CfnOutputをjsonファイルに出力する
cdk deploy -O output.json BackendOnlyStack
  1. 2.で出力されたJSONファイルを参照してフロントエンドの設定をする
  2. フロントエンドのスタックをデプロイする

デプロイ時の値を持つデータをデプロイする

少々前置きが長くなってしまいました。
v2.11.0ではこの問題の解決に繋がる機能がリリースされました。それがaws_s3_deploymentモジュールの下記メソッドです。

  • Source.data
  • Source.jsonData

ドキュメントはこちらから確認できます。

冒頭でも説明した通り、今回はこの構成をAWS CDKで構築して1回のcdk deployでデプロイしてみようと思います。
構築する環境の構成図

環境

  • node.js v16.13.2
  • aws-cdk v2.12.0
  • nuxt-edge v2.16.0-27358576.777a4b7f
  • @nuxt/bridge v0.10.1

なお、実際にはdevcontainer環境で操作しています。

devcontainerの内容を見る
.devcontainer/devcontainer.json
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/typescript-node
{
  "name": "Node.js & TypeScript",
  "build": {
    "dockerfile": "Dockerfile",
    // Update 'VARIANT' to pick a Node version: 16, 14, 12.
    // Append -bullseye or -buster to pin to an OS version.
    // Use -bullseye variants on local on arm64/Apple Silicon.
    "args": {
      "VARIANT": "16-bullseye"
    }
  },
  // Set *default* container specific settings.json values on container create.
  "settings": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.codeActionsOnSave": {
      "source.fixAll.eslint": true
    }
  },
  // Add the IDs of extensions you want installed when the container is created.
  "extensions": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "johnsoncodehk.volar"
  ],
  // Use 'forwardPorts' to make a list of ports inside the container available locally.
  // "forwardPorts": [],
  // Use 'postCreateCommand' to run commands after the container is created.
  // "postCreateCommand": "yarn install",
  // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
  "remoteUser": "node"
}
.devcontainer/Dockerfile
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/typescript-node/.devcontainer/base.Dockerfile

# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster
ARG VARIANT="16-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}

# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
#     && apt-get -y install --no-install-recommends <your-package-list-here>

# [Optional] Uncomment if you want to install an additional version of node using nvm
ARG EXTRA_NODE_VERSION=16.13.2
RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION} || nvm use --delete-prefix ${EXTRA_NODE_VERSION}"

# [Optional] Uncomment if you want to install more global node packages
RUN su node -c "npm install -g aws-cdk"

フロントエンド

フロントエンドのフレームワークにはNuxt.jsのbridgeバージョンを利用します。bridgeバージョンとはNuxt v2プロジェクトでNuxt v3に近い機能が使える中間バージョンのようなものですが、今回は記事の対象外ですので詳細は割愛させていただきます。

まず、次のコマンドでNuxt.jsのv2プロジェクトを構築します。

yarn create nuxt-app

構築できたらこちらの手順に沿ってbridgeに対応させます。
bridgeに対応させたらローカルでAPIのモックを作成します。
次の内容でserver/api/hello.tsを作成します。

server/api/hello.ts
export default (req, res) => 'Hello! I am local funtion!'

次にpages/index.vueを編集してAPIのレスポンスを表示する画面に変更します。

pages/index.vue
<script setup lang="ts">
const message = ref('Not called yet')
const { data: config } = useLazyAsyncData<{ endpoint: string }>(
  'config',
  () => $fetch('/config.json'),
  {
    server: false,
    default: () => ({ endpoint: '/api/' }),
  }
)
const clickButton = async () => {
  message.value = await $fetch(`${config.value.endpoint}hello`)
}
</script>

<template>
  <div>
    <div><button @click="clickButton">click me</button></div>
    <div>{{ message }}</div>
  </div>
</template>

後でconfig.jsonというjsonファイルが取得できるようになりますが、今は存在しないためデフォルト値としてローカルのエンドポイントを参照するオブジェクトを設定します。

Nuxtの開発モードを利用して確認してみます。

yarn dev

質素な画面ですが、APIのレスポンスを表示するには十分です。
ローカルでの実行

AWS CDK (Lambda含む)

次のコマンドでs3data-exampleという名前でCDKプロジェクトを作成します。分かりやすくするためフォルダ名はcdkに変更しています。

mkdir s3data-example
cd s3data-example
cdk init app --language typescript
cd ../
mv s3data-example cdk

ここはオプションです。私はCDKをプロジェクトルートで操作できるようcdk.jsonをプロジェクトルートへ移動させました。

mv cdk/cdk.json ./
cdk.json
{
-  "app": "npx ts-node --prefer-ts-exts bin/s3data-example.ts",
+  "app": "npx ts-node --prefer-ts-exts cdk/bin/s3data-example.ts",
  ...
}

それでは実際にスタックを定義します。

cdk/lib/s3data-example-stack.ts
import { spawnSync } from 'child_process'
import {
  Stack,
  StackProps,
  aws_cloudfront as cloudfront,
  aws_cloudfront_origins as origins,
  aws_lambda as lambda,
  aws_s3 as s3,
  aws_s3_deployment as s3deployment,
  DockerImage,
  CfnOutput,
  RemovalPolicy,
} from 'aws-cdk-lib'
import { Construct } from 'constructs'
import { CorsHttpMethod, HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha'
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha'

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

    const bucket = new s3.Bucket(this, 'Bucket', {
      autoDeleteObjects: true,
      removalPolicy: RemovalPolicy.DESTROY,
    })
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultRootObject: 'index.html',
      defaultBehavior: {
        origin: new origins.S3Origin(bucket),
      },
    })

    const api = new HttpApi(this, 'HttpApi', {
      corsPreflight: {
        allowOrigins: [`https://${distribution.domainName}`],
        allowMethods: [CorsHttpMethod.HEAD, CorsHttpMethod.GET],
      },
    })
    const helloFunction = new lambda.Function(this, 'HelloFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline(
        'exports.handler = async () => "Hello! I am lambda function!"'
      ),
    })
    api.addRoutes({
      path: '/hello',
      integration: new HttpLambdaIntegration('HelloIntegration', helloFunction),
    })

    new s3deployment.BucketDeployment(this, 'BucketDeployment', {
      destinationBucket: bucket,
      distribution,
      sources: [
        s3deployment.Source.jsonData('/config.json', {
          endpoint: api.url,
        }),
        s3deployment.Source.asset('./', {
          bundling: {
            image: DockerImage.fromRegistry('node:16'),
            local: {
              tryBundle: (outputDir: string) => {
                spawnSync('yarn', ['generate'], {
                  stdio: 'inherit',
                })
                spawnSync('mv', ['-f', '.output/public/*', outputDir], {
                  stdio: 'inherit',
                  shell: true,
                })
                return true
              },
            },
          },
        }),
      ],
    })

    new CfnOutput(this, 'URL', {
      value: `https://${distribution.domainName}`,
    })
  }
}

注目して頂きたいのはnew s3deployment.BucketDeployment内のこの記述です。

s3deployment.Source.jsonData('/config.json', {
  endpoint: api.url,
}),

HttpApi.urlはAPI Gateway V2のURLを返すプロパティです。このHttpApi.urlendpointというプロパティに設定したオブジェクトを/config.jsonというS3キーに保存しています。
s3deploymentの機能を使用せず、{ endpoint: api.url }をコード内でファイルに保存するとこのようになってしまいますが...

{
  "endpoint":"${Token[TOKEN.123]}"
}

実際にデプロイするとS3バケットにはデプロイ時に解決される値が入っています!
デプロイされたconfig.json

次はデプロイされたCloudFrontのドメインにアクセスして確認してみましょう。
デプロイした画面で操作

Lambdaのレスポンスが表示されていますね!!成功です!

さいごに

今回はHTTP APIのドメインの例で紹介しましたが、ドメイン名なら所有しているドメインを設定できるためこの仕組みを作る必要はないかと思います。ですが、リアルな話でいくとスタック毎にCognitoのユーザープールID等を利用する場合はこの仕組みがほしいのではないでしょうか。
また、お試しだったためページでconfig.jsonを直接取得していましたが、実運用するならミドルウェアやPiniaで状態管理することを推奨します。

ちなみに私は現在この機能の利用を想定したAmazon CloudWatch RUMのL2コンストラクトをRFCで提出しています。似たような内容でAmazon Pinpointのトラッキングライブラリの埋め込み等も可能になっていくのではないでしょうか。

今後のアップデートに期待ですね。

今回使用したソースはこちらに置いてあります。
https://github.com/WinterYukky/cdk-example-s3data

[追記] この仕組みとカスタムリソースを利用して、ビルド時に値を埋め込む素敵な方法を開発された方がいます。その方から直接コメントをいただいたので是非こちらも確認ください🥰
https://tmokmss.hatenablog.com/entry/20220515/1652623112

Discussion

tmokmsstmokmss

ゆっきーさんこんにちは! (CDK conference拝見しました :pray:)
一発でデプロイしつつ環境変数埋め込めたら楽だなーと思って、私もこんな記事を書いてみました
https://tmokmss.hatenablog.com/entry/20220515/1652623112
よければごらんくださいー

WinterYukkyWinterYukky

ttさんこんにちは、CDK conference見てくださって光栄です🥰

頂いた記事からGitHubのソースを確認しました。
確かにカスタムリソース内でビルドしてしまえばビルド時に埋め込むことができていいですね!✨
素敵な内容ですのでこちらの記事にリンクを貼らせていただきます👍

tmokmsstmokmss

光栄です、ありがとうございます!ゆっきーさんのさらなるCDK Tips発信も楽しみにしてますー :)