テスト環境でコンテンツが表示されない問題を解決したら、高級車買えちゃうくらいのコストダウンができた

## はじめに
私が担当しているWebサービスは10年以上前から稼働しているプロダクトなのですが、昔から大きな問題がありました。
それは、「テスト環境で画像や動画の大半が表示されない」ことです。
具体的には、CloudFrontのURLが404を返すため表示されないという事象です。

この影響でテスト環境なのに、画像などが表示されないため十分にテストが出来ずリリース後に「本番環境でも画像が表示されてないじゃん！」ということが度々起きていました。
「なんとかせねば👊」と思い、表示できるように対応したところ副産物で高級車が買えてしまうくらいにS3のコストダウンを実現出来たので、もし似たような境遇にいる方の参考になれればと思い記事にします。

## コンテンツ配信の構成図（テスト環境）
私が担当しているWebサービスでは、下記の構成でS3に保存したコンテンツ（画像など）をCloudFrontを使い配信しています。
![AWS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/613376/357d8cdd-6d32-1923-cec9-9563542669b5.png)
① 本番環境のS3バケットにコンテンツが保存されたことをトリガーにLambdaを起動
② 各テスト環境（開発・ステージング）バケットに一部のコンテンツのみコピーされる
③ 各環境で用途ごとに使用しているCloudFrontで、S3バケットを参照しコンテンツを配信
④ 開発環境とステージング環境で共通使用している一部のCloudFrontでは、間にNginxでクエリパラメータを元に画像圧縮をしているEC2を噛ませて配信

## 抱えていた問題
- 表示させたいコンテンツが本番環境用S3バケットにはあるが、テスト環境のS3バケットには無い状態が蔓延していて、テスト環境用のCloudFrontが404を返してしまい表示されない
- 本番バケットからテスト環境バケットへ一部のコンテンツのみコピーしているが、テスト環境で参照されなくなってもバケットから削除されず、チリツモでS3コストが肥大化→月あたり数百万円くらいのコストがS3だけで発生

## 問題の背景
まず前提として、本番環境用のDBとS3バケットには最新のコンテンツがどんどん入ります。
そして、テスト環境のDBも本番環境と同期を行うため、定期的に最新の状態に保たれます。
しかし、S3側は一部のコンテンツを除き大半が昔から同期されていませんでした。
（恐らく全てのコンテンツを各テスト環境のバケットにコピーさせると維持コストが極端に大きくなってしまうためだと思いますが、当時の状況が分からないため真偽は不明。）
その影響で、本番と同期したテスト環境用DBに保存されているファイル名やパスを元にテスト環境用のS3バケットを参照しても、CloudFrontが参照できないため404を返し結果、テスト環境で表示されない問題が生じます。

また、DBが本番と同期されることでテスト環境で参照されなくなった古いコンテンツがS3から削除されずにそのまま残ってしまう状態が続いていました。
そのため、どんどんバケットサイズが肥大化しコストを圧迫。
しかし、その溜まったオブジェクトの中には、参照されているものも含まれるので、どれが消して良いものなのか精査するのも非現実的で選別して消す対応が難しい状態でした。

### 解決への課題
- 本番環境と各テスト環境（開発環境、ステージング環境）のバケット間で差分を可能な限り小さくする
┗ 本番環境用バケットの全オブジェクトを各テスト環境へ同期するには膨大な時間とお金がかかるため非現実的...
- 開発環境とステージング環境のS3バケットに溜まっているゴミを一掃して無駄なコストを省く
┗ テスト環境用S3バケットに蓄積された量が膨大で、どれが必要で不要なのか精査するのは非現実的...
- 各テスト環境で登録されたコンテンツは独立して保持する（本番環境に影響は与えない）
┗ テスト環境用CloudFrontの向き先を本番環境バケットに向けるだけの対応は、テスト環境で登録されたコンテンツを参照できなくなるため×

上記の課題それぞれにアプローチ方法を検討しても「時間が...」「お金が...」という壁が立ちはだかりました...
が、シンプルに考えて **CloudFrontがテスト環境のバケットに覗きに行って、無かった時だけ本番環境バケットを見に行けばいいだけなのでは??** と思い、AWSの公式ドキュメントを読み漁っていたところ **救世主を見つけました！**

## S3オリジングループ
そう、その名は、[S3オリジングループ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorOriginGroups.html)
公式ドキュメントには下記のように書いてあります。
```
以下にプライマリオリジンが特定の HTTP ステータスコードを返す場合のオリジンフェイルオーバーの動作を示します。
HTTP 4xx または 5xx ステータスコード (クライアント/サーバーエラー): 返されたステータスコードがフェイルオーバー用に設定されている場合、CloudFront はオリジングループのセカンダリオリジンに同じリクエストを送信します。
```
え、これじゃん...まさにこれじゃん...!!と、胸が高鳴りました。
早速、テスト環境用CloudFrontでプライマリにテスト環境用バケット、セカンダリに本番環境用バケットを設定してみたところ正常に画像が表示されました...!!
![origingroup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/613376/6e92f68a-1cda-c8cd-da39-c03a32554524.png)
仕組みとしては、下記の通りです
① データを見にいく
② データが無ければ404
③ 404なら本番へ見に行く

