CLOVER🍀

That was when it all began.

LocalStackでAmazon SQSのFIFOキューを試してみる(AWS SDK for Javaを使用)

これは、なにをしたくて書いたもの?

Amazon SQSをちょっと試しておきたいなと思ったのですが、動かす時にいろいろ考えた結果FIFOキューを試してみることにしました。

Amazon SQS自体は本物ではなく、LocalStackのものを使用します。

Amazon SQS

Amazon SQS自体はこちら。

Amazon SQS(サーバーレスアプリのためのメッセージキューサービス)| AWS

「What is Amazon Simple Queue Service? - Amazon Simple Queue Service

AWSが提供する、分散キューのようです。

設定や仕組みなど、ドキュメントにいろいろ書かれています。

Amazon SQS キューの設定 (コンソール) - Amazon Simple Queue Service

Amazon SQS の仕組み - Amazon Simple Queue Service

キューは標準キューとFIFOキューの2種類があるようです。

Amazon SQS 標準キュー - Amazon Simple Queue Service

Amazon SQS FIFO (先入れ先出し) キュー - Amazon Simple Queue Service

標準キューとFIFOキューの特徴は、こんな感じのようです。

  • 標準キュー
    • 1秒あたりほぼ無制限のAPIコールをサポート
    • 少なくとも1回のメッセージ配信をサポート
    • メッセージの複数のコピーが順不同で配信されることがある
  • FIFOキュー
    • 標準キューの機能をすべて持つ
    • メッセージの配信はFIFOで行われ、順序が厳密に維持される
    • スループットに制限がある
    • キューの名前は.fifoで終わる必要がある

今回は、FIFOキューを使ってみたいと思います。

TerraformでAmazon SQSのFIFOキューをLocalStack上に作成し、AWS SDK for Java v2を使ってアクセスすることをテーマにしてみたいと
思います。

FIFOキューは、こちらも参考に。

【新機能】Amazon SQSにFIFOが追加されました!(重複削除/単一実行/順序取得に対応) | DevelopersIO

環境

今回の環境は、こちら。

LocalStack。

$ localstack --version
1.0.3

起動。

$ localstack start

Terraform。

$ terraform version
Terraform v1.2.6
on linux_amd64

Java

$ java --version
openjdk 17.0.3 2022-04-19
OpenJDK Runtime Environment (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1)
OpenJDK 64-Bit Server VM (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.3, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-122-generic", arch: "amd64", family: "unix"

Amazon SQSのFIFOキューを作成する

では、まずAmazon SQSのFIFOキューを作成しましょう。

作成したTerraformの構成ファイルは、こちら。

main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "4.24.0"
    }
  }
}

provider "aws" {
  access_key                  = "mock_access_key"
  region                      = "us-east-1"
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    sqs = "http://localhost:4566"
  }
}

resource "aws_sqs_queue" "queue" {
  name                        = "my-queue.fifo"
  fifo_queue                  = true
  content_based_deduplication = true
}

output "queue_url" {
  value = aws_sqs_queue.queue.url
}

TerraformでLocalStackに接続する設定は、こちらからAmazon SQSの部分のみ引用しています。

Custom Service Endpoint Configuration / Connecting to Local AWS Compatible Solutions / LocalStack

provider "aws" {
  access_key                  = "mock_access_key"
  region                      = "us-east-1"
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    sqs = "http://localhost:4566"
  }
}

FIFOキューの定義は、こちらを使いました。

Resource: aws_sqs_queue / FIFO queue

content_based_deduplicationというのは、重複メッセージを防止できる設定のようです。

resource "aws_sqs_queue" "queue" {
  name                        = "my-queue.fifo"
  fifo_queue                  = true
  content_based_deduplication = true
}

こちらですね。

1 回だけの処理 - Amazon Simple Queue Service

5分以内は、重複する同じメッセージを排除するようですね。

5 分間の重複排除間隔内に SendMessage アクションを再試行しても、Amazon SQS ではキューに重複を導入しません。

重複の排除は、以下のどちらかで行うようです。Terraformで有効にしているのは、前者ですね。

  • コンテンツベースの重複排除を有効にする
    • メッセージ本文を使用して (ただしメッセージの属性ではない) SHA-256 ハッシュでメッセージ重複排除 ID を生成するように指示
  • メッセージに明示的にメッセージ重複排除 ID を指定する

