S3上のオブジェクトを結合するnpmパッケージを公開した話(ESM, CJS対応)

2024.05.26

はじめに

S3上の細かいオブジェクト(ファイル)を1つのオブジェクトに連結したい要件がありましたが、色々調べると気にかける事が多く、挙動を理解する目的込みでnpmパッケージを作ることにしました。Pythonのs3-concatにInspireされ、Node.js(TS)で同じことを実現したいと思ったのもモチベーションの1つです。

ESModulesとCommonJSに対応しています。

S3にはMultipart upload機能があります。5GBを超えるファイルはCopyObjectで移動が出来ないため、Multipart uploadを使うケースが多いです。boto3のcopyメソッドは、Multipart upload機能を使っているようです。このMultipart upload機能は、ファイルの結合にも利用可能です。

この背景をベースに以下の理由から、npmパッケージ化することにしました。

リポジトリ、npmは以下のとおりです。

MITライセンスの下でOSSとして提供しています。利用者の皆様が自由に使用、修正、配布できるようにしています。なお、本パッケージの著作権は私に帰属します。利用する際に発生した問題や質問については、私個人(https://github.com/shuntaka9576/s3-concat/issues)へご連絡ください。

s3-concatの紹介

インストール方法

npm install s3-concat

ユースケース

1つのファイルに結合する

1GiBファイル1個と100MiBファイル9個と1MiBファイル124個を連結してみます。合計2GiBになれば成功です。s3://バケット名/srcにファイルをアップロードして、s3://バケット名/dstに出力します。

# 1GiBのファイルを1個作成するコマンド
dd if=/dev/zero of=file1GiB_1 bs=1M count=1024

# 100MiBのファイル9個作成するコマンド
for i in {1..9}; do
  dd if=/dev/zero of=file100MiB_$i bs=1M count=100
done

# 1MiBのファイルを124個作成するコマンド
for i in {1..124}; do
  dd if=/dev/zero of=file1MiB_$i bs=1M count=1
done

aws s3 sync . s3://{バケット名}/src/
import { S3Client } from '@aws-sdk/client-s3';
import { S3Concat } from 's3-concat';

const s3Client = new S3Client({});
const srcBucketName = process.env.srcBucketName!;
const dstBucketName = process.env.dstBucketName!;
const dstPrefix = 'dst';

const main = async () => {
  const s3Concat = new S3Concat({
    s3Client,
    srcBucketName: srcBucketName,
    dstBucketName: dstBucketName,
    dstPrefix,
    concatFileName: `concat.json`,

  });

  await s3Concat.addFiles('output');
  const result = await s3Concat.concat();

  console.log(JSON.stringify(result));
};

await main();

今回は、送信元と結合先は同じバケット名を指定しました。

export srcBucketName="バケット名(my-bucket-name)"
export dstBucketName="バケット名(my-bucket-name)"

npx vite-node src/index.ts

実行結果は以下の画像のとおりです。2GiBになっていることが確認できます。時間は1分半程度でした。

CleanShot 2024-05-25 at 07 14 03

minSizeを指定して複数のファイルに連結する

こちらは最小サイズを指定して、その最小サイズ以上になるようにファイルを連結します。最小サイズであり、指定サイズ以上になるようファイル単位で結合するので、そのサイズになるように分割するわけではないので注意です。例えばminSizeが5GBで、1GB,1GB,6GB,5GB,1GBの場合は、ファイル1(1GB,1GB,6GB),ファイル2(6GB),ファイル3(1GB)に連結します。これはListObjectV2で取得した順に連結します。

先ほどと同じく1GiBファイル1個と100MiBファイル9個と1MiBファイル124個を使い、最小サイズは150MiBを設定して実行してみます。

import { S3Client } from '@aws-sdk/client-s3';
import { S3Concat } from 's3-concat';

const s3Client = new S3Client({});
const srcBucketName = process.env.srcBucketName!;
const dstBucketName = process.env.dstBucketName!;
const dstPrefix = 'dst';

const main = async () => {
  const s3Concat = new S3Concat({
    s3Client,
    srcBucketName: srcBucketName,
    dstBucketName: dstBucketName,
    dstPrefix,
    minSize: '150MiB', // 変更点
    concatFileNameCallback: (i) => `concat_${i}.json`, // 変更点
  });

  await s3Concat.addFiles('src');
  const result = await s3Concat.concat();

  console.log(JSON.stringify(result));
};

await main();

結果は以下の通りで、150MiBになるようにファイルを結合した結果、100MiBの9個のうち8つが200MiBとして結合され、残りの1個が1GiBのファイルに結合されました。残りの1MiB 124個は全て結合されました。時間は48秒程度でした。

CleanShot 2024-05-25 at 07 25 26

結合するファイルの選定が、ListObjectV2で取得した順によるため、改善余地がありそうです。希望があればissueで起票して頂けるとありがたいです。

工夫点

それやらないとパッケージにする意味ないだろとつっこまれてしまいますが、、紹介します。

小さいオブジェクト(5GiBより大きい)のアップロード

5GiBより大きい場合は、5GiB単位で分割して送信。Multipart uploadが5GiBまでなので、分割しています。S3 to S3なのでメモリは気にせず、0-5GiBの範囲を指定して送っています。

小さいオブジェクト(5MiBより小さい)のアップロード

ローカルからストリームで読み出して5MiB以上の10MiBまでバッファリングして送るようにしました。元々@aws-sdk/client-s3は、GetObjectCommandOutputがReadableストリームなのでメモリ効率よく処理出来ます。

      for await (const chunk of partStream) {
        buffer = Buffer.concat([buffer, chunk]);

        while (buffer.length >= partSize) {
          const partBuffer = buffer.subarray(0, partSize);
          buffer = buffer.subarray(partSize);

          const uploadPartCommand = new UploadPartCommand({
            Bucket: this.dstBucketName,
            Key: task.concatKey,
            UploadId: uploadId,
            PartNumber: posPartNumber,
            Body: partBuffer,
          });

          const uploadPartResponse =
            await this.s3Client.send(uploadPartCommand);
          parts.push({
            ETag: uploadPartResponse.ETag,
            PartNumber: posPartNumber,
          });

          posPartNumber += 1;
        }
      }

非同期IOの制御

p-limitで楽しました。主に制御しているのは、5MiB以上のファイルの並列アップロードです。ファイル数が多いと、大量リクエストが飛んでしまうので制御しています。オプションで変更可能です。5MiB以下のファイルは、並列化せず都度ストリームを読み出して10MiB貯まったら送っています。前者と比べて並列化するのが大変そうだったので見送りました。

開発時足回りのTips

TypeScript 5.5.0-betaの利用

TS界隈ではかなり話題になったやつです。Inferred Type Predicates で、型ガードなしで推論できるようになりました。

    const response: ListObjectsV2CommandOutput = await s3Client.send(
      new ListObjectsV2Command({
        Bucket: bucketName,
        Prefix: prefix,
        ContinuationToken: continuationToken,
      })
    );

    if (response.Contents) {
      const files = response.Contents.filter(
        (
          content:
            | { Key: string; Size: number }
            | { Key: string; Size: undefined }
            | { Key: undefined; Size: number }
            | { Key: undefined; Size: undefined }
        ) => content.Key !== undefined && content.Size !== undefined
      )
        .filter((content) => content.Key.endsWith('/') === false)
        .map((content) => ({ key: content.Key, size: content.Size }));

const files変数が、{key: string; size: number}[]に推論されていることが分かります。

CleanShot 2024-05-24 at 21 23 44@2x

Viteを使ったESM/UMDクロスビルド

viteでサクッと実現でしました。ポイントは以下の通りです。

  • 型定義ファイルもvite側で生成するようにvite-plugin-dtsを利用
  • ビルド用にtsconfig.build.jsonを定義
    • (理由) 分けないと、ビルド対象外(テストディレクトリなど)のソースファイルでLSP上、importの警告が多発し開発しつらかったためです。

vite.config.ts

import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
    dts({
      tsconfigPath: 'tsconfig.build.json',
    }),
  ],
  build: {
    lib: {
      entry: resolve(__dirname, './lib/s3-concat.mts'),
      name: 's3-concat',
      fileName: 's3-concat',
      formats: ['es', 'umd'],
    },
  },
});

