LoginSignup
9
8

実はLambdaの関数URLは署名付きURL化できるよ、29秒の壁も越えられるよ、という話

Last updated at Posted at 2023-08-05

この記事を2行で

  • S3の署名付きURLと同じことが、Lambdaの関数URLにもできます
  • Lambdaの署名付きURLを使えば、APIGatewayの29秒タイムアウトを超えてLambdaを実行できます

何が嬉しいの?

  • AIのように時間のかかる処理を、Lambdaだけで処理できます
  • 署名付きURLなので、Cognito + APIGatewayのような認証がそのまま使えます

どうやって?

  • Lambdaの関数URLはAPI Gatewayの29秒タイムアウト制限の影響を受けない
  • S3の署名付きURLと同じ認証方法が、Lambdaの関数URLでも使える
    • ※サービスはLambdaだけ、ライブラリもboto3の標準機能だけで実現できます

このあたりの事情を説明(APIGatewayのタイムアウト制限とは?)

AWSのLambdaは、15分かかる処理までは受け付けることができます。
ただ、APIGatewayは、レスポンスを返すのに29秒以上かかるリクエストを強制的に中断する仕様になっています。

Lambdaは何しろ安価で、運用も楽で、AIの推論を動かせるだけのスペックがあるのですが、サービスとして運用するときの認証回りはAPIGatewayに依存しています。APIGatewayの認証を使ったまま、簡単に29秒の壁を超えることはできないかと、色々な方法が検討されてきました。

※イメージ図

data_struct.png

使う関数

from botocore.auth import SigV4QueryAuth

boto3が使える環境なら無条件で利用できます。
Googleで検索しても全く情報が出ないので、細かいところはコードを直接読んで調べる必要があります。

Lambdaの署名付きURLを発行する

前準備をする:リクエストを受け取るLambdaを作る

このようなLambdaを作成して、関数URLを発行しました。

lambda_handler.py
import json

def lambda_handler(event, context):
    user = event["queryStringParameters"]["user"]
    return {
        'statusCode': 200,
        'body': json.dumps(f'Hello {user} from Lambda!')
    }

qiita-image-function-url.png

認証タイプはAWS_IAMです。

署名付きURLを発行するソース

以下の関数(presign_for_lambda)を定義して、署名付きURLを作成します。
関数URLは対象の関数URLに置き換えてください。

app.py
from botocore.auth import SigV4QueryAuth
from botocore.awsrequest import AWSRequest
from botocore.credentials import create_credential_resolver
from botocore.session import Session


def presign_for_lambda(url: str, body: str):
    """
    Lambdaの署名付きURLを発行する
    """
    # 定数(変更しても反映されない値、変更しないほうがいい値)
    SERVICE_NAME = "lambda"  # サービス名はLambdaで固定
    METHOD = "GET"  # bodyはクエリ文字列に変換されるので、GET固定でよい
    EXPIRES = 300  # レスポンスを見ると、認証の有効期限は5分で固定される

    # botocoreのセッションを取得する
    session = Session()
    # 今の環境に設定されている認証情報を取得する
    resolver = create_credential_resolver(session)
    credentials = resolver.load_credentials()
    # リクエストに対して署名する
    request = AWSRequest(method=METHOD, url=url, data=body)
    SigV4QueryAuth(
        credentials=credentials,
        service_name=SERVICE_NAME,
        region_name=session.get_config_variable("region"),
        expires=EXPIRES,
    ).add_auth(request)

    return request.url

### -----------------------
# 呼び出し部分
### -----------------------

import json

# Lambdaの関数URL(末尾のスラッシュは不要です)
URL = "https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws"

result = presign_for_lambda(
    URL,
    # Lambdaに送信するペイロード
    json.dumps({"user": "Bjarne Stroustrup"}),
)

print(result)

上のファイルをapp.pyとして保存して、pythonで実行すると、長い署名付きURLが出力されると思います

$ python app.py
https://xxxxxxxxxxxxx...ずらずら長いURL...

curlに署名付きURLを渡してみると、Lambdaの実行結果が返ってきました。

$ curl `python app.py`
"Hello Bjarne Stroustrup from Lambda!"

渡したパラメータ(user)も正しく解析されています。

署名付きURLを調べてみる

発行された署名付きURLは以下のようになっています。

https://xxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws?
user=Bjarne%20Stroustrup&
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=AKIAxxxxxxxxxxxxxxxx%2F20230805%2Fap-northeast-1%2Flambda%2Faws4_request&
X-Amz-Date=20230805T042931Z&
X-Amz-Expires=300&
X-Amz-SignedHeaders=host&
X-Amz-Signature=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

リクエストの送信先はLambdaの関数URLです。
それはそうだろうとも言えますが、S3の署名付きURLと全く同じフォーマットです。

※JSON形式で渡したペイロードがクエリ文字列の中に入るので、そこだけ違います

検証:ペイロードを改ざんしてみる

署名付きURLのクエリ文字列を書き変えて、user=Bjarne%20Stroustrupを、user=Alan%20Curtis%20Kayに改ざんしてみます。リクエストを投げると、以下のようなレスポンスが返ってきます。

{"message":"The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details."}

Signatureが、クエリ文字列、認証情報、送信先のホスト、日時を検証しています。一つでも改ざんされていると、リクエストは通らなくなります。

検証:署名付きURLに有効期限切れのリクエストを投げてみる

署名付きURLの有効期限は発行から5分です。
※SigV4QueryAuthのExpiresに何を設定しても反映されません。5分固定です。

{"message":"Signature expired: 20230805T041907Z is now earlier than 20230805T042511Z (20230805T043011Z - 5 min.)"}

