AWS Lambda のタイムアウト発生時に別の Lambda 関数を起動し、元の Lambda 関数の処理内容を取得する

#はじめに
こんにちは。はじめまして。
最近、AWS で本番稼働する時のことをかんがえるようになりました。
Lambda の負荷テストをしていると、タイムアウトが発生して「コマッタ…」となった時のことを書いてみます。

#Lambda タイムアウト 15分問題

Lambda ってタイムアウト時間設定しなくちゃいけなくて、最大 15分しか設定できないんですよね。
（参考）
https://aws.amazon.com/jp/blogs/news/aws-lambda-timeout-15min/

で、15分以上処理しちゃうと、CloudWatch にタイムアウトのログを出すだけ… と。

まあ、Lambda に重たい処理をさせるな！ということでしょうけど、
もう作ってしまったし… なんとかできないものか…
せめて、ユーザに処理できなかったことを伝えられないものか…

#対策

ちょっと調べてみると、色々と対策はある模様。

- [タイムアウトでエラーとなってしまったLambdaのリカバリを行うLambdaを作成してみました](https://dev.classmethod.jp/server-side/serverless/aws-lambda-timeout-recovery/)
- [CloudWatch Logs を文字列検知してログ内容をメールを送信してみた](https://dev.classmethod.jp/cloud/aws/notification_cloudwatchlogs_from_sns/)

    ⇒ちょっと内容はちがうけど、タイムアウトの文字を検知してアラームを挙げることもできそう。

ただ、上記の方法だと、パラメータで渡される文字列によって処理が分岐するなど、
関数内でもアウトプットが複数パターンあると、どれで落ちたのかがわからず、
「どうしたらいいの？」ってなってしまった。

以下、ダラダラと経緯を書いていますが、結論から言うと、

- タイムアウトが発生する Lambda 関数
    - タイムアウトが発生する処理にログを仕込みます。
    - ログにはその値を特定するキーワードと Lambda 処理を識別する RequestId を付けます。
- タイムアウト後の Lambda 関数
    - Lambda 関数のトリガ設定は先ほど挙げた「[タイムアウトでエラーとなってしまったLambdaのリカバリを行うLambdaを作成してみました](https://dev.classmethod.jp/server-side/serverless/aws-lambda-timeout-recovery/)」と同じ。
    - タイムアウトのログから RequestId を取得します。
    - タイムアウトのログと同じログストリームから RequestId と値を特定するキーワードでフィルタします。

以上。

# 環境
- Python 3.8

# とりあえず、タイムアウトを再現してみる

タイムアウトを 1秒とし、10秒待機する処理を書いてみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/5d68c6ee-6d91-a5cc-8a29-42e8d6f59bfd.png)

``` python:sampleTimeout
import time

def lambda_handler(event, context):

    # ここに出力される key を取得することを今回の目標とします。
    print("key:%s" % event.get('key', 'default'))
    # 10秒待機
    time.sleep(10)
    
    return {}
```

#タイムアウト検出してみる

これは「[タイムアウトでエラーとなってしまったLambdaのリカバリを行うLambdaを作成してみました](https://dev.classmethod.jp/server-side/serverless/aws-lambda-timeout-recovery/)」にも記述がありますが、
```CloudWatch Logs```で```Task timed out after```というログが出力されたら Lambda 関数を発火するよう設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/fdf553f4-1933-f4e1-79f5-d3d8d1c80162.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/2947a0f8-5b39-3d37-d853-403ec9dd665d.png)

### 動作確認
```sampleTimeout.py``` をとりあえず実行してみます。

テストデータは下記の通り。

``` json
{
  "key": "test1"
}
```

ログはこんな感じに出力されてます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/1458e8c1-89ac-676a-06da-e76bd195cb6b.png)

```detectTimeout.py``` のログも確認してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/7967a36f-950e-bb58-8d77-0909b7ea41a1.png)

ちゃんと実行されてますね。

はて、どうするか ...
ロググループやログストリームは別処理で同じ所に出力されるときもあるし ....
その処理で特定できる何かキーワードはないモノか.....
最悪自分で発行するかな ......

ん？
ログに RequestId ってのがあるな。
ちょっと調べてみると、Lambda 関数コール時に生成されるユニークなキーっぽいですね。
lambda_handler の引数 ```context``` に含まれている模様。（Lambda 初心者まるだし）
（[参考](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-context-object.html)）

よし！やってみよう！

# タイムアウトのログから RequestId を抜き取る

```detectTimeout``` を書き換えます。

