echo("備忘録");

IT技術やプログラミング関連など、技術系の事を備忘録的にまとめています。

なぜAWS CDKでエラーハンドリングができないのか

はじめに

この記事は AWS CDK Advent Calendar 2023 23日目 の記事です。

qiita.com

概要

AWS CDKは、AWS公式のIaC(Infrastructure as Code)ツールということもあり、AWSでIaCを使ったインフラ構成管理を行いたい場合にお薦めで、プログラミング言語(TypeScript, Pythonなど)でコードを記載するので、アプリエンジニアにもおすすめのツールです。

その一方で「コードと違う挙動をする」と感じることがある方も多く、特に「エラーハンドリングが効かない!」と感じた人がかなり多いようです。(私はJAWS-UG CDK支部に結構関わらせてもらっていますが、実際その中でそういう話を聞くことが多いです)

そこで今回なぜ「AWS CDKでエラーハンドリングができないのか」の仕組みを記載したいと思います。

具体例

例えば、下記のコードです。
下記のコードでは、以下の挙動を想定しています。

  • パラメータストアからDockerイメージのイメージタグを取得する
  • 上記が未設定の場合エラーになるので、その際は固定イメージを設定する
  • 上記イメージタグのイメージからLambda関数を参照する
// ※注意!このコードは正しく動きません
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import { aws_lambda as lambda } from "aws-cdk-lib"
  
let imageTag = '';
  
try {
 // パラメータストアから最新Dockerイメージのイメージタグを取得
 imageTag= StringParameter.valueForStringParameter(this, 'IMAGE_TAG');
} catch (e) {
 // もし未設定だったらエラーになるので、その際は固定値を設定
 imageTag = 'initial';
}
  
const repo = new Repository(this, 'EcrRepo');
  
new lambda.DockerImageFunction(this, 'AssetFunction', {
  code: lambda.DockerImageCode.fromEcr(repo, {
    // 上記イメージタグのイメージからLambda関数を参照する
    tagOrDigest: imageTag,
  }),
});

しかし実際にはパラメータストアにイメージタグがない場合、deploy時に「パラメータストアに該当のキーが存在しない」エラーで強制終了してしまいます。

「ちゃんとtry~catchで判定しているのになんでだ!」となってしまうわけです。

AWS CDKの挙動を理解する

まずはAWS CDKの挙動について。

AWS CDKの挙動は大きく分けて、以下の2つです。(もちろん、正確にはもっとたくさんある)

合成(Synthesize、以下「synth」)
  • AWS CDKのソースコード(Constructsなど)から、CloudFormationテンプレートファイル(json/yaml)を作成する
  • CLIコマンドのcdk synth に該当
    • 正確には cdk synthesize コマンドだが、大抵省略形で書く
  • プログラミング言語の制御が効く(if文とか)
展開(Deploy、以下「deploy」)
  • synthで作成したCloudFormationテンプレートファイルを使用して、CloudFormationにデプロイを行う
  • CLIコマンドのcdk deploy に近い(同じではない)
    • cdk deploy はsynthの処理も一緒に行うため(synth→deploy)
  • プログラミング言語の制御が効かない

これを踏まえて、synth時とdeploy時で「なぜエラーハンドリングが効かないのか」を説明します。

synthでエラーハンドリングが効かない理由

まず前提として、synth時にはAWSへのアクセスは行われません。(ファイル変換のみ)
したがって、各種リソースのプロパティ(ARNなど)の取得も行われません。

じゃあARNなどが設定されている箇所ではどうしているかというと、「トークン(Token)」という一時的な値に置き換えられてます。

なのでこの「トークン」について説明します。

トークンを理解する

トークンについては、AWS CDKの公式リファレンスにも載っています。 docs.aws.amazon.com

Represents a special or lazily-evaluated value.

Can be used to delay evaluation of a certain value in case, for example, that it requires some context or late-bound data. Can also be used to mark values that need special processing at document rendering time.

Tokens can be embedded into strings while retaining their original semantics.

訳:
特別な、あるいは遅延評価された値を表す。

例えば、ある値がコンテキストや遅延バウンドデータを必要とする場合に、その値の評価を遅延させるために使用することができます。また、ドキュメントのレンダリング時に特別な処理が必要な値をマークするために使用することもできます。

トークンは、元のセマンティクスを保持したまま文字列に埋め込むことができます。

要はARNのような「deployしないとわからない」ような値について、一時的に仮の値を設定するために使用されているのがトークンです。

synthでエラーハンドリングが効かない理由(結論)

そしてこのトークンは「Token[...]」のような文字列値になっています。(console.logなどで出力すると分かります)

ここがポイントで、例えば「具体例」のケースだと、StringParameter.valueForStringParameter() の戻り値として 「Token[...]」 という文字列が返却されるため、特にエラーにはなりません。

したがって、当然エラーハンドリングも行われません。

説明が長くなりましたが、これが「synthでエラーハンドリングが効かない理由」です。

deployでエラーハンドリングが効かない理由

次にdeploy時にエラーハンドリングが効かない理由ですが、これは単に「jsonyamlファイルにエラーハンドリングなんて機構がないから」になります。

AWS CDKの挙動」で説明した通り、deployはCloudFormationテンプレートファイル(json/yaml)を使用します。

当然jsonyamlにエラーハンドリングなんて機構がない(そもそもプログラミングコードではない)ため、deploy時にエラーが発生しても、エラーハンドリングなんてできず、結果としてエラーで終了します。

と、ここまで長々とCDKでエラーハンドリングが効かない理由を記載しましたが「CDKでは、AWSリソースの有無で処理を分岐することはできない」と考えておくとよいかもしれません。(100%断定はできませんが...)

AWSリソースの有無で処理を分岐する方法

といっても、実際は「AWSリソースの有無で処理を分岐したい」というケースがあると思います。

その場合に一番手っ取り早いのが「AWS CLIを使う方法」です。(具体例は下記ソース参照)

// 「具体例」と同じimport分は省略
import { execSync } from 'child_process';
    