# Signature expired: ${署名の発行時間} is now earlier than ${現在時間 の 5分前}
# 「署名された時間は、現在時間の5分前よりも古い」
# のメッセージで弾かれる。
# 
# Expiresをどの数字(300にしても3600にしても12000000にしても)同じメッセージが返る

期限の切れたリクエストは通りません。

検証:認証なしでリクエストを投げてみる

認証情報をつけずにリクエストをすると、Forbiddenが返ります。

{"Message":"Forbidden"}

検証:POSTボディでJSONのままペイロードを渡せないの?

はい、SigV4QueryAuthを使う限りはGETのクエリ文字列でしかデータを渡せません。botocoreのソースに、data部(ペイロード)を全てクエリに置き換えて、元のdata部のペイロードを削除する処理が入っています。

SigV4QueryAuthではなくSigV4Authを使うとbodyのまま渡すこともできるのですが、認証情報がヘッダ部に入るので、若干面倒です。

auth.py
operation_params = ''
if request.data:
    # We also need to move the body params into the query string. To
    # do this, we first have to convert it to a dict.
    query_dict.update(_get_body_as_dict(request))
    request.data = ''

本題:LambdaでLambdaの署名付きURLを発行する

ここからが本題です。
LambdaでLambdaの署名付きURLを発行して、リクエストさせます。

構成図は以下の通りです。
AWSの資料でよく見る構成図そのままですが、S3の場所がLambdaになります。

structure.png

実装

署名付きURLを発行するLambdaを新規作成します。
※上で紹介したソースとほぼ同じソースですので、折り畳みで紹介します。

botocoreはLambdaに最初から入っているため、依存ライブラリは不要です。

Lambdaのソースコード
lambda_handler.py
from botocore.auth import SigV4QueryAuth
from botocore.awsrequest import AWSRequest
from botocore.credentials import create_credential_resolver
from botocore.session import Session
import json


def presign_for_lambda(url: str, body: str):
    """
    Lambdaの署名付きURLを発行する
    """
    # 定数(変更しても反映されない値、変更しないほうがいい値)
    SERVICE_NAME = "lambda"  # サービス名はLambdaで固定
    METHOD = "GET"  # bodyはクエリ文字列に変換されるので、GET固定でよい
    EXPIRES = 300  # レスポンスを見ると、認証の有効期限は5分で固定されている

    # botocoreのセッションを取得する
    session = Session()
    # 今の環境に設定されている認証情報を取得する
    resolver = create_credential_resolver(session)
    credentials = resolver.load_credentials()
    # リクエストに対して署名する
    request = AWSRequest(method=METHOD, url=url, data=body)
    SigV4QueryAuth(
        credentials=credentials,
        service_name=SERVICE_NAME,
        region_name=session.get_config_variable("region"),
        expires=EXPIRES,
    ).add_auth(request)

    return request.url


URL = "https://xxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws"

def lambda_handler(event, context):
    result = presign_for_lambda(
        URL,
        json.dumps({"user": "Bjarne Stroustrup"}),
    )
    return {
        'statusCode': 200,
        'body': json.dumps({"url": result})
    }

Lambdaの実行ロールを編集、関数URLの実行権限をつける

Lambdaにlambda:InvokeFunctionUrlの権限を付けます。

arn.png

実行してみる

作ったLambdaを実行すると、以下のようなレスポンスが返ってきたと思います。
Lambdaのロールで発行するとX-Amz-Tokenがつくため、署名付きURLは長くなります。

{"statusCode": 200, "body": "{\\"url\\": \\"https://xxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws?user=Bjarne%20Stroustrup&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIXXXXXXXXXXXXX%2F20230805%2Fap-northeast-1%2Flambda%2Faws4_request&X-Amz-Date=20230805T054557Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Security-Token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&X-Amz-Signature=37cdf421c249c4b3610a9b9135823417ba2871fcb376080af4cb69d11997c0cb\\"}"}

Lambdaから受け取ったurlにリクエストを投げると、正しくレスポンスが返ってきます。

"Hello Bjarne Stroustrup from Lambda!"

29秒の壁を越えてみる

関数URLを発行しているLambdaを、以下の通り編集します

  • タイムアウト時間を2分に変更します
  • sleepを入れて、1分処理を待つようにします

timeout.png

実行結果

署名付きURLでcurlを投げると、1分後に正しく結果が返ってきました。
もちろんエラーはありません。

curl_result.png

CloudWatch Logsのログを見ても、60002 ms(60秒)かかっていることが確認できます。

log_data.png

まとめ

Lambdaの関数URLは、S3の署名付きURLと同じフォーマットで署名ができます。仕組みも実装方法もS3の署名付きURLとほぼ同じです。署名付きURLを取得して、fetch("署名付きURL")をリクエストするだけでよいので、画面側の負担も少なくて済みます。

AIでリクエスト時間が延びる昨今、ぜひ関数URLを積極的に使っていただければと思います。

付録(説明とか)

Lambdaの関数URLとは?

APIGatewayを挟まずに、Lambdaを直接実行できるようにする機能です。
※2022年の4月に公開された機能です

署名付きURLとは?

権限を持っているユーザーや環境が、あらかじめ自身の権限でAWSリクエストに署名しておく機能です
署名されたAWSリクエストは、権限のないユーザーや環境が実行することができます

ただ、好きなような実行できるわけではなく、制限があります。

  • 署名は時間が経つと失効する
  • 送信する内容(URLクエリ、POSTで送るbody、ヘッダなど)と、データの送信先は、署名した時点から変更できない。※ただしファイルを送信するリクエストについては、ファイルの内容は送信時に決められる

委譲することで、タイミングと実行場所だけが動かせるようなイメージです。「署名付きURL=S3のサービス」だと思われがちですが、Cloudfront、Lambda、SQSなどでも署名付きURLを使うことができます。

9
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
8