``` python:detectTimeout
import json
import base64
import gzip
import re

def lambda_handler(event, context):
    
    # ログデータは base64 にエンコードされた gzip データで取得できる。
    # それを解凍し、json オブジェクトに置き換える。
    payload = base64.b64decode(event['awslogs']['data'])
    log = json.loads(gzip.decompress(payload).decode('utf-8'))
    
    # requestID を取得 (UUID の正規表現にマッチした文字列を取得)
    reg = r'[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
    match = re.search(reg, log['logEvents'][0]['message'])
    if match:
        requestID = match.group(0)
    else:
        print('requestID が正常に取得できませんでした。')
        return
    
    print('request id : ' + requestID)
    
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }
```

ログを確認すると、取れてますね！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/22418fc8-2301-2c74-7629-6dffa68c9fe2.png)

#タイムアウトする処理でタイムアウト後処理に渡したい値のログに RequestID を含める

``` python:sampleTimeout
import time

def lambda_handler(event, context):
    
    print("key:%s:%s" % (event.get('key', 'default'), context.aws_request_id))
    time.sleep(10)
    
    return {}
```

``` text:log
Function Logs:
START RequestId: 287d2b7a-9d61-40cd-9c4c-d6d6d6d2ba0d Version: $LATEST
key:test1:287d2b7a-9d61-40cd-9c4c-d6d6d6d2ba0d
END RequestId: 287d2b7a-9d61-40cd-9c4c-d6d6d6d2ba0d
REPORT RequestId: 287d2b7a-9d61-40cd-9c4c-d6d6d6d2ba0d	Duration: 1000.88 ms	Billed Duration: 1000 ms	Memory Size: 128 MB	Max Memory Used: 56 MB	Init Duration: 133.96 ms	
2019-11-28T08:53:53.633Z 287d2b7a-9d61-40cd-9c4c-d6d6d6d2ba0d Task timed out after 1.00 seconds
```

# requestID と key: を含むログを取得し、key の値を取得する

最後まで行きます！

``` python:detectTimeout
import json
import base64
import gzip
import re
import boto3

def lambda_handler(event, context):
    
    # ログデータは base64 にエンコードされた gzip データで取得できる。
    # それを解凍し、json オブジェクトに置き換える。
    payload = base64.b64decode(event['awslogs']['data'])
    log = json.loads(gzip.decompress(payload).decode('utf-8'))
    
    # requestID を取得 (UUID の正規表現にマッチした文字列を取得)
    reg = r'[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
    match = re.search(reg, log['logEvents'][0]['message'])
    if match:
        requestID = match.group(0)
    else:
        print('requestID が正常に取得できませんでした。')
        return
    
    print('request id : ' + requestID)
    
    # requestID と key: でフィルタし、対象メッセージを取得する。
    client = boto3.client('logs')

    logMsg = client.filter_log_events(
        logGroupName=log['logGroup'],
        logStreamNames=[log['logStream']],
        filterPattern='"' + requestID + '" "key:"'
    )
    msgs = logMsg.get('events', [{'message' : ''}])[0]['message'].split(':')
    if not len(msgs) == 3 : 
        print('key が正常に取得できませんでした。')
        return
    
    value = msgs[1]
    print('key is [%s] ' % value)

    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }
```

ログを確認。

``` text:log
[ERROR] ClientError: An error occurred (AccessDeniedException) when calling the FilterLogEvents operation: User: arn:aws:sts::309122365079:assumed-role/detectTimeout-role-w1rjh1a2/detectTimeout is not authorized to perform: logs:FilterLogEvents on resource: arn:aws:logs:ap-northeast-1:309122365079:log-group:/aws/lambda/sampleTimeout:log-stream:
Traceback (most recent call last):
  File "/var/task/lambda_function.py", line 29, in lambda_handler
    logMsg = client.filter_log_events(
  File "/var/runtime/botocore/client.py", line 357, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/var/runtime/botocore/client.py", line 661, in _make_api_call
    raise error_class(parsed_response, operation_name)
```

ログを見る権限が足りない。 orz.

###```detectTimeout``` の Lambda に設定しているロールを編集

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:FilterLogEvents",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:<AWSアカウントID>:*"
        }
    ]
}
```

こんどこそ…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315833/fdd5533a-97b1-24ed-f8fd-d65ad226529d.png)

でけた！
ただ、タイムアウトのリトライ数がデフォルトで２に設定されているので、次の処理が走ってしまってますね。
リトライ数を０にすることを忘れずに！（手順は省略）

# 最後に

これが本当に正しい手法か分かりませんが、タイムアウトが発生した処理のデータが取得できるようになりました。

やったことは、

- タイムアウトが発生する Lambda 関数
    - タイムアウトが発生する処理にログを仕込みます。
    - ログにはその値を特定するキーワードと Lambda 処理を識別する RequestId を付けます。
- タイムアウト後の Lambda 関数
    - トリガに CloudWatch Logs を設定
    - タイムアウトのログから RequestId を取得します。
    - タイムアウトのログと同じログストリームから RequestId と値を特定するキーワードでフィルタします。

です。
もっといい方法あったら教えてください。

以上、長々と失礼しました。