この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
こんにちは、ヤギです。
画像ストレージとして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
を使ってインポートしています。
index.ts
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 にデプロイする