とりあえず、準備はできたのでTerraformでFIFOキューを作成します。

$ terraform init
$ terraform apply -auto-approve

LocalStackで作成するにしてはやや時間がかかりますが(20秒以上)、FIFOキューが作成されました。

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

queue_url = "http://localhost:4566/000000000000/my-queue.fifo"

こちらを使って、プログラムを作成していきます。

Amazon SQSのFIFOキューを使ったプログラムを作成する

次は、作成したAmazon SQSのFIFOキューを使ったプログラムを書いていきます。

まずは、Maven依存関係やプラグインの設定。

    <dependencies>
        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>sqs</artifactId>
            <version>2.17.232</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.36</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.23.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

メッセージを受信するクラスを書き、メッセージの送信とその確認はテストコードで表現する、という形にしたいと思います。

メッセージを受信するプログラムは、こちら。

src/main/java/org/littlewings/aws/sqs/QueueSubscriber.java

package org.littlewings.aws.sqs;

import java.util.List;
import java.util.UUID;

import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest;
import software.amazon.awssdk.services.sqs.model.DeleteMessageResponse;
import software.amazon.awssdk.services.sqs.model.Message;
import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest;

public class QueueSubscriber {
    SqsClient sqsClient;
    String queueUrl;

    public QueueSubscriber(SqsClient sqsClient, String queueUrl) {
        this.sqsClient = sqsClient;
        this.queueUrl = queueUrl;
    }

    public List<String> subscribe() {
        ReceiveMessageRequest receiveMessageRequest =
                ReceiveMessageRequest
                        .builder()
                        .queueUrl(queueUrl)
                        .receiveRequestAttemptId(UUID.randomUUID().toString())
                        .maxNumberOfMessages(5)
                        .waitTimeSeconds(20)
                        .build();

        // receive message
        List<Message> receivedMessage = sqsClient.receiveMessage(receiveMessageRequest).messages();

        // process message
        List<String> messages = receivedMessage.stream().map(m -> m.body()).toList();

        // delete message
        receivedMessage.forEach(m -> {
            DeleteMessageRequest deleteMessageRequest =
                    DeleteMessageRequest
                            .builder()
                            .queueUrl(queueUrl)
                            .receiptHandle(m.receiptHandle())
                            .build();
            DeleteMessageResponse deleteMessageResponse = sqsClient.deleteMessage(deleteMessageRequest);

            if (!deleteMessageResponse.sdkHttpResponse().isSuccessful()) {
                throw new RuntimeException(
                        "Error: " + deleteMessageResponse.sdkHttpResponse().statusCode() + ", " + deleteMessageResponse.sdkHttpResponse().statusText()
                );
            }
        });

        return messages;
    }
}

このあたりを参考にしています。

Amazon SQSのクライアントと、キューのURLはコンストラクタで受け取るものとしておきます。

    public QueueSubscriber(SqsClient sqsClient, String queueUrl) {
        this.sqsClient = sqsClient;
        this.queueUrl = queueUrl;
    }

メッセージの受信。

        ReceiveMessageRequest receiveMessageRequest =
                ReceiveMessageRequest
                        .builder()
                        .queueUrl(queueUrl)
                        .receiveRequestAttemptId(UUID.randomUUID().toString())
                        .maxNumberOfMessages(5)
                        .waitTimeSeconds(20)
                        .build();

        // receive message
        List<Message> receivedMessage = sqsClient.receiveMessage(receiveMessageRequest).messages();

重複排除用にreceiveRequestAttemptIdを設定し、

Amazon SQS 受信リクエスト試行 ID の使用 - Amazon Simple Queue Service

waitTimeSecondsを20秒にしてロングポーリングとしています。

Amazon SQS ショートポーリングとロングポーリング / ロングポーリングを使用したメッセージの消費

受信したメッセージからボディを取り出したら

        // process message
        List<String> messages = receivedMessage.stream().map(m -> m.body()).toList();

これをメソッドの戻り値とします。

        return messages;

