echo("備忘録");

IT技術やプログラミング関連など、技術系の事を備忘録的にまとめています。

【AWS】Amazon S3 Object Lambdaを試してみた(その1)

はじめに

3/19(金)に「Amazon S3 Object Lambda」というS3の新機能がGAになりました。

aws.amazon.com

これは上記公式サイトにも

「S3 から取得したデータをアプリケーションに返す前に独自のコードを追加して処理できる新機能」

とあるように、例えばS3バケットのキーをgetObjectする際に、あらかじめ何かの処理(フィルタリングやマスキングなど)を実施した値になっているため、getObject()を呼び出すLambdaでの変換処理が不要になります。

というわけで、今回はこのS3OLについて試してみた話です。

TL;DR

  • S3OLの概要
  • TypeScriptで実装してみた
  • ハマった点など

S3OLの仕組み

S3OLの仕組みとしては、公式サイトの下の画像の通りです。
https://d2908q01vomqb2.cloudfront.net/da4b9237bacccdf19c0760cab7aec4a8359010b0/2021/03/16/s3-object-lambda-architecture-1.png

概要としては、こんな感じです。

  • S3OLアクセスポイント経由でS3バケットにアクセスすると、S3OLにて変換処理をした値を取得できる
  • S3OLアクセスポイントを経由しなければ、元の値(生値)を取得できる
  • 1つのS3バケットについて、S3OLアクセスポイント(≒変換処理)を複数設定できる

ユースケース

ユースケースとしては、下記のようなケースです。

  • 元の情報を編集して返す必要がある。
    • (例) 特定の情報をフィルタ/マスクする、画像のサイズを変える...など
  • 複数の処理(≒Lambda)で、上記のような処理を行う(=共通化)
    • モジュール化/レイヤー化より、管理は簡単かも

参考サイト

実装してみる

というわけで、さっそく実装します。(今回はTypeScriptで実装)

前提条件として、aws-sdkを最新バージョンに更新しておきます。(最新バージョンじゃないとS3OLをサポートしていないので。Ver2.874.0でサポートを確認)

まずは「参考サイト」のAWS公式ブログに載っているコードを元に、TypeScriptで下記コードを書きました。(AWS公式ブログはpythonですが、全く問題なく理解できる内容だと思います。)

※元となる情報ですが、今回も「ドルアーガの塔 宝物リスト」のjsonを使用しています。(内容はこのブログの最後に載せています)

今回は「上記リスト(tower_of_druaga.json)から、階数(Floor)が素数の階の情報のみ取得する」という処理を実装しています。

// s3_object_lambda.ts
import axios from 'axios'
  
import 'source-map-support/register';
import * as AWS from 'aws-sdk';
import S3 from 'aws-sdk/clients/s3';
  
// S3OLのレスポンスの定義
interface IS3ObjectLambdaResponse {
  statusCode: number
}
  
// ObjectContextの定義
interface IObjectContext {
  inputS3Url: string,
  outputRoute: string,
  outputToken: string
}
  
// フロアの詳細(Detail)の定義
interface IDetail {
  Condition: string,
  Effect: string,
  Memo: string,
  Name: string
};
  
// 各フロアの情報の定義
interface IFloor {
  Type: string,
  Floor: number,
  Detail: IDetail[]
}
  
// ハンドラ関数
export async function handler(event:any): Promise<IS3ObjectLambdaResponse>{
  console.log(`[event] ${JSON.stringify(event)}`);
  
  const context:IObjectContext = event.getObjectContext;
  const route:string = context.outputRoute;
  const token:string = context.outputToken;
  const url:string = context.inputS3Url
  
  const res:any = await axios.get(url);
  console.log(`[resData] ${JSON.stringify(res.data)}`);
  
  const originalContent:IFloor[] = res.data;  
  
  // 変換処理を実施する。
  const filteredContent:string = filter(originalContent);
  
  const s3:S3 = new AWS.S3();
  
  const param:S3.WriteGetObjectResponseRequest = {
    RequestRoute: route,
    RequestToken: token,
    Body: filteredContent
  };
  
  const result = await s3.writeGetObjectResponse(param).promise();
  
  const response:IS3ObjectLambdaResponse = {
    statusCode: 200
  }
  
  return response;
}
  
