maybe daily dev notes

私の開発日誌

AWS CDK Tips 記事まとめ

AWS CDKを使い始めて3年になるので、溜まった知見を書き出しています。記事を追加するごとにここも更新していきます。

以下はもう少し細かいユースケースごとの話です:

以下は昔Qiitaに書いた記事です:

AWS CDK Tips: クロスリージョンのデプロイ

AWS CDK TIpsシリーズの記事です。

AWSでサービスを構築する際、単一リージョンで提供するサービスであっても、クロスリージョンのデプロイが必要になる場合がまれにあります。AWS CDKでは、そのような構成も簡単に実装可能です。今回はCDKを使ったクロスリージョンアプリのデプロイ方法をまとめます。

クロスリージョンの必要な状況

まず、クロスリージョンのデプロイが必要になるのはどのような場合でしょうか?

DRやレイテンシー低減を考慮したマルチリージョンのアーキテクチャでは、もちろん必要でしょう。しかし実はそうでない場合、つまり単一リージョンで提供するサービスであっても、次の場合などにクロスリージョンのデプロイが必要になります:

1. CloudFrontでカスタムドメインを使いたい

CloudFront Distributionはデフォルトで cloudfront.net ドメインのURLを発行します。これを独自ドメインに設定するためには、AWS Certificate Manager (ACM) の証明書をDistributionに紐付ける必要があります。このとき、ACMの証明書は必ず us-east-1 リージョンで作成されたものでなければなりません (ドキュメント)。

このため、すべてのリソースをus-east-1にデプロイする場合を除いて、複数のリージョンを跨いでリソースを作成する必要が生じます。

2. AWS WAFを使いたい

AWS WAFを使う場合、Web ACLを作成する必要があります。Web ACLはWAFの挙動を定義するためのリソースで、us-east-1 リージョンでのみ作成可能です (ドキュメント)。

CloudFront DistributionやAmazon API Gateway APIなどを作成する際に、Web ACLのARNを追加指定することで、アクセスがWAFを経由するようになります。Web ACLを参照するリソースがus-east-1以外にあれば、クロスリージョンの参照が必要になります。

3. 他にも、色々

他にも上記のようなケースは考えられます。 例えばLambda@Edgeも、関数はus-east-1のみにデプロイ可能です (ドキュメント)。 こういった制約を網羅的に知るのは難しいので、サービスを使う時に適宜調べると良いでしょう。

また、特定のリージョンでは提供されていないサービスや、逆に特定のリージョンでのみ提供されるサービスもあります (リージョンごとの対応サービスリスト)。そうした場合も、その制約を回避するためにクロスリージョンのデプロイが必要になることがあるでしょう。

CDKでクロスリージョンデプロイする方法

前置きが長くなりましたが、ここから本題です。上記のような場合に使えるいくつかの方法を紹介します。

1. CDKネイティブのクロスリージョン参照

CDKでは2.50.0から簡単にクロスリージョンのデプロイができるようになりました (PR)。以下のコードは、東京リージョンからヴァージニアリージョンのリソースを参照する例です。クロスリージョンのリソースの受け渡し (スタック間参照) が実現できています。

const app = new cdk.App();

const virginia = new VirginiaStack(app, 'VirginiaStack', {
  env: {
    region: 'us-east-1',
  },
  crossRegionReferences: true,
});

new TokyoStack(app, 'TokyoStack', {
  env: {
    region: 'ap-northeast-1',
  },
  crossRegionReferences: true,
  certificate: virginia.certificate,
});

class VirginiaStack extends cdk.Stack {
  readonly public certificate: Certificate;

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    this.certificate = new Certificate(this, 'Certificate', { /* 略 */ });
  }
}

関連する2つのスタック両方に crossRegionReferences: true のpropを渡すことに注意してください。 今はpreview機能としての提供であり、crossRegionReferences がFeature Flagとして働きます。おそらくGAされればこのフラグは不要になり、完全に透過的にクロスリージョン参照を実装できるようになるでしょう。

2. カスタムリソースを使う

1の方法ではスタックをリージョンごとに作る必要があるため、やや面倒に感じることもあるでしょう。代わりに、CloudFormationのカスタムリソース機能を利用することができます。例えば ap-northeast-1のスタックにデプロイしたカスタムリソースから、us-east-1のAPIを叩いて必要なリソースを作成するのです。

このアプローチが実現されたものはいくつかあり、例えば DnsValidatedCertificateACM Certificateを作成・検証するカスタムリソースですが、作成するリージョンを任意に指定可能です。ただしこちらは既にDeprecatedであり、非推奨です。 また別の例として、AWS Prototyping SDK (PDK)*1では、AWS WAFのWeb ACLをリージョンまたぎで作成できます(参照: cloudfront-web-acl.ts)。