この前に、メッセージをキューから削除しておきます。

        // delete message
        receivedMessage.forEach(m -> {
            DeleteMessageRequest deleteMessageRequest =
                    DeleteMessageRequest
                            .builder()
                            .queueUrl(queueUrl)
                            .receiptHandle(m.receiptHandle())
                            .build();
            DeleteMessageResponse deleteMessageResponse = sqsClient.deleteMessage(deleteMessageRequest);

            if (!deleteMessageResponse.sdkHttpResponse().isSuccessful()) {
                throw new RuntimeException(
                        "Error: " + deleteMessageResponse.sdkHttpResponse().statusCode() + ", " + deleteMessageResponse.sdkHttpResponse().statusText()
                );
            }
        });

こちらを参考にしています。

Amazon Simple Queue Service メッセージの送信、受信、削除 / 受信後にメッセージを削除する

Amazon SQSでは、メッセージを受信してもキューから消えるわけではなく、明示的に削除する必要があるようです。

Amazon SQS 可視性タイムアウト - Amazon Simple Queue Service

メッセージ受信後に一時的に受信できなくなるようですが、一定時間(設定可能)が経過した後にまた見えるようになるとか。
この時間を、可視性タイムアウトと呼ぶようです。

続いて、送信側。こちらはテストコード上で表現します。また、先ほどのメッセージを受信するクラスも使用します。

src/test/java/org/littlewings/aws/sqs/SqsClientTest.java

package org.littlewings.aws.sqs;

import java.net.URI;
import java.util.List;
import java.util.UUID;

import org.junit.jupiter.api.Test;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequest;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequestEntry;
import software.amazon.awssdk.services.sqs.model.SendMessageBatchResponse;

import static org.assertj.core.api.Assertions.assertThat;

public class SqsClientTest {
    @Test
    public void sendReceive() {
        String queueUrl = "http://localhost:4566/000000000000/my-queue.fifo";

        AwsCredentials awsCredentials =
                AwsBasicCredentials.create("mock_access_key", "mock_secret_key");

        SqsClient sqsClient =
                SqsClient
                        .builder()
                        .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                        .region(Region.US_EAST_1)
                        .endpointOverride(URI.create("http://localhost:4566"))
                        .build();

        QueueSubscriber subscriber = new QueueSubscriber(sqsClient, queueUrl);

        List<String> messages1 =
                List.of(
                        "Hello-SQS 1",
                        "Hello-SQS 2",
                        "Hello-SQS 3",
                        "Hello-SQS 4",
                        "Hello-SQS 5"
                );

        send(sqsClient, queueUrl, messages1, "group-1");

        // receive
        List<String> receivedMessages1 = subscriber.subscribe();
        assertThat(receivedMessages1).containsExactlyElementsOf(messages1);

        List<String> messages2 =
                List.of(
                        "Hello-SQS 6",
                        "Hello-SQS 7",
                        "Hello-SQS 8",
                        "Hello-SQS 9",
                        "Hello-SQS 10"
                );

        send(sqsClient, queueUrl, messages2, "group-1");

        // receive
        List<String> receivedMessages2 = subscriber.subscribe();
        assertThat(receivedMessages2).containsExactlyElementsOf(messages2);
    }

    private void send(SqsClient sqsClient, String queueUrl, List<String> messages, String groupId) {
        // batch request
        SendMessageBatchRequest sendMessageBatchRequest =
                SendMessageBatchRequest
                        .builder()
                        .queueUrl(queueUrl)
                        .entries(
                                messages
                                        .stream()
                                        .map(m ->
                                                SendMessageBatchRequestEntry
                                                        .builder()
                                                        .id(UUID.randomUUID().toString())
                                                        .messageBody(m)
                                                        .messageGroupId(groupId)
                                                        .messageDeduplicationId(UUID.randomUUID().toString())
                                                        .build())
                                        .toList()
                        )
                        .build();

        // send
        SendMessageBatchResponse sendMessageBatchResponse = sqsClient.sendMessageBatch(sendMessageBatchRequest);
        assertThat(sendMessageBatchResponse.failed()).isEmpty();
    }
}

AWSのクレデンシャルの設定と、Amazon SQSのクライアントを作成。

        String queueUrl = "http://localhost:4566/000000000000/my-queue.fifo";

        AwsCredentials awsCredentials =
                AwsBasicCredentials.create("mock_access_key", "mock_secret_key");

        SqsClient sqsClient =
                SqsClient
                        .builder()
                        .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                        .region(Region.US_EAST_1)
                        .endpointOverride(URI.create("http://localhost:4566"))
                        .build();

