Technote

by sizuhiko

AWS Lambda の Node.js 14 を 18 に移行する(aws-sdk v3 移行編)

先日も紹介したクラメソさんの [アップデート] AWS Lambdaが Node.js 18をサポートしました 記事でもふれられていましたが、Node.js が 18 になるのに伴い、イメージ内の aws-sdk が v3 になりました。 Node.js 16 までは v2 系だったのですが、ここでは多くの変更が発生しています。

  • npm install する対象が aws-sdk から @aws-sdk/client-xxx のように AWS のサービスごとのクライアントライブラリになった
  • 書き方が、各クライアントを new して send でコマンドを送信する記述に統一された

大きくはこの2点でしょうか。 私たちは aws-sdk の利用に関してリポジトリパターンの中に閉じ込めているので、基本的にそこだけ対応すれば良いのですが、API で同期実行だったものが非同期実行に変更となっているので、そこだけはインターフェースを Promise に変更する必要があります。 これは実際のコマンド内部は同期で問題ないけど、 Client.send(command) が基本的に Promise を返却するようになったための副作用だと思ってください。 ( aws-sdk v2 のときは command().promise() のようにすると、非同期コマンドは Promise を返却するようになっていたのですが、この promise() 部分がなくなり、基本的な戻りは Promise になっています)

具体的なコードで見る v2 と v3 の違い

StepFuntions を実行するコードで違いをみてみましょう。

v2 のコード

import { StepFunctions } from 'aws-sdk';

const stepFunctions = new StepFunctions();
const results = await stepFunctions.startExecution({
  stateMachineArn: arn,
  input: JSON.stringify(event),
}).promise();

v3 のコード

import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';
import { build, parse } from '@aws-sdk/util-arn-parse';

const { region } = parse(arn);
const stepFunctions = new SFNClient({ region });
const results = await stepFunctions.send(new StartExecutionCommand({
  stateMachineArn: arn,
  input: JSON.stringify(event),  
}));

StepFunctions では、利用するライブラリ名が aws-sdk/client-sfn になりました。 ほとんどの場合は、サービス名を使っています(たとえば S3 なら aws-sdk/client-s3 だし、 S3Client です)が、StepFunctions の場合は sfn に省略されているので注意が必要です。(僕はこの略名に気付くまでメチャクチャ探したことを告白しておきます)

クライアントライブラリを探すときは AWS SDK for JavaScript v3 の公式ドキュメントを参照すると良いのですが、ぱっとみてサービス名からクライアントライブラリが見つけられない場合は、それっぽい略名から探してみると良いでしょう。

v2 と v3 を比較するとわかりますが、大きな変更でないことはわかると思います。

v3 移行するときに役立つドキュメント

次に v3 移行するときに役立つドキュメントを紹介していきます。

1つ目は公式のSDKドキュメントですね。クライアントライブラリを探すときも、コマンド名を確認するときにも良く参照します。

2つ目はエラーハンドリングのやり方の違いについてです。詳しくはドキュメントを読めばわかるのですが、従来はエラーの判定方法が (<AWSError>e).code === 'ExecutionDoesNotExist' みたいにしていたのを e instanceof ExecutionDoesNotExist のように例外クラスのインスタンスとして判定できるようになっています。これも v3 に移行するときに注意したいポイントです。

3つ目はSDKのv3へのアップグレードガイドで、主に破壊的変更があった部分について解説されています。単純に XxxxClient と XxxxCommand に変更したときに型エラーが出たときは、このドキュメントを読んで破壊的変更(変数名の変更や、JSON構造の変更など)がないか確認すると解決への近道になります。

ただし、先ほどの例で書いた StepFunctions のコンストラクタのように

const stepFunctions = new SFNClient({ region });

リージョンが v3 から必須パラメータになったのですが、こういった変更は動かしてみるまでわからないことが多いので、ユニットテストを書いておくことは重要です。

ユニットテスト

私たちは以下のサービスについては、モックせずにローカルで代替ライブラリを使って結合テストを行なっています。

v2 でのユニットテスト

上記以外について、v2 のときは以下のようにモックしていました。ここでは Jest での書き方を紹介します。 sfn の start というメソッドで StepFunctions を呼び出していると想定してください。

