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

# この記事を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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/aaedd628-b15f-a305-e2ec-87cb27a831aa.png)


## 使う関数

```
from botocore.auth import SigV4QueryAuth
```

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


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

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

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

```python: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/5c4d42ce-0bee-2892-7656-238cc938b98f.png)

認証タイプは`AWS_IAM`です。

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

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

```python: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が出力されると思います

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

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

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

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

## 署名付きURLを調べてみる

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

```bash
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`に改ざんしてみます。リクエストを投げると、以下のようなレスポンスが返ってきます。

```bash
{"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分固定です。

```bash
{"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が返ります。

```bash
{"Message":"Forbidden"}
```

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

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

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

https://github.com/boto/botocore/blob/develop/botocore/auth.py

```python: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/0f99ef2b-06a8-e8f2-1f11-4bffbff2b9fd.png)

## 実装

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

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

<details><summary>Lambdaのソースコード</summary><div>

```python: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})
    }
```
</div></details>


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

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

![arn.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/a1a4d2dd-59aa-eb92-623c-a5334753a581.png)

## 実行してみる

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

```bash
{"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にリクエストを投げると、正しくレスポンスが返ってきます。

```bash
"Hello Bjarne Stroustrup from Lambda!"
```

## 29秒の壁を越えてみる

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

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

![timeout.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/6b0577dd-2a03-6a82-ad38-50b159fbd2c5.png)

### 実行結果

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

![curl_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/b8aa51e1-15f1-e511-175b-e4adc56940ce.png)

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

![log_data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/521f58d8-bb2f-7ad0-0b89-6dcad4157e6b.png)

# まとめ

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

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

# 付録（説明とか）

### Lambdaの関数URLとは？

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

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-urls.html

### 署名付きURLとは？

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

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

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

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