先ほどの、メッセージを受信するクラスのインスタンスを作成。

        QueueSubscriber subscriber = new QueueSubscriber(sqsClient, queueUrl);

あとはメッセージを2回に分けて送り、送信したメッセージがすべて受信できたことを確認する、というテストにしました。

        List<String> messages1 =
                List.of(
                        "Hello-SQS 1",
                        "Hello-SQS 2",
                        "Hello-SQS 3",
                        "Hello-SQS 4",
                        "Hello-SQS 5"
                );

        send(sqsClient, queueUrl, messages1, "group-1");

        // receive
        List<String> receivedMessages1 = subscriber.subscribe();
        assertThat(receivedMessages1).containsExactlyElementsOf(messages1);

        List<String> messages2 =
                List.of(
                        "Hello-SQS 6",
                        "Hello-SQS 7",
                        "Hello-SQS 8",
                        "Hello-SQS 9",
                        "Hello-SQS 10"
                );

        send(sqsClient, queueUrl, messages2, "group-1");

        // receive
        List<String> receivedMessages2 = subscriber.subscribe();
        assertThat(receivedMessages2).containsExactlyElementsOf(messages2);

メッセージ送信部分を見ていきます。

    private void send(SqsClient sqsClient, String queueUrl, List<String> messages, String groupId) {
        // batch request
        SendMessageBatchRequest sendMessageBatchRequest =
                SendMessageBatchRequest
                        .builder()
                        .queueUrl(queueUrl)
                        .entries(
                                messages
                                        .stream()
                                        .map(m ->
                                                SendMessageBatchRequestEntry
                                                        .builder()
                                                        .id(UUID.randomUUID().toString())
                                                        .messageBody(m)
                                                        .messageGroupId(groupId)
                                                        .messageDeduplicationId(UUID.randomUUID().toString())
                                                        .build())
                                        .toList()
                        )
                        .build();

        // send
        SendMessageBatchResponse sendMessageBatchResponse = sqsClient.sendMessageBatch(sendMessageBatchRequest);
        assertThat(sendMessageBatchResponse.failed()).isEmpty();
    }

メッセージは、バッチ送信することにしました。

        // batch request
        SendMessageBatchRequest sendMessageBatchRequest =
                SendMessageBatchRequest
                        .builder()
                        .queueUrl(queueUrl)
                        .entries(
                                messages
                                        .stream()
                                        .map(m ->
                                                SendMessageBatchRequestEntry
                                                        .builder()
                                                        .id(UUID.randomUUID().toString())
                                                        .messageBody(m)
                                                        .messageGroupId(groupId)
                                                        .messageDeduplicationId(UUID.randomUUID().toString())
                                                        .build())
                                        .toList()
                        )
                        .build();

グループIDと、重複排除用のIDを指定しています。

                                                        .messageGroupId(groupId)
                                                        .messageDeduplicationId(UUID.randomUUID().toString())

Amazon SQS メッセージグループ ID の使用 - Amazon Simple Queue Service

Amazon SQS メッセージ重複排除 ID の使用 - Amazon Simple Queue Service

送信結果にエラーがないか確認。

        // send
        SendMessageBatchResponse sendMessageBatchResponse = sqsClient.sendMessageBatch(sendMessageBatchRequest);
        assertThat(sendMessageBatchResponse.failed()).isEmpty();

まずはこんな感じでしょうか。

まとめ

Amazon SQSのFIFOキューをLocalStackで試してみました。

Amazon SQS自体をあまり知らないので、用語の確認等でいろいろ学ぶことになりましたが、もうちょっと情報を追っておきたいですね。
今回はこんなところで。

AWS SAM(Node.js)でAWS Lambda関数をパッケージングする時に、package-lock.jsonを見るようになっていたという話

これは、なにをしたくて書いたもの?

AWS Lambda関数を書く時にAWS SAMをよく使っているのですが、Node.jsを使ってsam buildした時に.aws-samディレクトリに
package-lock.jsonをコピーせずにnpm installしてしまうイメージがありました。

実際に前はそうだったのですが、ちゃんと確認してみるとこの挙動が変わっていたので、書いておこうかなと。

また、npm ciも使えるようになったみたいです。

AWS SAMとpackage-lock.json

最初に書いたとおり、以前のAWS SAMを使ってNode.jsのAWS Lambda関数に対してsam buildするとpackage-lock.jsonをコピーしてくれない
(たとえプロジェクト内には存在していても)という挙動だったのですが、これが以下のPull Requestで修正されているようです。

