この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
こんにちは、八木です。
以前Lambdaを使った画像最適化の記事を執筆しました。
今回はこのLambda関数を応用して、S3にアップロードされた画像を自動で最適化するワークフローを作成します。また、オブジェクトの一覧を確認できるように、DynamoDBテーブルにオブジェクト情報を保存します。
構成図は以下です。
まずS3バケットへ画像がアップロードされたらEventBridgeでイベント検出し、Step Functionsのワークフローを実行します。
ワークフロー内では、まずDynamoDBテーブルにオブジェクトのレコードを作成します。続いてLambda関数でS3からオリジナルの画像オブジェクトを取得し、最適化した後、別のS3バケットに保存します。最後にDynamoDBテーブルのレコードを処理完了のステータスに更新します。
なお、S3へのオブジェクトアップロードをトリガーにLambda関数を実行するには、S3イベント通知から直接Lambda関数を実行することもできます。
しかし、今回のようにStep Functionsと組み合わせるなど、拡張を行いたい場合は、EventBridgeを使うと良いでしょう。
作ってみた
ということで早速作ってみました。
S3バケットの作成
オリジナルの画像を保存するソースバケットと、最適化された画像を保存するターゲットバケットをそれぞれ作成します。
今回はデフォルトの暗号化を無効にした状態で作成しました。
続いてソースバケットでEventBridgeへのイベント通知を有効にします。これを行わなければ、EventBridgeでルールを設定しても、S3がイベントを作成してくれません。
ソースバケット詳細画面の「プロパティ」タブに存在する「イベント通知」で設定を変更します。Amazon EventBridgeの編集から通知をオンにします。
DynamoDBテーブルの作成
オブジェクトのレコードを保存するDynamoDBテーブルを作成します。
テーブル名を optimizedImages
、パーティションキーを objectKey
(文字列型)としました。
Lambda関数の作成
画像最適化を行うLambda関数を作成します。
まず必要なライブラリをインストールします。
npm i -D typescript @types/aws-lambda @types/node esbuild
npm i @squoosh/lib @aws-sdk/client-s3
そして本命、最適化を行うLambda関数のコードは以下になります。
index.ts
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
import { Context, S3ObjectCreatedNotificationEvent } from 'aws-lambda'
import { Readable, Stream } from 'stream'
import { cpus } from 'os'
const squoosh = require('@squoosh/lib')
const client = new S3Client({})
// Lambda関数のエントリポイント
export const handler = async (
event: S3ObjectCreatedNotificationEvent,
context: Context
) => {
const object = await getObject(
event.detail.bucket.name,
event.detail.object.key
)
if (!object) {
return
}
const buf = await streamToBuffer(object as Readable)
const optimized = await optimizeImage(buf)
if (!process.env.TARGET_BUCKET) {
throw new Error(
'expected environment variable "TARGET_BUCKET" does not exit.'
)
}
await putObject(process.env.TARGET_BUCKET, event.detail.object.key, 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
}
くわしい処理の説明は以前の記事を参照ください。
ハイライトしている部分が以前の記事からの変更点です。
まずソースバケット名およびオブジェクトのキーを S3ObjectCreatedNotificationEvent
型のパラメータから取得しています。
ここに入力されるパラメータは後程Step Functionsで定義しますが、EventBridgeのパラメータをそのまま渡すようにします。
2つ目の変更点はターゲットバケット名です。ターゲットバケット名はLambda関数の環境変数に設定します。
続いてLambda関数のデプロイを行います。
まずLambda関数にアタッチするIAMロールをマネジメントコンソールで作成します。
信頼されたエンティティタイプはAWSのサービス、ユースケースにLambdaを選択します。
許可を追加では AWSLambdaExecute
を選択し、ロール名は lambdaImageOptimizer
としました。
続いてLambda関数の作成です。
まずは先ほどのコードをビルドし、zipファイルにまとめます。
npx esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js && \
cp node_modules/@squoosh/lib/build/*.wasm dist && \
cd dist && \
zip -r index.zip *
今回作成したLambda関数には、squooshライブラリに含まれているwasmファイルが必要なため、一緒にzipファイルにまとめています。
最後にAWS CLIからLambda関数を作成します。
aws lambda create-function \
--function-name image-optimizer \
--runtime "nodejs16.x" \
--role <iam_role_arn> \
--zip-file "fileb://index.zip" \
--handler index.handler \
--memory-size 2048 \
--timeout 300 \
--environment "Variables={TARGET_BUCKET=<target_bucket_name>}"
iam_role_arn
には先ほど作成したIAMロールのARN、 target_bucket_name
にはS3のターゲットバケット名を設定します。
ここで注意したいのが、 target_bucket_name
には絶対にソースバケット名を指定しないことです。
ターゲットバケット名にソースバケット名を設定すると何が起こるのか?それは無限ループです。
ソースバケットに画像をアップロード→EventBridgeがStep Functionsを起動→Step FunctionsがLambda関数を実行→Lambda関数が画像を最適化しソースバケットにアップロード→(以降無限ループ)
上記のような動作となり、無限にお金が溶けていきます。十分気をつけましょう。
安全策として、Lambda関数内で「ソースバケット名とターゲットバケット名が同じ場合、オブジェクトをアップロードしない」といった処理を入れた方が良いかもしれません。
これでLambda関数が作成できました。
Step Functionsステートマシンの作成
続いてLambda関数の呼び出しおよびDynamoDBへの記録を行うステートマシンを作成します。
以下のASL(Amazon State Language)でステートマシンを作成します。
なお、lambda_function_arn
の部分は作成したLambda関数のARNを指定します。
{
"StartAt": "DynamoDB PutItem",
"States": {
"DynamoDB PutItem": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Parameters": {
"TableName": "optimizedImages",
"Item": {
"objectKey": {
"S.$": "$.detail.object.key"
},
"eventTime": {
"S.$": "$.time"
},
"status": {
"S": "processing"
}
}
},
"Next": "Lambda Invoke",
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Next": "Fail"
}
],
"ResultPath": null
},
"Lambda Invoke": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"Payload.$": "$",
"FunctionName": "<lambda_function_arn>:$LATEST"
},
"Retry": [
{
"ErrorEquals": [
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException"
],
"IntervalSeconds": 2,
"MaxAttempts": 6,
"BackoffRate": 2
}
],
"Next": "DynamoDB UpdateItem",
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Next": "Fail"
}
],
"ResultPath": null
},
"DynamoDB UpdateItem": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:updateItem",
"Parameters": {
"TableName": "optimizedImages",
"Key": {
"objectKey": {
"S.$": "$.detail.object.key"
},
"eventTime": {
"S.$": "$.time"
}
},
"UpdateExpression": "SET #st = :val",
"ExpressionAttributeNames": {
"#st": "status"
},
"ExpressionAttributeValues": {
":val": {
"S": "done"
}
}
},
"End": true,
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Next": "Fail"
}
]
},
"Fail": {
"Type": "Fail"
}
}
}
これでステートマシンの作成は完了です。
EventBridgeルールの作成
最後にS3オブジェクトのアップロードを検出し、Step Functionsを実行するEventBridgeルールを作成します。
これが作成されると、画像のアップロードから自動的にワークフローが実行されます。
イベントバスをdefaultとし、以下のイベントパターンを設定します。
source_bucket_name
には作成したソースバケット名を指定します。
{
"source": ["aws.s3"],
"detail-type": ["Object Created"],
"detail": {
"bucket": {
"name": ["<source_bucket_name>"]
}
}
}
ここでまた注意です。バケット名は必ずソースバケットのみを指定してください。
ターゲットバケットも含むようにルールを作成してしまうと、先ほどの説明と同様に無限ループが発生します。
ご注意ください。
続いてターゲットには作成したStep Functionsステートマシンを選択し、ルールを作成します。
検証
全ての設定が完了しました。実際にS3のソースバケットに画像をアップロードし、自動的に処理が行われるかを確認します。
ここで期待する動作は以下です。
- ソースバケットに画像がアップロードされる
- Step Functionsが実行される
- DynamoDBにオブジェクトに対応するレコードが作成される
- Lambdaによって画像が最適化され、ターゲットバケットに保存される
- DynamoDBのレコードが更新される
それでは試してみます。
S3のソースバケットに適当なjpeg画像をアップロードします。
するとEventBridgeがイベントを検出し、Step Functionsステートマシンが動き始めました。(画像は実行が完了した時のものです)
DynamoDBのテーブルを見てみると、レコードが作成されています。
実行が一瞬だったので、レコードの状態がstatus=processingのスクショは撮れませんでした…)
最後にS3のターゲットバケットにも最適化後の画像が保存されていました。
サイズ見てみると、ソースバケットにアップロードしたオリジナル画像が872.1KBだったのに対し、最適化後の画像は702.2KBになっていることがわかります。
画像のアップロードから最適化まで、自動で行われていることが確認できました。
最後に
今回は以前作成したLambda関数を応用して、S3への画像アップロードから画像最適化の自動ワークフローを作成しました。
今回行ったように、EventBridgeとStep Functionsを組み合わせると、さまざまな自動ワークフローを簡単に作ることができます。
定型作業で時間もったいないな〜と思っている作業があれば、ぜひこの機会に自動化を考えてみてはいかがでしょうか?
自動化を駆使して業務効率を上げていきましょう!
以上、全ての仕事を自動化したい、八木でした!