maybe daily dev notes

私の開発日誌

CDK Construct ライブラリを開発する際のTips

最近 deploy-time-build というCDK Constructをリリースできたので、その過程で得られた知見を共有する。

github.com

Tips

プロジェクトの初期化はprojenで一発

プロジェクト生成ツールのprojenを使うことで、コマンド一発でリポジトリのベースを生成できる。詳しくはここを参照。コマンドはこんな感じ。

mkdir cool-construct
cd cool-construct
npx projen new awscdk-construct

これに共通で必要なもの (package.jsonやeslintrc.jsonGitHub Actionsの構成などまで!) はすべて入っているので、割と本当にConstructを定義するコードだけ書けば、自作Constructを良い感じにリリースできる。 この開発者体験はかなり良いと思った。

あと、projenで管理するプロジェクトは基本的にすべて .projenrc.js というファイルで諸々の変更を管理する。例えば package.jsoneslintrc.json なども。対象のファイルは↓のようなコメントが記載されているので、そういったファイルは直接編集しないように注意しよう。

~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen".

じゃあどう編集すれば良いのかだが、リファレンスがここにある。ただし、CDKのReferenceほど正直見やすくないので、TSの型定義を追ったほうが良いかもしれない。projen、ドキュメントまわりはかなり発展途上に見える。

ちなみに、Projenの使い方はこの記事も詳しい。 qiita.com

コミットメッセージは conventional commits に従うべし

www.conventionalcommits.org

Conventional commits は、機械的に解釈できる統一的なフォーマットをもつコミットメッセージを書こうねというルール。ざっと例を挙げると

  • fix: hoge: バグ修正のコミット
  • feat: hoge: 機能追加のコミット
  • fix!: hoge: APIに破壊的変更を伴うバグ修正のコミット

他にも↑のサイトに例があるので、ぜひ参照されたい。BREAKING CHANGE というフッターをつけたり、 chorerefactor といったコミットをすることも許可されている。

これが semantic versioning を採番する上で便利に機能する。例えば

  • fix のコミットだけある場合はパッチバージョンを上げる (bump)
  • feat のコミットだけある場合はマイナーバージョンを上げる
  • fix!feat! のコミットがある場合はメジャーバージョンを上げる

といったように機械的に判別できるので、結果としてconventional commitsに従うリポジトリでは自動的にsemantic versioningのバージョン番号を採番できる。

Projenで作成されるテンプレートでもこれが機能していて、コミットメッセージを conventional commits に従って書けば、↓のようにリリースノートやバージョン番号も良い感じにしてくれる。このため、CDK Constructを作るときはとりあえずこのルールに従うのがお勧め。

ただし、projenではメジャーバージョンのbumpだけは機械的には実行せず、開発者が明示的にbumpする必要があるらしい。このあたりの挙動はメジャーバージョンが0か1以上かでも異なるらしいので、まだ試せていない。(少なくとも v0.x.yのうちは、破壊的変更があってもマイナーバージョンがbumpされるだけで済む。)

Lambda関数を含めたい時は?

いつもの書き方で大丈夫?

お手製のConstructライブラリにはLambda関数が含まれる場合もあるだろう。そのような時、通常のCDKアプリを書くときと同じ感覚でLambda関数を定義すると、好ましくない場合がある。

例えば、 NodejsFunctionPythonFunction を使って定義したとする。この場合、そのConstructを利用する全ユーザーは、CDKデプロイ時にその関数をビルドする時間を掛ける必要が生じる。これは明らかに無駄である。できるなら最初からビルド済みのコードをパッケージとして提供する方が、余計なビルド時間をユーザーに課さなくても済む。

また、Custom resourceのハンドラーとしてLambda関数を使う場合、 SingletonFunciton という特殊なConstructを利用することが推奨される。これは同一スタック内で何度定義されても実体は必ず1つだけになるFunctionなので、ライブラリとして提供するCustom resourceのハンドラーには都合が良いことが想像できるだろう。実際、CDK公式が提供しているCustom resourceハンドラの実装 (例: BucketDeployment) は大抵これが利用されている。重要なのは SingletonFunctionlambda.Function を直に継承するリソースのため、 NodejsFunctionPythonFunction の機能を利用できないということ。この点でも、いつもとは少し異なる方法でLambda関数を定義すべきであることがわかる。

Construct library way

ではどう定義すればよいかだが、まだベストプラクティスは定まっていなさそうに思える。基本的には、Lambda関数は外部ライブラリ(Lambda実行環境に標準装備のaws-sdk/boto3は除く)に依存するとデプロイが面倒くさくなるので、できるだけ標準ライブラリだけで書くのが良い。とはいえ、少しの工夫で依存関係を含めることも可能。踏まえると、以下のパターンが考えられるだろう:

  1. Lambdaを素のPythonで書く (Node.jsよりは標準ライブラリの機能が充実している)
  2. Lambdaを素のTypeScriptで書く (projen build中にtscのステップを挟む必要あり)
  3. Lambdaを素のJavaScriptで書く (tscも不要のため楽)
  4. LambdaをTypeScriptで書き、外部ライブラリの依存はesbuildでバンドルする

1〜3の方法は割と自明。定義する際は単純にハンドラのコードのパスと関数名を指定するだけで済み、ビルドプロセスも単純。外部ライブラリの依存を避ける消極的な解決策とは思えるが、実際巷のConstructライブラリを見るとこの方法を使っているものは多い。