この方法はシングルスタックに保てるという利点はあるものの、大抵カスタムリソースのハンドラ実装がCFnの再発明になり諸々の困難が予測されます。それらを覚悟の上でなら、こちらの方法もアリでしょう。

3. cdk-remote-stack (昔の方法)

従来CDKでクロスリージョン参照といえば、cdk-remote-stack を使う方法が主流でした (更にその前は同じような機能を各自で実装していました)。ググって出てくる記事もこの方法が多いと思います。私もよくお世話になったライブラリです。

2023年では1の方法が利用できるため、この方法をあえて使う理由は無くなったと考えて良いでしょう。ただしこちらは弱い参照 (後述)なので、人によってはもうしばらく出番があるかもしれません。

1の仕組み

せっかくなので1 (CDKネイティブのクロスリージョン参照) の仕組みもまとめます。意外と手の込んだ仕様で面白いです。

基本的な思想は、CloudFormationのクロススタック参照の挙動を模擬する方針のようです。このため、例えば他のスタックに参照されているパラメータは更新や削除ができません。使用者はダウンタイムを回避するため、複数段階に分けて安全にデプロイする必要があります。(もし使用中のパラメータを書き換える事ができたら、参照している側はデプロイされるまで古いパラメータを利用し続けることになります。これでは動作の保証ができず、危険です。)

図にするとこのようなものです。Lambda関数は、CDKが自動的に作成するカスタムリソースのハンドラです。参照の更新時や削除時の挙動は、従来のクロススタック参照と似たものになります。

クロススタック参照に似た強い参照の挙動は、検証時など頻繁にリソースを更新・削除する用途では不便だという声もあります。これを受けて、弱い参照の実装も検討されているようです (RFC)。元のPR にも strong-ref というキーワードが頻出しますし、また「この仕組みはそのまま弱参照の実装に利用可能だ」という旨も記載されています。期待しておきましょう。

まとめ

AWS CDKを使えば、クロスリージョンのシステムも簡単にデプロイできます。便利に使っていきましょう!

*1:これはAWS Prototypingのオーストラリアチームが主に開発したコンストラクトライブラリです。日本ではあまり見ない方法もあるので面白いです。AWS Prototyping SDK

AWS SDK JavaScript v3でS3のファイル操作 チートシート

ワタミチートシート以来、久々のカンペ記事。

LambdaのNode.js 18ランタイムではAWS SDK v3のみプリインストールされているなど、いよいよAWS SDK for JSを移行すべき状況になっている。 しかし私は未だにSDK v3でS3のファイルをダウンロード/アップロードする操作に慣れないので、この記事にまとめる。

事前準備

以降のコードに必要なライブラリは、基本的に以下の一つだけでOK。

npm install @aws-sdk/client-s3

コード

S3頻出のパターンとして、以下5つがあるだろう:

  1. メモリ上のデータをS3にアップロード
  2. ファイルシステムのデータをS3にアップロード
  3. メモリ上にS3のデータをダウンロード
  4. ファイルシステムにS3のデータをダウンロード
  5. S3上のファイルを他のS3バケットコピー/移動

それぞれのコードを下記のとおりである。

1. メモリ上からアップロード

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});

// Bodyのデータをtest.txtとしてアップロード
await s3.send(
  new PutObjectCommand({
    Body: 'some data',  // Bufferなども指定可
    Bucket: process.env.BUCKET_NAME,
    Key: 'test.txt',
  })
);

2. ファイルシステム上からアップロード

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { createReadStream } from 'fs';

const s3 = new S3Client({});

// ローカル上のtest.txtをアップロード
await s3.send(
  new PutObjectCommand({
    Body: createReadStream('test.txt'),
    Bucket: process.env.BUCKET_NAME,
    Key: 'test.txt',
  })
);

ファイルを一度メモリ上に展開する方法も考えられるが、streamを使うほうが省メモリで済む。

備考: 対象のファイルサイズが大きな場合

対象のファイルサイズが大きな場合は、Multipart機能を使うことでより高速にアップロードできる場合がある。これを便利に使うための機能がSDK v3にはあるので、追加でインストールする。

npm i @aws-sdk/lib-storage

コードは以下:

import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream } from 'fs';

const s3 = new S3Client({});
const upload = new Upload({
  client: s3,
  params: {
    Body: createReadStream('sample.bin'),
    Bucket: process.env.BUCKET_NAME,
    Key: 'sample.bin',
  },
  // 性能改善用の細かなパラメータ
  queueSize: 10,  // アップロードの並列数
  partSize: 1024 * 1024 * 5,  // 分割時のサイズ 全体の分割数(合計サイズ/partSize)が10000を超えないようにする
});

await upload.done();

手元で500MB程度のファイルをアップロードしたところ、およそ40%速くなった。色々な条件にも依ると思うため、参考までに。こちらも参照

3. メモリ上にダウンロード

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});

