S3アップロード用の署名付きURLを発行するLambda(※ 甘く見るといろいろ嵌まる)

こんにちは。 たーせるです。

突然ですが、本業の方でS3 アップロード機能に対する需要がここ近年ゆるやかに増えています。

主にはAPI Gatewayを通すにはデカすぎるファイルを AWS の世界に持ち込む』『とりあえずファイルを溜めておいて、後でまとめてバッチ処理する』あたりが典型的なユースケースなのですが、昨今 AI と連携するシステム要件が増えたせいで、こうした基本的な機能が逆に大事にされるようになった印象が強いです。

たとえば RAG のような仕組みを作るにしても、あるいは前回ご紹介した背景除去を非同期バッチ化したい場合も、クライアントから S3 にファイルをアップロードする機能は当然の如く求められるでしょう。

tercel-tech.hatenablog.com

そもそもこの『S3アップロード』機能、一見簡単そうに見えますが、意外ときちんと作ろうとするとハマりどころがいろいろ潜んでいるのです。

そこで今日は基本に立ち戻りつつ、具体的なソースとともに実装方法をご紹介していきたいと思います。

署名付きURLについて

このセクションは、『署名付きURLってナニ?』という人のための補講になります。 先刻ご存知の方は丸ごと読み飛ばしてください。

あらためて今回、議論の対象とするのは『クライアントから S3 に直接ファイルをアップロードする』というシチュエーションです*1

フロントエンドにアップロード用のフォームがあって、そこにユーザがファイルを添付してアップロードボタンを押すと、ファイルが S3 に直送されるという仕掛けを思い浮かべてください。

しかしながら、いつでも誰でもアップロード可能にしてしまうと、意図しない人間によって悪戯いたずらの温床にされかねません。ですので、選ばれし者にしかアップロードを許したくないとしましょう。

どうすればよいでしょうか。

少し前、このブログで『一時クレデンシャルによる AWS リソースアクセス』についてご紹介しました。 この戦略がほぼ核心に近いのです。

tercel-tech.hatenablog.com

上述の記事は汎用性抜群で最強の手法なのですが、S3 アクセスの場合はより簡便な「署名付きURL」という仕組みが用意されています。

署名付きURLは、(非常に雑な説明ですが)認証情報が埋め込まれた時限式のURLです。 この URL に対して GET リクエストや PUT リクエストを送ると、S3 に対してファイルをダウンロードしたりアップロードしたりできます。 ただし、一定期間を過ぎるとその URL は効力を失い、二度と利用できなくなります。

署名付きURLを構成する大部分は推測困難な文字列の羅列であり、ざっくり言ってしまえばログイン用のパスワードを突破する方がよほど簡単だったりします*2。 加えて、有効期限が設定されているため、万一URLが漏洩したとしても、それによる被害を最小限に抑えることができるといわれています。

ですから、何らかの手段によって署名付きURLが漏洩したとしても、それを手掛かりにして有効な署名付きURLを類推することはまず無理ということになります。

よく使われるのはダウンロード用の署名付き URL です。『有効期限つきのダウンロードリンク』などはその典型例です*3

アップロード用の署名付きURLは難しい

ただし、ダウンロード用の署名付きURLと較べると、アップロード用の署名付きURLの方が諸々の難易度は高いので、思い通りにいかず挫折した方もいらっしゃるのではないでしょうか。

よくある失敗例が、『ライブラリのリファレンスに書いてあるとおりに実装して、デプロイも成功し、Lambda も正常に動作している。それなのに、発行した署名付き URL がうまく叩けない』というものです。

こういう厄介な不具合に頭を抱えて、なかなか WBS の進捗が 99% から 100% にできない ── そんな迷える子羊の皆さんは、この記事を読めばたぶん解消するはずです。

API仕様

まずは今回作る API の仕様を合わせておきましょう。

  • ファイル名、ファイルタイプ(いわゆる Content-Type)を渡すと、S3 バケットにアップロードするための署名付きURLを発行し、返却する
  • ファイル名は衝突を防止するため、適当な接頭辞が付与される
  • また、ファイル名に半角英数とハイフン、アンダーバー以外の文字が含まれる場合は、「_」に置き換える
メソッド POST
Content-Type application/json; charset=utf-8
認証 なし
API リクエスト
  • リクエスト Body は以下のような形式となります。
{ "fileName": "xxx.png", "fileType": "image/png" }
  • fileName(必須)
    • アップロードしたいファイル名
  • fileType(必須)
    • アップロードしたいファイルの Content-Type