const getParameterStoreValue = (keyName: string, region: string): string => {
   const command = `aws ssm get-parameter --name ${keyName} --region ${region} --query "Parameter.Value" --output text`;
   
   try {
     const value =  execSync(command).toString().trim();
     return value;
   } catch (error) {
     return 'initial';
   }
 };  
  
const imageTag = getParameterStoreValue('IMAGE_TAG', 'ap-northeast-1');
  
// あとは同じなので省略

これならAWS CDKの外部での処理なので、ちゃんとリソースの有無で判定できますし、エラーハンドリングも効きます。

他にもやり方はあると思いますが、一例として。

まとめ

以上、AWS CDKでエラーハンドリングができない理由でした。

といっても、理由さえわかってしまえばそこまで難しい話ではないので、色々対処は出来ると思います。

AWS CDK、慣れると使いやすくて色々便利ですし、何より現在でも頻繁にアップデートがされている便利なツールですので、ぜひ導入してインフラ管理を便利なものにしてみてください。

それでは、今回はこの辺で。

【AWS CDK】【AWS Lambda】Lambdaオーソライザを実装する

はじめに

これは「AWS Lambda と Serverless Advent Calendar 2023」 16日目の記事です。

qiita.com

今回のお題

  • Lambdaオーソライザを使用し、API GatewayからLambdaを実行する前段で認可処理を実施する
  • LambdaオーソライザはRequestベースのものを使用する
  • Lambdaオーソライザ含め、API GatewayからLambdaのリソースを全てAWS CDKで作成する

Lambdaオーソライザって何?

API Gatewayにリクエストを送信された時に「特定のリクエストにのみ対象のLambdaを実行させたい」という認可処理を行いたい時があります。

この「特定のリクエスト」のチェックを行う機構が「オーソライザ」になります。
そして、このオーソライザをLambdaのソースコードで実装したものが「Lambdaオーソライザ」です。

API Gatewayでは、オーソライザとしてこの「Lambdaオーソライザ」を使用することが可能です。

そこで、今回はこのLambdaオーソライザを実装する処理について記載します。

具体的なユースケースは?

具体的なユースケースの例としては、以下の通りです。

  • ログインしているユーザーにのみ該当のLambdaを実行させる
  • アプリ上で何らかの権限を有しているユーザーにのみ該当のLambdaを実行させる

Lambdaオーソライザの種類

Lambdaオーソライザには、以下の2種類があります

種類 説明 送信する値 備考
TOKENオーソライザ JSON ウェブトークン (JWT) やOAuth, SAMLなどのベアラートークンでの認可を行う トークンの値
Requestオーソライザ リクエスト情報として渡された各種情報を元に認可を行う 各種リクエスト情報 例えば、Authorizationヘッダの値を元にログインチェックを行う、など

なお、今回はRequestオーソライザのみを扱います。(Tokenオーソライザは扱いません)

Tokenオーソライザについては、下記のブログが参考になりますので、こちらをご参照ください。

参考サイト

Lambdaオーソライザの実装

Lambdaオーソライザの実装ですが、いきなりサンプルコードを記載します。
※基本的には「参考サイト」に記載したAWS公式サイトのコードとほぼ同じです。

import { Context, APIGatewayRequestAuthorizerEvent, APIGatewayAuthorizerResult } from "aws-lambda";
  
const generateAuthorizerResult = (effect: string, resource: string): APIGatewayAuthorizerResult => {
  const result = {
    principalId: 'Authorizer',
    policyDocument: {
      Version: "2012-10-17",
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: effect,
        Resource: resource,
      }]
    }
  } as APIGatewayAuthorizerResult;
  
  return result;
}
  
export const handler = async (event: APIGatewayRequestAuthorizerEvent, context: Context ): Promise<APIGatewayAuthorizerResult> => {
  
  const param = event?.headers?.Authorization ?? '';
  console.log(`param is ${param}`);
  
  if (param === 'allow') {
    return generateAuthorizerResult('Allow', event.methodArn);
  } else if (param === 'deny') {
    return generateAuthorizerResult('Deny', event.methodArn);
  } else {
    throw new Error('Unauthorized');
  }
};

重要なのは以下の点。(前者は問題ないと思うので、以後は後者のみ記載)

  • 未認証(ステータス:401) を返したいときは、Unauthorized エラーをスローする
  • それ以外は APIGatewayAuthorizerResult 型のオブジェクトをreturnする

後者の APIGatewayAuthorizerResult 型オブジェクトで最も重要なのは policyDocument.Statementの「Effect」 で、これを「Allow」にすれば許可、「Deny」にすれば不許可です。

またActionやResourceも設定できるので、「最小権限の原則」に従い、Allowする場合はここも設定しておくとよいと思います。(実際には上ソースのように「Resource」にはevent.methodArnの値をそのまま指定すればOKです)

なおDenyの場合、ActionやResourceはどちらも"*"でもいいと思います。(もちろん指定してもいい)

ちなみに APIGatewayAuthorizerResult 型オブジェクトについては、下記AWS公式サイトに詳しく載っているので、そちらも参照してください。

Amazon API Gateway Lambda オーソライザーからの出力(AWS公式サイト)

AWS CDKでLambdaオーソライザを定義する

次はLambdaオーソライザをAWS CDKで作成する方法です。

// Lambdaオーソライザ用Lambda関数  
const authorizerLambda = new NodejsFunction(this, 'AuthorizerFunction', {
  entry: path.resolve(__dirname, '../../lambda', 'authorizer.ts'),
  handler: 'handler',
  functionName: 'AuthorizerFunction',
});
  
// Lambdaオーソライザ
const authorizer = new RequestAuthorizer(this, 'RequestAuthorizer', {
   handler: authorizerLambda,
   // オーソライザで認証に使う値
   identitySources: [IdentitySource.header('Authorization')],
   authorizerName: 'RequestAuthorizer',
   // キャッシュは思わぬ副作用があるので注意(後述)
   resultsCacheTtl: cdk.Duration.seconds(0),
});
  