// S3上のtest.txtをダウンロード
const s3Object = await s3.send(
  new GetObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: 'test.txt',
  })
);
// バイト列として取得したいときはこちら
// const bytes = await s3Object.Body?.transformToByteArray();
const str = await s3Object.Body?.transformToString();

少し前までこれが少し面倒だったのが最近楽になった件は、ここにも書いた。

TIL: AWS SDK for JavaScript v3 で s3.GetObject する最新の方法 - maybe daily dev notes

4. ファイルシステム上にダウンロード

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import {createWriteStream } from 'fs';
import { Readable } from 'stream';

const s3 = new S3Client({});

// S3上のtest.txtをダウンロードし./test.txtに保存
const s3Object = await s3.send(
  new GetObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: 'test.txt',
  })
);
await new Promise((resolve, reject) => {
  if (s3Object.Body instanceof Readable) {
    s3Object.Body.pipe(createWriteStream('test.txt'))
      .on('error', (err) => reject(err))
      .on('close', () => resolve(0));
  }
});

この場合は依然としてStreamを繰る必要がある。

5. S3からS3にコピー/移動

import { S3Client, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});

// test.txtをtest_copy.txtにコピー 
await s3.send(
  new CopyObjectCommand({
    Bucket: process.env.TARGET_BUCKET_NAME,
    Key: 'test_copy.txt',
    CopySource: `${process.env.SOURCE_BUCKET_NAME}/test.txt`
  })
);

// 移動の際は元ファイルを削除
await s3.send(
  new DeleteObjectCommand({
    Bucket: process.env.SOURCE_BUCKET_NAME,
    Key: 'test.txt',
  })
)

コピー元であるCopySourceの指定方法がすこし特殊になる。詳細はこちら

移動したい場合は、コピー後に元ファイルを削除する。2つの操作をアトミックに実行するS3 APIは今のところ存在しないので、整合性が重要な場合は要注意 (あまりないと思うが。)

なお、CopyObject APIを使えるのはファイルサイズが5GBまで。5GBを超えるファイルは、コピー元ファイルをダウンロードし、コピー先にアップロードする必要がある。

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';

const s3 = new S3Client({});

// 5GBを超えるファイルをコピーする
const s3Object = await s3.send(
  new GetObjectCommand({
    Bucket: process.env.SOURCE_BUCKET_NAME,
    Key: 'sample.bin',
  })
);

// 大きなファイルで高効率なMultipartアップロードを利用
const upload = new Upload({
  client: s3,
  params: {
    Body: s3Object.Body,
    Bucket: process.env.TARGET_BUCKET_NAME,
    Key: 'sample_copy.bin',
  },
});

await upload.done();

注意

いくつかの疑問が生じたので、ついでに調べた:

  • @aws-sdk/client-s3S3S3Client の2つあるが、どっち使えば良い?

このドキュメントに詳しく書かれている。S3 はv2と似た体験を実現するために用意されたもの。これを使うと、s3.putObject のように xxCommand クラスを使わずにAPIを呼べる。

一方でTree shaking観点ではこの古い方法はイマイチらしく、フロントエンドなどバンドルサイズの要求がシビアな場面では S3Client を使う方法が好まれる。個人的には書き方を使い分けるのも面倒なので S3Client の方で統一するのが良いと思うが、バックエンドでの利用などバンドルサイズが多少大きくても問題ない場合は書きやすい昔の記法もアリだろう。

なおこれはS3に限らず、DynamoDBやEC2など他のすべてのサービス用SDKで共通の話。

  • new S3({}){}、必要?

必要。型定義上この引数を省略することはできない。理由は不明だが、オプショナルにするとnullチェックが追加で必要になるので、多少気持ちはわかる。このIssueで提案はされたが、特に対応されなかった模様。

以上、AWS SDK for JS v3でS3のファイルを扱うときのコードをまとめた。

TypeScriptのcode-firstなGraphQL開発ツール比較: TypeGraphQL vs Nexus vs Pothos

GraphQLサーバーを開発する際は、まず schema-first か code-first かを決めることになるでしょう。前者はまず graphql.schema を手書きし、そこから言語固有のコードを生成する方法です。後者は言語固有のコードを書いてから、 graphql.schema を生成する方法です。

今回2つのどちらが良いかは議論しませんが、後者の code-first & TypeScriptでGraphQL開発をする場合、ライブラリの選択肢がいくつかあります。この記事では、それらのライブラリの特徴をまとめます。特に以下の観点で比較します:

  • Prismaとの連携: 私がTypeScriptでウェブ開発するときはORMのPrismaをよく使います。GraphQLと組み合わせる上では、型定義の重複やN+1問題を回避するため、ライブラリ間の連携は重要です。
  • 開発の活発さ: 将来的にライブラリがDeprecateされるリスクはできるだけ避けたいものです。今のメンテナンスの活発具合でこのリスクを占います。

