echo("備忘録");

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

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関数が削除されてしまうため、やめました

【JAWS-UG】JAWS FESTA 2023 KYUSHUに参加してきました

はじめに

10/7(土)に、JAWS-UG(Japan AWS User Group)の一大イベント「JAWS FESTA」が4年ぶりに完全オフラインで、福岡・福岡工業大学で開催されました。

九州の地というかなり遠い場所ではありましたが、4年ぶり完全オフラインということで「これは参加せねば!」と思い、行ってきました。

運営サポートとして参加

今回はJAWS-UGのイベントでは初めて、運営側の立場で参加しました。(運営サポート)

私は会場入り口の手前(下の画像の場所)で、誘導を行ってました。
ちなみに会場までの入り方が分かりにくいと思い、X(Twitter)で道案内をツイートしたのですが、「あのツイートがあって助かりました!」と言ってくれた方がいたので、ちょっと嬉しかったです。

本当は発表(登壇)することでも貢献したかったのですが、残念ながら今回はCFP採用には至らなかったので、それはまた次回以降の楽しみにしておきます。

ちなみに、下記「JAWS FESTA 2023 in Kyusyu 直前スペシャル!!」で発表した「AWS CDKでインフラ、アプリを分離した際に困ったこと」がCFPの内容だったのですが、本編や懇親会で「すごい役立ちました」という感想を結構頂きまして、自分的には嬉しかったです。

connpass.com

また、オープニングからめっちゃ盛り上がってたみたいです。(自分は誘導があったため参加できませんでしたが)

本編

本編も色々盛り上がってました。

私は参加してませんが、お祭りブースの「福岡市の隅っこで世界に向けて AWS を叫ぶ」「JAWS FESTA版!チーム対抗 AWS ウルトラクイズ」とかは、かなり盛り上がってて、他のセッションを聴いててもなんかめっちゃ声が聞こえてきました。

そして「お茶会テーブル」では、普段はなかなかお話しできない色々な方と交流できて、とても良い時間を過ごせました。
あと八女茶がとても美味しかったです。