// 階数(Floor)が素数の階の情報のみ抜き出す処理。  
// 素数の判定は、今回は手抜きしてます。
function filter(_content:IFloor[]):string {
  // const items:IFloor[] = _content

  const filteredItem:IFloor[] = _content.filter(x => [2,3,5,7].findIndex(y => y === x.Floor) !== -1);
  console.info(`[filteredItem] ${JSON.stringify(filteredItem)}`)
  return JSON.stringify(filteredItem);
}

キーになるのは、「event.getObjectContext」で取得できる以下3つの値です。

キー名 説明 備考
outputRoute S3OLからLambdaに渡されるルーティングトーク writeGetObjectResponseで使用
outputToken S3OLからLambdaに渡される認証(マッピング)トーク 同上
inputS3Url S3から元のキー内容を取得するためのURL

これらをevent引数から取得したら、あとはソースにある通り、

  • inputS3Urlにgetリクエストを投げて、そのレスポンスから元のキー内容を取得
    • 「元のキー情報(=ファイル名)」はgetObjectの引数「key」で設定される
  • 変換処理を実施し、元のキー情報から変換後の値を取得
    • 上のソースなら「filter」関数
  • writeGetObjectResponse関数で、getObjectの戻り値を変換後の値に更新
  • このLambda自体のレスポンスを返却する。(statusCode:200)

という処理を実施する感じです。

あとは上記Lambdaを、AWSに新規Lambda関数として作成しておいてください。

  • コンソール直でも、CloudFormation(以下Cfn)経由でもどちらでもOKです。
  • Cfnの場合、定義は通常のLambda関数と全く同じでOKです。
    • もちろんServerless Frameworkも

また、上記S3OLのLambda関数を呼び出すLambda(s3_object_lambda_base.ts)も実装しました。
(詳細は省略しますが、レスポンスのBodyとして、「raw」にtower_of_druaga.jsonのそのままの値を、「filter」に先述の「階数(Floor)が素数の階のみの情報」を格納します。)

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import 'source-map-support/register';
import { Context } from 'vm';
import * as AWS from 'aws-sdk';
import S3, { GetObjectRequest, GetObjectOutput } from 'aws-sdk/clients/s3';
  
interface IDetail {
  Condition: string,
  Effect: string,
  Memo: string,
  Name: string
};
  
interface IFloor {
  Type: string,
  Floor: number,
  Detail: IDetail[]
}
  
export async function handler(event:APIGatewayProxyEvent, _context:Context):Promise<APIGatewayProxyResult> {
  console.log(`[event] ${JSON.stringify(event)}`);
  
  const s3:S3 = new AWS.S3();
  
  const param_raw:GetObjectRequest = {
    Bucket: 'suzukima-s3-object-lambda-test',
    Key: 'tower_of_druaga.json'
  };
  
  const param_filtered:GetObjectRequest = {
    Bucket: '<S3OLアクセスポイントのARN>',
    Key: 'tower_of_druaga.json'
  };
   
  const result:IFloor[] = await Promise.all([s3getObject(s3, param_raw), s3getObject(s3, param_filtered)]);
  const response:APIGatewayProxyResult = {
    statusCode: 200,
    body: JSON.stringify({
      raw: result[0],
      filtered: result[1]
    })
  }
  
  return response;
}
  
async function s3getObject(_s3:S3, _param:GetObjectRequest):Promise<IFloor> {
  const res:GetObjectOutput = await _s3.getObject(_param).promise();
  console.info(`[res] ${JSON.stringify(res)}`);
  return JSON.parse(res.Body.toString());
}

設定する(コンソール上で)

