S3にある高解像度の画像ファイルを配信する方法について

今回はパフォーマンスチューニングに関する記事です。

タイトルにある通り、S3に高解像度の画像ファイル(容量で言えば数MBクラス)が大量にあり、それをWEBサイトで配信したい場合の方法について触れたいと思います。

最近はスマホのカメラの性能が良くなり、普通に撮影した写真が数MBとなるのは当たり前となっています。

そのため、その写真をアップロードするWEBアプリの場合、画像配信に関する考慮が不足していると、途端に重たいサイトとなってしまいます。

そして、WEBサイトの特性によっては高解像度のまま配信するケースもありますが、大抵がサムネイル等のサイズを縮小したもので問題無いケースが多いです。

画像をリサイズして配信する方法については、いくつか考えられます。

  1. 高解像度の画像ファイルとは別に予め縮小した画像ファイルを作成しておく。
  2. 画像配信専用のサーバー経由で画像を配信するようにし、そのサーバーで縮小等の加工に対応する。
  3. 外部のリアルタイム画像加工サービスを利用する。
  4. CloudFrontのLambda@Edgeを使ってリアルタイムに画像を縮小する。

1.の方法はシンプルであり、予め縮小した画像ファイルがあるので、画像配信のパフォーマンスとしては最強です。しかし、縮小した画像ファイルを保管するための容量が追加で必要なのと、S3への画像ファイル保存をトリガーにLambdaで縮小した画像ファイルを作成する必要があります。

2.の方法は自分でサーバーを立てることでカスタマイズ性が高いです。しかし、画像配信専用のサーバーの構築と運用が必要になるのでそれなりに工数とコストとノウハウが必要になります。結構なトラフィックが来ますし、画像縮小でCPUリソースを食われるので、きちんとチューニングしないと画像配信専用サーバーがボトルネックになりえます。(そこで得られる経験はエンジニアとしてはいいものですけど)

3.の方法はコストが許すのであれば実は一番いい方法かもしれません。しかし、カスタマイズ性が外部の会社に依存するため、サービスの仕様を予め確認しておいたほうがいいです。

4.の方法はLambda@Edgeという耳慣れない機能を使うため、世の中にノウハウがそこまで出回っていないので、若干実装に苦労するかもしれません。

しかし、今回は色々検討した結果、実装のスピードから4.を採用しました。

Lambda@Edgeを使ってリアルタイムに画像を縮小する処理を組み込みました。細かい設定方法は他のページに譲るとして、コアとなるLambdaのコードを共有します。

const querystring = require("querystring");
const AWS = require("aws-sdk");
const Sharp  = require('sharp');
const convert = require('heic-convert');

// region 指定
const S3 = new AWS.S3({
  region: "ap-northeast-1"
});

// bucket名指定
const BUCKET = <<バケット名>>;

const THUMBNAIL_RATIO = 0.1;

const allowedExtension = [ "jpg", "jpeg", "png", "webp", "heic", "JPG" ];

exports.handler = (event, context, callback) => { 

  const response = event.Records[0].cf.response;
  const request = event.Records[0].cf.request;

  const params = querystring.parse(request.querystring);
  
  // パラメータなしの場合はオリジンの画像をそのまま表示
  if (isNaN(parseInt(params.thumbnail)) || parseInt(params.thumbnail) != 1) {
    callback(null, response);
    return;    
  }

  const uri = request.uri;
  const [, imageName, extension] = uri.match(/\/(.*)\.(.*)/);

  // リサイズ画像のformat
  const requiredFormat = extension == 'webp' ? 'webp' : 'jpeg'
  const originalKey = `${imageName}.${extension}`;

  // リサイズ対応画像チェック
  if(!allowedExtension.includes(extension)){
    response.status = '500';
    response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/plain' }];
    response.body = `${extension} is not allowed`;
    callback(null, response);
    return;
  }

  // S3から画像を読み込み
  S3.getObject({ Bucket: BUCKET, Key: originalKey }).promise()
    // heicファイルの場合は、jpegへ変換後処理。
    .then(data => {
      if(extension === 'heic'){
        return convert({
          buffer: data.Body,
          format: 'JPEG',    
          quality: 1         
        });
      }else{
        return data.Body;
      }
    })
    .then(input => {
      const image = Sharp(input);
      image
        .metadata()
        .then(meta => {
          const resizeWidth = parseInt(meta.width * THUMBNAIL_RATIO)
          const resizeHeight = parseInt(meta.height * THUMBNAIL_RATIO)
          // 画像をリサイズさせる。
          return image
            .rotate() // EXIFの情報を参考に回転する
            .resize({
              width: resizeWidth,
              height: resizeHeight,
              fit: 'inside'
            })
            .toFormat(requiredFormat)
            .toBuffer();
        })
        .then(buffer => {
          // responseへリサイズした画像を設定して返す。
          response.status = 200;
          response.body = buffer.toString('base64');
          response.bodyEncoding = 'base64';
          response.headers["content-type"] = [
            { key: "Content-Type", value: "image/" + requiredFormat }
          ];
          callback(null, response);
        })
    })
    .catch((e) => {
      response.status = '404';
      response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/plain' }];
      response.body = `${request.uri} is not found.`;
      callback(null, response);
    });  
}

このソースコードのポイントしては2つあります。

まず1つ目はパラメータが無い場合にオリジンの画像をそのまま配信する部分です。

パラメータが無い場合はS3から取得した画像を無加工で返しても良かったのですが、少しでもレスポンスを早くしたかったので、パラメータの有無を冒頭にチェックし、パラメータが無ければ即時でreturnするようにしています。意外とこの情報がネット上で見つけられず苦戦しました。

続いて2つ目はEXIFの情報をベースに回転させる部分です。

実装初期は考慮していなかったですが、そうすると縮小した画像が意図せず回転してしまっているケースが発生しました。色々調べた結果、ブラウザは本来EXIFの情報を参考に向きを決めているようですが、画像を縮小するとおそらくEXIF情報が欠落しているのではないかと推測しました。

対策としては、縮小前にEXIF情報を参考に回転させるようにしました。

この対策のおかげで縮小した画像も正しい向きで表示されるようになりました。

今後S3上の画像を配信する方の参考になれば!