こんな形でvite buildのみで、型定義ファイルと、esmとumdの生成が可能です。

$ npx vite build
vite v5.2.11 building for production...
✓ 676 modules transformed.

[vite:dts] Start generate declaration files...
computing gzip size (0)...[vite:dts] Declaration files built in 975ms.

dist/s3-concat.js  151.09 kB │ gzip: 40.21 kB
dist/s3-concat.umd.cjs  110.57 kB │ gzip: 35.54 kB
✓ built in 1.75s

$ ls -al dist
.rw-r--r-- 2.6k shuntaka 25 5  09:47 s3-concat.d.mts
.rw-r--r-- 151k shuntaka 25 5  09:47 s3-concat.js
.rw-r--r-- 111k shuntaka 25 5  09:47 s3-concat.umd.cjs
.rw-r--r--  335 shuntaka 25 5  09:47 s3-util.d.mts
.rw-r--r--  321 shuntaka 25 5  09:47 storage-size.d.mts

Testcontainers with Localstackを使ったユニットテスト

Testcontainersは、コンテナを使ったユニットテストを簡単に実行できるツールです。従来、テスト環境は事前に整備してからテストを実行していましたが、Testcontainersではコードでコンテナの定義と操作が可能です。主な利点は以下の通りです。

  • テスト環境の自動整備:コード内でコンテナの生成と破棄を定義できるため、npx vitest runコマンド一つでテスト環境を自動的に整備(コンテナの起動、削除)できます。テスト側で外部コマンドラッパーを書いても良いですが、シグナルハンドリングやエラーメッセージなどコードが複雑になりやすいです
  • 認知負荷の軽減:テスト側から環境設定やコンテナの操作が可能なため、テスト実行がシンプルになります
  • CIとの連携:Dockerがインストールされていれば、GitHub ActionsなどのCIツールでも特別な設定なしに動作するため、ローカル環境でうまく動作すればCIでも同様に動作します

