S3上のオブジェクトを結合するnpmパッケージを公開した話(ESM, CJS対応)
はじめに
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パッケージ化することにしました。
- Multipart uploadの最小サイズは、パートサイズ5 MiBから5 GiB(最後のパートには、最小サイズの制限なし)という制約がある
- 小さいファイルが多く存在する場合は、5MiB以上まで読み出して、アップロードする必要がある(最後のパート以外)
- 大きいファイルが存在する場合は、5GiBを分割して読み出して、アップロードする必要がある
- 単純にMultipart uploadの手順が多い
リポジトリ、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分半程度でした。
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秒程度でした。
結合するファイルの選定が、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}[]
に推論されていることが分かります。
Viteを使ったESM/UMDクロスビルド
viteでサクッと実現でしました。ポイントは以下の通りです。
- 型定義ファイルもvite側で生成するように
vite-plugin-dts
を利用 - ビルド用にtsconfig.build.jsonを定義
- (理由) 分けないと、ビルド対象外(テストディレクトリなど)のソースファイルでLSP上、importの警告が多発し開発しつらかったためです。
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を初期から導入する上で注意したい点は以下です。
- メジャーバージョン0系をサポートしていない Can I set the initial release version of my package to 0.0.1?
- リポジトリのパッケージバージョンが更新されない Why is the package.json’s version not updated in my repository?
それぞれをリンクをみると納得が行きます。後者の説明にならい今回は、package.jsonのバージョンは0.0.0-semantically-released
として、GitHub ReleaseやNPM側を真とするようにしています。semantic-releaseはデフォルトで、Conventional Commitを判定してsemverを自動採番してくれるので、今回は設定ファイルを書いたり等はしませんでした。
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のワークフローで、型チェックとテストの実行が共通していたので共通化しています。
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を利用していますか、サードパーティツールでも行けそうなので試してみようと思います。
非常にニッチなツールな自覚はあるので、ユースケースが合う場合は使ってみてフィードバックを頂けると嬉しいです!