const startExecutionMock = jest.fn(); // startExecution の引数チェック用
const startExecution = jest.fn(); // startExecution の戻り値モック用
jest.mock('aws-sdk', () => {
  return {
    StepFunctions: jest.fn().mockImplementation(() => {
      return {
        startExecution: startExecutionMock.mockImplementation(() => {
          return { promise: startExecution };
        }),
      };
    });
  };
});

beforeEach(() => {
  startExecutionMock.mockReset();
  startExecution.mockReset();
});

startExecution.mockResolvedValue({ executionArn });
const resuls = await sfn.start(sfnArn, event);

expect(results.executionArn).toBe(executionArn);
expect(startExecutionMock.mock.calls).toEqual([
  [{ stateMachineArn: arn, input: JSON.stringify(event) }]
]);

v2 でのモックは面倒でしたね。

v3 でのユニットテスト

v3 で aws-sdk をモックするときは AWS SDK v3 Client mock を利用します。

Library recommended by the AWS SDK for JavaScript team - see the introductory post on the AWS blog.

ということで公式にもオススメされるライブラリということで良いですね。

上記の v3 でのテストコードは以下のようになります。

import 'aws-sdk-client-mock-jest';
import { mockClient } from 'aws-sdk-client-mock';
import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';

const SFNClientMock = mockClient(SFNClient);
beforeEach(() => SFNClientMock.reset());

SFNClientMock.on(StartExecutionCommand).resolves({ executionArn });
const resuls = await sfn.start(sfnArn, event);

expect(results.executionArn).toBe(executionArn);
expect(SFNClientMock).toHaveReceivedNthCommanddWith(1, StartExecutionCommand, {
  stateMachineArn: arn, input: JSON.stringify(event)
});

モックライブラリを使うと、v2 のときより可読性も良くわかりやすくなっています。

さいごに

ここまで aws-sdk を v2 から v3 にする方法をまとめてみました。 aws-sdk-client-mock 便利ですね。 しかし、どうやって SFNClient をモックしているんだろうか?と思いませんか? sfn.start の実装側では、普通に aws-sdk から import した SFNClient を使って実装していて、 mockClient の結果で得られたモックを使っているわけでもありません。 そのあたりは、次回の記事で明らかにしていきたいと思っていますので、ご期待ください。

AWS Lambda の Node.js 14 を 18 に移行する(CI/CD環境移行編)

クラメソさんの [アップデート] AWS Lambdaが Node.js 18をサポートしました 記事のとおり、やっと AWS Lambda でも Node.js 18 が使えるようになりました。

現在 Node.js 14 を使って AWS Lambda で API サーバーを構築しているのですが、 Node.js 16 の LTS が 9ヶ月終了が早まったこともあり、 このタイミングで Node.js 18 へ移行することにしました。

本稿では CI/CD 環境を Node.js 14 から Node.js 18 へ移行するときに実施した作業を振り返って、まとめておきます。

現在の構成はこんな感じです。

  • Node.js 14 / TypeScript
  • CI/CD に GitHub Actions を利用
  • Amazonlinux ベースの amazon/aws-cli コンテナをベースにパッケージを追加した独自コンテナイメージで CI/CD を実施
  • Serverless Framework でビルド/デプロイ

CI/CD イメージの Node.js を 18 にする

まずはビルド/デプロイをするコンテナイメージを Node.js 18 にしていきます。 実際に Node.js 18 をインストールしようとしたのですが、エラーになってしまいます。