秒で解決。素晴らしいAWS。ありがとうAWS。

以上で、全て解決しまし(ry

と、ここで終わりたかったのですが、S3オリジングループが使えない箇所が1つありました。
それは、間にNginxで画像圧縮をしているEC2を噛ませて配信している一部のCloudFrontです。
下記の④の部分です
![AWS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/613376/357d8cdd-6d32-1923-cec9-9563542669b5.png)
この仕組み自体、特殊だと思いますが厄介なことに、間に噛ませているEC2が画像が無い場合でも1px四方のドットを200のHTTPステータスコードで返すという謎な仕様だったため、画像が無くても404が返りません。
EC2側の修正をして、オリジングループでテスト環境と本番環境のALBを設定することも考えたのですが、テスト環境用CloudFrontへのリクエストが本番のALBに流れるのは避けたく、またこのEC2が他のWebサービスでも共通で使用しているということもあり影響範囲が大きかったため、直接EC2の修正はせず別の方法を検討することにしました。

とはいえ、この仕組みで本番からコンテンツを持ってくるには
- S3オリジングループのフェイルオーバーを「何か」が代理で担う
- EC2が行っている画像圧縮を「何か」が代理で担う
必要がありました。

代理でやってくれる「何か」が必要なんです。

そこでまたAWSのドキュメントを読み漁っていると...いました。2人目の救世主が！

## Lambda@Edge
そう、その名は[Lambda@Edge](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html)
CloudFrontとS3の間にLambdaを挟み込めるなんて、便利すぎる。ありがとう、AWS。
ドキュメントに記載されている **オリジンリクエスト** と **オリジンレスポンス** に、それぞれ404時のフェイルオーバーや画像圧縮の役割を担わせることで、こちらも解決することができました！

仕組みは下記の通りです
![lambdaedge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/613376/48b446ad-177f-f758-ec76-54595fab6c6a.png)
① CloudFrontがプライマリのS3バケットを参照しにいくタイミングでオリジンリクエストに設定したLambda@Edgeが起動
② Lambda@EdgeがS3バケットへ覗きに行って画像があるかチェック
③ S3からレスポンスが返る
④ 画像があれば、Lambda@Edgeはそのままレスポンスを返す。→ テスト環境用バケットが参照される
　画像が無ければ、オリジン先を本番用バケットへ向くように編集してCloudFrontへレスポンスを返す
⑤ オリジンレスポンスに設定したLambda@Edgeが起動
⑥ オリジンリクエスト側のLambda@Edgeで返したレスポンスに沿って、S3バケットを参照
⑦ 画像を取得
⑧ CloudFrontのURLクエリパラメータを元に画像の圧縮を行って画像を返す

### Lambda@Edgeを使う場合の注意点
[こちらのドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-how-it-works.html)に記載がある通り
- Lambda@Edgeを置くリージョンは「米国東部 (バージニア北部) 」じゃないといけません
- CloudFrontに関連づけるには、Lambda@Edge側でバージョン発行してそのバージョンを指定する必要があります

## ソースコード
各Lambda@Edgeに記載したソースは下記の通りです。

ランタイム: Node.js 18.x
アーキテクチャ: x86_64

```javascript:オリジンリクエスト側
'use strict';
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

export const handler = async (event, context, callback) => {
    // CloudFrontから飛んできたevent変数からバケット名を取得
    const request = event.Records[0].cf.request;
    const splitUri = request.uri.slice(1).split("/");
    const originBucket = splitUri.shift();
    
    const prodBucket = '本番用のバケット名を指定してください';
    const prodBucketDomain = prodBucket + '.s3.amazonaws.com';

    try
    {
        const command = new GetObjectCommand({
            Bucket: originBucket,
            Key: splitUri.join("/")
        });
        const s3Client = new S3Client({ region: "バケットのあるリージョンを指定してください" });
        await s3Client.send(command);

        // オリジンレスポンス側へ参照するバケット名を渡す
        request.querystring += '&bucketName=' + originBucket;
    }
    catch (error)
    {
        // S3を参照して見つからない場合は例外へ
        // 新しいオリジン情報をセット
        request.origin = {
            "s3": {
                "authMethod": "none",
                "customHeaders": {},
                "domainName": prodBucketDomain,
                "path": '',
            }
        };

        request.headers.host = [
            {
                "key": "Host",
                "value": prodBucketDomain
            }
        ];

        // オリジンレスポンス側へ参照するバケット名を渡す
        request.querystring += '&bucketName=' + prodBucket;
    }

    // オリジンレスポンス側のLambda@Edgeで情報が必要なので、クエリパラメータに情報を追加する
    const path = request.uri.replace("/" + originBucket, "");
    request.uri = path;

    // リクエストをCloudFrontへ返す
    return request;
};

```

```javascript:オリジンレスポンス側
'use strict';

import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
// sharpとquerystringは別途モジュールを用意する必要があります
import sharp from 'sharp';
import querystring from 'querystring'

export const handler = async (event, context, callback) => {

  let response = event.Records[0].cf.response;
  const request = event.Records[0].cf.request;
  // 画像の拡張子を取得
  const ext = request.uri.split('.').pop();
  const imgExt = (ext == "jpg") ? "jpeg" : ext;
  const splitUri = request.uri.slice(1).split("/");
  // クエリパラメータを取得
  const params = querystring.parse(request.querystring);
  // 参照するバケット名を取得
  const bucket = params.bucketName;

  try {
    const s3Client = new S3Client({ region: "バケットを置いてるリージョンを指定してください" });

    const command = new GetObjectCommand({
      Bucket: bucket,
      Key: splitUri.join("/")
    });

    // S3から画像を取得
    const { Body } = await s3Client.send(command);
    const img = await streamToBuffer(Body);
    const originalImageBuffer = Buffer.from(img, 'base64');

    // クエリパラメータから圧縮の設定値を取得
    const width = parseInt(params.width) || undefined;
    const height = parseInt(params.height) || undefined;
    const quality = parseInt(params.quality) || undefined;

    let compressedImageBuffer;
    // 画像のリサイズ
    if (imgExt == 'png') { // 画像の透過を維持するためpngのみ条件分け
      compressedImageBuffer = await sharp(originalImageBuffer)
        .resize(width, height, {
          fit: 'inside', // アスペクト比を維持
          withoutEnlargement: true, // オリジナルサイズ以上の設定値だった場合は拡大しない
        })
        .png({ quality })
        .toBuffer();
    } else {
      compressedImageBuffer = await sharp(originalImageBuffer)
        .resize(width, height, {
          fit: 'inside',
          withoutEnlargement: true,
        })
        .jpeg({ quality })
        .toBuffer();
    }

    // 圧縮後の画像データを変換
    const compressedImageBase64 = compressedImageBuffer.toString('base64');

    // responseを修正
    response.status = 200;
    response.body = compressedImageBase64;
    response.bodyEncoding = 'base64';
    response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + imgExt }];

    // CloudFrontへ返す
    callback(null, response);

  } catch (error) {
    console.error('Error:', error);
    return {
      statusCode: 500,
      body: 'Internal Server Error',
    };
  }

  async function streamToBuffer(stream) {
    return new Promise((resolve, reject) => {
      const chunks = []
      stream.on('data', chunk => chunks.push(chunk))
      stream.on('error', reject)
      stream.on('end', () => resolve(Buffer.concat(chunks)))
    })
  }
};
```

もっと効率の良い仕組みやコードの書き方があると思いますが...現状はこれで解決しています

## S3コスト
S3オリジングループとLambda@Edgeを用いたことで、テスト環境用バケットにコンテンツが無くても本番環境用バケットを参照することでほぼ全てのコンテンツを賄うことができ、テスト環境で一気に表示されるコンテンツが増えました！
おかげで、バケットに溜まっていたゴミオブジェクト達もS3にライフサイクルを設定して一掃することができ、結果として9割以上のコスト削減が実現！
年間で高級車が余裕で買えてしまうくらいのコストダウンを実現することが出来ました
（元々のコストが高すぎ＆長いこと無駄なコストをかけていたことに震えましたが...）

↓ライフサイクル設定後に減少した開発環境のバケットサイズ（330TBくらい → 20TBくらいに削減）
![devS3Cost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/613376/92990ba3-3f15-e3fd-3694-58fdae315390.png)

↓ライフサイクル設定後に減少したステージング環境のバケットサイズ（330TBくらい → 18TBくらいに削減）
![S3cost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/613376/dee3153c-8667-65fc-5021-68c33043e0ce.png)

株価なら天を仰ぐレベルの落差ですね

## おわりに
今回、頭の中でイメージした方法が **S3オリジングループ** と **Lambda@Edge** のおかげで実現することができ、その上コストも大幅に削減することが出来ました。
無駄なコストや技術負債の解消に繋がるところが、まだまだあるはずなので引き続き取り組んでいきたいと思います。

長文になりましたが、読んで頂きありがとうございました！