// API Gateway(restApi)やLambdaオーソライザ認可後に実施する
// Lambda(hogeLambda)の定義は省略
const res = restApi.root.addResource('hoge');
res.addMethod('GET', new LambdaIntegration(hogeLambda), {
  authorizer,
  // Lambdaオーソライザの場合、ここはCUSTOMを指定
  authorizationType: AuthorizationType.CUSTOM,
});

上記ソースの通り、

  1. オーソライザ用Lambda関数を作成する
  2. Lambdaオーソライザを定義し、そこで使用するオーソライザ用Lambda関数を指定する
  3. 最後にAPI Gatewayのメソッド定義で、オーソライザと認証タイプ(AuthorizationType.CUSTOM)を指定する

という感じで定義すればOKで、AWS CDKの定義自体は意外とシンプルです。

キャッシュを扱う場合の注意

Lambdaオーソライザではキャッシュを扱うことができます。
うまく使えば処理時間になりますが、ちょっと注意が必要なケースもあります。

前提として、キャッシュは下記の挙動をします。

  • キャッシュの結果は「認証ソース」の値ごとに保持される
    • AWS CDKにおける「identitySources」で設定した項目の値
  • キャッシュはTTLで指定した秒数の間保持される(デフォルトは5分)
    • AWS CDKの「resultsCacheTtl」の値
    • キャッシュを使用しない場合は0を指定する

そして「キャッシュがあると予期しない挙動をする」ケースとして、以下に一例を示します。

キャッシュが予期しない挙動をする具体例