具体的なエラーは /lib64/libc.so.6: version `GLIBC_2.14’ not found. Why am I getting this error? と一緒で、調べていくと Amazonlinux2 ベースである CentOS7 と Node.js 18 は nodeのv18を使ったらエラーになった(CentOS7) などにもあるように単純な話ではないようです。

おや?でも Lambda は Node.js 18 が使えるんですよね?どういうこと?と思いますよね。

Node.js のディストリビューション issue を確認すると distribution package Amazon Linux 2022 not supported なんてのがありました。

で、Amazon Linux 2022 って何なん?!

amazonlinux のコンテナイメージをみると、 latestamazonlinux2 なんですが、タグをみていくと確かに 2022 があります。 あと現時点では aws-cli のイメージは 2022 には追従していません。

そこで、 amazonlinux2022 をベースにして Node.js 18 をインストールするのを試してみます。

しかし、ここでプロビジョニングツールが amazonlinux 2022 に対応していないことがわかります(グルグル循環して脳が溶けてくる… 溶けてやがる、まだ早すぎたんだ….)。違うプロビジョニングツールも調べてみたのですが、どれも 2022 には対応していませんでした。ここでプロビジョニングツールを捨てる選択となりました。

(実際僕らは Chef を使っているんですが、Chef Workstation 自体に amazonlinux 2022 の対応は入っていました(Omnitruck artifact does not exist for version 17 on platform Amazon Linux 2022 )。ただまだリリースパッケージに含まれていないので、いずれリリースされるバージョンでは対応されているでしょう。Ansibleも調べましたが、同様にまだリリースパッケージには含まれていませんでした。)

調べていたら、AmazonLinux3じゃなくってAmazon Linux 2022 (AL2022) だってさ。 という記事が見つかりました。 そんで

AmazonLinux2022以降はメジャーバージョンが2年ごとにリリースされる

まじかー。まぁ今までの AmazonLinux2 が長かったですね。そのぐらいで OS イメージを最新にしていかないとですよね。 ということで、今後も踏まえて CI/CD のフローを含め利用するコンテナイメージを検討しなおすことにしました。 (2年ごとの変更がプロビジョニングツールを捨てる決定的な要因になったのは間違いない)

CI/CD フローの変更

現時点のフローを整理してみました

  1. チェックアウト
  2. ビルド
  3. デプロイが必要な場合
    1. 環境のスイッチロール
    2. デプロイ

だいたいこんな感じです。 これを今までは全部1つのコンテナでやっていたのですが、それぞれ分割していくことにしました。

  1. チェックアウト -> alpine/git
  2. ビルド -> amazon/aws-lambda-nodejs:18 ベースに zip/unzip を追加したもの
  3. デプロイが必要な場合
    1. 環境のスイッチロール -> amazon/aws-cli ベースに jq を追加したもの
    2. デプロイ -> node:18

ビルドに関しては node:18 でも良かったのですが、ここは以前と同じく稼働する OS イメージと合わせています。 今後 OS イメージを追従するというより、実際の Lambda 実行コンテナイメージを使うことで、 Node.js ランタイムにあった OS バージョンを気にせず利用できるメリットがあると考えました(サイズ大きいけど)。

それ以外は必要な部分に最低限のコンテナという感じですね。

さいごに

LTS に合わせて開発環境をアップデートしていくのは、とても大事ですね。 記事にすると、さらっと解決したように見えますが、それぞれのツールのソースコードや issue を確認しながら進んでいたので、かなり時間を取られてしまいました。 ただ、今後同じようにな環境でアップデートしていこうと思う人の助けになればと思います。

Node.js で Lambda ハンドラのテストを書くときに AWS イベントを生成する

AWS Lambda ハンドラをテストしたいとき、多くはリクエストのバリデーションの結果で 400 エラーを返すのか、ロジックが正常終了したとき 200 を戻すのか、みたいなことを書きたいことがあると思います。

TypeScript で Lambda ハンドラを記述する場合は、以下のようになると思います。

import { APIGatewayProxyHandler } from 'aws-lambda';

export const hello: APIGatewayProxyHandler = async (event) => {
  // バリデーションやロジックの呼び出し
}

このとき eventAPIGatewayProxyEvent 型になるのですが、項目がたくさんあります。 自分で定義した interface などの場合、 factory.ts だったり、factory-bot みたいなライブラリを使って書けば良いのですが、 Lambda ハンドラのイベントはとても項目が多いので、 factory 定義を書くのも大変です。

faker-ts の利用

そこで以前は faker-ts というライブラリを使っていて @types/aws-lambda から適当な値を生成していました。

import { tsMock } from 'faker-ts';

const mocker = tsMock(['/node_modules/@types/aws-lambda/index.d.ts']);
const event = mocker.generateMock('APIGatewayProxyEvent');

しばらくこの方法で問題はなかったのですが、 Node.js v16 と jest の組み合わせになり、メモリ不足エラーが出るようになってしまったので、見直しが必要になりました。

faker-ts の仕組み

faker-ts は以下の2つのステップから構成されていました。

これを別々に実行して試していくプランとしました。

typescript-json-schema の利用

まずはJSONスキーマの生成から。 このNPMパッケージには CLI ツールも付いているので、npm script でスキーマファイルを生成できるようにしました。

"scripts": {
  "tjs": "typescript-json-schema --required"
}

のように package.json に記述したら、コマンドを実行して JSONスキーマファイルを生成します。

$ npm run tjs -- -o test/schema/APIGatewayProxyEvent.json node_modules/@types/aws-lambda/index.d.ts APIGatewayProxyEvent

このようにして、必要な型のJSONスキーマを test/schema の下に生成していきます。

json-schema-faker の利用

テストコードでは、以下のようにすることでJSONオブジェクトを生成します。

import { JSONSchemaFaker } from 'json-schema-faker';
import { faker } from '@faker-js/faker/locale/ja';

JSONSchemaFaker.option('useExamplesValue', true);
JSONSchemaFaker.option('useDefaultValue', true);
JSONSchemaFaker.option('faker', () => faker);

const event = <APIGatewayProxyEvent>JSONSchemaFaker.generate(require('test/schema/APIGatewayProxyEvent.json'));
event.body = /** テストで使う入力値 */

さいごに

これで TypeScript Interface から型定義に従った Fake オブジェクトが作れるようになりました。 faker-ts はあまりメンテされていなかったので、コードを fork してライブラリを最新追従したりして使っていたのですが、その手間も不要となりました。

2つに処理を分割してわかったのは、 typescript-json-schema が結構時間がかかっていたので、これを CLI にすることでメモリの利用状況も少なくなりました。 そのため、毎回JSONスキーマを生成しなくなった分、テストも速くなり良いことが多かったです。 この方法だと自分で作った TypeScript の型などでも一度 JSONスキーマを生成しておけば、複数の factory ライブラリを使わなくても良くなるので、良いかもしれません。(ただ現時点では factory-bot とかの方が使い勝手が良いとは思います。たとえば buildList みたいなことができないので)

Node.js で AWS SQS を使ったコードの自動テストを記述する

前回のNode.js で BigQuery を使ったコードの自動テストを記述すると同じように外部サービスを使った部分の自動テストについてです。 今回は AWS SQS を使ったコードの自動テストを書く場合です。

BigQuery と違い、 AWS SQS のエミュレータは検索するとすぐに見つかりました。 たとえば roribio16/alpine-sqs というもの。 こちらは日本語の使ってみた記事もいくつか出ています。

roribio16/alpine-sqs

さっそく上記の記事や、公式ドキュメントを参考に動かしてみます。

Dockerコンテナをあげて aws cli から実行してみると、簡単に利用することができたので、 Node.js + Jest のテストコードを書いて試してみます。 すると ECONNRESET エラーとなって動きません。おやぁ?

GitHub のリポジトリを見てみると、関連する issue が出てました。 どうも Apple Silicon (いわゆるM1とかM2とか)からだとエラーになるようです。 解決するための Pull Request も出てるんですが、マージされる気配がないようです。 現在、開発用の端末は M1 MacBook Pro を使っているので、困ってしまいました。

alpine-sqs の Dockerfile を見ると elasticmq-server を使っているようなので、それを調べてみます。

elasticmq-server

ElasticMQ の GitHub を見てみると、普通に

Amazon SQS-compatible interface

って書いてありました。 これだけで良いのでは?という感じです。 (alpine-sqs の利点は何だったんだろう?)

で、それっぽいキーワードで検索すると、記事もありました。

調べ方の問題ですかね?キーワードの指定の方法によっては、こっちの方がたくさん出てきました。

さてこちらも Docker コンテナがあるので、それを使って起動します。

$ docker run -d -rm -p 9324:9324 -p 9325:9325 softwaremill/elasticmq-native

起動時にキューを作っておくには config ファイルを記述してファイルをマウントします。

$ docker run -d -rm -p 9324:9324 -p 9325:9325 -v `pwd`/custom.conf:/opt/elasticmq.conf softwaremill/elasticmq-native

config の書き方は、 GitHub に例が書いてあるので、そちらを参考にしてください。

Node.js からの接続

まず aws-sdk のクレデンシャルに適当な値を設定しておきます。 あとは SQS クラスのインスタンスを生成するだけです。

import * as AWS from 'aws-sdk';
import { SQS } from 'aws-sdk';

AWS.config.credentials = {
  secretAccessKey: 'secretAccessKey',
  accessKeyId: 'accessKeyId',
  sessionToken: 'sessionToken',
};

let sqs: SQS;

beforeAll(() => {
  sqs = new SQS({
    region: 'ap-northeast-1',
    endpoint: 'http://localhost:9324',
  })
});

キューのURLはデフォルト設定だと http://localhost:9324/000000000000/キュー名 のようになります。 このあたりは aws-cli などでキュー一覧を取得してもわかるので、そこから情報を取得しても大丈夫でしょう。

さいごに

はい。 これで AWS SQS を使ったコードの UnitTest も書けるようになりますね。 どんどん UnitTest を充実させていきましょう。

Node.js で BigQuery を使ったコードの自動テストを記述する

BigQuery へクエリするコードを書くとき、どうしていますか? ORM を使って RDB を使うコードを書いている場合などは、 SQLite などを使って UnitTest を書いていることもあるでしょう。 BigQuery についても、何かそういったことができないかな?と思い、調べていました。

BigQuery Emulator

そんなとき、ちょうど BigQuery Emulator の存在を知り、試してみることにしました。

作者の goccy さんのスライドです。

GitHub の README によると Docker コンテナでも動かせるようですので、私たちはそれを利用することにしました。

$ docker pull ghcr.io/goccy/bigquery-emulator:latest

コードのサンプルとしては Python と Golang での書き方は提供されていたので、 Node.js でも何とかなるんじゃない?… と思いやり始めたのです。

Node.js – BigQuery Emulator

Node.js で BigQuery を利用するには、公式のクライアントをインストールします。

$ npm i @google-cloud/bigquery

接続の方法は Python のクライアントを見る限り、

  • エンドポイント
  • プロジェクトID
  • 匿名接続(AnonymousCredentials)

を指定すれば良さそうです。

client_options = ClientOptions(api_endpoint="http://0.0.0.0:9050")
client = bigquery.Client(
  "test",
  client_options=client_options,
  credentials=AnonymousCredentials(),
)

Node.js だとこんな感じでしょうか?

new BigQuery({
  projectId: 'test',
  apiEndpoint: 'http://0.0.0.0:9050',
  credentials: /* 何を指定すれば良いんだ? */
});

ここで、 credentials の指定に AnonymousCredentials みたいなものが無いことに気がつきます。 JavaScript 以外のクライアント、上記の Python や Go には、匿名接続のオプションがあるようです。

現時点の credentials に指定できるのは、公式ドキュメントによると

credentials?: CredentialBody | ExternalAccountClientOptions;

だけです。 ExternalAccountClientOptions は外部のアカウント連携を使った認証をする場合のオプションになっています。

@google-cloud/bigquery のコードを調べる

クライアントの言語違いで接続が変わるわけではないので、 BigQuery のサーバー側は匿名接続できるようになっているが、クライアント側の実装がサポートしていない、ということは想定できるでしょう。 こういうときは、クライアントのソースコードを調べるしかありません。 正面突破は無理でも、何かハックできる方法があるかもしれません。

BigQuery クラス

GitHubのリポジトリGoogle BigQuery: Node.js Clientです。 まず、 BigQuery クラスのコンストラクタを調べます。

このあたりで、オプションを作り直して親クラスのコンストラクタを呼び出すみたいです。

    const config = {
      apiEndpoint: options.apiEndpoint!,
      baseUrl,
      scopes: ['https://www.googleapis.com/auth/bigquery'],
      packageJson: require('../../package.json'),
      autoRetry: options.autoRetry,
      maxRetries: options.maxRetries,
    };

    if (options.scopes) {
      config.scopes = config.scopes.concat(options.scopes);
    }

    super(config, options);

credentialsoptions の中に入ったままなので、 BigQuery 特有のオプションを config に置き換えて親クラスである @google-cloud/common/Service を呼び出す感じでしょうか。

Service クラス

Service クラスは @google-cloud/common という共有パッケージにあるので、そこのリポジトリを調べます。 Serviceクラスのコードです。

このあたりで、リクエストコンフィグを作って、ユーティリティクラスのクレデンシャルファクトリを呼び出すみたいです。

    const reqCfg = extend({}, config, {
      projectIdRequired: this.projectIdRequired,
      projectId: this.projectId,
      authClient: options.authClient,
      credentials: options.credentials,
      keyFile: options.keyFilename,
      email: options.email,
      token: options.token,
    });

    this.makeAuthenticatedRequest =
      util.makeAuthenticatedRequestFactory(reqCfg);
    this.authClient = this.makeAuthenticatedRequest.authClient;
    this.getCredentials = this.makeAuthenticatedRequest.getCredentials;

なるほど、なるほど。

makeAuthenticatedRequestFactory

続いて、 makeAuthenticatedRequestFactory を見てみましょう。

コードには関数定義がいろいろあって、最終的には makeAuthenticatedRequest という関数をファクトリメソッドは戻すようです。 ふむふむ。

    const mar = makeAuthenticatedRequest as MakeAuthenticatedRequest;
    mar.getCredentials = authClient.getCredentials.bind(authClient);
    mar.authClient = authClient;
    return mar;

つまり BigQuery クラスのインスタンスを生成すると、認証クライアントまでは生成するけど、接続などにはいかないことがわかります。 続いてファクトリメソッドの引数を調べてみましょう。

興味深いオプションが2つありますね。

認証エンドポイントをカスタマイズできるようです。 では、ファクトリでは、このオプションをどうやって使っているのか見てみましょう。

ここにありました。

          const authorizeRequest = async () => {
            if (
              reqConfig.customEndpoint &&
              !reqConfig.useAuthWithCustomEndpoint
            ) {
              // Using a custom API override. Do not use `google-auth-library` for
              // authentication. (ex: connecting to a local Datastore server)
              return reqOpts;
            } else {
              return authClient.authorizeRequest(reqOpts);
            }
          };

コメントを読んでください。

ex: connecting to a local Datastore server

「例えば、ローカルのサーバーとかに接続するときな」って、今回の用途じゃないですか!

どうやったら指定できるのか?

さて、ここまでの調査を振り返りましょう。

  • BigQuery クラスで Service クラスのコンストラクタを呼び出す
  • Service クラスでリクエストコンフィグを作って、ユーティリティクラスのクレデンシャルファクトリを呼び出す

BigQuery クラスからのオプションでは customEndpoint を指定する方法がない!という結論。

ちょっと待てよ?

サービスクラスで生成したファクトリどこに入れてたっけ?

    this.makeAuthenticatedRequest =
      util.makeAuthenticatedRequestFactory(reqCfg);

this (=== BigQuery クラスのインスタンス) ですね。

サービスクラスの定義を見てみましょう。

  makeAuthenticatedRequest: MakeAuthenticatedRequest;

private じゃないってことは、オーバーライドできるじゃないですか。

ハックする

import { BigQuery } from '@google-cloud/bigquery';
import { util } from '@google-cloud/common';

let bigQuery: BigQuery;

beforeAll(() => {
  const options = {
    projectId: 'test',
    apiEndpoint: 'http://0.0.0.0:9050',
    baseUrl: 'http://0.0.0.0:9050',
    scopes: ['https://www.googleapis.com/auth/bigquery'],
    packageJson: require('@google-cloud/bigquery/package.json'),
    customEndpoint: true,
  };
  bigQuery = new BigQuery(options);
  bigQuery.makeAuthenticatedRequest = util.makeAuthenticatedRequestFactory(options);
});

options の基本項目は、 BigQuery クラスのコンストラクタで指定されていた項目をそのまま流用しています。 それに customEndpoint を指定して makeAuthenticatedRequestFactory を呼び出しインスタンス変数の値を上書きします。

うまくいきましたよね!

これで Node.js の UnitTest から BigQuery Emulator を使ってクエリのテストコードを書くことができるようになりました。 BigQuery Emulator があって良かった!