特徴

ということで見ていきましょう。今メジャーなcode-first GraphQLライブラリは3つあります。

1. TypeGraphQL

github.com

Nest.jsTypeORMと同様に、TypeScriptのデコレータ記法をベースにしたフレームワークです。デコレータ記法は今流行りのフロントエンドライブラリではほとんど使わないので、好みが分かれるところかもしれません。

Prismaとの連携は TypeGraphQL Prisma というインテグレーションが用意されています。結構大胆で、何も設定しなくても、Prismaで定義したすべてのテーブルのCRUDゾルバーを勝手に実装してくれます。微調整も可能な模様。ただし、ORMとしては同じデコレータ記法を使うTypeORMのほうが相性良さそうに思います。

開発の活発さについて、GitHub上では一見現状の最終リリースが2020/11/5と停滞しているように見えます。しかし、実は去年v1.2.0 RCをプレリリースし、また今はv2のベータの開発を進めているようです。ほぼ作者一人の個人プロジェクトなのでやや今後に不安を覚えますが、少なくともスポンサーは複数付いています。

Contribution数はほぼ一人が支えている

正直私自身はこれをあまり使ってないので深いことは書けません。

2. Nexus

github.com

Nexus organizationメンバー3人中2人がPrismaの人という、Prismaと密接なつながりを持っているフレームワークです。TypeGraphQLとは異なりデコレータは一切使わず、コードの雰囲気もどことなくPrismaと似ています。

Nexusは実行時にTypeScriptの型定義ファイルを生成します。このため、開発時は以下のようにNexusサーバーを常駐させることが推奨されています。この仕様のため、エディタでの型チェックの反映がワンテンポ遅いという欠点もあります。

# ts-node-devで常駐させる
ts-node-dev --transpile-only --respawn nexus-server.ts

Prismaと連携するにはnexus-prisma ライブラリを利用します。前身の nexus-plugin-prisma ライブラリもあるのですが、こちらは既に開発終了で deprecated です。

ただし nexus-prisma も若干雲行きが怪しく、2022半ば頃は開発が停滞していたようです。そんな中、2022/10からメンテナンスの主体がPrismaからコミュニティに移管されつつあります。移管先は現状開発者一人のため開発速度が大きくブーストされるかは不透明ですが、今は過渡期のため当面状況を見守る必要があるでしょう。

Nexus自体も生存確認のIssueが立つ程度には開発が活発ではありません。後述のPothosに移行するためのツールを作る開発者もいるなど、穏やかではない状況です。また、メインのメンテナであるtgriesserさんが後述のPothosに感心し、NexusからPothosへの漸進的な移行方法を提供したいとも発言しています。

上記を考慮すると、今のままではNexusがユーザー数を維持・拡大する可能性は低いと推測されます。ひとまずは公式声明を待ちたいですが、当面は新規採用を控えるのが無難かもしれません。

3. Pothos

github.com

初版のリリースが2020/7と3つの中では最も若いライブラリです。元はGiraphQLという名前でしたが、視認性や検索性の問題で改名したようです。

記法はNexusとよく似ており、TypeGraphQLのようなデコレータ記法は用いません。このため、上記の通りNexusからPothosに移行する動きもあるようです。 また、謳い文句によればランタイムのオーバーヘッドがなく軽量で高速なことが売りのようです。コード生成にも頼らないため、Nexusよりも型チェックが俊敏です。

Prismaとの連携も容易で、プラグインが用意されています。密接にPrismaと統合されており、リゾルバーの中で宣言的にPrismaのクエリを書くことも可能です。

ただし、個人的にはPrisma連携で一つつらみがありました: こちらで議論されているのですが、Prismaの型を持ってくる時にデータ型やnullabilityを改めて指定する必要があり、Prisma側とコードの重複が生じる点です。ここはNexusやTypeGraphQLのほうがスマートに書けます。

// Prismaの型定義
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
}

// PothosのObject定義
 builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    name: t.exposeString('name', { nullable: true }),
  }),
});

開発は明らかに活発で、他の追随を許さない勢いでバージョンアップが重ねられています。今はhayesさんの個人開発なのがやや不安ですが、後述の通りユーザー数がここ1年で激増しているので、モチベーション高く開発されているようです。今のところは楽観視できるでしょう。

npmダウンロード数も比べてみましょう。緑がTypeGraphQL、オレンジがNexus、青がPothosです。全体の数はTypeGraphQL > Nexus > Pothosですが、ここ1年の伸び率は真逆でPothos > Nexus > TypeGraphQLです。

@pothos/core vs nexus vs type-graphql | npm trends

Pothosはまだユーザー数は少ないものの指数関数的な成長です。性能の良さや開発の活発さが評価されているのでしょう。Nexusの動向やTypeGraphQL v2の出来次第で、今後更に伸びるのではないでしょうか。

