Lambda タイムアウトと無限ループ、ちゃんと理解できてますか?

AWS

Lambda を使い始めて最初につまずくのって、たいてい lambda タイムアウト 絡みなんですよね。「なぜか関数が終わらない」「SQSのメッセージが何度も処理される」「気づいたら請求額がえらいことになってた」みたいな。自分も最初はそのあたりをあまり深く考えずに使っていて、ちょっと痛い目を見ました。

今回はLambdaのタイムアウト設定と、それに起因する無限ループの話をまとめてみます。特にSQSと組み合わせているときのハマりどころは知っておいて損はないはずです。

この記事でわかること

  • Lambdaのタイムアウト設定の基本(デフォルト値・最大値)
  • タイムアウトが原因で無限ループが起きる仕組み
  • SQSとの組み合わせで特にハマるポイント
  • AWSが提供する再帰ループ検出の仕組み
  • 実際の対策コードと設定例

Lambda タイムアウトの基本から整理する

まず基本の確認から。Lambda には実行できる最大時間の制限があります。

  • デフォルト値:3秒(コンソールの初期値)
  • 最大値:900秒(15分)
  • 1秒単位で調整可能

デフォルトが3秒というのが結構トラップで、外部APIを叩いたりS3からファイルを取ってくるだけで普通にタイムアウトします。最初に「なんで動かないんだ」ってなるの、だいたいここです。

タイムアウトになる主な原因

AWSの公式ドキュメントにも書かれていますが、タイムアウトの一般的な原因はこのあたりです:

  • S3やDynamoDBなどのデータストアからのダウンロードが予想より時間がかかる
  • 外部サービスへのHTTPリクエストのレスポンスが遅い
  • 処理するデータ量が多く、計算に時間がかかる
  • メモリリークによって処理がどんどん重くなる

メモリリークのケースは気づきにくくて厄介です。最初は問題なく動いていても、呼び出しを重ねるうちにジリジリとタイムアウトが増えてくるパターンがあります。

タイムアウト値の設定方法(CLI・SAM)

AWS CLIで設定する場合はこんな感じです。

# Lambdaのタイムアウトを120秒(2分)に変更する例
aws lambda update-function-configuration \
  --function-name my-function \
  --timeout 120

AWS SAM(template.yaml)で設定する場合:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      Runtime: python3.12
      Timeout: 120  # ← ここで秒数を指定
      MemorySize: 256

ポイントは「タイムアウト値を関数の平均実行時間に近づけすぎない」こと。平均が90秒の処理に対して100秒を設定すると、ちょっとした負荷変動で予期せずタイムアウトします。余裕を持たせましょう。

Lambda タイムアウトが無限ループを引き起こす仕組み

ここが本題です。タイムアウト単体では「処理が中断される」だけなんですが、これがリトライ設定と組み合わさると無限ループっぽい挙動になります。

非同期呼び出しのリトライ

Lambda を非同期で呼び出した場合(EventBridgeやS3イベントトリガーなど)、デフォルトでエラー時に2回リトライします。つまり最大3回実行されます(タイムアウトも「エラー」扱いに含まれます)。

仮に関数が15分かかる処理なのに、タイムアウトを10分に設定してしまっていたとします。

  1. 関数が10分でタイムアウト → エラー
  2. 自動リトライ1回目 → また10分でタイムアウト → エラー
  3. 自動リトライ2回目 → また10分でタイムアウト → エラー

これで終わりならまだいいんですが、EventBridgeで定期実行している場合、次のスケジュール実行もあるので実質ずっとこれが続きます。コストも積み上がるし、処理が完了しないのでデータもおかしくなります。

SQSトリガーの場合はもっと複雑

SQSをトリガーにしているとさらにハマりやすくなります。詳しくは次のセクションで。

SQS + Lambda の「無限ループ地獄」を理解する

余談ですが、自分がLambdaで一番ヒヤッとしたのはSQS絡みでした。ちょうどキューに大量メッセージが溜まっていたときのことで、正直トラウマです。

可視性タイムアウトとは

SQSには「可視性タイムアウト(Visibility Timeout)」という設定があります。これは「あるコンシューマーがメッセージを取得してから、他のコンシューマーにそのメッセージが見えなくなる時間」のことです。

Lambdaがメッセージを取得して処理している間、他のLambdaは同じメッセージを取得できない…という仕組みです。

問題が起きるのはここです。SQSの可視性タイムアウトをLambdaの関数タイムアウトより短く設定してしまうと、Lambdaが処理中にもかかわらずメッセージが再度キューに現れてしまいます。すると別のLambdaが同じメッセージを処理し始め、重複処理が発生します。

正しい可視性タイムアウトの設定

AWSが推奨しているのは、SQSの可視性タイムアウトをLambdaの関数タイムアウトの少なくとも6倍に設定することです(SQS→Lambdaのトリガー構成の推奨として明記されています)。

# Lambdaのタイムアウト: 30秒の場合
# SQSの可視性タイムアウト: 30 × 6 = 180秒(3分)に設定する

aws sqs set-queue-attributes \
  --queue-url https://sqs.ap-northeast-1.amazonaws.com/123456789/my-queue \
  --attributes VisibilityTimeout=180

Pythonで処理時間が延びそうなときに可視性タイムアウトを延長する例:

import boto3
import json

sqs = boto3.client('sqs')
QUEUE_URL = 'https://sqs.ap-northeast-1.amazonaws.com/123456789/my-queue'