feat: Use flag to run npm ci and respect lockfile by mildaniel · Pull Request #338 · aws/aws-lambda-builders · GitHub

ビルド時のNodejsNpmrcCopyActionというアクションで.npmrcしかコピーしてくれなかったのが、加えてpackage-lock.json
npm-shrinkwrap.jsonが存在していればコピーしてくれるNodejsNpmrcAndLockfileCopyActionアクションになったようです。

2022年3月の修正ですね。

ソースコードとしては、こちらが該当します。

https://github.com/aws/aws-lambda-builders/blob/v1.18.0/aws_lambda_builders/workflows/nodejs_npm/actions.py

AWS SAM CLIのバージョンとしては1.41.0での内容みたいです。

https://github.com/aws/aws-sam-cli/blob/v1.41.0/requirements/reproducible-linux.txt#L15

Release aws-lambda-builders v1.14.0 release · aws/aws-lambda-builders · GitHub

また、ドキュメントを読んでいるとtemplate.yamlMetadata / BuildProperties配下にUseNpmCi: Trueと記述することでnpm ci
実行してくれるようにもなっているみたいです。

Node.js アプリケーションでは、npm installの代わりに、npm ciを使用して依存関係をインストールします。npm ci を使用するには、Lambda 関数の Metadata リソース属性の BuildProperties の下に UseNpmCi: True を指定します。npm ci を使用するには、アプリケーションは Lambda 関数の CodeUri に package-lock.json または npm-shrinkwrap.json ファイルが必要です。

AWS Serverless Application Model / アプリケーションの構築 / 例

こんなところに書いてあるんだ、という気はしますが…。

今回は、これらを確認してみようと思います。

環境

AWS SAM CLIのバージョン。

$ sam --version
SAM CLI, version 1.53.0

利用するNode.jsのバージョン。

$ node --version
v16.16.0


$ npm --version
8.11.0

AWS SAMプロジェクトを作成する

まずは、AWS SAMプロジェクトを作成します。ランタイムはnodejs16.xで、Amazon API GatewayAWS Lambdaのテンプレートを選択。

$ sam init --name sam-nodejs-npm-ci --runtime nodejs16.x --app-template hello-world --package-type Zip --no-tracing

作成されたプロジェクト内に移動。

$ cd sam-nodejs-npm-ci

ディレクトリツリー内に移動。

$ tree
.
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── app.js
│   ├── package.json
│   └── tests
│       └── unit
│           └── test-handler.js
└── template.yaml

4 directories, 6 files

package.jsonを確認すると、dependenciesdevDependenciesが含まれているので、こちらをこのまま使うことにします。

hello-world/package.json

{
  "name": "hello_world",
  "version": "1.0.0",
  "description": "hello world sample for NodeJS",
  "main": "app.js",
  "repository": "https://github.com/awslabs/aws-sam-cli/tree/develop/samcli/local/init/templates/cookiecutter-aws-sam-hello-nodejs",
  "author": "SAM CLI",
  "license": "MIT",
  "dependencies": {
    "axios": ">=0.21.1"
  },
  "scripts": {
    "test": "mocha tests/unit/"
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "mocha": "^9.1.4"
  }
}

そのままビルドしてみる

最初はこの状態のままビルドしてみます。

$ sam build

以下のようなアクションが実行されます。

Running NodejsNpmBuilder:NpmPack
Running NodejsNpmBuilder:CopyNpmrcAndLockfile
Running NodejsNpmBuilder:CopySource
Running NodejsNpmBuilder:NpmInstall
Running NodejsNpmBuilder:CleanUp
Clean up action: .aws-sam/deps/ef743125-04fc-4805-a623-5281392d189f does not exist and will be skipped.
Running NodejsNpmBuilder:CopyDependencies
Running NodejsNpmBuilder:CleanUpNpmrc
Running NodejsNpmBuilder:LockfileCleanUp
Running NodejsNpmBuilder:LockfileCleanUp

ここで、.aws-samディレクトリ内を確認してみます。

