S3に保存された画像を最適化するLambda関数を作ってみた

2022.08.29

この記事は公開されてから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)
}

他の各関数を呼び出して、

  1. ソースバケットから画像オブジェクトを取得
  2. 取得したオブジェクトのデータ変換
  3. 変換した画像オブジェクトを最適化する
  4. 最適化したオブジェクトをターゲットバケットに保存する

といった処理を行います。

getObject関数はS3から指定されたオブジェクトを取得します。res.BodyReadable | 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 にデプロイする