Nexusも2021の後半勢いづいていますが、こちらの理由は不明です。今後はPothosへの置き換えが進んでいく可能性も高いと思われます。

TypeGraphQLは2020/11以降大きなアップデートもないため、線形な成長率です。今後v2がリリースされれば、また変化が見られるでしょう。

考察

ということで、どれを選ぶべきでしょうか。

私個人の意見としては、デコレータ記法がなじまないのでTypeGraphQLはNG、またNexusは先行きが不安のためNGで、より将来性を感じるPothosを選びます。ここはまだ正解がないので、各々が考える必要があるでしょう。答え合わせは半年後になるでしょうか。

また、この選択をする上では開発体験も重要なファクターです。ここは主観にもよるところがあるので、3つのライブラリで簡単なGraphQL APIを実際に作ってみて、体験を評価することをおすすめします。

まだ私自身もGraphQLを使い始めたばかりなので、もう少しPothos or Nexusをやり込んだら再び記事を書きたいと思います。以上、TypeScript x GraphQL x code-firstなライブラリ3つの比較でした。

Next.jsのstandaloneモードをAWS Lambda + API Gatewayでサーバーレス化する

これは AWS LambdaとServerless Advent Calendar 2022 の記事です。

Next.jsをホスティングする手段の一つとして、Standaloneモードで動かす方法があります。 コンテナ1個で動かせるため非常にお手軽な選択肢で、GCPのCloud RunやAWSのApp Runnerなどで動かす例を見ることも多いです。

この記事では、AWS Lambda + API Gatewayのサーバーレス鉄板構成でNext.js standaloneモードを公開する方法を紹介します。巷ではあまり見かけない構成だったので、新しい選択肢となることに期待したいです。この構成は趣味運用でできる限り費用を抑えたい方にもオススメです。

方法

具体的な方法はCloud RunなどでNext.jsを動かす従来の方法とほぼ同じです。Lambdaで動かすための面倒な工夫やコードの変更はそれほど必要ありません。もしすでにStandaloneのコンテナがあるのでしたら、それに数行の変更を加えるだけです。

まずは nextjs.config.js に以下の行を追加します:

const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
+   output: 'standalone',
};

次にDockerfileを見てみましょう。基本的にはCloud RunApp Runner用のDockerfileがそのまま使えます。記述に見覚えがある方も多いんではないでしょうか。

# Multi-stage buildでNext.jsをビルド
FROM node:16 AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . ./
RUN npm run build

# ベースイメージの変更
FROM amazon/aws-lambda-nodejs:16

# Lambda Web Adapterのインストール
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.5.0 /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=3000

COPY --from=builder /build/next.config.js ./
COPY --from=builder /build/public ./public
COPY --from=builder /build/.next/static ./.next/static
COPY --from=builder /build/.next/standalone ./

# ベースイメージ変更に伴う調整
ENTRYPOINT ["node"]
CMD ["server.js"]

何点かポイントがあるので説明します:

1. Lambda Web Adapterのインストール

今回は AWS Lambda Web Adapterを利用しています。こちらはコンテナのWebアプリをほぼそのままLambdaで動かせるようにするツールです。

github.com

細かい話は省きますが、Lambda Web AdapterはLambda Extensionとして動作するので、 /opt/extensions に必要なファイルをコピーすることでインストールされます。 そのためには、以下の1行を追加すればよいです:

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.5.0 /lambda-adapter /opt/extensions/lambda-adapter

基本的にはこれで良いのですが、追加で環境変数経由でいくつかの設定をしています。今回は以下だけで十分だと思いますが、一応他にも設定項目はあるので適宜ご確認ください。

  • PORT: Next.jsサーバーが動作するポート番号

また、Lambda Web Adapterの仕組みについては後日記事を公開予定です。乞うご期待! (追記: 公開しました) これを使えば大抵のコンテナWebアプリは同じようにLambdaで動かせるので、とてもオススメです。App Runnerを使わずに済む場合も増えるんじゃないかと思っています。

2. ベースイメージの変更

※ これはオプショナルな変更です。コールドスタート時間を軽減させたい方は試してみてください。

従来の方法では、ベースイメージとして node など公式のNode.jsイメージを使うことが多いです。 一方Lambdaの場合は、 amazon/aws-lambda-nodejs のように他の多くのLambdaユーザーもよく使うコンテナイメージを利用するのがオススメです。

FROM amazon/aws-lambda-nodejs:16

理由: これは私個人の推測に基づく情報にはなりますが、Lambdaがコンテナイメージをキャッシュする仕組みの都合上、利用者の多いイメージをベースにするほうがコールドスタート時の性能が良くなるようでした。この動画この動画を見ると、Lambdaのイメージキャッシュはベースイメージはユーザー間でcommonなことが仮定されています。このため、他のユーザーはあまり使わないイメージをベースにするとキャッシュヒット率が下がり、コールドスタート時間が長引くのではと思います。