これにより、テストの信頼性と開発者の生産性が向上します。

今回はテストにLocalstackを採用しました。モックを使わなかった理由として、AWS SDKの利用方法変更をした場合テストが失敗する偽陽性の問題を防ぎ、リファクタリングへの耐性を上げるためです。実際のコードは以下の通りです。少し工夫が必要でした。以下のコードはLocalStackのコンテナを起動しています。完全に起動する前にテストが始まってしまうので、waitForLocalStackで、HTTPリクエストを飛ばして2XX系応答が返ってきてからテストが実行されるようにしています。

HTTPクライアントのkyも便利で、リトライ処理がシュッとかけて良かったです。保守の観点では、nodeのglobal fetchが組み込みなので優れており、機能面とトレードオフで判断が必要です。

import {
  LocalstackContainer,
  type StartedLocalStackContainer,
} from '@testcontainers/localstack';
import ky from 'ky';
import type { GlobalSetupContext } from 'vitest/node';

declare module 'vitest' {
  export interface ProvidedContext {
    localStackHost: string;
  }
}

let container: StartedLocalStackContainer;

export const setup = async ({ provide }: GlobalSetupContext) => {
  container = await new LocalstackContainer().start();
  provide('localStackHost', container.getConnectionUri());

  // Wait until the LocalStack endpoint is available
  await waitForLocalStack(container.getConnectionUri());
  console.log('container lunched');
};

export const teardown = async () => {
  await container.stop();

  console.log('container stopped');
};

const waitForLocalStack = async (uri: string) => {
  const maxAttempts = 10;
  const timeout = 60 * 1000 * 5; // ms

  await ky.get(uri, {
    retry: {
      limit: maxAttempts,
    },
    timeout,
  });
};