$ find .aws-sam | grep -v node_modules/
.aws-sam
.aws-sam/build
.aws-sam/build/template.yaml
.aws-sam/build/HelloWorldFunction
.aws-sam/build/HelloWorldFunction/app.js
.aws-sam/build/HelloWorldFunction/package.json
.aws-sam/build/HelloWorldFunction/node_modules
.aws-sam/build.toml
.aws-sam/deps
.aws-sam/deps/ef743125-04fc-4805-a623-5281392d189f
.aws-sam/deps/ef743125-04fc-4805-a623-5281392d189f/node_modules

AWS Lambda関数が配置されているディレクトリ内の一部がコピーされ、npm installが行われたらしき状態であることが確認できます。

ここで、いったん.aws-samディレクトリを削除します。

$ rm -rf .aws-sam

package-lock.jsonを作成してsam buildする

では、今度はpackage-lock.jsonを作成してからsam buildしてみます。

まずは、npm iでモジュールをインストール。

$ cd hello-world
$ npm i
$ cd ..

このようになりました。

$ tree -L 2
.
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── app.js
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   └── tests
└── template.yaml

4 directories, 6 files

では、sam buildしてみます。

$ sam build

実行されたアクション。

Running NodejsNpmBuilder:NpmPack
Running NodejsNpmBuilder:CopyNpmrcAndLockfile
Running NodejsNpmBuilder:CopySource
Running NodejsNpmBuilder:NpmInstall
Running NodejsNpmBuilder:CleanUp
Clean up action: .aws-sam/deps/8f327018-920e-4514-817b-d48da3872879 does not exist and will be skipped.
Running NodejsNpmBuilder:CopyDependencies
Running NodejsNpmBuilder:CleanUpNpmrc
Running NodejsNpmBuilder:LockfileCleanUp
Running NodejsNpmBuilder:LockfileCleanUp

今度は、package-lock.json.aws-samディレクトリ内に現れます。

$ find .aws-sam | grep -v node_modules/
.aws-sam
.aws-sam/build
.aws-sam/build/template.yaml
.aws-sam/build/HelloWorldFunction
.aws-sam/build/HelloWorldFunction/app.js
.aws-sam/build/HelloWorldFunction/package-lock.json
.aws-sam/build/HelloWorldFunction/package.json
.aws-sam/build/HelloWorldFunction/node_modules
.aws-sam/build.toml
.aws-sam/deps
.aws-sam/deps/8f327018-920e-4514-817b-d48da3872879
.aws-sam/deps/8f327018-920e-4514-817b-d48da3872879/node_modules

最初にAWS SAMのソースコードを確認したように、CopyNpmrcAndLockfileでコピーが行われているようです。

Running NodejsNpmBuilder:CopyNpmrcAndLockfile

ちなみに、参照しているソースコードが正しければnpm installは以下のオプションで実行されているはずです。

            self.subprocess_npm.run(
                ["install", "-q", "--no-audit", "--no-save", mode, "--unsafe-perm"], cwd=self.artifacts_dir
            )

https://github.com/aws/aws-lambda-builders/blob/v1.18.0/aws_lambda_builders/workflows/nodejs_npm/actions.py#L113-L115

1度.aws-samディレクトリを削除して

$ rm -rf .aws-sam

確認してみましょう。

$ strace -f -e trace=execve sam build

straceの出力内容を追っていくと、こんな感じになっていたので大丈夫でしょう。

[pid 29457] execve("$HOME/.nvm/versions/node/v16.16.0/bin/node", ["node", "$HOME/.nvm/versions/nod"..., "install", "-q", "--no-audit", "--no-save", "--production", "--unsafe-perm"], 0x7fff2c4a5c98 /* 73 vars */) = 0

npm ciを使う

最後は、こちらに習ってnpm ciを使うようにしてみましょう。

AWS Serverless Application Model / アプリケーションの構築 / 例

template.yamlAWS Lambda関数のリソース定義部分はこちらですが、

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs16.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get

以下のようにMetadataを追加してUseNpmCiTrueにします。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs16.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get
    Metadata:
      BuildProperties:
        UseNpmCi: True

この部分ですね。

    Metadata:
      BuildProperties:
        UseNpmCi: True

1度、package-lock.jsonを削除してみましょう。

$ rm hello-world/package-lock.json

sam buildします。

$ sam build

ふつうに動作してしまいました。package-lock.jsonがない場合は、npm installになるようです。

