続 カッコの付け方

AWSを始めとしたクラウドコンピューティング全般と、唯一神emacsにおける()の付け方についてだらだら書きます

AWS Lambdaが何故か何回もinvokeされてる、しかも成功してるのに

AWS Lambdaで久々にハマったので、理解を深めて残しておこうと思う。 もうGAからずいぶん経っているが、自分が初期のLambdaをよくわかってない、ということがわかった。

全部すっ飛ばして結論

  • Lambdaを同期実行 + AWS SDK/CLI などで単体実行するときは、Client側のタイムアウト設定を、Lambda側のタイムアウトよりも長くする
  • Boto3(Python)の場合は、1分以上Lambdaの実行にかかる場合は、これを守らないと3回ぐらいLambdaが勝手にInvokeされる

概論・今となっては特殊な動かし方の説明

Lambdaは基本なにか別サービスと連携して使うものです。しかし単体でも実行させることは可能です。(どこかのテックブログにはできないと書いてましたがこれは嘘です。)

今回の目的は、この完全に単体でLambdaを実行することです。結構レアケースだと思いますが、もはや世の中の情報が溢れかえっているのでこのレアケースを正確に示すためだけに外堀の解説を超雑にします。

同期・非同期実行

想像ですが、初期のLambdaは同期実行しかできなかったはず。同期とは、想像通り呼び出し後、Lambdaが完走するまで待ちます。結果もすぐに取り出せます。

対して非同期は文字取り、呼び出しが成功した時点で、一旦成功が呼び出し側に返ります。
アナタの言いたいことわかります、「じゃあ、全部非同期でよくね?」
しかし、おそらくアナタの考える非同期とちょっと違います。「Invokeすると Invoke-Idが帰ってくるんでしょ?それをポーリングすればいいんでしょ?」と思って調べるとそんなAPIないです
Lambdaの非同期実行の結果を取得する場合、別のサービスに投げる・失敗時向けとしてはDLQに投げる設定が可能で、その結果を能動的に 取得する必要があります。

サービス連携と同期実行

一番多いパターンは API Gateway + Lambda のパターンだと思いますが、この場合は基本、同期実行。非同期もできるはずですが、書きたいことから外れるので端折る。
API Gateway がリクエストを受けて、Lambdaを叩いてすぐに結果がほしいから、なんとなく同期で納得するとおもう。Httpリクエストで何分も待たせることないしね。
同期実行で動いているパターンでも、サービス連携している場合がほとんど。ここで言いたいのは、同期実行 ≠ 単体実行 ということ

単体・同期実行やりたい理由

「そもそもなんでお前、単体実行したいの?」については
権限をギンギンに絞りたい場所・リソースにアクセスする必要があるのだが、処理は超簡単でシュッと結果だけがほしい
という、今となってはレアケースとなってしまった、初期のLambdaの使い方にマッチすることがあったから。

「まあ、そのケースでも EventBridgeとかでCron実行するでしょ?」となるので、ホンマに単体実行なんてレアやとおもいますが、もっと正確に言うと、定期実行するジョブの一つの工程で、上記のケースが発生したため使った。

本題・問題発生パターン

単体・同期実行で発生しうる問題は
呼び出しがタイムアウトした場合、boto(core)の機能によりリトライされて何回もinvokeされる
詳しく説明

同期実行の場合、Lambdaのinvokeは、Lambdaが実行完了 or 異常終了するまで処理まちします。この処理まちというのが、一般的なhttpsリクエストと同じです。 Lambda側で最大実行時間(タイムアウト)は設定できます、が一般的なhttpsリクエストと同じなので、client側がちゃんとLambda側のタイムアウトまで待ってくれるように仕込まないと、ちょっと重い処理ならclientが早々にあきらめて、永遠に成功しない。

awscli / boto3 で lambda invoke を叩いた場合、このclient側のタイムアウトは、botoの標準設定となります。ちなみにbotoは60秒です。ちなみにbotoは自動リトライ機能もついています。デフォルト3回だったはず

実験

labmda関数は timeoutと名付けて、下記コード。サンプルにsleep足しただけ。
これで、Lambda側の timeoutは 2分にしておく。

import json
import time

def lambda_handler(event, context):
    time.sleep(90)
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

awscliで叩いてみる。全部垂れ流すように --debug 付きにしといたほうがいい。

$ aws lambda invoke --function-name timeout result.txt --debug

2022-04-23 17:33:41,590 - MainThread - botocore.retries.standard - DEBUG - Max attempts of 3 reached.
2022-04-23 17:33:41,590 - MainThread - botocore.retries.standard - DEBUG - Not retrying request.
2022-04-23 17:33:41,590 - MainThread - awscli.clidriver - DEBUG - Exception caught in main()
Traceback (most recent call last):
  ...
socket.timeout: The read operation timed out

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
 ...
urllib3.exceptions.ReadTimeoutError: AWSHTTPSConnectionPool(host='lambda.us-west-2.amazonaws.com', port=443): Read timed out. (read timeout=60)

ご覧の通り、read timeout = 60で、client側がLambdaをまちきれずに諦めている、それにリトライも3回やってる。
Lambda側のメトリックスだけみると、「成功してるのになんで3回も叩いてるの?アホなの?」となる。
awscliでも timeoutを指定できるので、今度は2分 client側も待つようにする。

あとから考えると aws cliを使うのはやめて、認証情報をつけることさえできれば、素直にcurlなり普通のhttp clientでやったほうがええです。

$ aws lambda invoke --function-name timeout result.txt --cli-read-timeout 120 --debug
 ...
2022-04-23 17:44:23,495 - MainThread - awscli.formatter - DEBUG - RequestId: ....
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

今度は成功する。

対策boto3の場合

import boto3
from botocore.config import Config

conf = config(read_timeout=120, retries={'max_attempts': 0})
client = boto3.client("lambda", config=conf)
client.invoke(...)

retryも黙らせたほうがいい。