amazon/aws-lambda-nodejs にも nodenpm など基本的なコマンドはインストールされているので、多くの場合そこまで抜本的な変更にはならないはずです。

また、この変更に伴って ENTRYPOINTCMD も変更しています。本来は CMD ["node", "sever.js"] だけで済ませたいところですが、ベースイメージの ENTRYPOINT を上書きしたいのでこのようにしています。

ENTRYPOINT ["node"]
CMD ["server.js"]

とはいえこれ以外のベースイメージでも問題なく動作はする ので、面倒な方はそのままでも良いでしょう。かなり雑な体感値ですが、コールドスタート時間は amazon/aws-lambda-nodejs だと1秒程度、node だと最大3秒程度になることがある感じでした。3秒でも十分短いと思う人もいると思います。

以上2点の変更だけで、AWS Lambda上で動くNext.js standaloneイメージを作成することができました。

デプロイする

後はデプロイするだけですが、デプロイはAWS CDKを使うのが手っ取り早いです。 以下の簡単なコードで、上記のLambda関数とそれにブラウザからアクセスするためのAmazon API Gateway HTTP APIをデプロイできます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Duration } from 'aws-cdk-lib';
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha';
import { DockerImageCode, DockerImageFunction } from 'aws-cdk-lib/aws-lambda';
import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { Platform } from 'aws-cdk-lib/aws-ecr-assets';

export class Frontend extends cdk.Stack {
  readonly endpoint: string;

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id);

    // Next.js standaloneを動かすLambdaの定義
    const handler = new DockerImageFunction(this, 'Handler', {
      code: DockerImageCode.fromImageAsset('./frontend', {
        platform: Platform.LINUX_AMD64,
      }),
      memorySize: 256,
      timeout: Duration.seconds(30),
    });

    // Amazon API Gateway HTTP APIの定義
    new HttpApi(this, 'Api', {
      apiName: 'Frontend',
      defaultIntegration: new HttpLambdaIntegration('Integration', handler),
    });
  }
}

諸々含めたサンプルコードはこちらに公開しています。Next.js 13をCDKでLambdaにデプロイする例です。簡単なのでぜひお試しください。

github.com

この構成のメリット

この構成のメリットは、非常に手軽にNext.jsをAWS上でホストできることです。サーバーレス構成なのでもちろんVPCは必要ないですし、リクエストが来ていない時に料金は発生しません。

また、Lambda + API Gatewayの構成は成熟していて、開発体験も良いです。CDKのhotswap機能を使えばLambdaのデプロイは10秒程度で終わります。WAFやCloudFrontとの統合、カスタムドメインの設定なども容易です。

Next.jsをStandaloneでAWSにデプロイする選択肢としては非常に良いのではと考えています。 一方でISRなどより高度な機能を使いたい場合は、やはり Serverless components を使ったデプロイやAWS Amplify hostingの利用を検討すると良いでしょう。

まとめ

Next.js standaloneをAWS Lambda上で動かす方法を紹介しました。そのまま動くレポジトリもあるので、ぜひお試しください!

追記) Lambda Web Adapterの性能については、簡易的なものですが、こちらの記事でまとめました。

aws.amazon.com

AWS CDK Tips: コンストラクトで構造化しよう

AWS CDK TIpsシリーズの記事です。

CDKのコードが散らかってきた、スタックのコードがベタに数百行以上もあって読みづらい… そんな状況に直面したことはないですか?今回はCDKコードを構造化して、可読性・保守性を高めるコツを紹介します。

背景

前回の記事で、CDKのスタックを不必要に分けるのは避けたい旨を紹介しました。

tmokmss.hatenablog.com

しかしながら、全リソースを1スタックにまとめたとき生じうる問題の1つが、そのスタックのコードが長大になることです。長いコードは認知負荷も上がります。そもそもコードを読みやすくするためにスタックを手頃なサイズに分割している、という話を聞くことすらあります。

例えば、以下のコードは読みづらくなってきたスタックの例です。ざっと概観を見ていただくだけで大丈夫です。スタック定義の中に必要なリソースがフラットに定義されています。

こうしたコードには次のデメリットがあるでしょう:

  • 1メソッド (constructor) としては長大で、ぱっと理解しづらい
  • すべての変数が同じスコープにあり、変数間の依存関係が分かりづらい
  • リソースID (第2引数) が重複したら1つでもエラーになるため、命名に工夫が必要
    • 前提として、CDKのコンストラクトはコンストラクトツリーとして管理され、1つの階層(scope)の中でリソースIDはユニークである必要があります

界隈の言葉を借りれば、凝集度が低いとも言えると思います。 CDKのベストプラクティスにもあるとおり、ここはCDKのコンストラクトを使ってコードを整理・構造化していきましょう。

コンストラクトで構造化する