API レスポンス
  • 署名付きURLを以下の形式で返します。
{
    "uploadUrl": 署名付きURL,
    "key": S3オブジェクトのキー(Lambda側で命名),
    "expiresIn": 署名付きURLの有効期限(秒)
}

オブジェクトのキーは、APIに渡したファイル名に基づいてLambda側で生成されます。

ファイル名をそのままキーにしてもよいのですが、ファイル名を保持する必要がない場合は、オブジェクトキーは英数字に揃えておく方が無難だと考えます。

署名付きURLの有効期限は固定値で、300秒ほどに設定しておきます。

SAMテンプレート

それでは早速、SAM テンププレートを書いていきましょう。

ここでは、アップロード先の S3 バケットUploadBucketも関数と同時に作成しています。

また、API に対するプリフライトリクエストが落ちないよう、API Gateway に CORS 設定を追加するため、ApiGatewayリソースを明示的に定義しています。

もしフロントエンドとバックエンドを同一オリジンで運用するならば CORS 対応は不要になります。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  S3 presigned upload example (Lambda(Python) + API Gateway + S3)

Globals:
  Function:
    Runtime: python3.13
    Timeout: 10
    MemorySize: 128

Parameters:
  BucketName:
    Type: String
    Description: S3 bucket name for uploads

Resources:
  UploadBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref BucketName
      CorsConfiguration:
        CorsRules:
          - AllowedHeaders:
              - '*'
            AllowedMethods:
              - PUT
              - GET
            AllowedOrigins:
              - '*'
            ExposedHeaders:
              - ETag
            MaxAge: 3000

  GetUploadUrlFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: presigned-upload-url-python
      CodeUri: src/
      Handler: app.lambda_handler
      Environment:
        Variables:
          BUCKET_NAME: !Ref BucketName
          URL_EXPIRES_IN: 300       # 署名URLの有効期限(秒)
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - s3:PutObject
              Resource: !Sub '${UploadBucket.Arn}/*'
      Events:
        Api:
          Type: Api
          Properties:
            Path: /upload-url
            Method: POST
            RestApiId: !Ref ApiGateway

  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      Name: presigned-upload-api
      StageName: prod
      Cors:
        AllowMethods: "'OPTIONS,POST'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowOrigin: "'*'"

Outputs:
  ApiEndpoint:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/prod/upload-url"

  BucketNameOutput:
    Description: "Upload bucket name"
    Value: !Ref BucketName

S3 バケットAPI Gateway の CORS 対応に注目しましょう。

属性の綴りや設定値の書き方が微妙に異なる点がハマりどころです。

また、ApiGatewayリソースを明示的に定義したことで、Outputsセクションも微妙に書き換えている点にも注意してください。

!Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/prod/upload-url"

Lambda 関数

続いて Lambda 関数の実装です。

主処理の前半はパラメータチェックで、実際にはgenerate_presigned_url()という API を叩いているだけのように見えるかも知れません。

ただし、いくつか見落としがちなポイントがあり、ここを取りこぼすと「関数はエラーなく動くのに、なぜか S3 にアップロードできない」という厄介な事態に陥ります。

それらの注意点は、またのちほど。 まずは四の五の言わずサンプルコードから。

src/app.py

import json
import os
import re
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

REGION = os.environ.get("AWS_REGION")
ENDPOINT = f"https://s3.{REGION}.amazonaws.com"

BUCKET_NAME = os.environ.get("BUCKET_NAME")
URL_EXPIRES_IN = int(os.environ.get("URL_EXPIRES_IN", "300"))

s3_client = boto3.client("s3",
    region_name = REGION,
    endpoint_url = ENDPOINT,
    config = Config(signature_version="s3v4"))

def lambda_handler(event, context):
    try:
        body = event.get("body")
        if body and isinstance(body, str):
            body = json.loads(body)
        elif body is None:
            body = {}

        file_name = body.get("fileName")
        file_type = body.get("fileType")

        if not file_name or not file_type:
            return _response(
                400,
                {"message": "fileName and fileType are required"},
            )

        object_key = f"uploads/{_build_object_key(file_name)}"

        try:
            presigned_url = s3_client.generate_presigned_url(
                ClientMethod="put_object",
                Params={
                    "Bucket": BUCKET_NAME,
                    "Key": object_key,
                    "ContentType": file_type,
                },
                ExpiresIn=URL_EXPIRES_IN,
            )
        except ClientError as e:
            return _response(
                500,
                {"message": "Failed to generate presigned URL"},
            )

        return _response(
            200,
            {
                "uploadUrl": presigned_url,
                "key": object_key,
                "expiresIn": URL_EXPIRES_IN,
            },
        )

    except Exception as e:
        return _response(500, {"message": "Internal server error"})

