Technote

by sizuhiko

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 があって良かった!

@swc-node/jest を使ってテストを高速化する(v1.5対応版)

少し前のブログで @swc-node/jest を使ってテストを高速化する という記事を書いたのですが、最後のまとめで以下のように紹介していました。

ちなみに 1.5系からは tsconfig を読み込むように変更されているのですが、現時点私たちのプロジェクトではビルドが失敗するので、まだ 1.4 系を利用しています。 問題はすでに issue TypeScript path mapping is not working. になっており、 パスエイリアスを使っているときに、うまくファイルが import できないところなのですが、これが解決されれば transformer の設定も不要になるので、 とても便利になるはずです。

そこから他の issue を調べたりしているうちに解決策がわかったので、今回の記事で追記していきます。

@swc-node/jest の最新版(現時点では 1.5.2 )を使います。

パスエイリアスの指定方法

1.5系からは tsconfig を読み込むので、 jest.config.jstransform は以下のように記述すれば良いことになっています。

/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
  moduleFileExtensions: ['ts', 'js'],
  transform: {
    '^.+\\.ts$': ['@swc-node/jest']
  },
  testMatch: ['**/*.test.ts'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = config;

パスエイリアスは moduleNameMapper に記述されているとおり、テストコードからは @/domain/entities/user-entity みたいに @ をルートにしてパスエイリアスを使えるようにしています。

で、このままだとパスエイリアスがすべて解決されません、というのが前回までの内容でした。

今回は調べるなかで、以下のように指定することでパスエイリアスがうまく利用できるようになったので、紹介します。

/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
  moduleFileExtensions: ['ts', 'js'],
  transform: {
    '^.+\\.ts$': [
      '@swc-node/jest',
      // ここから
      {
        paths: {
          '@/*': [`${__dirname}/*`],
        }
      }
      // ここまで
    ]
  },
  testMatch: ['**/*.test.ts'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = config;

transformpaths の指定をするとパスエイリアスが使えるようになります。 tsconfig にも paths の指定が以下のようになっています

  "paths": {
    "@/*": ["./*"]
  },

で、tsconfig に記述しているとおりコードが書き変わると、そのまま相対パスになってしまい、 インポートパスが見つからないということになっていました。 TypeScript は tsconfig でパスエイリアスを指定するとき ./* のように書くとプロジェクトルートからの解釈をしてくれるのですが、 swc ではそのように解釈はしてくれません。 そのため jest.config.jstransform で絶対パスに上書きするように記述します。 jest.config.js はJavaScriptコードなので、 __dirname を使って絶対パスに変更することができるようになります。

これで安心して @swc-node/jest も最新版に追従できるようになりました。 もし同じような問題になっている人の手助けになる記事になっていれば幸いです。