アップロードされた画像を自動的に最適化するワークフローを作ってみた

2022.09.06

この記事は公開されてから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のソースバケットに画像をアップロードし、自動的に処理が行われるかを確認します。

ここで期待する動作は以下です。

  1. ソースバケットに画像がアップロードされる
  2. Step Functionsが実行される
  3. DynamoDBにオブジェクトに対応するレコードが作成される
  4. Lambdaによって画像が最適化され、ターゲットバケットに保存される
  5. DynamoDBのレコードが更新される

それでは試してみます。

S3のソースバケットに適当なjpeg画像をアップロードします。

するとEventBridgeがイベントを検出し、Step Functionsステートマシンが動き始めました。(画像は実行が完了した時のものです)

DynamoDBのテーブルを見てみると、レコードが作成されています。
実行が一瞬だったので、レコードの状態がstatus=processingのスクショは撮れませんでした…)

最後にS3のターゲットバケットにも最適化後の画像が保存されていました。

サイズ見てみると、ソースバケットにアップロードしたオリジナル画像が872.1KBだったのに対し、最適化後の画像は702.2KBになっていることがわかります。

画像のアップロードから最適化まで、自動で行われていることが確認できました。

最後に

今回は以前作成したLambda関数を応用して、S3への画像アップロードから画像最適化の自動ワークフローを作成しました。

今回行ったように、EventBridgeとStep Functionsを組み合わせると、さまざまな自動ワークフローを簡単に作ることができます。
定型作業で時間もったいないな〜と思っている作業があれば、ぜひこの機会に自動化を考えてみてはいかがでしょうか?
自動化を駆使して業務効率を上げていきましょう!

以上、全ての仕事を自動化したい、八木でした!