maybe daily dev notes

私の開発日誌

AWS CDKのカスタムリソースをCodeBuildで処理する

AWS CDKを使えば、CloudFormationのカスタムリソースをLambda関数で簡単に定義することができます。 しかしながら、時にLambda関数だけでは都合が悪く、別のコンピュート環境を使いたくなる場合もあるでしょう。

私の作っている deploy-time-build というCDKコンストラクト (詳細はこの記事も参照) でも、最近カスタムリソースのメイン処理をCodeBuildで実行するようにしたので、その時の知見をまとめます。

Why not Lambda?

deploy-time-build では、CFnデプロイ時にカスタムリソースを使ってNode.jsアプリ (主にReactなどフロントエンドアプリを想定) をビルドします。これを実現するため、以前はカスタムリソースハンドラのLambda関数自体の中で npm run build などのコマンドを実行していました。

https://github.com/tmokmss/deploy-time-build/blob/v0.2.3/imgs/architecture.png?raw=true

Lambdaを使う方法は、起動が速いことや構築が簡単というメリットがある一方で、次の問題があることもわかりました:

  • Lambdaのfile descriptor上限数問題: Lambdaではfile descriptor数のハードリミットが1024です。しかしながらnpmは npm ci などの操作時に多くのfile descriptorを開く必要があるようでした。実際にそれなりの規模の package-lock.json をもつプロジェクトで npm ci を実行したところ、too many open files のエラーが出ることを確認しています。これを修正するにはLambdaのハードリミットを上げるか、npmを改善する必要がありますが、前者は現状不可能ですし後者は困難でしょう。
  • Lambdaのストレージは/tmp以外読み込み専用: Lambdaのエフェメラルストレージは、/tmp 以外書き込むことができません。一方で npm はデフォルトで /tmp 以外のストレージにも書き込みを行う (キャッシュなど) ため、追加の設定が必要になります。コレ自体は設定すれば解決する話ですが、npmパッケージの中には特定ディレクトリへの書き込みが必要な場合もあるなど考えると、不安要素ではありました。
  • Lambdaの実行時間15分制限: ビルド環境として使う場合、大きなプロジェクトではビルドが長時間になることもあるでしょう。Lambdaではビルドに15分以上かかるプロジェクトを扱いづらいというのも、汎用ライブラリとしては不安です。
  • Node.jsのバージョンを変えにくい: ビルド環境のNode.jsバージョンはユーザーに自由に選ばせたいところですが、私の試した限りLambdaのNode.js 16環境ではnpmを使えませんでした。このため、現状LambdaがサポートしているNode 14 or 18のみしか対応できず、またビルドスクリプトを両方のランタイムで動作確認するのが大変だという問題もあります。

Why CodeBuild?

上記の背景により、別のコンピュート環境を検討することにしました。満たすべき条件は以下のとおりです:

  • Lambda環境に課せられていた制約 (上記) ができるだけ存在しない
  • Lambdaに近いレベルで手軽に使える (VPCレス、起動速い、時間課金など)

一番に思いつくのがAWS CodeBuildです。VPCレスで使える毎分の従量課金なサーバーレスのコンピュート環境です。

そもそも今回本来の目的はアプリのビルドなので、まさにそのためのサービスとも言えるでしょう。 Lambdaのように実行時間やストレージに制限はなく、Node.jsのバージョンも自由に変更できます。 file descriptor数に関連するドキュメントは見つけられませんでしたが、実際試したところ問題ありませんでした (そもそも大抵のビルド用途には対応できるはずのサービスなので、問題になるとは考えづらい)。

CodeBuildのデメリットとしては (あえて挙げるなら) 以下が考えられます:

  • 最小課金単位時間やベースの料金を比べると、Lambdaよりは利用料金が若干高くなる
    • 1分あたり0.7円程度なので、デプロイ時のビルド用途としては多くの場合許容できるでしょう
  • ビルドをリクエストしてから実際にビルド処理が始まるまでに、ビルド環境のプロビジョニングなどで追加で30秒程度掛かる
    • Lambdaよりは遅いですが、CloudFormationデプロイ中の待ち時間としては許容できるレベルでしょうか

今回は他の手段を考えてもCodeBuildより際立って良いものはないので、デメリットも受け入れて採用することにしました。

変更後のアーキテクチャは下図になります (リリース済み):

https://github.com/tmokmss/deploy-time-build/blob/main/imgs/architecture.png?raw=true

開発中の気付き

上記を実装した際の考慮事項・気付きをつらつらとメモります。似たようなものを作る際は、お役立てください。

CloudFormation カスタムリソースのコールバックは誰が叩くのか

カスタムリソースハンドラーは非同期に実行されるため、処理が終了したときはCloudFormationに通知する必要があります。 Lambdaハンドラが呼びされた時にコールバック先のURLをCloudFormationから渡されるので、そちらにリクエストを送れば良いです。 コールバックを叩かないかぎりCloudFormationスタックのデプロイは完了しません。その場合でも1時間後にタイムアウトしますが、できる限り確実にコールバックを叩けるに越したことはないです。