ということで、まずはS3OLをコンソール上で作成します。
といってもS3OLはS3アクセスポイントを必要とするので、まずはS3アクセスポイントを作成します。

コンソールから[S3]→[アクセスポイント]→[アクセスポイントの作成]と選択します。

f:id:Makky12:20210404084232p:plain

キー名 説明 備考
アクセスポイント名 一意な名前を指定
バケット 対象のS3バケットを指定 「S3の参照」から選択可能
ネットワークオリジン VPCかインターネットか。VPCならVPCIDも指定する。 今回は「インターネット」を指定

次に、S3OLのアクセスポイントを作成します。 コンソールから[S3]→[オブジェクトLambdaアクセスポイント]→[オブジェクトLambdaアクセスポイントの作成]と選択します。

f:id:Makky12:20210404091638p:plain

今回は下記項目のみ設定しました。(それ以外はデフォルトのまま)

キー名 説明 備考
オブジェクトLambdaアクセスポイント名 一意な名前を指定
サポートするアクセスポイント 対象のS3アクセスポイントのARNを指定 さっき作ったS3アクセスポイントのARN
Lambda関数の呼び出し 呼び出すLambda関数を名前で指定するか、ARNで指定するか 今回は「アカウントの関数から選択」を指定
Lambda関数(のARN) 「Lambda関数の呼び出し」の値に従い、関数名か関数のARNを指定

ここまで出来たら、動作を確認してみましょう。(今回はコンソール上で動作確認を行います)

動作確認は、コンソールから[S3]→[オブジェクトLambdaアクセスポイント]から上で作成したS3OLアクセスポイントを選択し、「オブジェクト」タブでキーをチェックして、「アクション」から「開く」を実施することで実施できます。

f:id:Makky12:20210404092904p:plain

ハマった点など

  

「writeGetObjectResponse関数がない)」といわれる

これは参考サイトの クラスメソッドさんの記事にある通り、「AWS側のaws-sdkランタイムのバージョンが最新ではない」ことが原因です。

対策ですが、上記の記事通りに最新版のaws-sdkをLambdaレイヤーとして登録すればOKです。(Laambdレイヤーの説明は、今回は省略します)

結果が「Forbidden」になる

自分はこれにかなりハマりました。

結果としては、S3バケット&S3OLアクセスポイントへのポリシー設定で、下記のActionを許可すればOKです。

  • S3バケット:s3:GetObject
  • S3OLアクセスポイント:s3-object-lambda:WriteGetObjectResponse

ただ、僕が勘違いしてたのは、

  • WriteGetObjectResponseを(S3OLアクセスポイントではなく)S3バケットに対して設定していた
  • WriteGetObjectResponseを「s3:WriteGetObjectResponse」と勘違いしていた

点で、ここの調査にだいぶ時間を費やしてしまいました。(分かってしまえばなんてことはないんですけどね...)

結果

というわけで、最終的にs3_object_lambda_base.ts(AWS上ではjsだけど)を呼び出した結果が下の通り。
「raw」が「tower_of_druaga.json」そのままの値なのに対し、「filter」の方はちゃんとFloorが素数の情報のみになっています。

なので、S3OLが正しく動いている、と考えてよさそうです。

HTTP/1.1 200 OK  
  
(中略)  
  