構造化とは物事を親子関係で管理する方法だと理解しています。日常生活でも多くの人が無意識に使っていて、例えばドキュメントを書くときに箇条書きでネストさせるのも一つの構造化ですね。また、説明するときに抽象的な段階から徐々に深掘りして具体化するのも構造化の一例でしょう。

CDKコードでも同じ話で、構造化されたコードはメリットが多いです。まずは構造化を実現するための方法から見ていきましょう。

方法

CDKにおいて構造化はコンストラクトを使って行うのが一般的です。これは上記の通りCDKのリソースはコンストラクトツリーで管理されるものだからです。ツリーとはすなわち構造であり、この仕組みを利用しない手はありません。

CDKのコンストラクトは実はいつも皆が目にしています ― s3.Bucketec2.Vpc もコンストラクトの1つなのです。それらはCDKが公式に提供しているものですが、以下のように自分でコンストラクトを定義することも可能です:

import { Construct } from 'constructs';

// 入力のインターフェース
export interface MyConstructProps {
}

// コンストラクトの本体
export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);
    // ここに必要なリソースを定義する
    // 書き方はスタックのconstructor内と同じ
  }
}

こうして定義したコンストラクトは、スタックや他のコンストラクトなど、他のCDKコードから呼び出すことができます。この仕組みを使って、関連するリソースをコンストラクトにまとめ、もともとのフラットなコードを構造化して整理しましょう。

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    // 自作コンストラクトの呼び出し
    new MyConstruct(this, "MyConstruct", {});
  }
}

実際にこの方法でスタックを定義しているコード例は、こちらもご覧ください(関連記事)。

github.com

次にこのようなコードのメリットを考えてみます。

構造化のメリット

コンストラクトを使ってそれぞれのリソースを適当な凝集度でまとめれば、冒頭のスタックのコードはこのように整理されます:

export class BackendStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        const auth = new Auth(this, 'Auth');
        const storage = new Storage(this, 'Storage');
        const handler = new Handler(this, 'Handler', {
            userPool: auth.userPool,
            userPoolClient: auth.userPoolClient,
            connectionIdTable: storage.connectionIdTable,
        });
        const websocket = new WebSocket(this, 'Websocket', {
            authHandler: handler.authHandler,
            websocketHandler: handler.websocketHandler,
        });
    }
}

このコードには次のメリットがあるでしょう:

  • スタック内の構成が、一見してより分かりやすい
    • 大きく分けて認証、データ、LambdaのHandler、WebSocket APIの4パーツに分かれていることは少なくとも一目でわかります
  • リソースIDの重複をあまり気にしなくてよい
    • コンストラクトに分けることで、コンストラクトツリーのscopeがスタックから各コンストラクトになるため、重複を注意すべきscopeがより狭くなると言えるでしょう
    • 何百行のコードのなかで重複を気にするのは大変ですが、1つのscope内に定義されるリソースの数が減るため、重複を避けるのはより簡単になります
  • スタックの分割も容易
    • 例えばWebSocket APIやLambda関数だけを別のスタックに切り出したいという場合も、コンストラクト単位で容易にリファクタ可能です (もちろん物理的なリソースの置き換えは考慮する必要がありますが!)
    • 密に関連するリソースが各コンストラクト内にまとまっているためです

また最近はCloudFormationのマネジメントコンソールで、スタック内のリソースをコンストラクトツリーに沿って表示できるようになりました。こちらについてもコンストラクトで構造化していたほうが見やすいので、1つの大きなメリットでしょう。

なお、コンストラクトの良い分け方はケースバイケース(a.k.a. 模索中)です。チームで開発している場合は、メンバーと相談しながら認知負荷の低い形を目指すのも良いでしょう。個人的には、少なくともコンストラクト間で循環依存が発生するような状況さえ避ければ、機能ごとに良い感じに分ければ十分だと思います。一点、S3バケットやDynamoDBテーブルなどステートフルなリソースはリファクタがより困難なので、より慎重に配置したほうが良いかもしれません。

コンストラクトを書く際のTips

以降はいくつかの関連する便利情報をお伝えします。

リソースIDに Default を使いロジカルIDを短縮する

コンストラクトの中にコンストラクトを配置…のようにツリーのネストが深くなると、ロジカルIDが長い問題が生じることがあります。(ロジカルIDはテンプレート合成後のCloudFormationのIDです。)

長いと何が困るかというと、CFnが自動生成するリソース名が分かりづらくなることです。S3バケットやDynamoDBテーブルなどはCFnに自動命名させがちですが、これらはロジカルIDから名前が生成され、ロジカルIDが長い場合は後ろからtruncateされます。結果として ${ロジカルIDの前半 (あまり情報量がない)}-${ランダム文字列} のような名前になってしまうのです。

バケットの区別が付きづらい状況

