S3に保存された画像を最適化するLambda関数を作ってみた
こんにちは、ヤギです。
画像ストレージとしてS3は非常に優秀であり、静的ファイルのホスティング先としても頻繁に使われます。
この際、画像サイズが大きすぎると、ユーザへの表示が遅くなったり、SEOで不利になったりします。
そこでオリジナルの画像とは別に、表示用の最適化した画像を生成するといったワークフローが必要になってきます。
今回はS3バケットに保存されたオリジナルの画像を最適化し、別のS3バケットに保存するLambda関数を作ってみました。
コードはGitHubで公開しています。
使用技術
言語:TypeScript 4.7.4
ランタイム:Node 16.15.1
ライブラリ:
@aws-sdk/client-s3 3.154.0
@squoosh/lib 0.4.0
なお今回使用したSquooshはブラウザ、CLIでも提供されています。
現在開発はゆっくりと進められており、コードではバグ修正されているものの、リリースされていない部分もあります。本番ワークロードで利用したい方はご注意ください。
作ってみる
それでは早速作ってみます。
ライブラリのインストール
まず必要なライブラリをインストールします。
npm i -D typescript @types/aws-lambda @types/node
npm i @squoosh/lib @aws-sdk/client-s3
Lambda関数コードの作成
続いて画像処理を行うLambda関数のコードを作成します。
SquooshはTypeScriptへ対応していないため、require
を使ってインポートしています。
import { GetObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3' import { Readable, Stream } from 'stream' import { cpus } from 'os' const squoosh = require('@squoosh/lib') const client = new S3Client({}) interface Params { sourceBucket: string targetBucket: string objectKey: string } // Lambda関数のエントリポイント export const handler = async (params: Params) => { const object = await getObject(params.sourceBucket, params.objectKey) if (!object) { return } const buf = await streamToBuffer(object as Readable) const optimized = await optimizeImage(buf) await putObject(params.targetBucket, params.objectKey, optimized) } // S3からオブジェクトを取得する const getObject = async (bucketName: string, objectKey: string) => { const getCommand = new GetObjectCommand({ Bucket: bucketName, Key: objectKey, }) const res = await client.send(getCommand) return res.Body } // S3へオブジェクトを保存する const putObject = async ( bucketName: string, objectKey: string, image: Uint8Array ) => { const outputCommand = new PutObjectCommand({ Bucket: bucketName, Key: objectKey, Body: image, }) await client.send(outputCommand) } // Stream型をUint8Array型に変換する const streamToBuffer = async (stream: Stream): Promise<Uint8Array> => { return await new Promise((resolve, reject) => { const chunks: Uint8Array[] = [] stream.on('data', (chunk: Uint8Array) => { return chunks.push(chunk) }) stream.on('error', reject) stream.on('end', () => resolve(Buffer.concat(chunks))) }) } // Squooshを使って画像を最適化する const optimizeImage = async (original: Uint8Array): Promise<Uint8Array> => { const imagePool = new squoosh.ImagePool(cpus().length) const image = imagePool.ingestImage(original) const encodeOptions = { mozjpeg: 'auto', } await image.encode(encodeOptions) const encoded = await image.encodedWith.mozjpeg await imagePool.close() return encoded.binary }
簡単にコードの説明をします。
まず handler
関数がLambda関数のエントリポイントです。
export const handler = async (params: Params) => { const object = await getObject(params.sourceBucket, params.objectKey) if (!object) { return } const buf = await streamToBuffer(object as Readable) const optimized = await optimizeImage(buf) await putObject(params.targetBucket, params.objectKey, optimized) }
他の各関数を呼び出して、
- ソースバケットから画像オブジェクトを取得
- 取得したオブジェクトのデータ変換
- 変換した画像オブジェクトを最適化する
- 最適化したオブジェクトをターゲットバケットに保存する
といった処理を行います。
getObject
関数はS3から指定されたオブジェクトを取得します。res.Body
は Readable | ReadableStream | Blob | undefined
の型ですが、ランタイムによって変わるようで、Nodeで実行した場合は Readable | undefined
型になるようです。
const getObject = async (bucketName: string, objectKey: string) => { const getCommand = new GetObjectCommand({ Bucket: bucketName, Key: objectKey, }) const res = await client.send(getCommand) return res.Body }
streamToBuffer
関数はStream型をUint8Array型に変換します。ここでは画像オブジェクトの型を変換しています。
const streamToBuffer = async (stream: Stream): Promise<Uint8Array> => { return await new Promise((resolve, reject) => { const chunks: Uint8Array[] = [] stream.on('data', (chunk: Uint8Array) => { return chunks.push(chunk) }) stream.on('error', reject) stream.on('end', () => resolve(Buffer.concat(chunks))) }) }
optimizeImage
関数はSquooshライブラリを使って画像の最適化を行います。
今回はJPEG形式の画像を対象とし、圧縮率は自動に設定しました。
mozjpegをエンコーダに使っていますが、他のアルゴリズムを使用することもできます。また、品質の指定やリサイズなども行うことができます。詳しくは公式ドキュメントを参照ください。
なお、圧縮率を自動に設定した場合、ファイルサイズが大きくなる可能性もあります。
const optimizeImage = async (original: Uint8Array): Promise<Uint8Array> => { const imagePool = new squoosh.ImagePool(cpus().length) const image = imagePool.ingestImage(original) const encodeOptions = { mozjpeg: 'auto', } await image.encode(encodeOptions) const encoded = await image.encodedWith.mozjpeg await imagePool.close() return encoded.binary }
最後に putObject
関数で最適化した画像をターゲットのS3バケットに保存します。
const putObject = async ( bucketName: string, objectKey: string, image: Uint8Array ) => { const outputCommand = new PutObjectCommand({ Bucket: bucketName, Key: objectKey, Body: image, }) await client.send(outputCommand) }
Lambda関数のデプロイ
TypeScriptで書いたLambda関数をデプロイするには、主に以下の3つの手法があります。
- AWS SAMを使用する
- AWS CDKを使用する
- AWS CLIとesbuildを使用する
今回は3つ目の「AWS CLIとesbuildを使用する」方法でデプロイします。
他の2つの手法を利用したい方は、公式ドキュメントをご参照ください。
また、Lambda関数に付与するIAMロールを作成するために、一部マネジメントコンソールを使用します。(AWS CLIでも可能です。)
TypeScriptのコンパイル
まずesbuildをインストールします。
npm install -D esbuild
インストールしたesbuildを使って、ソースコードをコンパイルします。
npx esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js
続いて重要な部分ですが、Squooshライブラリに含まれるwasmファイルをコピーします。
これらのファイルがないと、Lambda関数実行時に失敗します。
cp node_modules/@squoosh/lib/build/*.wasm dist
最後に、コンパイルしたJavaScriptのファイルと、Squooshライブラリからコピーしたwasmファイルを、1つのzipファイルにまとめます。
cd dist && zip -r index.zip *
これでLambda関数で実行するコードの準備が出来ました。
Lambda関数のデプロイ
まず、Lambda関数が使用するIAMロールを作成します。
マネジメントコンソールからIAMロールを作成します。
信頼されたエンティティタイプにはLambdaを選択します。
ポリシーはAWS管理ポリシーのAWSLambdaBasicExecutionRole
を追加します。
ロール名をimageOptimizeLambdaRole
とし、ロールを作成します。
続いて作成したIAMロールに、以下のようなインラインポリシーを追加します。
{ "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:GetObject", "s3:PutObject" ], "Resource": "arn:aws:s3:::*/*", "Effect": "Allow" } ] }
ポリシー名はGetPutS3Object
としました。
これで準備が出来ました。最後にCLIからLambda関数を作成します。roleにはマネジメントコンソールで作成したIAMロールのarnを指定してください。
aws lambda create-function \ --function-name image-optimizer \ --runtime "nodejs16.x" \ --role arn:aws:iam::123456789012:role/imageOptimizeLambdaRole \ --zip-file "fileb://index.zip" \ --handler index.handler \ --memory-size 2048 \ --timeout 300
計算量が大きい処理なので、メモリを多くし、タイムアウトを長めに設定しています。
実行してみる
Lambda関数が作成できたので、実行してみます。
オリジナルの画像が入っているバケット(image-optimizer-source-bucket
)と、最適化した画像を保存するバケット(image-optimizer-target-bucket
)の2つのS3バケットを作成しました。
ソースバケットには、mountain.jpg
というサンプルのjpeg画像を入れます。
Lambda関数のページのテストタブから、実際に動作をさせてみます。
イベントJSONで対象バケット、オブジェクトを指定し、テストを実行します。
{ "sourceBucket": "image-optimizer-source-bucket", "targetBucket": "image-optimizer-target-bucket", "objectKey": "mountain.jpeg" }
実行後、ターゲットバケットを見てみると、最適化された画像が保存されていることがわかります。
画像のサイズも872.1KBから702.2KBへ小さくなりました。最適化前後の画像を見比べても、劣化はほとんど見受けられませんでした。
オリジナル画像
最適化画像
なお、圧縮率をautoにしているため、画像が想定以上に小さくなったり、反対にほとんど変わらないという可能性もあります。
固定値にする場合は、ブラウザ版を利用して最適な値を模索すると良いでしょう。
最後に
今回はSquooshというライブラリを使用して、画像を最適化するLambda関数を作ってみました。
画像を最適化することで、SEOで有利になったり、ユーザ体験が向上する可能性があります。また、コスト削減のために、最適化した画像をアプリケーションでは利用し、オリジナル画像は低頻度アクセスストレージに移行するといった運用が可能です。
Lambdaを使用すれば、他にもさまざまな処理が実行可能です。ぜひこの機会にLambdaの更なる活用をご検討ください!
参考リンク
AWS Lambda 実行ロール
.zip ファイルアーカイブを使用して、トランスパイルされた TypeScript コードを Lambda にデプロイする