{
  "raw": [
    {
      "Type": "treasure",
      "Floor": 1,
      "Detail": [
        {
          "Condition": "グリーンスライムを3匹倒す",
          "Effect": "壁を宝箱を取る前後1回ずつ壊せる",
          "Memo": "",
          "Name": "カッパーマトック"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 2,
      "Detail": [
        {
          "Condition": "ブラックスライムを2匹倒す",
          "Effect": "足が速くなる",
          "Memo": "",
          "Name": "ジェットブーツ"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 3,
      "Detail": [
        {
          "Condition": "ブルーナイトのどちらかを倒す",
          "Effect": "ミスしても残機が減らない(1回だけ)",
          "Memo": "正確な条件は、「ブルーナイトのうち、先にフロアに出現した方を倒す」",
          "Name": "ポーション・オブ・ヒーリング"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 4,
      "Detail": [
        {
          "Condition": "扉を通過する",
          "Effect": "フロア開始時、鍵がある方向を向くと音が鳴る",
          "Memo": "宝箱を出すより先に鍵を取ってしまうと出ない",
          "Name": "チャイム"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 5,
      "Detail": [
        {
          "Condition": "メイジの呪文を歩きながら盾で3回受ける",
          "Effect": "攻撃力UP",
          "Memo": "18階のドラゴンスレイヤーを取るのに必要",
          "Name": "ホワイトソード"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 6,
      "Detail": [
        {
          "Condition": "最上段に上がった後、下がる",
          "Effect": "10階までゴーストが見えるようになる",
          "Memo": "",
          "Name": "キャンドル"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 7,
      "Detail": [
        {
          "Condition": "カッパーマトックをなくす",
          "Effect": "宝箱を取る前に1回、取った後2回使える",
          "Memo": "最大で宝箱を取る前に4回、取った後に5回まで使えるが、Effectの回数より多く使うと1/3の確率で壊れる",
          "Name": "シルバーマトック"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 8,
      "Detail": [
        {
          "Condition": "ステート時点からX軸,Y軸共にずれた位置で剣を振る",
          "Effect": "体力が上がる(その階のみ)",
          "Memo": "",
          "Name": "ポーション・オブ・パワー"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 9,
      "Detail": [
        {
          "Condition": " 最上段の右から8列目と左から8列目の両地点を通過する。",
          "Effect": "体力が下がる(その階のみ)",
          "Memo": "",
          "Name": "ポーション・オブ・エナジー・ドレイン"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 10,
      "Detail": [
        {
          "Condition": "レッドスライムの放つ呪文を盾で受ける。",
          "Effect": "26階の宝箱(ハイパーガントレット)を取るのに必要。",
          "Memo": "レッドスライムの気分次第。",
          "Name": "ガントレット"
        }
      ]
    }
  ],
  "filtered": [
    {
      "Type": "treasure",
      "Floor": 2,
      "Detail": [
        {
          "Condition": "ブラックスライムを2匹倒す",
          "Effect": "足が速くなる",
          "Memo": "",
          "Name": "ジェットブーツ"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 3,
      "Detail": [
        {
          "Condition": "ブルーナイトのどちらかを倒す",
          "Effect": "ミスしても残機が減らない(1回だけ)",
          "Memo": "正確な条件は、「ブルーナイトのうち、先にフロアに出現した方を倒す」",
          "Name": "ポーション・オブ・ヒーリング"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 5,
      "Detail": [
        {
          "Condition": "メイジの呪文を歩きながら盾で3回受ける",
          "Effect": "攻撃力UP",
          "Memo": "18階のドラゴンスレイヤーを取るのに必要",
          "Name": "ホワイトソード"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 7,
      "Detail": [
        {
          "Condition": "カッパーマトックをなくす",
          "Effect": "宝箱を取る前に1回、取った後2回使える",
          "Memo": "最大で宝箱を取る前に4回、取った後に5回まで使えるが、Effectの回数より多く使うと1/3の確率で壊れる",
          "Name": "シルバーマトック"
        }
      ]
    }
  ]
}

まとめ

というわけで、S3OLの紹介&実際に動かしてみました。

今までモジュールなどで行っていましたが、これを使うことで、確かに便利なケースは出てきそうですね。 (例えば公式ブログにあった画像サイズ変換とか、あどクラスメソッドさんの記事にもあった文字コード変換とか)

なお、今回はS3OLの設定をコンソール上で行いましたが、その2ではこれはCloudFormation(実際はServerless Frameworkだけど)から定義・作成する方法を書こうと思っています。(serverless.tsの内容は、その際に公開します。)   

告知

先日行われた「JAWS DAYS 2021 re:Connect」における、私のセッションのアーカイブ動画が公開されたしたので、よろしければご参照ください。 www.youtube.com

また、他の方の動画も公開されているので、見逃したという人も、この機会にぜひご覧ください。

それでは、今回はこの辺で

参考資料:ドルアーガの塔 宝物リスト

過去のServerless Meetup Japan VirtualやQiita Advent Calendar 2020などで使用している「ドルアーガの塔 宝物リスト」の内容になります。
(「ドルアーガの塔」がわからない人は、ググってみると幸せになれるかもしれません。)

内容としては、下記情報を格納したオブジェクトの配列になります。(実際は60階分あるけど、今回は10階まで用意)

キー名 キー名(Detail内) 説明 備考
Type 内容の種類 「treasure」固定。パーティションキー
Floor 階数(1~60) ソートキー
Detail 下記4項目が格納されたオブジェクトの配列 配列なのは、45階だけ宝物が2つあるから
Condition 宝物の出現条件
Effect 宝物の効果。
Memo 一言メモ
Name 宝物のアイテム名 一部微妙に違ってるかも
[
  {
    "Type": "treasure",
    "Floor": 1,
    "Detail": [
      {
        "Condition": "グリーンスライムを3匹倒す",
        "Effect": "壁を宝箱を取る前後1回ずつ壊せる",
        "Memo": "",
        "Name": "カッパーマトック"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 2,
    "Detail": [
      {
        "Condition": "ブラックスライムを2匹倒す",
        "Effect": "足が速くなる",
        "Memo": "",
        "Name": "ジェットブーツ"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 3,
    "Detail": [
      {
        "Condition": "ブルーナイトのどちらかを倒す",
        "Effect": "ミスしても残機が減らない(1回だけ)",
        "Memo": "正確な条件は、「ブルーナイトのうち、先にフロアに出現した方を倒す」",
        "Name": "ポーション・オブ・ヒーリング"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 4,
    "Detail": [
      {
        "Condition": "扉を通過する",
        "Effect": "フロア開始時、鍵がある方向を向くと音が鳴る",
        "Memo": "宝箱を出すより先に鍵を取ってしまうと出ない",
        "Name": "チャイム"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 5,
    "Detail": [
      {
        "Condition": "メイジの呪文を歩きながら盾で3回受ける",
        "Effect": "攻撃力UP",
        "Memo": "18階のドラゴンスレイヤーを取るのに必要",
        "Name": "ホワイトソード"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 6,
    "Detail": [
      {
        "Condition": "最上段に上がった後、下がる",
        "Effect": "10階までゴーストが見えるようになる",
        "Memo": "",
        "Name": "キャンドル"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 7,
    "Detail": [
      {
        "Condition": "カッパーマトックをなくす",
        "Effect": "宝箱を取る前に1回、取った後2回使える",
        "Memo": "最大で宝箱を取る前に4回、取った後に5回まで使えるが、Effectの回数より多く使うと1/3の確率で壊れる",
        "Name": "シルバーマトック"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 8,
    "Detail": [
      {
        "Condition": "ステート時点からX軸,Y軸共にずれた位置で剣を振る",
        "Effect": "体力が上がる(その階のみ)",
        "Memo": "",
        "Name": "ポーション・オブ・パワー"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 9,
    "Detail": [
      {
        "Condition": " 最上段の右から8列目と左から8列目の両地点を通過する。",
        "Effect": "体力が下がる(その階のみ)",
        "Memo": "",
        "Name": "ポーション・オブ・エナジー・ドレイン"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 10,
    "Detail": [
      {
        "Condition": "レッドスライムの放つ呪文を盾で受ける。",
        "Effect": "26階の宝箱(ハイパーガントレット)を取るのに必要。",
        "Memo": "レッドスライムの気分次第。",
        "Name": "ガントレット"
      }
    ]
  }
]