とはいえどうしても外部ライブラリに依存したいときはあるので、そのときは4の方法を採る。基本的な方針は、NodejsFunction がCDK synth時にやっている処理を、projen build時にやればOK。実際の例は弊リポジトリを参照

あるいは、 lambda.ts という拡張子のファイルを配置することで、projenにバンドル処理やFunctionコンストラクトの定義を肩代わりさせることができる。こちらを参照。ただしこの方法ではSingletonFunctionは現状使えないようなので注意。

仕組み

簡単に仕組みを説明すると、projenではCIのビルドステップもまたコードで管理されており、この辺りのプロパティから触ることができる。今回はcompileTaskの辺りでLambda関数をビルドすれば良さそうなので、下記のコードを .projenrc.js に追加している。

project.projectBuild.compileTask.prependExec('npm ci && npm run build', {cwd: 'lambda/nodejs-build'});

これにより、projenのコンパイルタスクの際に、 lambda/nodejs-build ディレクトリで npm ci && npm run build のコマンドが実行される。さらに、lambda/nodejs-build/package.json を見てほしい。

  "scripts": {
    "build": "esbuild index.ts --bundle --outdir=./ --platform=node --external:aws-sdk"
  },
  "dependencies": {
    "adm-zip": "^0.5.9",
    "aws-sdk": "^2.1130.0",
    "extract-zip": "^2.0.1",
    "node-fetch": "^3.2.4"
  },

つまるところ、まず npm ci で依存関係をインストールし、 npm run build で依存関係込みで TypeScriptをトランスパイル・バンドルしている。ビルド生成物はlambda/nodejs-build/index.js に保存され、それを SingletonFunction から参照することでLambda関数を定義している。

const handler = new SingletonFunction(this, 'CustomResourceHandler', {
  runtime: Runtime.NODEJS_14_X,
  code: Code.fromAsset(join(__dirname, '../lambda/nodejs-build')),
  handler: 'index.handler',
  uuid: '643fc8aa-9cdf-41ad-9b26-dc5b258cc071', // generated for this construct
  lambdaPurpose: 'NodejsBuildCustomResourceHandler',
});

これで、依存関係のあるLambda関数を定義でき、さらにユーザー側では何のビルド処理も不要となる。ちなみに ↑ のuuid はSingletonFunctionごとに生成する必要があるので、決して既存のコードからコピペしないように気をつけよう。UUIDは任意の方法で生成できるが、手軽なのはこれ

ちなみにsrc以外にTypeScriptのファイルがあるとeslintが騒ぐので、projenから黙らせている。

  eslintOptions: {
    ignorePatterns: ['example/**/*', "lambda/**/*"],
  },

example フォルダを作ると親切

Constructの使い方は基本的には README.md に書くべき。しかしながら、動作する完全なサンプルがあるとさらに役立つものである。このため、CDK Constructでは example ディレクトリ以下にサンプルのスタック定義を含めることが多い模様。

一例はここ。最低限 cdk.jsoncdk.App の定義があれば動作するようなので、簡潔な記述・ファイル構成でサンプルを提供できる。

// index.ts
import { Stack, StackProps, App } from 'aws-cdk-lib';

class TestStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps = {}) {
    super(scope, id, props);
    // 自作Constructの利用例を書く
  }
}

class TestApp extends App {
  constructor() {
    super();

    new TestStack(this, 'TestStack');
  }
}

new TestApp().synth();

// cdk.json
{
    "app": "npx ts-node --prefer-ts-exts index.ts"
}

リリース前に確認すべき projen の設定項目

まず、authorauthorAddressrepositoryUrl は、正しい値が入っていることを確認しよう!

また、Construct Hub にライブラリを掲載するには、 keywordsaws-cdk を含める必要があるので注意。詳細はFAQ を参照。これだけで、npm に publishしてから30分以内に自動で掲載される。

最後に description も必ず書いておこう。Construct Hub のページで見出しの一言説明として利用されるため (下図) 。

まとめるとこんな感じになるだろう。

const project = new awscdk.AwsCdkConstructLibrary({
  author: 'tmokmss',
  authorAddress: 'hoge@example.com',
  cdkVersion: '2.20.0',
  defaultReleaseBranch: 'main',
  name: 'deploy-time-build',
  repositoryUrl: 'https://github.com/tmokmss/deploy-time-build.git',
  keywords: ['aws', 'cdk', 'lambda', 'aws-cdk'],
  description: 'Build your frontend apps during CDK deployment!',
});

パッケージレジストリにリリースするために

Construct Hubに掲載されるには、最低限 npm にパッケージを登録する必要がある。 これも簡単にできて、 ただGitHubリポジトリのSecretに NPM_TOKEN を登録すれば良い。 この辺りの記事が参考になるだろう。

なお、他の言語のライブラリを公開する場合も、同様に各パッケージレジストリのキーを取得し、登録すれば良いと思われる。現実的には結構面倒くさいので、Node.jsとPythonだけ公開しているライブラリが多い模様。この辺りは仕方ないね…

まとめ

ProjenでCDKのConstructライブラリを開発するときのTipsを紹介した。まだまだ駆け出しなので、今後開発が継続してさらに知見が溜まったら、また放出できればと思う。