もちろんセッション本編も、さすがは本採用されるだけあって、非常に有用なものばかりでした。(個人的にはcohalz氏の「ECSのCI/CD改善と標準化の取り組み」なんかは業務に直結する部分だったので、非常に参考になりました。

また「JAWS-UG名古屋の魅力 ~みんなで支える、毎月開催の背後にある物語~」も、普段名古屋で会っているメンバーが福岡の地に集結して、色々感慨深いセッションでした。(記念撮影までしてもらったり)

懇親会

そして本編が終わり懇親会ですが...

いや、まじでなんかセレブのパ↑ーティ↑ーですやん...すごい豪華な会場。めちゃくちゃびっくりしました。

そしてここでも色々な方とお話ししたり、先程の「JAWS FESTA 2023 in Kyusyu 直前スペシャル!!」での感想を頂けたり、あっという間でしたが、非常に楽しい時間を過ごせました。

そして2次会、3次会、果てには4次会まで参加して、とーっても楽しい1人を過ごすことができました。(次こそはオールで参加したいなあ。てか年齢かなあ)

いやあ、マジで参加してよかったです。はるばる名古屋から行ってよかったです。

感想

という訳で、とても楽しい時間を過ごすことができ、非常に充実したイベントでした。
また次回も必ず参加したいです。

ただ、少し前のServerless Days Tokyo 2023でも思ったんですが、運営の方々、めっちゃ大変だっただろうなあと思いました。
ただでさえ大学(福岡工業大学)を借りるだけでも大変なのに、あんな豪華な懇親会会場を借りたり、いろいろ機材・段取り・食べ物飲み物を準備したり...

2次会で実行委員長の阿部さんともお話ししたんですが、相当大変だったんだろうなあと感じました。

なので、こういうイベントが開催されることを「当たり前」と感じず、自分が出来る範囲で、自分が出来ることで何かしら貢献できればなあと思いました。

そして繰り返しになりますが、次回は本編で発表(登壇)できればいいなあ、と感じました。

最後に、実行委員長の阿部さん始め運営の皆様、福岡工業大学の関係者の皆様、登壇者&お話しさせて頂いた皆様、参加者の皆様、ありがとうございました。

という訳で、今回はこの辺で。

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

今回のお題

前回、LambdaをDockerイメージでデプロイする方法の前編として「AWS CDKの定義」について書きました。

今回はその後編として「Dockerfileの定義&ハマりどころ」について記載したいと思います。

  • 前編:AWS CDKの定義(これは前回)
  • 後編:Dockerfileの定義&ハマりどころ(今回はここ)

Dockerfileの定義

Dockerfileの定義ですが、これは下記AWS公式ドキュメントに沿って記載すればOKです。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-image.html

上記ドキュメントにも記載がある通り、Amazon ECR リポジトリでもLambda用のnode.jsイメージが公開されているので、特に理由がない限りはそれを使えばOKです。

https://gallery.ecr.aws/lambda/nodejs

実際の記載例は下記の通りです。(前編で紹介したコードそのままです)

なおindex.jsが実際のLambdaのソースコードです。(TypeScriptの場合、事前にビルドしておいて下さい)

# Dockerfile
# バージョンは適宜選択
FROM public.ecr.aws/lambda/nodejs:18
# /var/task/は、Lambda環境変数LAMBDA_TASK_ROOTの値
WORKDIR /var/task/
# Copy function code
COPY index.js .
  
# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "index.handler" ]

ポイントはDocker側で /var/task/ フォルダにLambdaのソースコードをコピーすることです。(/var/task/AWSでLambdaソースファイルがコピーされるフォルダで、Lambda環境変数 LAMBDA_TASK_ROOT でも確認できます)

また最後に CMD でエントリポイントとしてハンドラー関数を指定します。(これを忘れると実行できません)

なお上記Dockerfileにはないですが、node_moduleを使用する場合、事前にpackage.jsonのコピーや RUN npm install を忘れないでください。(前回説明した通り、Dockerでデプロイした場合はLambda Layerが使用できないので)

それとDockerfileは上位のファイルは参照できないので、ご注意を。(パスに ../ などは設定できない)

あとは該当のECRにDockerイメージをpushすればOKです。特に問題なければ、Lambdaが実行できるはずです。(Lambda関数URLなりAPI Gateway経由なり)

ハマりどころ:複数のLambda関数をデプロイする場合はどうするの?

次はLambdaをDockerイメージでデプロイする際のハマりどころですが、「複数のLambda関数をデプロイする場合はどうするの」という点です。

Dockerfileの COPY コマンドで複数のLambdaファイルをコピーできますが、CMD コマンドでエントリポイントに指定できるLambda関数は1つだけです。
なので下記のようなコードでは正しく動作しません。

かといって、各Lambda関数ごとにDockerイメージを作成するのは非常に手間です。

# 注意:このコードは正しく動きません
FROM public.ecr.aws/lambda/nodejs:18
WORKDIR /var/task/
# Copy function code
COPY index.js .
COPY index2.js .
COPY index3.js .
  
# このコードを実行しても、エントリポイントとして動くのはindex3.handlerだけです。(上書き更新)
CMD [ "index.handler", "index2.handler", "index3.handler" ]

ではどうやるかというと、下記の方法で実行できます

  • Lambda関数単位でフォルダを分ける
  • エントリポイントをAWS CDK側で定義する

Lambda関数単位でフォルダを分ける

これは名前の通り、Docker側のフォルダをLambda関数単位で分けます。

先ほどの正しく動かないDockerfileを例にすると、下記のように記載すればOKです。

FROM public.ecr.aws/lambda/nodejs:18
WORKDIR /var/task/
  
# Lambda関数単位でフォルダを分ける
COPY index.js ./func1/index.js
COPY index2.js ./func2/index2.js
COPY index3.js ./func3/index3.js
  
# Dockerfile内でエントリポイントを定義しないので、CMDは削除

エントリポイントをCDK側で定義する

エントリポイントはDockerfileではなくAWS CDK側で定義するので、上記の通り CMD は削除します。

その代わり、AWS CDK の DockerImageFunction 側でエントリポイントを定義します。(下記コードを参照)

// fromEcr() を使用してますが、fromImageAsset()でも同じです。  
// ecrRepoは事前に作成したECRのインスタンス
const func1 = new lambda.DockerImageFunction(this, 'Function1', {
  code: lambda.DockerImageCode.fromEcr(ecrRepo, {
    // fromEcrのpropsにcmdがあるので、そこでエントリポイントを定義する
    // 指定するパスは「/var/task/」以下を記載する
    cmd: ['./func1/index.handler'],
  }),
  functionName: 'Function1',
});
  
const func2 = new lambda.DockerImageFunction(this, 'Function2', {
  code: lambda.DockerImageCode.fromEcr(ecrRepo, {
    cmd: ['./func2/index2.handler'],
  }),
  functionName: 'Function2',
});
  
const func3 = new lambda.DockerImageFunction(this, 'Function3', {
  code: lambda.DockerImageCode.fromEcr(ecrRepo, {
    cmd: ['./func3/index3.handler'],
  }),
  functionName: 'Function3',
});

あとはECRにDockerイメージをpushすれば、どのLambda関数も正しく動作するはずです。

告知

明日(9/12(火)) に、名古屋で開催の「JAWS-UG 名古屋 AWS認定試験を受けてみようと思った方へ送る勉強会」イベントにて、「AWS ソリューションアーキテクト アソシエイト試験体験記」についてお話しします。(オフライン)

jawsug-nagoya.doorkeeper.jp

またその翌日の9/13(水)に開催される「JAWS-UG CDK支部 #9」イベントにて、AWS CDK座談会のパネラーの一人として参加します。(オンライン)

jawsug-cdk.connpass.com

さらに9/16(土)に愛媛県松山市で開催される「四国クラウドお遍路 2023 - 四国の外のモノサシを知ってみよう-」イベントにて、「AWS CDKでインフラ、アプリを分離した際に困ったこと」という内容でLTをさせていただきます。(オフライン)

jawsohenro.doorkeeper.jp

上記イベントに参加される方、およびこれから参加しようと思っている方、ぜひよろしくお願いいたします。

それでは、少し時間が空いてしまいましたが、今回はこの辺で。

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

今回のお題

タイトルの通りですが、AWSにおいてLambda関数をDockerイメージとしてデプロイする方法です。

なお今回のお題は前編・後編に分かれており、今回は前編になります。

  • 前編:AWS CDKの定義(今回はここ)
  • 後編:Dockerfileの定義&ハマりどころ(これは次回)

どんな時にDockerイメージでデプロイするの?

自分もこの辺はあまり詳しくないのですが、下記のようなケースでしょうか?(正直自信がない)

Dockerに詳しい・Dockerの知見が豊富
そりゃそうか...というか、Dockerに詳しいなら、得意な方法で作成した方がやりやすい&早いからというのはあるかも

フロントエンドなどをDockerで作成する必要がある
ECSを使うなど、フロントエンドなどをDockerで作成する必要がある場合、それならばLambdaも合わせてDockerで...とした方が、環境として統一しやすいのかも

インフラ側とアプリ側の管理の分離
Dockerを使わない場合、基本的にLambdaのソースはインフラ側ソース(AWS CDKなど)と一緒に管理する必要がありますが、諸々の理由*1で「インフラ側とアプリ側は分けたい」というのはよく出てくる話です。

Dockerならば、アプリ側はECRにイメージをpushするだけであり、デプロイそのものはインフラ側でAWS CodePipelineなどで定義できるので、「インフラ側とアプリ側の管理の分離」の解決手段の1つになると思います。(他にも方法はあると思いますが)

今回使用するコード

今回使用するコード(Dockerfile, Lambda関数)は、下記AWS公式ページを参考にしたものを使用します。(Dockerfileについては後編で詳しく触れます)
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-image.html#nodejs-image-instructions

なおDockerfileでは上位フォルダは参照できないので、注意しましょう。(自分もこれを知らなくて少しハマった)

# Dockerfile
FROM public.ecr.aws/lambda/nodejs:18

# /var/task/は、Lambda環境変数LAMBDA_TASK_ROOTの値
WORKDIR /var/task/
# Copy function code
COPY index.js .
  
# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "index.handler" ]
// Lambda関数。なおデプロイ時は事前にtscコマンドなどで
// jsにトランスパイルしてください。
import { Context, APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
  
// ハンドラ関数
export const handler = async(event: APIGatewayProxyEvent, context: Context): Promise<APIGatewayProxyResult> => {
    const res = createResponse(200, `this is ${context.functionName} message`);
    return res;
};
  
// レスポンスオブジェクト(APIGatewayProxyResult)を作成する関数
const createResponse = (statusCode: number, message: string = ""): APIGatewayProxyResult => {
    return {
      statusCode: statusCode,
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({
        status: statusCode === 200 ? 'success' : 'fail',
        message,
      }),
    } as APIGatewayProxyResult;
  }   

AWS CDKの定義

で、本題の「AWS CDKでLambdaをDockerイメージでデプロイする方法」ですが、非常にシンプルで、下記DockerImageFunctionクラスの定義をスタックに追加するだけです。

参考:https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.DockerImageFunction.html

const lambdaDockerFunction = new lambda.DockerImageFunction(this, 'SampleLambdaDockerFunction', {
  // プロパティは他にもたくさんあるが、必須項目はcodeのみ
  code: lambda.DockerImageCode.fromImageAsset(path.resolve(__dirname, '../lambda'),

  // functionName(=関数名)も任意項目。(分かりやすくするために追加しているだけ)
  functionName: 'SampleLambdaDockerFunction',
});
  
// 必須ではないが、動作確認用に関数URLを設定する
// authTypeはセキュリティに関する部分なので、ご注意を  
lambdaDockerFunction.addFunctionUrl({
  authType: lambda.FunctionUrlAuthType.NONE,
});

codeにはDockerImageCodeインスタンスを指定しますが、実質以下のstatic関数のいずれかを指定する形になります。

  • DockerImageCode.fromImageAsset(directory, props?):「Dockerfile」ファイルがあるフォルダのパスを指定する
  • DockerImageCode.fromEcr(repository, props?):Lambdaイメージが格納されているECRリポジトリを指定する

なお、どちらもpropsの説明は省略するので、気になる人は下記公式サイトを見てください

fromImageAsset()を使う方法

fromImageAssetを使う場合、引数directoryに「Dockerfile」ファイルがあるフォルダのパスを指定するだけでOKです。(先程のソースを参照)

この状態でcdk deploy すると、Lambda関数の作成だけでなく、イメージビルド、タグ付け、イメージpushなどが全て行われます。

なおpushされたイメージは、cdk bootstrap 時に自動作成された cdk-hnb659fds-container-assets-<アカウント番号>-<リージョン名> というECRリポジトリに格納されています。(下の画像の通り、pushするとdocker buildした際のハッシュがイメージタグとして付けられるようです)

またLambda関数もちゃんとデプロイできています。(関数名&関数URLは別で追加しています。)

関数URLをクリックすると、ちゃんと正常動作することが確認できます。

fromEcr()を使う方法

fromEcrを使う場合、引数repositoryにEcrリポジトリ(正確にはIRepositoryインターフェース)を指定すればOKです。(下記ソースを参照)
ソースコードなどのパスを指定する(=リポジトリに含める)必要がないので、インフラとアプリの分離などを行いたい際に便利です。

// 注意:このソースをそのままcdk deployすると、Lambda関数の作成でエラーになります。(理由は後述)
// ECRリポジトリの定義
const ecrRepo = new ecr.Repository(this, 'SampleLambdaDockerEcrRepository', {
  repositoryName: 'resigtory-for-lambda-docker-sample',
});
    
// fromEcr()以外はfromImageAssetと同じなので、詳細は省略
const lambdaDockerEcrFunction = new lambda.DockerImageFunction(this, 'SampleLambdaDockerEcrFunction', {
  code: lambda.DockerImageCode.fromEcr(ecrRepo),
  functionName: 'SampleLambdaDockerEcrFunction',
});
    
lambdaDockerEcrFunction.addFunctionUrl({
  authType: lambda.FunctionUrlAuthType.NONE,
});

ただし注意点があり「該当のリポジトリにLambdaのイメージがpush指定したされていないと、cdk deploy時にLambda関数の作成がエラーになる」という点があります。

そのため、実際に適用する際はそこら辺の整合性をうまく取る必要があります。(この辺も後編で記載しようと思います) *2

Dockerでデプロイした場合の制約

マネジメントコンソールでソースコードを表示・編集できない

先程の画像でも表示されている通り、Dockerでデプロイした場合、マネジメントコンソールでソースコードを表示・編集できません。
「一時的にちょっとだけソースを変えたい」という場合でも、イメージビルド・push作業を実施する必要があります。

Lambda Layerが使えない

Dockerでデプロイした場合、Lambda Layerは使えません。(有効なのはコンテナ内部のソースのみ、ということだと思います)
なので必要なモジュールなどは、Dockerfileで RUN npm install を実行するなどして、あらかじめイメージに含めておく必要があります。

まとめ

以上が、LambdaをDockerイメージでデプロイする際のAWS CDKの定義です。

ちょっとややこしい面もありますが、便利な面もあるので、必要に応じて使い分けていけるといいですね。

次回は後編として、Dockerfileの書き方とか、発生する問題に対する解決策などを記載しようと思います。

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

*1:インフラとアプリのライフサイクルが異なる、ソースの開発は外部に依頼する...など

*2:なので上記ソースは、いきなりcdk deployしても正常にデプロイできません。ただし今回は説明の簡素化のためにあえて一緒に書きました