この問題を緩和するためには、リソースIDに Default という文字列を積極的に使いましょう。リソースIDからロジカルIDを生成する際、DefaultというIDは除去されるためです。例を見てみましょう:

export class BackendApi extends Construct {
  constructor(scope: Construct, id: string, props: BackendApiProps) {
    super(scope, id);
    // リソースIDによるSynth後のテンプレートの違いを見たい
    new HttpApi(this, 'Default');
    new HttpApi(this, 'Api');
  }
}

// スタックのコード
export class MyStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        new BackendApi(this, "BackendApi");
    }
}

リソースIDが DefaultApi の2つのリソースを定義してみました。このとき、合成後のCFnテンプレートは以下のようになります:

// リソースID = DefaultのCloudFormation生成結果
"BackendApi7423A747": {
  "Type": "AWS::ApiGatewayV2::Api",
  "Metadata": {
    "aws:cdk:path": "MyStack/BackendApi/Default/Resource"
  },
  // ...

// リソースID = ApiのCloudFormation生成結果
"BackendApiApi24DA8825": {
  "Type": "AWS::ApiGatewayV2::Api",
  "Metadata": {
    "aws:cdk:path": "MyStack/BackendApi/Api/Resource"
  },
  // ...

リソースIDが Default の場合は、ロジカルIDが少し短くなっていることに注目してください。 BackendApi7423A747 vs BackendApiApi24DA8825 一段階なら少しの変化ですが、ネストが重なると大きな変化です。もちろんリソースIDが Default のリソースは1つのコンストラクトの中に1つまでしか存在できないので、効果的な箇所に使いましょう。

また、この仕組みを使うとリファクタの際も便利です。例えばスタック直下に定義していた "Api" というリソースIDのリソースを別のコンストラクトの中に移動したい場合、ロジカルIDを保ったまま移動可能です。

export class MyStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        // このリソースを…
        new HttpApi(this, "Api");

        // BackendApiコンストラクトの中に移動する
        new BackendApi(this, "Api");
        // BackendApiの中でHttpApiはDefaultなので、HttpApiのロジカルIDは保持される
        // 再デプロイしても同一リソースとして扱われ、replacementが発生しない
    }
}

ちなみにリソースIDが Default のリソースノードは Construct.defaultChild メソッドで取得可能です。Escape hatchを使うときも少し便利ですね。

リソースIDの命名規則はPascalCaseがおすすめ

細かい話にはなりますが、リソースIDはPascalCaseで命名するのがおすすめです。理由はCFnが自動生成する名前を見やすくするためです。より詳細は以前こちらにまとめています:

qiita.com

// パスカルケース これが良し (第2引数に注目!)
const upperCamelTable = new Table(this, 'ItemTable', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

// 以下はメリット薄い
const kebabTable = new Table(this, 'item-table', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});
const snakeTable = new Table(this, 'item_table', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

VSCodeコードスニペットを活用する

コンストラクトはCDKを使っていれば日常的に定義するものです。毎度定型コードを入力するのは大変なので、コードスニペットを使って一瞬で入力できると便利です。

スニペットの登録方法はこちら。また、こちらは私が使っているスニペットのjsonファイルです。ついでに aws-cdk-lib から各モジュールをインポートするコードも楽に書けるようにしてます。

まとめ

CDKを書く際はぜひコンストラクトも活用してみましょう。

TIL: AWS SDK for JavaScript v3 で s3.GetObject する最新の方法

最近はもっぱらNode.jsのAWS SDKはv3を使っているのですが、一つ大きな不満がありました。 それは s3.GetObject がv2よりも使いづらかった点です。ユーザーはS3のファイルを読み込むために ReadableStream をなめていくコード () を自分で書く必要がありました。

これが毎回必要になるため地味に面倒で、SDK v2に戻るか…と思案した人もいたのではと思います。

この面倒ですが、今は過去のものになりました! SDK v3公式にReadable Streamをstring型に変換する関数が追加されました。

コード

ということで、最新のAWS SDK v3によるS3上ファイルの取得方法は以下です:

import { S3, GetObjectCommand } from '@aws-sdk/client-s3'

const s3 = new S3({});

const getObjectCommand = new GetObjectCommand({
  Bucket: process.env.BUCKET_NAME,
  Key: 'sample.txt',
});
const response = await s3.send(getObjectCommand);
// transformToString が新たに追加された!
// strに sample.txt の中身の文字列が入る
const str = await response.Body?.transformToString();
// バイナリの場合はこちら
const arr = await s3Object.Body?.transformToByteArray();

かなり簡単になりましたね!文字列だけでなく、バイト列に変換することも容易です。

コード自体は2022/10/22に追加されたようですが、なかなか該当のIssueがクローズしないので見逃してました。 おそらくそろそろクローズされるんではと思います。

github.com

実装はこの辺りです: sdk-stream-mixin.ts。気になる方はご覧ください。

github.com