単体テストは、他のテストケースから隔離された状態実行されることが望ましいです。ゆえに理想は1テスト1コンテナなんですがオーバヘッドが大きいので、妥協案として各テストでs3をuuidで作成して、1テストに対して1つのS3を作成して複数テストケースが同時に動作しても影響を起こさないようにしました。

describe('concat', () => {
  test('SingleFileOutputWithoutMinSize', async () => {
    // Given:
    const files = [
      {
        fileSize: 1000 * KiB,
        fileCount: 11,
      },
      {
        fileSize: 5 * MiB,
        fileCount: 3,
      },
    ];
    const prefix = 'tmp';
    const dstPrefix = 'output';
    const concatFileName = 'output.json';
    const s3ClientHelper = new S3ClientHelper(
      new S3Client({
        endpoint: LOCAL_STACK_HOST,
        region: 'us-east-1',
        forcePathStyle: true,
        credentials: {
          secretAccessKey: 'test',
          accessKeyId: 'test',
        },
      })
    );
    const { bucketName } = await s3ClientHelper.setupS3({
      files,
      prefix,
    });
    const s3Client = new S3Client({
      endpoint: LOCAL_STACK_HOST,
      region: 'us-east-1',
      forcePathStyle: true,
      credentials: {
        secretAccessKey: 'test',
        accessKeyId: 'test',
      },
    });
    const s3Concat = new S3Concat({
      s3Client,
      srcBucketName: bucketName,
      dstBucketName: bucketName,
      dstPrefix,
      concatFileName,
    });
    await s3Concat.addFiles(prefix);

    // When:
    const result = await s3Concat.concat();

    // Then:
    expect(result).toEqual({
      keys: [
        {
          key: 'output/output.json',
          size: 26992640,
        },
      ],
      kind: 'concatenated',
    });
    const got = await s3Client.send(
      new ListObjectsV2Command({
        Bucket: bucketName,
        Prefix: dstPrefix,
      })
    );
    expect(got.Contents).toEqual([
      {
        ETag: expect.any(String),
        Key: `${dstPrefix}/${concatFileName}`,
        LastModified: expect.any(Date),
        Size: 1000 * KiB * 11 + 5 * MiB * 3,
        StorageClass: 'STANDARD',
      },
    ]);
  });
(中略)
});

semantic-releaseを使ったタグづけリリースノート作成の自動化

semantic-releaseを初期から導入する上で注意したい点は以下です。

それぞれをリンクをみると納得が行きます。後者の説明にならい今回は、package.jsonのバージョンは0.0.0-semantically-releasedとして、GitHub ReleaseやNPM側を真とするようにしています。semantic-releaseはデフォルトで、Conventional Commitを判定してsemverを自動採番してくれるので、今回は設定ファイルを書いたり等はしませんでした。

.github/workflows/release.yml

name: release

on:
  push:
    branches:
      - main
permissions:
  contents: write
jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Action ci
        uses: ./.github/actions/ci

      - name: Build lib
        if: ${{ steps.cache_dependency.outputs.cache-hit != 'true' }}
        shell: bash
        run: npm run build

      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npm run publish

GitHub Actionsの一部フローのaction共通化

Composite Actionを使えば、GitHub Actionを再利用できます。今回はciとreleaseのワークフローで、型チェックとテストの実行が共通していたので共通化しています。

.github/actions/ci/action.yml

name: action ci

runs:
  using: 'composite'
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version-file: './.node-version'

    - name: Restore node modules
      uses: actions/cache@v4
      id: cache_dependency
      env:
        cache-name: cache-dependency
      with:
        path: '**/node_modules'
        key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}

    - name: Install node modules
      if: ${{ steps.cache_dependency.outputs.cache-hit != 'true' }}
      shell: bash
      run: npm ci --no-audit --progress=false --silent

    - name: Run Check
      shell: bash
      run: npm run check

    - name: Run Unit Tests
      shell: bash
      run: npm run test

