Lambda + S3 Presigned URLを用いてS3バケットにファイルをアップロード

[つばさ株式会社](https://www.world-wing.com/)でのアルバイトにおいて署名付きURL(S3 Presigned URL)を使用する機会があったので、備忘録として実装の流れをメモしておきます。
ちなみにこの記事を書いている時間も時給を頂いています。最高です。

- S3PresignedURLは日本語訳で署名付きURLとされています。今回は署名付きURLという名称で統一します。
- この記事では、署名付きURLについての軽い説明と、実際に実装していく際の流れについて説明しています。
- 自分が担当した部分が局所的であったため、局所的な記事になっていますがお許し下さい(Cognitoを使ったログイン処理の実装を省いています)。
- 目次がありますので、要らないところは適宜スキップして下さい。

#目次
1. 署名付きURLって？
2. S3を使うと何が嬉しいの？
3. 実装開始
4. IAMロールの作成
5. Lambda関数の作成
6. API Gatewayの設置
7. テストしてみる
8. まとめ


##1. 署名付きURLって？
　webサービスを作る際、そのサービスのユーザにS3へファイルをアップロードをさせたい時ってありますよね？？ただ、誰でもアップロードできるようにS3をパブリックにしちゃうと困りますよね。困るんですよ。
　そんな悩みを抱えているなら、署名付きURLを使用しましょう！！

　署名付きURLとは特定のS3のオブジェクトに対して、誰でもファイルのアップロード/ダウンロードができるようなURLを制限時間付きで発行する機能です。
　S3はIAMユーザ単位での細かなアクセス制御などを行うことができますが、署名付きURLはこれの一部権限を制限時間付きで与えることができます。
　これを利用することで普段はプライベートにしてセキュアに保ち、ファイルをアップロードする際には権限を与えるといったことが可能になります。


##2. 署名付きURLを使うと何が嬉しいの？
　ここまでの説明だけだと、「いや別にファイルをアップロードするだけならLambdaとAPIGatewayとか使って普通に捌けばよくない？？」って思いますよね。
　場合によってはそうなんですが、署名付きURLにも以下のようなメリットがあります。

**運用コストが安価ですむ**
　署名付きURLを発行した後は、ユーザーが直接ファイルをアップロード/ダウンロードするだけなので激安です。フツーのサーバーモデルと違って発行後はインスタンスを起こしてる必要すらありません。

**実装が簡単**
　実装が簡単です。僕でもできます。

**セキュアにできる**
　署名付きURLはアクセス可能な時間を細かく設定できるため、短い時間にすることで不正なアクセスなどに晒されるリスクを減らすことができます。

**アップロード自体を受け付けるよりもでかいファイルを捌ける**
　API Gateway + LambdaでやるとサーバーにアップしてからサーバーからS3にアップみたいな中継ができません(できないこともないですがPOSTできる最大サイズがめちゃ小さい)。署名付きURLならクソでかい動画でも余裕でアップロードできちゃいます。

　なんか良さそうな感じしますね。ちなみに今回の実装で終わるとAPIのURLを知っている人は誰でも署名付きURLを発行できてしまう為セキュリティはガバガバです。AWS CognitoでログインしたユーザのみにAPI Gatewayを触らせるように実装するなど、各自工夫してください(？？？？)

##3. 実装の流れ
<img width="839" alt="スクリーンショット 2020-06-23 16.28.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/97cad739-9e41-8733-b5e9-fed04e98dfe3.png">
　完成図はこんな感じになっています。フロント周りおよびアップロード先となるS3バケットの作成については割愛しています。
　権限の作成→その権限をLambda関数に付与→署名付きURLを発行するためのLambda関数を実装→そのLambda関数を発火するAPI Gatewayを設置 という流れで書いていきます。


##4. IAMロールの作成
　まず権限を作成します。
<img width="915" alt="スクリーンショット 2020-06-18 17.51.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/f870e5f1-87ea-00eb-ac4d-3612a81b96c6.png">

　AWSのサービス検索バーを開いてIAMと入力すると、IAMの設定ページに移動できます。
<img width="998" alt="スクリーンショット 2020-06-18 17.54.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/b3488e32-b4e2-f2f6-08b0-358cf75b0e8d.png">

　そこの左のバーにある「アクセス管理」の「ロール」をクリックし、「ロールの作成」をクリックします。
<img width="1001" alt="スクリーンショット 2020-06-18 17.55.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/8c7a5ab8-3314-3da1-31d7-bb456434ee43.png">
　このIAMロールはLambda関数に紐付ける予定なので「AWSサービス」から「Lambda」を選択します。


　アクセス権限ポリシーとして、今回はファイルのアップロードを行うのでAmazonS3FullAccessをアタッチします。その後、タグをつけるか聞かれますが、タグはいらないので無視してください。
　<img width="1014" alt="スクリーンショット 2020-06-18 18.05.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/6e486cd3-ac5b-ccef-c6df-68ba296a3db1.png">

　ロールの名前を決めた後(名前は何でもいいですが、今回はAmazonS3FullAccessOnlyとして進めます)、適当に説明を書いて「ロールの作成」を押します(自分のアカウントだと同じ名前のロールを既に作成しているので警告されていますが、本来は出ません)。
　これでIAMロールの作成ができました。次はこれを紐づけるLambda関数を作成しましょう。

##5. Lambda関数の作成
　IAMロールを作成した際と同様に、AWSのサービス検索バーを開いて「Lambda」と入力するとLambda関数の一覧画面に移動できるので、そこで「関数の作成」をクリックします。

<img width="1316" alt="スクリーンショット 2020-06-20 17.25.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/5191af8b-c8d9-d37e-eb5d-801424edb56d.png">
「一から作成」から上のように情報を入力します。
　関数名は自由です(拘りがなければ私と同じくPreSignedUrlCreaterにしておいて下さい)。ランタイムは今回Python3でサンプルコードを記載しているためPython3系にしておいて下さい。さらに、実行ロールから先ほど作成したIAMロールの「AmazonS3FullAccessOnly」を選択し、「関数の作成」をクリックしましょう。

　Lambda関数の作成ができましたら、いよいよ中身を書いていきます。サンプルコードは以下のようになります(s3バケット名とリージョン名は適宜変更して下さい)。

```python:lambda_function
import boto3
from botocore.client import Config
import json
import uuid

def lambda_handler(event, context):
   
    PUT_BUCKET = 's3_bucket' #S3バケット名
    UUID = str(uuid.uuid4()) #uuid(一意に識別するためのランダム値)を発行
    PUT_KEY = 'put_folder/' + UUID #S3にアップロードされるファイルの保存名を決定(uuidを用いて重複を避ける)

    BORROW_TIME = 300  #URLの有効期限(sec)
    
    #region_nameはS3のリージョン名(東京リージョンなら'ap-northeast-1')
    s3 = boto3.client('s3', region_name='region_name', config=Config(signature_version='s3v4'))

    s3.put_object(
        Bucket=PUT_BUCKET,
        Key=PUT_KEY
    )
        
    put_url = s3.generate_presigned_url(
        ClientMethod = 'put_object', 
        Params = {'Bucket' : PUT_BUCKET, 'Key' : PUT_KEY}, 
        ExpiresIn = BORROW_TIME, 
        HttpMethod = 'PUT')
    
    #body要素にjsonを与えると、GETリクエストに対してよしなにjsonを返してくれる
    return {
        'statusCode': 200,
        'isBase64Encoded': False,
        'body': json.dumps(
            {
                'PUT_URL': put_url,
                'UUID': UUID
            }
        )
    }
```
(今回使用したboto3などのライブラリは最初からLambda側に用意されているのでインポートするだけでOKです。)
　この関数を実行すると、指定したS3バケットにファイル名(PUT_KEY)を確保し、そこに対してファイルのアップロードを可能にする署名付きURLを発行します。

##6. API Gatewayの設置
　これやるならCognitoでログイン制御まで書けやって話なんですが、許してください。
　例によって、AWSのサービス検索バーを開いて「API Gateway」と入力するとAPI Gatewayの一覧画面に移動できるので、そこで「APIの作成」をクリックします。
<img width="547" alt="スクリーンショット 2020-06-21 17.44.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/a18f71f8-eac9-0c49-c587-f3ee0055a75c.png">
　色々種類がありますが、今回はREST APIの構築をしていきます。
<img width="1159" alt="スクリーンショット 2020-06-21 17.46.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/fcab2f02-1e60-d85b-d050-9466b7c7a14b.png">
　上から順に「REST」「新しいAPI」を選択し、API名と説明を適宜入力して、「APIの作成」をクリックします。
　<img width="588" alt="スクリーンショット 2020-06-21 17.50.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/0f3e7f5c-9e64-0df1-ebdf-908a087754bb.png">

　「アクション」から「リソースの作成」をクリックします。

<img width="1174" alt="スクリーンショット 2020-06-21 17.53.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/11316d21-f7d8-185d-68b3-e30edb474ac1.png">
　リソースの設定画面になるのでリソース名を入力します。「API Gateway CORSを有効にする」には必ずチェックを入れておいて下さい。

<img width="605" alt="スクリーンショット 2020-06-21 17.57.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/4f1a70c5-c773-ae45-c872-558209b4e72c.png">

　次にメソッドを作成します。
　メソッドの作成をクリックすると、メソッドの種類を選択するタブが出てくるので「GET」を選択してチェックボタンを押します。

<img width="1184" alt="スクリーンショット 2020-06-21 17.59.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/6a69098c-c3a9-7136-f3ea-ba07b322bea3.png">
　セットアップするためのフォームが出てくるので、上の画像のように入力後、「保存」を押します(Lambda 関数に権限を追加するかの確認が出ますが、OKを押して下さい)。
　これでAPIの設置ができました。

<img width="1308" alt="スクリーンショット 2020-06-21 18.37.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332801/c762bd47-4239-320e-dd32-fd05eb9875cb.png">
　先ほど作成したLambda関数の詳細に戻ってみると、先ほど作成したAPI Gatewayが追加されています。
　このAPI Gatewayをクリックすると、その下にAPIエンドポイントが表示されます。このURLに対してGETリクエストを飛ばすと、署名付きURLとUUIDが返されます(Lambda関数で設定した返り値のbodyが返されます)。実際に使う際にはこの署名付きURLにアップロードしたいファイルをPUTしてあげればOKです。

##7. テストしてみる
　どのような環境でファイルのアップロードを行うのかは人それぞれだと思うので、テストはコンソールを用いた方法について記載します。(カールせずに普通にブラウザでurl叩けばOKですが。)

```terminal
$ JSON=$(curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/PutUrlGetter/put-url-getter)
$ $JSON
zsh: command too long:{~jsonの中身~}
$ export URL="jsonの中身から署名付きURLをコピペする"
$ curl -D - -X PUT --upload-file アップロードするファイル名 $URL
```

これで指定したS3バケットにファイルがアップロードされていれば成功です。

##8. まとめ
　今回はAPI GatewayからLambda関数を呼び出し、Lambda関数から署名付きURLを発行するところまでをやってみました。序盤に書いた通り、このままではAPIエンドポイントのURLが漏れると誰にでもファイルアップロードを許してしまうので、実際に運用するのには不安が残ります。
　これを解決するためにログイン処理の実装まで記載すべきであるのは承知しているのですが、今回それは僕の担当ではないため、割愛させていただきました(気が向いたら追記します)。

　拙い内容ですが、署名付きURLを使ってみたいという人の参考になれば幸いです。
　ご一読いただきまして、ありがとうございます。