Technote

by sizuhiko

aws-sdk v3 で TS2345 が出てコンパイルエラーになる

dependabot で @aws-sdk/client のバージョンアップがコンパイルエラーになる

少し久しぶりの記事になってしまいましたが、この間も Node.js v18 / aws-sdk v3 への移行を行なっています。 一部のリポジトリではコードの移行は終わって、 dependabot でライブラリの最新追従を行なっているのですが、以下のようなコンパイルエラーが出るようになりました。

error TS2345: Argument of type 'typeof LambdaClient' is not assignable to parameter of type 'InstanceOrClassType<Client<ServiceInputTypes, MetadataBearer, any>>'.
  Type 'typeof LambdaClient' is not assignable to type 'ClassType<Client<ServiceInputTypes, MetadataBearer, any>>'.

aws-sdk v3 では複数の aws サービスを使っていると複数のパッケージに依存するようになるのですが、複数のバージョンアップが dependabot によって PR されるうち、一部のアプデだけ上記のようなエラーになります。

issue を探してみる

aws-sdk の issue を調べていくと、それっぽいものがありました。

Typescript compilation problems since 3.52.0 in lib-dynamodb

この issue 自体はクローズされていないのですが、コメントのスレッドの中に有用な情報がありました。

  • @aws-sdk/types が依存に入っていて、異なるバージョンの client-xxx があるとエラーになる(なんで package-lock.json を消してから npm i しなおすとうまくいくよ -> そんなんやるか!)
  • @aws-sdk/client-xxxx の各クライアントはすべて最新バージョンに追従してください

のようなものです(要約してあります)。

aws-sdk@v3 はサービスごとにパッケージを分割することで v2 と比べて良い、というのがウリなはずなんですが、これではパッケージ管理崩壊しているのでは?と思わなくもないですが、まぁ各クライアントのバージョンを合わせれば良いだけなので、従うことにしました。 ちなみにこの時点では複数の @aws-sdk/client-xxx を全て最新版に追従することで解消されました。

この問題が発生する状況

この問題が発生する状況は以下のとおりです。

  • すでに1つ以上の @aws-sdk/client-xxxx を入れていて、後日機能追加によって別のクライアントをインストールするとき
  • dependabot でパッケージごとにバージョンアップの PR が発動する

おすすめの対応

なので、現時点でのオススメの対応は以下のとおりです。

  • 新しく @aws-sdk/client-xxxx を追加するときは、既存のクライアントも含めてすべて同じバージョンに変更する
  • dependabot による更新を @aws-sdk/* については無効にする

Github Actions の dependabot でアプデを無効にするには、 dependabot.yml を以下のように記述すれば良いです。

version: 2
updates:
  - package-ecosystem: 'npm'
    directory: '/'
    ignore:
      - dependency-name: '@aws-sdk/*'
        # aws-sdk に対するすべての更新を無視

私たちは AWS Lambda の Node.js v18 ランタイムを使っているので、aws-sdk はランタイムにグローバルインストールされているから、あえて最新版に追従しなくても問題ないというのが主な理由です。 新しいクライアント追加のときに最新に追従すれば十分という判断です。

さいごに

aws-sdk@v3 を使ったアプリのナレッジが少ないので、何か小さなことでも記事にしていこうと思います。 同様の問題に遭遇した人の解決に役立てれば幸いです。

aws-sdk-client-mock はどのように aws-sdk をモックしているのか?

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

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

書いたとおり、どうやってモックしているのか、紹介していきます。

前回のおさらい

aws-sdk 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)
});

モックするときは mockClient(SFNClient) とクラスを渡しているだけでしたね。 生成した SFNClientMock はテストクラスでしか利用していません。

aws-sdk-client-mock の実装を見てみる

mockClientのコードを見てみましょう。

export const mockClient = <TInput extends object, TOutput extends MetadataBearer>(
    client: InstanceOrClassType<Client<TInput, TOutput, any>>,
): AwsClientStub<Client<TInput, TOutput, any>> => {
    const instance = isClientInstance(client) ? client : client.prototype;

ふむふむ mockClient の引数がインスタンスかどうか調べて、クラスだったら prototype が利用されるわけですか。 さらに見てみると

const sendStub = stub(instance, 'send') as SinonStub<[Command<TInput, any, TOutput, any, any>], Promise<TOutput>>;

なるほど send メソッドをスタブしてますね。 あー、つまりクラスを指定すると、 prototype 定義が置き換わっちゃうわけですね。

使い分けが必要

prototype が置き換わることで mockClient に渡したクラスの send はすべてのシーンでスタブに置き換わります。 テストしたい実装側でクライアントのインスタンスをDIなどで注入していない場合は、この方法で良いですね。 逆にシーンによっては、実際に send を実行したい場合は困ってしまいます。

localstack などを使って部分的にインテグレーションテストをしたい場合は、なるべくインスタンスをDIできる仕組みにした方が良いでしょう。 インスタンスを生成するメソッドを作って、それをモックして mockClient の戻り値を返すようにしても良いですね。

とくにイングレーションテストとかなく、すべてのクライアント呼び出しをモックするのであれば、クラス指定が楽ですね。

このように使い分けることで、テストコードを書いていきたいですね。 簡単ではありますが、aws-sdk-client-mock はどのように aws-sdk をモックしているのか? を紹介しました。

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 みたいなことができないので)