仮に「Lambdaオーソライザの実装」で示したソースについて、間違えて`Authorizationヘッダが「allow」の時もDenyする処理を書いてしまったとします。

if (param === 'allow') {
  // Allowと間違えてDenyにしてしまってる
  return generateAuthorizerResult('Deny', event.methodArn);
} else if (param === 'deny') {
  return generateAuthorizerResult('Deny', event.methodArn);
} else {
  throw new Error('Unauthorized');
}

この時Authorizationヘッダに'allow'を指定しても、当然そのリクエストはDenyされます。

そしてその後ミスに気付いて、コードを修正後に再度Authorizationヘッダが'allow'のリクエストを送信すると、そのリクエストは当然Allowされる

...と思いきや、しばらくはDenyされ続けてしまいます。

これが「キャッシュによる予期しない挙動」で、「Authorizationヘッダ'allow'はDenyされた」という結果をTTLの時間が経過するまでキャッシュとして保持します。

その結果、「(Authorizationヘッダが'allow'の場合)本来Allowされるべきリクエストもキャッシュが有効な期間はDenyされる」という現象が発生してしまいます。

他にも、クラスメソッドさんの下記ブログで紹介されている「Authorizationヘッダ以外の値でAllowかDenyを判定している」ケースでもこの現象が発生しうるので、キャッシュを扱う際には考慮が必要です。

dev.classmethod.jp

もちろん、うまく使えば処理時間も短縮できて便利なので、そこは扱い方次第だと思います。

まとめ

以上、AWS CDK&Lambdaオーソライザを実装する方法でした。

AWSでのサーバーレスにおいて、API Gateway - Lambda というのは「黄金パターン」と言うほどの定番なので、そこに認可処理を挟めるLambdaオーソライザはなかなか便利な機能です。

これを用意しておくと、各Lambdaで個別に認可処理を記載する必要がなくなる(かも)ので、機会があれば一度導入を検討してみてもよいと思います。

それでは、今回はこの辺で。

AWS Community Builderになって半年経った感想

はじめに

この記事は AWS Community Builders Advent Calendar 2023 の9日目の記事です。

AWS Community Builder とは?

もう前の方がさんざん書いているので概要だけ。

  • AWSの知識・知見の共有(≒アウトプット)
  • AWS技術コミュニティへの活動・貢献

をした人に、AWSが技術リソース、教育、ネットワーキングの機会を提供してくれるというプログラムです。

aws.amazon.com

普段はどういう活動をしているの?

主にこんな活動をしています。

個人

  • ブログ・SNSでのAWSに関する情報の発信
  • 社内でのAWSに関する知識・知見の展開、社内勉強会での登壇
  • AWS関連のイベントへの参加、協力
    • AWS公式の各種イベント(AWS Summit, AWS Dev Dayなど)
    • 各社さんが開催するAWSイベント(知見共有などの勉強会など)
  • AWS以外のイベントで、AWSを絡めた技術知見での登壇

コミュニティ

  • JAWS-UG, Serverless Tokyo等, 各種コミュニティイベントへの参加
    • 出来る限りオフライン参加
  • 上記イベントでの登壇
  • コミュニティ運営への協力
    • JAWS Festa 2023での運営お手伝い
    • 最近は特にJAWS-UG CDK, およびJAWS-UG 名古屋 で活動しています

どんな申請をしたの?

申請時に「自分のAWSに関する活動実績、及びAWSに関する自分の思い」的なものを記載する必要があります。(ここに苦労する人もいるかもしれません)

ちなみに、私は下記のような事を記載しました。(うる覚えですが)

  • 仕事でAWSを用いたアプリケーションの設定、AWS CDKでのインフラ構築をしており、AWSに非常に関わりが深い
  • 仕事だけでなく個人的にもAWSが好きで、休日にもたまにAWSを触って知識の習得やスキルアップ活動を行っている
  • 個人的にもサーバーレスやAWS CDKが大好きで、今後もこのあたりの知識を中心にAWSを追求していきたい
  • その他、上記「個人」や「コミュニティ」に記載した活動を通してAWSの知識共有やコミュニティ活動に貢献しており、今後もそれを続けていくつもりである

その結果、今年の8月にサーバーレス分野でAWS Community Builderに選出していただきました。

Community Builder になってよかったこと

色々なモチベーションが上がる

いろんな分野でのCommunity Builderの方と交流ができますし、そういう活動や交流を通して得られる知見・知識はめちゃくちゃ大きいです。

先日の JAWS-UG横浜 #63 AWS re:Invent 2023 宇宙一早い Recap でkawanoさんが「イベント後の飲み会こそ、最大のインプットの場所である」ということを話していましたが、上のような交流を通して、本当にそれを実感しています。(もちろん参加する・しないは個人の自由です)

そういう機会を多く得られるというのは、エンジニアとして本当にモチベーションが上がるし、自分にとってもプラスになります。

最新情報をキャッチできる

Community Builderになる際、NDAを締結する必要があります。

その関係で、未公開の最新情報をいち早くキャッチしたり、未公開のサービスに触れる機会もあります。(もちろんフィードバックすることを前提で)

コミュニティ活動にさらに積極的になった

上記のようなメリットをコミュニティ活動を通した結果得られたので、その分さらにコミュニティ活動に貢献しよう!という気持ちがさらに高まりました。

もちろん、それがなくてもコミュニティ活動を通して色々な方と交流できたり、ものすごい知見を得られるというメリットがあるので「Community Builderの為だけに」コミュニティ活動を行っているわけでは全然ないのですが、Community Builderになってからはより一層下記の点で「自分ができることは何か」を考えるようになりました。

  • コミュニティ活動をさらに盛り上げるためにはどうすればよいか
  • 自分が得た知見を、他の人に共有するためにはどうすればよいか

そして、過去記事でkagaさんや丹後さんも述べていますが、そういうコミュニティ活動を通して「各種情報に関するアンテナの感度が高くなった」「アウトプット意識が向上した」などの副次的なメリットも享受できたのが大きなメリットだなと感じています

Community Builder になって戸惑ったこと

英語の壁

これは過去の記事でも書かれていますが、Community Builderのやりとりは、専用のSlackチャンネルで英語でやり取りされます。
なので、英語が苦手だとなかなかコミュニケーションが難しいと感じます。

というか、JSConf JPやre:Invent 2023でも感じましたが、英語ができないだけで機会損失してしまう場面が少なからずあるので、やっぱり英語は必須だなと感じました

会社での評価

Community Builderになったからといって、会社で何か評価が変わるとか、そういう事があるかは分かりません。(会社や上司次第)
ここら辺はいろいろ言われている「コミュニティ活動って本当に必要なの?」問題に通じるものがあるのかなと思います。

これに関しては地道に活動して理解してもらうか、あるいはそういう活動を理解してくれる環境に異動するとかしかないかなあと思います。

まとめ

会社での評価はともかく、個人的にはコミュニティビルダーになったことで非常に活動の幅が広がりましたし、モチベーションもかなり上がった一年でした。

また、今年は本格的にコミュニティイベントへの参加・登壇・協力などにコミット出来た一年だったと思いますし、そういう活動を通じて得られたものも大きかったです。

今後もそういった活動を継続し、来年もCommunity Builderに認定されるようにしたいと思います。

あと「来年こそは」と思っている方、どんな些細なことでもいいのでなにか実績があれば、ダメ元でもいいのでぜひ一度申請してみることをお勧めします。(自分もまさか認定されるとは思わなかった)

それでは、今回はこの辺で。

JSConf JP 2023の「Bunがメジャーリリースされたけど、本当にBunはNode.jsに取って代るほどすごいのか?をAWS Lambdaで検証してみた」で説明しきれなかった点の補足

はじめに

この記事は、Bun Advent Calendar 2023 4日目の記事です。

qiita.com

JSConf JP 2023について

先月の11/19(日) に「JSConf JP」という、JavaScriptの一大Festivalが開催されました。

jsconf.jp

そしてその中で「Bunがメジャーリリースされたけど、本当にBunはNode.jsに取って代るほどすごいのか?をAWS Lambdaで検証してみた」という(長いタイトルの)内容で登壇させて頂きました。

※聞いてくださった方、ありがとうございました!

jsconf.jp

今回のJSConf JPのセッションではかなりレア(もしかしたら唯一?)のAWS & 100% バックエンドの内容だったので、どうかなあという感じでしたが、結果的には多くの方が聞きに来てくれて、良かったという感じでした。

あとは本番の魔物(接続トラブル)さえなければ...

で、今回はそのJSConf JPのセッションで話しきれなかったことについての話になります。

ちなみに、発表資料はこちらになります。

speakerdeck.com

アジェンダ

  • 結局、Bunって実際の実行速度はそうでもないの?
  • 「Bun(Node.jsビルド)」って、何?
  • 「TypeScriptで動かす」の補足
  • 「ビルドファイルが動かない現象を回避する方法」の補足

結局、Bunって実際の実行速度はそうでもないの?

資料の中で「AWS Lambda(以下Lambda)ではBunは思ったほど早くなかった」という検証結果を話しました。

それについて、発表後に「Webサーバーとかではどうですか」とか「Webサーバーとしては早いですよ」という意見を頂きました。

そこで調べたところ、確かにnode.js ネイティブやExpressではBunの方が速いという結果が出ているようです。

ただし、Fastifyではnode.jsの方が速いようです。

また他にも「Bunの方が速い」という結果を出していた海外の比較サイトもありました。(サイトは失念...)

なので、結論から言うと「環境次第」ということなんでしょう。(自分がフロントは専門外なので、あまり突っ込んで調査はしていない)

ただBun自体、まだ9/8にメジャーリリースされたばかりですし、Bun公式サイト でトップページのトップでサーバーサイドレンダリングベンチマーク結果を表示していることからも、Webサーバーとしての速度は今後どんどん速くなっていくんでしょうね。

「Bun(Node.jsビルド)」って、何?

上記「検証結果」の項目の中で「Bun(Node.jsビルド)」という項目があります。

それについて「『Bun(Node.jsビルド)』って、何?」という質問がありましたので、改めてここで記載します。

Bunにはビルドターゲットとして以下の3つがあり、それぞれビルド結果が異なります。(browserは今回は検証から除外)

  • Bun: Bunランタイムに最適な形式でビルドされる
  • node: Node.jsに最適な形式でビルドされる
  • browser:Webブラウザで動作させるのに最適な形式でビルドされる

参考:Bun.Build

そして「Bun(Node.jsビルド)」は上記のターゲット:nodeでビルドした場合の結果になります。

ちなみにBun公式のbun-lambdaパッケージにも記載がある通り、BunをLambdaで動かす場合、API GatewayのeventはRequest形式に変換されます。

「TypeScriptで動かす」の補足

BunでLambdaを動かすことのメリットとして「TypeScriptのままLambdaにアップロードできる」という事を挙げました。
これに関する補足です。

具体的には、下記の手順を踏むことでBunでTypeScriptのままLambdaを実行する事が出来ます。

  1. Bun用のLambda Layerを作成する(これは先述のbun-lambdaパッケージで作成可能)
  2. Lambdaで使用するnpmモジュールを別途Lambda Layerとして作成する
  3. TypeScriptでLambdaを記載する
  4. 該当のLambda関数の設定で、1~2のLambda Layerを使用する設定を行う

ただし、下記のデメリットが発生するので注意です。

  • Lambdaの実行時間が長くなる(これは資料内でも説明した通り)
  • これだけでLambda Layerを2つ使う
    • Lambda Layerは最大5つまで。(あまり意識する必要はないかも?)

「ビルドファイルが動かない現象の回避方法」の補足

また発表内で、BunをLambdaで動かす際の注意点として、下記を挙げました。

  • node_modulesを使用すると、ビルド後のjs ファイル実行時に「(intermediate value).require is not a function 」エラーが発生する
  • ビルド後のjsファイルの先頭に下記2行を追加することで回避可能
import { createRequire as createImportMetaRequire } from "module";
import.meta.require ||= (id) => createImportMetaRequire(import.meta.url)(id);

これについて、具体的には下記の手順で実行します。

  1. Bunのビルド設定ファイル (build.ts) を用意する
  2. build.ts に下記ソースを記載する
  3. Bunのビルド時に、下記コマンドのように build.ts を使用してビルドするようにする。
bun run build.ts
// build.tsの内容
import path from "node:path";
import process from "node:process";
import fs from "node:fs";
import { Transform, TransformCallback } from 'stream'
  
// ビルドするファイルの設定。
// 最終的にビルドファイルのパスが取れればOK
// ここでは1ファイルのみだが、もちろん複数ファイルでもOK
const projectBaseDir = process.cwd();
  
const input_bun = path.resolve(
  projectBaseDir,
  "lambda/index_bun.ts"
);
  
// ビルドファイルの出力先&拡張子設定
const output = path.resolve(projectBaseDir, "dist");
const ext = "mjs";
  
// targetはbunまたはnodeを指定
await Bun.build({
  entrypoints: [input_bun],
  outdir: output,
  target: "bun",
  format: "esm",
  naming: `[dir]/[name].${ext}`
});
  
// ビルドされたファイルを1つずつ読み込む。
// ビルドファイル名.bakという一時ファイルを作成する
// Stream形式にしているのは、ビルドされたファイルのサイズが
// 大きくてもOOMで落ちないようにするため
const pathToBuildedFileBun = path.resolve(projectBaseDir, `dist/index_bun.${ext}`);
const files = [pathToBuildedFileBun]
  
for (const file of files) {
  const inputFile = fs.createReadStream(file, {
    encoding: "utf-8"
  });
  const outputFile = fs.createWriteStream(`${file}.bak`, {
    encoding: "utf-8"
  });
  
  // .bakファイルの先頭に回避用コードを記載
  outputFile.write('import { createRequire as createImportMetaRequire } from "module"; import.meta.require ||= (id) => createImportMetaRequire(import.meta.url)(id);\n\n')
  
  // あとはビルドファイルのコードをそのままコピー
  const decoder = new TextDecoder();
  const transformer = new Transform({
    transform(
      chunk: Uint8Array, 
      encoding: string, 
      done: TransformCallback
    ): void {
      let chunkString = decoder.decode(chunk);

      this.push(chunkString) // 加工処理
      done()
    },
  })
  
  // https://qiita.com/suin/items/8bf63cd457d75b709530
  // https://qiita.com/masakura/items/5683e8e3e655bfda6756
  inputFile.pipe(transformer).pipe(outputFile);
  
  // 最後に元のビルドファイルを削除して、.bakファイルの
  // ファイル名を元のビルドファイル名にリネーム
  fs.unlinkSync(file);
  fs.renameSync(`${file}.bak`, file);
}

まとめ

以上、JSConf JPで説明しきれなかった点の補足でした。

今回の発表で、始めてBunを本格的に触ってみましたが、パッケージインストール・ビルド・テストなど、ローカル作業の速度に関してはかなり速く、とても魅力的でした。

また本番稼働させるには色々と壁がありますが、先述の通り、まだまだメジャーリリースされたばかりなので、今後の進化に期待ですね。

それでは、今回はこの辺で。
明日の Bun Advent Calendar 2023 もお楽しみに!

【AWS CDK】Lambda定義でCloudFormationリソース数を削減するTips

今回のお題

タイトル通り、AWS CDKでLambda定義を書く際にちょっとした工夫でCloudFormationリソース数を削減する方法を紹介します。
普段はそこまで神経質になる必要はありませんが、業務などで多数のリソースをAWS CDKで管理している場合に役に立つかもしれません。

なお今回触れるのは、Lambdaのロール&ポリシー関連の書き方です。

TL;DR

  • ロールは事前に定義したものをアタッチする
  • ポリシーはinitialPolicyではなく、上記ロールにaddToPrincipalPolicyでアタッチする
  • ロールやポリシーを重複して定義しない
  • ただし、普段はそこまで神経質になる必要はない

リソースが多くなる書き方

無意識にLambda関数を定義すると、こんな書き方をするかもしれません。

// デフォルトポリシーの定義
const initialPolicy = new iam.PolicyStatement({
  ...(なんか定義)
});
    
// Lambdaの定義  
// roleを設定せず、initialPolicyでデフォルトポリシーを埋め込む
const nodejsLambda = new NodejsFunction(this, 'HogeFunction', {
  entry: path.resolve(__dirname, '../../lambda', 'index.ts'),
  handler: 'handler',
  functionName: 'HogeFunction',
  initialPolicy: [initialPolicy],
});

ただしこの書き方でデプロイすると、各Lambdaに対してロール&ポリシーが一つずつ作成されてしまいます。
つまりLambda関数の総数 * 2のリソースが追加で作成されることになります。

例えばLambdaを10個作成したら、20個のリソースが追加で作成されます。

各LambdaでRoleやPolicyが違う場合は別ですが、共通で問題ない場合、これだと無駄にリソース数を消費してしまいます。

リソースが少なくなる書き方

そこでどういう書き方をするかというと「事前作成したRoleをLambda関数にアタッチする(Roleを自動生成させない)」というもので、具体的には下記になります。

ちなみにRoleの内容は、自動生成されるRoleと全く同じ内容です。(AWSLambdaBasicExecutionRoleのみが割り当てられている)

またAWS管理ポリシーをfromAwsManagedPolicyNameする場合、service-roleなどのプレフィックスも引数に含めないとエラーになるので、そこは注意です。

// デフォルトポリシーの定義(これは変更なし)
const initialPolicy = new iam.PolicyStatement({
  ...(なんか定義)
});
  
// Lambdaに割り当てるロールの定義
const role = new Role(this, 'LambdaRole', {
  assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
  managedPolicies: [
    ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
  ]
});
   
// 上記ロールにデフォルトポリシーをアタッチする   
role.addToPrincipalPolicy(initialPolicy);
    
// Lambdaの定義  
// initialPolicyは設定せず、代わりにroleに事前作成したロールを割り当てる
const nodejsLambda = new NodejsFunction(this, 'HogeFunction', {
  entry: path.resolve(__dirname, '../../lambda', 'index.ts'),
  handler: 'handler',
  functionName: 'HogeFunction',
  role,
});

この定義でデプロイすると、どれだけLambda関数を作成しても、作成されるロール&ポリシーはそれぞれ1つのみです。
つまり「(Lambda関数の総数 - 1) * 2」個のリソースが削減できるので、Lambda関数の数が多くなるほど、削減効果が大きくなります。

先程の「Lambdaを10個作成」で言うと、18個のリソースが削減できることになり、結構馬鹿になりません。

まとめ

以上、Lambda定義でCloudFormationリソース数を削減する小ネタでした。

最初に記載した通り、普段はそこまで神経質になる必要はありませんが、多数のリソースをAWS CDKで管理している場合、リソース削減に役に立つかもしれません。

なお今回はLambdaで説明しましたが、もちろんLambda以外のリソースでも有用ですし、「ロールやポリシーを重複して定義しない」ことがリソース削減の第一歩になるかと思います。(その他、設計を見直す...とか)

告知

明日 2023/11/28(火) 19時~開催の「VS Code Meetup #26 - with Postman! @LODGE」にて、LTをさせて頂くことになりました。

今回はPostmanフィーチャー回ということで、「Rest Clientユーザーの自分がPostman の VS Code拡張機能を扱ってみた感想」という内容でお話しさせて頂きます。

vscode.connpass.com

それでは、今回はこの辺で。

【AWS Lambda】Lambdaのログ関連の新機能をCDKで実装してみた&実際に動かしてみた

はじめに

こんにちは。

最近re:Inventもあってか、AWSのアップデートがものすごいことになってますね。
そんな中、11/16(木) にLambdaのログ周りに関する下記アップデートが発表されました。

aws.amazon.com

概要としては下記の通りです。

JSONでのログ出力をサポート

今まではテキスト形式のみでしたが、今回のアプデでJSON形式も標準でサポートしました。
これでメトリクスフィルタやAmazon Athenaなどでのフィルタ、解析がやりやすくなるのではないかと思います。

これまではJSON出力するにはアプリ側でコードを書くかAWS Powertoolsを使うしかなかったですが、Lambdaの設定だけでJSON出力が行えるようになりました。

ただAWS PowertoolsにはJSON出力以外にも便利な機能が多数ありますので、標準でJSONがサポートされたからといって不要になるなんてことは一切ありません。(by AWS Powetools大好きユーザー)

ログレベルの制御をサポート

INFO, WARN, ERROR などのログレベルの制御を、Lambdaの設定で扱えるようになりました。(開発は全部出力、本番はERRORのみ...など)

今まではアプリ側で制御用のコードを書く必要がありましたが、それが不要になりました。

出力先のCloudWatchロググループを指定可能

今までは /aws/lambda/<Lambda関数名> で固定だった 出力先のCloudWatchロググループが、任意に指定可能になりました。

例えば類似処理を行っているLambdaのログを集約することで管理がしやすくなる(かも)といったことがあります。

その他、ロググループを集約することでCloudWatchアラームも1つのロググループにまとめることができ、コスト削減につながるかもしれません。

ただし「どのLambda関数のログなのか」を識別できるようにする情報(context.functionName など)をログに加えないと分からなくなるので、その辺は注意が必要です。

なおこのロググループ集約に関しては、クラスメソッドの若槻さんがブログに記載されているので、詳しく知りたい方はそちらもご参照ください

dev.classmethod.jp

試してみる

という訳で、実際に上記アップデートを試してみます。

なお、Lambdaのリソース作成&設定はAWS CDKで行い、API Gateway経由でそのLambdaを動かしてみます。

また、今回3つのLambda関数を作成しますが、その中身は全て共通で下記のソースとなっています。(重要なのはconsole.xxxでのログ出力)

import { APIGatewayEvent, APIGatewayProxyResult, Context } from "aws-lambda";

export const handler = async (event: APIGatewayEvent, context: Context ): Promise<APIGatewayProxyResult> => {
  
  console.info(`This is ${context.functionName} INFO log`);
  
  console.info('This is info message');
  console.warn('This is warning message');
  console.error('This is error message');
  
  const result: APIGatewayProxyResult = {
    statusCode: 200,
    headers: {
      contentType: 'application/json',
    },
    body: JSON.stringify({
      status: 'success',
    }),
  };
  
  return result;
};

AWS CDK

まずはAWS CDKの定義から。
AWS CDKの定義は下記の通りです。

肝心なのは applicationLogLevel, systemLogLevel, logFormat, logGroup の 4つです

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction, LogLevel } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RetentionDays, LogGroup } from 'aws-cdk-lib/aws-logs';
import { RestApi, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';
import path from 'path';
  
export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const customLogGroup = new LogGroup(this, 'CustomLogGroup', {
      logGroupName: '/custom/jsconf/lambda/bun',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
  
    const nodejsLambda = new NodejsFunction(this, 'JsConfNodeJsFunction', {
      entry: path.resolve(__dirname, '../../lambda', 'index.ts'),
      handler: 'handler',
      timeout: cdk.Duration.seconds(30),
      environment,
      functionName: 'JsConfNodeJsFunction',
      logRetention: RetentionDays.ONE_WEEK,
      // ログレベルはどちらもINFO、フォーマットはJSON
      applicationLogLevel: 'INFO',
      systemLogLevel: 'INFO',
      logFormat: 'JSON',
    });
    
    const nodejsLambda2 = new NodejsFunction(this, 'JsConfNodeJsFunction2', {
      entry: path.resolve(__dirname, '../../lambda', 'index.ts'),
      handler: 'handler',
      timeout: cdk.Duration.seconds(30),
      environment,
      functionName: 'JsConfNodeJsFunction2',
      // ログレベルは未指定、フォーマットはText(下記注意点を参照)
      // logRetention: RetentionDays.ONE_WEEK,
      // applicationLogLevel: 'WARN',
      // systemLogLevel: 'WARN',
      logFormat: 'Text',
      logGroup: customLogGroup
    });
    
    const nodejsLambda3 = new NodejsFunction(this, 'JsConfNodeJsFunction3', {
      entry: path.resolve(__dirname, '../../lambda', 'index.ts'),
      handler: 'handler',
      timeout: cdk.Duration.seconds(30),
      environment,
      functionName: 'JsConfNodeJsFunction3',
      // logRetention: RetentionDays.ONE_WEEK,
      // アプリログはERROR、システムログはDEBUG、フォーマットはJSON
      applicationLogLevel: 'ERROR',
      systemLogLevel: 'DEBUG',
      logFormat: 'JSON',
      logGroup: customLogGroup
    });
    
    const restApi = new RestApi(this, 'JsConfRestApi', {
      restApiName: 'JsConfRestApi'
    });
    
    const nodejs = restApi.root.addResource('nodejs');
    nodejs.addMethod('GET', new LambdaIntegration(nodejsLambda));
    
    const nodejs2 = restApi.root.addResource('nodejs2');
    nodejs2.addMethod('GET', new LambdaIntegration(nodejsLambda2));
    
    const nodejs3 = restApi.root.addResource('nodejs3');
    nodejs3.addMethod('GET', new LambdaIntegration(nodejsLambda3));
  }
}

applicationLogLevel, systemLogLevel, logFormat, logGroup の 説明は以下となります。

項目 説明 設定可能な値 デフォルト値
applicationLogLevel アプリログ(console.xxxなどで出力するログ)の出力レベル設定 TRACE/DEBUG/INFO/WARN/ERROR/FATAL INFO
systemLogLevel システムログ(INIT_START, REPORTなどLambda側で自動するログ)の出力レベル設定 DEBUG/INFO/WARN INFO
logFormat ログのフォーマット Text/JSON Text
logGroup ログ出力先のCloudWatchロググループ ILogGroup(LogGroupクラスインスタンスなど) /aws/lambda/<Lambda関数名>

注意点としては下記の通りです。

  • logFormat に Textを設定した場合、applicationLogLevelおよびsystemLogLevelは指定できません。
  • logGrouplogRetentionを両方指定することはできません。(どちらか一方のみ)
  • applicationLogLevelおよびsystemLogLevelは上のコードのように文字列で直指定してください。
    • LogLevel というEnum があるのですが、これで指定するとcdk deply時にエラーになります。
    • ログレベルは「INFO」のように全部大文字で指定する必要がありますが、LogLevel は Info などPascal形式のため

実行結果

上記設定を行った各Lambdaを実行した結果のログは以下の通りです。

JsConfNodeJsFunction

  • ログがJSONで出力されています。
  • アプリログ(console.xxx)に関して、INFO/WARN/ERRORが出力されています。(INFO以上)
  • システムログについて「platform.initStart」「platform.start」「platform.report」が出力されています。
  • ロググループは /aws/lambda/<Lambda関数名> です。

JsConfNodeJsFunction2

  • ログがテキストで出力されています。
  • ロググループは(logGroupで指定した) custom/jsconf/lambda/bun です。

JsConfNodeJsFunction3

  • ログがJSONで出力されています。
  • アプリログ(console.xxx)に関して、ERRORのみが出力されています。
  • システムログについて「platform.initStart」「platform.start」「platform.report」の他、「platform.initRuntimeDone」「platform.initReport」「platform.runtimeDone」も出力されています。
  • ロググループは(logGroupで指定した) custom/jsconf/lambda/bun です。

いずれのLambda関数も、ちゃんとAWS CDKの定義通りに動作していますね。

まとめ

AWS Lambdaについて、ログ周りの機能が強化されたことで、CloudWatchとの連携(メトリクスフィルタ、アラームなど)やログレベル制御がやりやすくなりました。

開発だけではなく、運用・監視にも大いに役立ちそうな機能ですね。

では、今回はこの辺で

【AWS CDK】LambdaをDockerイメージでデプロイする方法(最終編)

今回のお題

前々回、及び前回にわたり「LambdaをDockerイメージでデプロイする方法」について記載しました。
今回はその最終編ということで、前回紹介した「ハマりどころ」の続きを記載しようと思います。

なお今回触れる事項は、前々回に少し触れた「インフラ側とアプリ側でソース管理を分ける」ケースで発生するものです。

アジェンダ

  • アプリ側のimageタグの設定をCDKにも反映するには?
  • 初回のみイメージがなくてエラーになる現象を回避するには?

アプリ側のimageタグの設定をCDKにも反映するには?

インフラ側とアプリ側でソース管理を分ける場合、LambdaのDocker Imageに付与するイメージタグは、基本アプリ側に依存します。(よくあるのが、GitHubコミットハッシュの先頭数文字」)

そして、CDKを更新する場合に、CDKのLambda関数の定義にこのイメージタグを設定する必要があります。

前々回に紹介したEcrImageCodePropsのpropsに「tagOrDigest」というプロパティがあり、これのデフォルト値は「latest」です。
なのでイメージタグを指定しないと該当Lambda関数が参照するイメージタグが強制的に「latest」になってしまい、当然そんなタグが付いたイメージはECRにないのでエラーになってしまいます。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.EcrImageCodeProps.html

そのため、デプロイ時にアプリ側Lambdaのimageタグを取得する必要があります。

対策

これの対策としては「CDKとアプリ側でイメージタグを共有する仕組みを作る」方法があります。

やり方は色々ありますが「AWS SSM パラメータストアで値を共有する」方法がセキュアでよいのではないかと思います。

具体的には手順は下記の通り感じです。

  1. アプリ側でLambda関数を更新(=イメージタグを採番)する際、そのイメージタグをパラメータストアに登録する
  2. CDK側でデプロイを実施する際、1のパラメータストアの値を取得する
  3. 2の値を tagOrDigest に設定する

ポイントとしては下記の4点です。

  • パラメータストアへの登録は、AWS CLIを使うと良いです。
    • AWS CDKではパラメータストアへの登録はできません。(取得は可能)
  • パラメータストアの値は、aws-cdk-lib.aws_ssmStringParameter.valueForStringParameter() で取得可能。
  • パラメーターストアのキー値は、アプリ側とCDK側で共有しておく
    • 可能ならば環境変数などで設定しておく(CodePipelineなど)
  • 初回のCDKデプロイ時(=アプリ側がまだ存在していない)のみ、該当キーの値が存在せずエラーになるので、何か対策しておく(例えば下記)
    • 仮の値を手動でパラメーターストアに登録する
    • CDK側でもパラメータストアの値をAWS CLIで取得して、存在しない場合には仮の値を入れる *1
// 関連部分以外は省略
// パラメータストアからイメージタグタグの取得
const tag = StringParameter.valueForStringParameter(this, 'lambdaImageTag');
  
// イメージタグを適用(tagプロパティはdeprecatedなので使わないこと)
const lambdaDockerEcrFunction = new lambda.DockerImageFunction(this, 'SampleLambdaDockerEcrFunction', {
  code: lambda.DockerImageCode.fromEcr(ecrRepo, {
    tagOrDigest: tag,
  }),
});

初回のみイメージがなくてエラーになる現象を回避するには?

前々回でも触れましたが、DockerImageFunction でLambda関数を作成する場合、指定したECRに該当Lambdaのイメージがないとデプロイ時にエラーになってしまいます。
通常はそれで問題ないですが、初回デプロイ時だけはアプリ側がまだ存在していないため、そのままだと100%エラーになってしまいます。

なので、初回デプロイ時のみこの問題に対策する必要があります。

対策:

これについては「初回限定の仮イメージを登録する」ことで対処可能です。
※初回限定なので、Lambda定義さえあれば中身は何でもよいです。

で、この「初回限定イメージのECR登録」については「cdk-ecr-deployment」を使用すると非常にシンプルに実装できます。

github.com

この「cdk-ecr-deployment」を用いて、下記の処理を行うことで対応できます。

  1. パラメータストアにイメージタグの値を取得する
  2. 1の値が取得不可だったら(=初回デプロイ時のみ発生)、イメージタグに仮の値を設定する(下記ソースでは「initail」)
  3. イメージタグが仮の値だったら、cdk-ecr-deploymentを使用してECRに仮のイメージを設定する*2
import * as ecr_deployment from 'cdk-ecr-deployment';
import { aws_ecr } as ecr from 'aws-cdk-lib';
import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets';  
import path from 'path';

const INITIAL_IMAGE_TAG = 'initial';
  
// 1および2
// getParameterStoreValueは、AWS CLIでパラメータストアからイメージタグの値を取得する関数。(なければ空文字を返す)
const imageTag = getParameterStoreValue(LATEST_IMAGE_TAG_KEY_NAME, profile) || INITIAL_IMAGE_TAG;  
  
// ECRの作成
const repo = new ecr.Repository(this, 'Repository', {
  ...(省略)
});
  
// 3(初回のみ仮イメージをECRにpush)
if (imageTag === INITIAL_IMAGE_TAG) {
 
  // 仮イメージの設定。
  // directoryにはDockerfileファイルがあるフォルダのパスを指定
  const image = new DockerImageAsset(this, 'DockerImageAssetInitialOnly', {
    directory: path.resolve(__dirname, '../docker');
  });
  new ecr_deployment.ECRDeployment(this, 'InitialOnlyImage', {
    
    // ここでは仮イメージをECRに設定
    src: new ecr_deployment.DockerImageName(image.imageUrl),
    dest: new ecr_deployment.DockerImageName(repo.repositoryUriForTag(INITIAL_IMAGE_TAG)),
    
    // なおsrcにはローカルのイメージ以外にも、ECRパブリックリポジトリやDocker Hubなどのイメージも指定可能。 
    // src: new ecr_deployment.DockerImageName(`public.ecr.aws/docker/library/nginx:mainline-alpine3.18-slim`),
  });
}

ちなみにこのあたりの「アプリとインフラを分離した際に発生する問題」に関しては、9/30(土)に開催されたJAWS FESTA 2023 in Kyusyu 直前スペシャル!! にて「AWS CDKでインフラ、アプリを分離した際に困ったこと」という内容で発表を行いましたので、よろしければそちらの資料をご参照ください。

speakerdeck.com

宣伝

11/19(日) 開催のJSConf JPにて、発表をさせて頂くことになりました。

jsconf.jp

内容としては「最近話題の(?)Bun について、実際にプロダクトワークロード(とりあえずLambda)で動かしたらどうなの?」という内容になりますので、よろしくお願いいたします。

それでは、今回はこの辺で。

*1:AWS CLIで直接値を取得すれば、cdk synthの時点で値を取得するので、try~catchによる制御が可能。 ※StringParameter.valueForStringParameterではできない

*2:なお始めは「イメージタグが存在する場合のみ、Lambda定義の処理を通るようにする」という処理をしていましたが、それだと万が一何かあった際にLambda関数が削除されてしまうため、やめました