Running NodejsNpmBuilder:NpmPack
Running NodejsNpmBuilder:CopyNpmrcAndLockfile
Running NodejsNpmBuilder:CopySource
Running NodejsNpmBuilder:NpmInstall
Running NodejsNpmBuilder:CleanUp
Clean up action: .aws-sam/deps/4819e4df-04ba-4cd1-8921-702ea890f991 does not exist and will be skipped.
Running NodejsNpmBuilder:CopyDependencies
Running NodejsNpmBuilder:CleanUpNpmrc
Running NodejsNpmBuilder:LockfileCleanUp
Running NodejsNpmBuilder:LockfileCleanUp

.aws-samディレクトリの中身はこちら。

$ find .aws-sam | grep -v node_modules/
.aws-sam
.aws-sam/build
.aws-sam/build/template.yaml
.aws-sam/build/HelloWorldFunction
.aws-sam/build/HelloWorldFunction/app.js
.aws-sam/build/HelloWorldFunction/package.json
.aws-sam/build/HelloWorldFunction/node_modules
.aws-sam/build.toml
.aws-sam/deps
.aws-sam/deps/4819e4df-04ba-4cd1-8921-702ea890f991
.aws-sam/deps/4819e4df-04ba-4cd1-8921-702ea890f991/node_modules

もう1度、package-lock.jsonを作成します。

$ cd hello-world
$ npm i
$ cd ..

1度.aws-samディレクトリを削除。

$ rm -rf .aws-sam

sam build

$ sam build

すると、今度はNpmCIアクションが使われるようになりました。UseNpmCiが有効であることの表示も出ています。

Building codeuri: /path/to/sam-nodejs-npm-ci/hello-world runtime: nodejs16.x metadata: {'BuildProperties': {'UseNpmCi': True}} architecture: x86_64 functions: HelloWorldFunction
Running NodejsNpmBuilder:NpmPack
Running NodejsNpmBuilder:CopyNpmrcAndLockfile
Running NodejsNpmBuilder:CopySource
Running NodejsNpmBuilder:NpmCI
Running NodejsNpmBuilder:CleanUp
Clean up action: folder .aws-sam/deps/4819e4df-04ba-4cd1-8921-702ea890f991 will be cleaned
Running NodejsNpmBuilder:CopyDependencies
Running NodejsNpmBuilder:CleanUpNpmrc
Running NodejsNpmBuilder:LockfileCleanUp
Running NodejsNpmBuilder:LockfileCleanUp

ポイントは、こちらのようですね。

npm ci を使用するには、アプリケーションは Lambda 関数の CodeUri に package-lock.json または npm-shrinkwrap.json ファイルが必要です。

AWS Serverless Application Model / アプリケーションの構築 / 例

CodeUriは今回はこうなっています。

    Properties:
      CodeUri: hello-world/

.aws-samディレクトリの内容。

$ find .aws-sam | grep -v node_modules/
.aws-sam
.aws-sam/build
.aws-sam/build/template.yaml
.aws-sam/build/HelloWorldFunction
.aws-sam/build/HelloWorldFunction/app.js
.aws-sam/build/HelloWorldFunction/package-lock.json
.aws-sam/build/HelloWorldFunction/package.json
.aws-sam/build/HelloWorldFunction/node_modules
.aws-sam/build.toml
.aws-sam/deps
.aws-sam/deps/4819e4df-04ba-4cd1-8921-702ea890f991
.aws-sam/deps/4819e4df-04ba-4cd1-8921-702ea890f991/node_modules

npm ciが動作していることも確認してみましょう。

https://github.com/aws/aws-lambda-builders/blob/v1.18.0/aws_lambda_builders/workflows/nodejs_npm/actions.py#L158

.aws-samディレクトリを削除。

$ rm -rf .aws-sam

straceを仕込んで確認。

$ strace -f -e trace=execve sam build

OKですね。

[pid 31187] execve("$HOME/.nvm/versions/node/v16.16.0/bin/node", ["node", "$HOME/.nvm/versions/nod"..., "ci"], 0x7ffcadd9b850 /* 73 vars */) = 0

まとめ

AWS SAMでNode.jsを使ったAWS Lambda関数をパッケージングする際に、以前はpackage-lock.jsonが利用されなかったのですがこの状況が
変わっていること、そして設定次第ではnpm ciを利用できることを確認できました。

package-lock.jsonの扱いについては気になっていたことのひとつだったので、解消されていたことに気づけて良かったかなと思います。