Technote

by sizuhiko

Node.js v18 / aws-sdk v3 の Lambda アプリが突然動かなくなる

「Lambda アプリが突然動かなくなる」なんて、どうせバグなんでしょーというのが当然のリアクションですが、これは本当にバグなのか…

本当にあった怖い話をします。

ある日、Lambda アプリが突然動かなくなる

Node.js v18 のライタイムで動く Lambda にデプロイされているアプリで、ある日突然エラーが出るようになりました。

original: TypeError: Cannot read properties of undefined (reading ‘RUNNING’)

この部分ですが TypeScript のコードでは以下のようになっていました。

import {
  DescribeExecutionCommand,
  ExecutionDoesNotExist,
  ExecutionStatus,
  SFNClient,
  StartExecutionCommand,
  paginateListExecutions,
} from '@aws-sdk/client-sfn';

// 省略

if (latest?.status === ExecutionStatus.RUNNING) {

ExecutionStatus は StepFunctions のクライアントで export されている値です。 StepFuntions のジョブが実行中かどうか判定している箇所で。少し前まで普通に動いていたのに….

Lambda アプリの構成

AWS Lambda は aws-sdk がランタイムにグローバルインストールされているので、最新版に追従して問題なければパッケージに含める必要はありません。 Lambda にデプロイするアプリケーションのサイズを少しでも減らすために、 aws-sdk は入れないようにしています。

node コンソールで確認してみる

手元では devDependencies に @aws-sdk/client-sfn が入っているので、 node コンソールに入って require を実行してみました。

> const { ExecutionStatus} = require('@aws-sdk/client-sfn')
undefined
> ExecutionStatus
{
  ABORTED: 'ABORTED',
  FAILED: 'FAILED',
  RUNNING: 'RUNNING',
  SUCCEEDED: 'SUCCEEDED',
  TIMED_OUT: 'TIMED_OUT'
}

ふむ、問題なさそうだが?

最新版の aws-sdk で変わったのだろうか? 一時的に最新版にバージョンアップをしてみて、確認してみます

> const { ExecutionStatus} = require('@aws-sdk/client-sfn')
undefined
> ExecutionStatus
undefined

えええええええーーー

aws-sdk の該当箇所の修正履歴をチェックする

hore(codegen): export enums as const

元のコード

/**
 * @public
 */
export enum ExecutionStatus {
  ABORTED = "ABORTED",
  FAILED = "FAILED",
  RUNNING = "RUNNING",
  SUCCEEDED = "SUCCEEDED",
  TIMED_OUT = "TIMED_OUT",
}

変更後のコード

/**
 * @public
 * @enum
 */
export const ExecutionStatus = {
  ABORTED: "ABORTED",
  FAILED: "FAILED",
  RUNNING: "RUNNING",
  SUCCEEDED: "SUCCEEDED",
  TIMED_OUT: "TIMED_OUT",
} as const;

おいおい、これって @public なのに後方互換性なくなっとるだろ….

なぜ undefined になるのか

enum はコンパイルされて JavaScript になると以下のようなコードになります。

var ExecutionStatus;
(function (ExecutionStatus) {
    ExecutionStatus["ABORTED"] = "ABORTED";
    ExecutionStatus["FAILED"] = "FAILED";
    ExecutionStatus["RUNNING"] = "RUNNING";
    ExecutionStatus["SUCCEEDED"] = "SUCCEEDED";
    ExecutionStatus["TIMED_OUT"] = "TIMED_OUT";
})(ExecutionStatus = exports.ExecutionStatus || (exports.ExecutionStatus = {}));

でも export const as const になると、JavaScript にはコンパイルされることなく、変数の利用箇所に埋め込みの形になります。

最初にも書きましたが、 aws-sdk v2 では普通だった

AWS Lambda は aws-sdk がランタイムにグローバルインストールされているので、最新版に追従して問題なければパッケージに含める必要はありません。 Lambda にデプロイするアプリケーションのサイズを少しでも減らすために、 aws-sdk は入れないようにしています。

こういう運用はすでにオワコンなんでしょうか? いやでも、v3 になってもランタイムに aws-sdk は入っています。

少なくとも ExecutionStatus が内部変数で export されてない private な値だったら良いのですが、これはさすがにマズイんじゃないですかね?awsさん。

この問題が発生するケース

  • AWS Lambda でランタイムの aws-sdk を使っていて、TypeScript から enum の値を参照していた場合(同一の変更ですべての enum が const に書き変わっています)

amplify とか Lambda でなくコンパイルされているケースでは問題は起きませんが、いやこういうのサクッと変更しちゃダメだと思うんですが… しかもランタイム側に入っているので、動作している Lambda アプリケーションが突然動作しなくなります。

aws-sdk v3 を安心して使うためには(追記)

  • 基本的にバージョンは最新版、もしくは最新に近い同じバージョンに揃える
  • peerDependencies が指定されていたら、バージョンを揃えるために自分でインストールする
  • 関数/クラス以外、たとえば enum の値などは基本的に利用しない

大事なこと3つ目を追加しました。

aws-sdk@v3 のパッケージ管理に日々不安が募りますが、同様の問題に遭遇した人の解決に役立てれば幸いです。

aws-sdk v3 でコンパイルエラーになる - その2

先日の aws-sdk v3 で TS2345 が出てコンパイルエラーになる という記事でも書いたとおり、 @aws-sdk/client-xxxxx を追加するときは

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

のがオススメなのは変わりがないのですが、これだけだとうまく対応できないことがあったので、別記事として書いておきます。

ある日の変更前依存パッケージ

  "devDependencies": {
    "@aws-sdk/client-dynamodb": "^3.264.0",
    "@aws-sdk/client-s3": "^3.264.0",
    "@aws-sdk/client-s3-control": "^3.264.0",
    "@aws-sdk/client-secrets-manager": "^3.264.0",
    "@aws-sdk/lib-dynamodb": "^3.264.0",
  }

DynamoDB, S3, Secret Manager を利用するようになっていました。

パッケージを追加したらエラーになる

で、そこに @aws-sdk/lib-storage というパッケージを追加しようとしたところ、また先日と同様に TS2345 でコンパイルエラーになります。 ちゃんとバージョンをすべて最新にしてみたのですが、ダメでした。

ふたたび issue を探る

そうすると、issue ではなく discussions に変更されてしまったものを発見しました。

Peer dependencies not pinned for lib-dynamodb

なぜこんなことになるのか

やはりパッケージ管理が崩壊しているとしか思えないのですが、何でこうなっているのかというのを解説していきます。

以下は、今回追加しようとした @aws-sdk/lib-storagepackage.json の一部です。 追加時点の lib-storage のバージョンが 3.272.0 だったので、上記の変更前依存パッケージでインストール済みだったものも 3.272.0 に更新済みです。

  "dependencies": {
    "@aws-sdk/middleware-endpoint": "3.272.0",
    "@aws-sdk/smithy-client": "3.272.0",
    "buffer": "5.6.0",
    "events": "3.3.0",
    "stream-browserify": "3.0.0",
    "tslib": "^2.3.1"
  },
  "peerDependencies": {
    "@aws-sdk/abort-controller": "^3.0.0",
    "@aws-sdk/client-s3": "^3.0.0"
  },

peerDependencies の依存がありますね。

で、すでにインストール済みだった @aws-sdk/client-dymanodbpackage.json の一部も見てみましょう。

  "dependencies": {
    "@aws-crypto/sha256-browser": "3.0.0",
    // 省略
    "@aws-sdk/smithy-client": "3.272.0",
    "@aws-sdk/types": "3.272.0",
    // 省略
    "uuid": "^8.3.2"
  },

依存が多いのでちょっと省略しましたが、注目したいところだけ書きました。 こちらには peerDependencies の依存はありません。

@aws-sdk/client-s3 は上記のとおりインストール済みだったので、 @aws-sdk/abort-controller を見てみましょう。

  "dependencies": {
    "@aws-sdk/types": "3.289.0",
    "tslib": "^2.3.1"
  },

おや? @aws-sdk/types のバージョンが違うじゃん…

“@aws-sdk/abort-controller”: “^3.0.0”,

なぜ、ここは ^3.0.0 なのだ?? というのが、最初のディスカッションでもふれられている訳ですが、本当にパッケージ管理が (ry

対応案

使っている aws-sdk のパッケージで peerDependencies になっているのを調べて、すべてバージョン指定でインストールします。

ということで変更後の依存は以下のとおりにします。

    "@aws-sdk/abort-controller": "3.272.0",
    "@aws-sdk/client-dynamodb": "3.272.0",
    "@aws-sdk/client-s3": "3.272.0",
    "@aws-sdk/client-s3-control": "3.272.0",
    "@aws-sdk/client-secrets-manager": "3.272.0",
    "@aws-sdk/lib-dynamodb": "3.272.0",
    "@aws-sdk/lib-storage": "3.272.0",
    "@aws-sdk/smithy-client": "3.272.0",
    "@aws-sdk/types": "3.272.0",

abort-controller, smithy-client, types が利用しているパッケージの中で peerDependencies にあったので、すべてバージョンを揃えてコンパイルエラーは解消されました。

aws-sdk v3 を安心して使うためには

  • 基本的にバージョンは最新版、もしくは最新に近い同じバージョンに揃える
  • peerDependencies が指定されていたら、バージョンを揃えるために自分でインストールする

この2つとても大事です。

こんなの discussions に変更した上で放置していて良いのか?…

とても悲しい状況ですが、同様の問題に遭遇した人の解決に役立てれば幸いです。

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 の結果で得られたモックを使っているわけでもありません。 そのあたりは、次回の記事で明らかにしていきたいと思っていますので、ご期待ください。