npmへパッケージをパブリッシュする上での注意点

npmへのpublish後、そのバージョンを削除した場合24時間は再リリースが出来ない

例えば1.0.0でnpmパッケージをpublishした後、型定義ファイルのpublish漏れに気づいた場合、24時間は再リリースが出来ません。また、72時間以内にunpublishしなかった場合、パッケージの削除自体が難しくなります。

ゆえに、事前のテストは入念する必要があると思います。

(対策) npm linkを使った動作確認

作成したパッケージ側で以下のコマンドで、他のプロジェクトからパッケージを参照可能にします。

npm link
npm ls --global

以下のコマンドで、プロジェクト側から作成したパッケージをインストールすることが出来ます。実際は、上記のプロジェクトへのシンボリックリンクが、npm_modules/パッケージ名で作成されます。

npm link <パッケージ名>
npm unlink <パッケージ名> --global

(対策) publishされるビルド成果物の確認

以下のコマンドでnpmへpublishされるビルド成果物を確認できます。.npmignoreの設定ミスに気付けたりできますし、意図しないファイルをpublishしてしまうとインシデントに繋がるため、自信があってもやっておくと良いと感じます。

$ npm pack --dry-run
npm notice
npm notice 📦  s3-concat@0.0.0-semantically-released
npm notice === Tarball Contents ===
npm notice 1.1kB   LICENSE
npm notice 2.9kB   README.md
npm notice 2.6kB   dist/s3-concat.d.mts
npm notice 151.1kB dist/s3-concat.js
npm notice 110.6kB dist/s3-concat.umd.cjs
npm notice 335B    dist/s3-util.d.mts
npm notice 321B    dist/storage-size.d.mts
npm notice 1.9kB   package.json
npm notice === Tarball Details ===
npm notice name:          s3-concat
npm notice version:       0.0.0-semantically-released
npm notice filename:      s3-concat-0.0.0-semantically-released.tgz
npm notice package size:  79.1 kB
npm notice unpacked size: 270.9 kB
npm notice shasum:        2efb053e4ae78640adbe86f5fdfe89cf440aebea
npm notice integrity:     sha512-s1Xk5Nl3QgmPx[...]yyIujg94XKQkg==
npm notice total files:   8
npm notice
s3-concat-0.0.0-semantically-released.tgz

(対策) package.jsonのモジュール名(name)を変更してテストリリース

これは最終手段ですが、事前のチェックをしてもどうしてもミスは起きると思います。例えば正常に動作させるために必要なファイルを勘違いしていたら、起きてしまいます。npmにパッケージ名で一度パブリッシュし、プロジェクトにインストールすれば確実に動作することが確認できます。失敗しても、仮のパッケージ名なので本体のリリースが遅れることはありません。必要な手順もpackage.jsonのnameを変更するだけなので、比較的に簡単に実施できると思います。

改めて考えてみるとtest的なバージョンを定義してリリースするのが、良いとは思います。今回はsemantic-release経由のリリースだったので、Conventional Commitsに基づいてバージョンが自動採番されてしまうので、このアプローチが良いと考えました。

余談

1.0.0のpublishに失敗して、unpublishする前にnpmの一覧から削除してしまったのが原因か、24時間経過後も1.0.0でpublish出来なくなり、1.0.1から始まっています(泣

さいごに

npmパッケージ自体は、以前もパブリッシュした経験があるのですが、やる時期が4半期に1回くらいなので忘れてハマったりすることが多いので今回記事化することにしました。リリース自動化周りは、モノレポ利用時にlernaやchangesetsを使ったことがあったのですが、今回はライトにできそうなsemantic-releaseを採用しました。ツール毎に思想が違っており面白かったです。

また現状カバレッジはcodecovを利用していますか、サードパーティツールでも行けそうなので試してみようと思います。

非常にニッチなツールな自覚はあるので、ユースケースが合う場合は使ってみてフィードバックを頂けると嬉しいです!