def _build_object_key(file_name: str) -> str:
    safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", file_name)
    return f"{int(__import__('time').time())}-{safe_name}"

def _response(status_code: int, body: dict):
    return {
        "statusCode": status_code,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Headers": "Content-Type,Authorization",
            "Access-Control-Allow-Methods": "OPTIONS,POST",
        },
        "body": json.dumps(body),
    }

ハマりどころとしては、boto3.client()メソッドの引数をしっかり指定しないと、署名付きURLを叩いたときにCORSエラーが発生することがある点です。

特にendpoint_urlを指定しないと、署名付きURLXXX.s3.amazonaws.comを叩いた際、XXX.s3.ap-northeast-1.amazonaws.com に 301/307 リダイレクトが入って、そのリダイレクト応答が CORS を満たさずプリフライトが落ちることがあります。

また、もうひとつのポイントとして、オブジェクトキーを生成する際に、「英数字・ドット・アンダースコア・ハイフン」以外の文字を、全部 _ に置き換える処理をしています。

これは、ファイル名をそのままオブジェクトキーにすると、後々扱いが面倒になることがあるためです(たとえば、ファイル名に#という記号が残っていると、URLのクエリと衝突しやすいなどの不都合があります)。

また、既存のオブジェクトとキーの衝突を防ぐために、接頭辞を付加しています。

面倒くさいときは、UUIDv7 に置換してしまうのもよいかもしれません。

動作確認用HTML

では最後に、署名付き URL を利用してファイルを S3 にアップロードするフロント側の処理を実装します。

以下の HTML を用意します。 https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/upload-urlは、デプロイした API の URL に読み替えてください。

<!DOCTYPE html>
<html lang="ja">
<meta charset="UTF-8">
<body>

<input type="file" id="file"><br><br>

<input type="text" id="api" size="80"
  placeholder="https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/upload-url">
  <br><br>

<button onclick="upload()">アップロード</button>

<p id="progress">進捗: 0%</p>

<script>
async function upload() {
  const file = document.getElementById("file").files[0];
  const api = document.getElementById("api").value;

  if (!file || !api) return alert("ファイルとAPI URLを入力してください");

  // 署名付きURLを取得
  const res = await fetch(api, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fileName: file.name, fileType: file.type })
  });
  const { uploadUrl } = await res.json();

  // S3 に PUT
  const xhr = new XMLHttpRequest();
  xhr.open("PUT", uploadUrl);
  xhr.setRequestHeader("Content-Type", file.type);

  xhr.upload.onprogress = e => {
    if (e.lengthComputable) {
      const percent = (e.loaded / e.total * 100).toFixed(1);
      document.getElementById("progress").textContent = `進捗: ${percent}%`;
    }
  };

  xhr.onload = () => alert("アップロード完了");
  xhr.onerror = () => alert("アップロード失敗");
  xhr.send(file);
}
</script>

</body>
</html>

ここでは、署名付き URL の取得と、S3 へのアップロードという2回の HTTP リクエストを発行しています。

ただし、前者は fetch、後者は xhr を意図的に使い分けています。 ファイルのアップロードには一般的に時間がかかるため、進捗をリアルタイムに画面に反映するためです。

まとめ

署名付きURLを使って S3 バケットにファイルをアップロードする!

── というと、だいたい『そのくらい誰でもできるやろ』と言われがちです。

アーキ図を描く際も頻出の設計パターンなのですが、改めて記事化しようとしたら、意外とハマりがちなポイントが見えてきました。

もしかしたら、いまこの瞬間も、ファイルアップロードがうまく作れなくて途方に暮れているジュニアエンジニアが世界のどこかに一人くらいはいるかもしれないので、そこに届けばいいなと思いました。

ではでは。

*1:既に実行ロールがアタッチされたバックエンドと S3 でよろしくやりとりをするのではない点に注意してください。

*2:今は多要素認証やパスキーの普及によって不正ログインのリスクが減っているため、単純比較はできないですが、少なくとも人間が暗記可能な10文字程度のランダム文字列とは較べものになりません。

*3:「ダウンロードリンクを物凄い勢いで拡散されたら、知らん人にもファイルダウンロードされるおそれがあるだろ」と突っ込む人がいるかも知れませんが、URL の代わりにダウンロードしたファイルそのものを共有されたら結局同じことなので、そこは意味のある議論ではないのです。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.