では、リクエストはどこから送るのが良いでしょうか? Lambda関数単体でカスタムリソースの処理をする場合は、そのLambda内でコールバックを叩けば良いでしょう。 今回はCodeBuildの処理が完了してからコールバックを叩く必要があるので、いくつかの方法が考えられます:

  1. CodeBuildのビルドジョブ内で、ジョブ終了時に叩く
    • 単純
    • これだけではコールバックを叩けない場合があることに注意 (CodeBuildのジョブ起動に失敗するケースなど)
  2. CodeBuildのジョブステータス変化イベント (EventBridge) を連携した先で叩く
    • 堅牢
    • イベントはat least onceで配信されるので、ジョブが完了・失敗した際にコールバックを叩けない可能性は極めて低い (イベント連携先のサービスでFailしない限り)
    • EventBridgeからLambdaを呼んでも良いですし、Step FunctionsでStartBuild APIを同期で呼び出せば、イベントの処理が透過的になり楽
  3. CodeBuildのビルドジョブのステータスをポーリングして、ジョブが完了したら叩く
    • 単純
    • ポーリングのためのリソースがややもったいない
    • 仮にLambdaでポーリングする場合は15分制限に注意したいところ

今回は2を避けることにしました。ライブラリの使用者目線では使うAWSサービスをいたずらに増やさないほうが良いだろうと考えたためです。 使うサービスが増えるほど個々のユーザーのセキュリティポリシーなどに違反する可能性が高まりますしね。 代わりに1を採用しました。今のところ、ジョブ起動に失敗する可能性は極めて稀だと考えられるためです (ユーザーにランタイムを選択する自由度を与えていないため、そのエラーは生じえない)。これでしばらく様子見して、もしコールバックが叩かれずにデプロイがスタックしたぞという報告があれば、対応を考えたいです。

さらなる改善案は1と3のハイブリッドで、StartBuild APIを呼んだあと数分間同じLambda内でジョブのステータスをポーリングし、起動に失敗していたらコールバックを叩くというのは考えています (起動のエラーは数分以内に発生するだろうと想定)。とはいえここまでするなら、2のStep Functionsを使う方向に切り替えるかもしれません。

実装の詳細を隠蔽しておいて良かった

今回LambdaからCodeBuildへと大きく内部実装を変更しましたが、コンストラクトのAPI自体は破壊的変更なしで済みました。 こんなこともあろうかと、APIからは徹底して実装の詳細を隠蔽していたためです (例えばRAMサイズなど、Lambdaに特有のプロパティを設けていない)。

new NodejsBuild(this, 'ExampleBuild', {
    assets: [
        {
            path: 'example-app',
            exclude: ['dist', 'node_modules'],
        },
    ],
    destinationBucket,
    distribution,
    outputSourceDirectory: 'dist',
    buildCommands: ['npm ci', 'npm run build'],
    buildEnvironment: {
        VITE_API_ENDPOINT: api.url,
    },
});

もし破壊的変更が加われば、ライブラリの全ユーザーに単純なバージョンアップだけではない対応を強いることになるかもしれません。 最近はこのライブラリが会社のチーム内で使われることも増えているので、それらの対応工数を減らせたことになり、これは我ながらGJでした。

プロジェクトに固有のCDKコンストラクトを書くときは、カジュアルに実装の詳細を公開することも多いと思います。例えば中身のLambda Functionへのアクセサーを設けたりなど。一方でこうした汎用コンストラクトライブラリを実装する際は、できるだけAPIの抽象度を高く保つことで、今後の内部的な変更を容易にできるかもしれません。

餅は餅屋

元々の実装で、ビルドの実行環境としてLambdaを使っていたことに違和感を覚えた方もいるかもしれません。 その感覚は正しく、私も結果的には最初からCodeBuildを採用すべきだったと思います。Lambdaにビルドさせるのも技術的には可能ですが、結局上記の通り様々な不都合が生じました。

元々、カスタムリソースのハンドラとしてはLambdaを使うのがシンプルだと思い、安直にLambdaでビルドまで済ませようとしたのですが、結局実装としてはより複雑になってしまった印象です。主にはLambda上でnpmコマンドを使うためにいくつかの工夫・ハック () が必要なためです。シンプルさを求めた結果逆に複雑になってしまうという、ありがちなアンチパターンだと思います。

素直に、各サービスを各サービスの得意な方法で使うのが良いでしょう。

今後の展望

これまではLambda上で実行していたためにNode.js以外のビルドに対応することが大変でした。 CodeBuildではビルド環境をより柔軟に構成できるため、より幅広い用途に活用を広げることができると思われます。

とはいえデプロイ時にビルドしたいユースケースはあまり多くない (自分の観測範囲ではフロントエンドの環境変数埋め込みくらい) ように思うので、何かある方はIssueで教えてください。

まとめ

自作コンストラクdeploy-time-build でカスタムリソースの処理にCodeBuildを利用した話をまとめました。参考になれば幸いです。