def lambda_handler(event, context):
    for record in event['Records']:
        receipt_handle = record['receiptHandle']
        body = json.loads(record['body'])

        # 処理が長引きそうなら可視性タイムアウトを延長する(いわゆるハートビート)
        # VisibilityTimeout は 0〜43200秒(12時間)まで。
        # ただし「最初に受信してから12時間」を超えて延長できない点に注意(リセットはできない)
        sqs.change_message_visibility(
            QueueUrl=QUEUE_URL,
            ReceiptHandle=receipt_handle,
            VisibilityTimeout=300  # 5分延長
        )

        # 実際の処理
        process(body)

def process(body):
    # 重い処理...
    pass

DLQ(デッドレターキュー)で暴走を止める

Lambdaがタイムアウトし続けるとメッセージが何度もリトライされます。最終的にはDLQに移動させることで「無限に処理を繰り返す」状態を止められます。

DLQはSQSの「リドライブポリシー」で設定します。maxReceiveCount(最大受信回数)を超えたメッセージが自動的にDLQに送られます。

# リドライブポリシーの設定例(コンソールでも設定可能)
aws sqs set-queue-attributes \
  --queue-url https://sqs.ap-northeast-1.amazonaws.com/123456789/my-queue \
  --attributes '{
    "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:123456789:my-dlq\",\"maxReceiveCount\":\"5\"}"
  }'

maxReceiveCountは最低でも5程度に設定するのが推奨です。1や2にすると一時的なエラーでもすぐDLQに飛んでしまいます。

ちなみにmaxReceiveCountを5にして、可視性タイムアウト×5回分の時間が経過してからようやくDLQに移動する、ということは覚えておきましょう。Lambdaのタイムアウトが30秒なら最短でも可視性タイムアウト(180秒)×5回 = 最大15分かかります。

AWSの「再帰ループ検出」機能を知っておく

実はAWSはこの問題に対して、再帰ループ検出(recursive loop detection)という機能を提供しています。これはAWSの公式発表(AWS Compute Blog)で紹介されていて、デフォルトで有効です。

仕組みはざっくりこうです。

  • Lambda は AWS X-Ray のトレースヘッダーの仕組み(lineage)を使って「同じイベントがどれくらい連鎖してるか」を追跡する
  • サポートされた経路(サポートされたAWS SDK経由の送信など)でイベントが連鎖するとカウンタが増える
  • 同じトリガーイベントによる呼び出しが16回を超えると、次の呼び出しをドロップしてメトリクス(RecursiveInvocationsDropped)を出す

X-Ray のアクティブトレーシングを有効にしていなくても動作します。

ただし、「検出できる再帰ループ」はサポートされている経路に限られます。あと、全部を止めてくれる魔法というよりは、あくまで最後の安全網です。基本は設計で防ぐのが大事かなと。

再帰ループになりがちなアンチパターン

よくある事故パターンを挙げておきます。

import boto3
import json

s3 = boto3.client('s3')

def lambda_handler(event, context):
    # ⚠️ アンチパターン:S3のPUTをトリガーにしているのに
    #    同じバケット・同じプレフィックスにファイルを書き込んでいる
    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        key = record['s3']['object']['key']

        # ファイルを加工して...
        processed_data = process(bucket, key)

        # 同じバケットの同じプレフィックスに書き込む → また自分がトリガーされる!
        s3.put_object(
            Bucket=bucket,
            Key=key,  # ← ここが問題。別のprefixやバケットに書くべき
            Body=processed_data
        )

S3トリガーの場合、書き込み先を別のプレフィックス(例: processed/)や別のバケットにするだけで防げます。

        # ✅ 修正版:別のプレフィックスに書き込む
        s3.put_object(
            Bucket=bucket,
            Key=f"processed/{key}",  # プレフィックスを変えるだけでOK
            Body=processed_data
        )

context オブジェクトで Lambda タイムアウト前に安全に終了させる

Lambdaのハンドラーには context オブジェクトが渡されます。これを使うと残りの実行時間をミリ秒で取得できます。長い処理をする場合は、タイムアウト前に安全に終了するロジックを入れるのが堅牢な設計です。

import json

def lambda_handler(event, context):
    items = event.get('items', [])
    processed = []

    for item in items:
        # 残り実行時間が5秒を切ったら処理を中断して結果を返す
        remaining_ms = context.get_remaining_time_in_millis()
        if remaining_ms < 5000:
            print(f"タイムアウト間近のため処理を中断。残り: {remaining_ms}ms")
            break

        result = process_item(item)
        processed.append(result)

    return {
        'statusCode': 200,
        'processed_count': len(processed),
        'remaining': len(items) - len(processed)
    }

def process_item(item):
    # 実際の処理
    return item

SQSとの組み合わせで大量メッセージを処理するときは特に有効です。「途中で打ち切って次回に回す」よりも、タイムアウトエラーになってリトライが積み重なる方がずっとコストがかかります。

まとめ:Lambda タイムアウトと無限ループ対策

Lambdaのタイムアウトと無限ループについて整理しました。

  • タイムアウトのデフォルトは3秒。外部リソースを使うなら必ず見直す
  • タイムアウト値は平均実行時間に近づけすぎない。余裕を持たせる
  • SQS利用時は可視性タイムアウトをLambdaタイムアウトの少なくとも6倍に設定する
  • DLQでリトライの上限を設ける。無限ループの安全網になる
  • S3トリガーは書き込み先のプレフィックスやバケットを分けることで再帰を防ぐ
  • AWSの再帰ループ検出はデフォルトで有効だが、万能ではない(最後の安全網)
  • context.get_remaining_time_in_millis() で残り時間を見ながらグレースフルに終了させる

タイムアウトと無限ループの問題は、知っているかどうかで設計の堅牢さがかなり変わります。自分も最初に理解していれば防げたミスがいくつかありました。同じところで詰まっている人の参考になれば!

参考になったらクリックしてもらえると嬉しいです!

Blogmura CloudAWS Ranking
タイトルとURLをコピーしました