【第4回】AWS SQS × Lambda Python 自動化入門 — 本番運用で使えるTipsとよくある落とし穴まとめ

AWS

このセクションでわかること

  • Visibility Timeout の正しい設定値と設定ミスの影響
  • Partial Batch Response で無駄な再試行を減らす実装方法
  • DLQ監視での落とし穴と正しいメトリクス選び
  • maxConcurrency と maxReceiveCount の運用上の注意点
  • DLQ リドライブの自動化方法

前回のおさらいと今回のテーマ

前回(第3回)は、SQS × Lambda の基本的なイベントソースマッピング設定と、バッチ処理の実装方法を紹介しました。「動く」ところまではできたと思います。

ただ、動くのと「本番で安心して使える」のは別の話で。今回はそのギャップを埋める話です。具体的には、よくある設定ミスや監視まわり、障害時の動きなど、実際に運用してみて「これ知っておけばよかった」と感じたポイントをまとめています。

正直、自分もまだ全部を本番で踏み抜いたわけじゃないですが、調べたことと実際にハマったことを混ぜながら書きます。

Visibility Timeout の設定ミスは地味に致命的

まずここから。SQS × Lambda を使うとき、一番よくある設定ミスが Visibility Timeout(可視性タイムアウト)です。

簡単に言うと「Lambda がメッセージを受け取って処理中の間、他のコンシューマーからそのメッセージが見えなくなる時間」のことです。これが Lambda の実行時間より短いと、処理中にもかかわらずメッセージが再び「見える」状態になって、別の Lambda インスタンスが同じメッセージを拾い始めます。結果として二重処理が起きる。

AWSのドキュメントでは、Visibility Timeout は Lambda のタイムアウト × 6 + MaximumBatchingWindowInSeconds(バッチウィンドウを使う場合) を最低ラインとして設定することを推奨しています。

たとえば Lambda のタイムアウトが 30秒で、MaximumBatchingWindowInSeconds を 0秒(使ってない)なら、Visibility Timeout は最低でも 180秒(3分)にする必要があります。

# boto3でキュー作成する場合の設定例
import boto3

sqs = boto3.client('sqs')

response = sqs.create_queue(
    QueueName='my-queue',
    Attributes={
        'VisibilityTimeout': '180',   # Lambda timeout(30s) × 6(+ バッチウィンドウを使うならその秒数も足す)
        'MessageRetentionPeriod': '86400',
        'RedrivePolicy': '{"deadLetterTargetArn":"arn:aws:sqs:ap-northeast-1:xxxxxxxxxxxx:my-dlq","maxReceiveCount":"5"}'
    }
)

余談ですが、これを設定ミスしていると「なぜかたまにメッセージが2回処理される」という症状になるので原因がわかりにくい。最初しばらく悩みました。

Partial Batch Response(部分失敗)を必ず実装する

バッチサイズを 10 とかに設定して処理するとき、1件だけ失敗したらどうなるか。デフォルトでは「バッチ全体が失敗扱い」になって、成功した9件も含めて全部リトライされます。これはかなり無駄が多い。

Partial Batch Response(ReportBatchItemFailures)を有効にすると、関数が部分的な成功を返せるようになり、不要なレコードの再試行回数を減らせます。

実装はシンプルで、失敗したメッセージの messageIdbatchItemFailures として返すだけです。

import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def handler(event, context):
    batch_item_failures = []

    for record in event['Records']:
        message_id = record['messageId']
        try:
            body = json.loads(record['body'])
            process_message(body)
        except Exception as e:
            logger.error(f"Failed: {message_id} / {e}")
            batch_item_failures.append({'itemIdentifier': message_id})

    return {'batchItemFailures': batch_item_failures}


def process_message(body):
    # 実際のビジネスロジック
    pass

注意点が一点あって、FIFO キューでこの機能を使う場合は、最初の失敗が発生した時点でそれ以降のメッセージの処理を止め、失敗したメッセージと未処理のメッセージをまとめて batchItemFailures に入れる必要があります。メッセージの順序を保つためです。

スタンダードキューとFIFOで挙動が違うのでここも注意。自分は最初この違いをちゃんと理解していなくて微妙な実装をしていました。

DLQ の監視、メトリクス選びを間違えると無意味になる

デッドレターキュー(DLQ)にメッセージが入ったら即アラートを飛ばしたい——というのは当然の要件だと思います。ただここにひとつ罠があります。

DLQ に自動的に移動されたメッセージ(処理の失敗によるリドライブ)は、NumberOfMessagesSent メトリクスにキャプチャされません。そのため、DLQの状態を監視するには ApproximateNumberOfMessagesVisible メトリクスを使うことが推奨されています。

つまり NumberOfMessagesSent でアラームを設定しても、DLQにメッセージが溜まっているのに通知が来ない、という事態が起こりえます。これを知らずに「DLQの監視してます」と言っている状態はけっこうヤバい。

正しい設定のイメージはこうです:

import boto3

cw = boto3.client('cloudwatch')

cw.put_metric_alarm(
    AlarmName='DLQ-MessageVisible-Alert',
    MetricName='ApproximateNumberOfMessagesVisible',  # ここ注意
    Namespace='AWS/SQS',
    Statistic='Maximum',
    Dimensions=[{'Name': 'QueueName', 'Value': 'my-dlq'}],
    Period=60,
    EvaluationPeriods=1,
    Threshold=1,
    ComparisonOperator='GreaterThanOrEqualToThreshold',
    AlarmActions=['arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:my-alert-topic'],
    TreatMissingData='notBreaching'
)

あわせて ApproximateAgeOfOldestMessage(最古メッセージの滞留時間)も監視しておくと、「処理は回ってるけど追いついていない」状況を早期に検知できます。このメトリクスは最大集計で確認し、通常の処理時間のベースラインを把握した上で閾値を設定するのが良いとされています。メッセージが保持期間に近づくと自動的に破棄されてしまうため、その前に検知できるよう設定することが重要です。

監視でおさえておきたいメトリクスまとめ

  • ApproximateNumberOfMessagesVisible(DLQ):DLQへの流入検知。これが基本
  • ApproximateAgeOfOldestMessage:処理が追いついているか確認する
  • NumberOfMessagesSent:DLQ監視には使えない。通常キューのトラフィック確認向け
  • Throttles(Lambda):後述するmaxConcurrencyと組み合わせて確認

同時実行数(maxConcurrency)の設定と落とし穴

SQSのイベントソースマッピングには maxConcurrency(最大同時実行数)という設定があります。これを使うとLambdaのスケールアウトに上限をかけられるので、下流のDBやAPIを守るためのレートリミットとして便利です。

SQSトリガーの最大同時実行数設定を使うと、Lambda関数の実行数を一定に保ちながら運用しやすくなり、結果としてスロットリングが起きにくくなるケースもあるようです。ただし、スロットリングが発生するとメッセージが再配信されて再試行が増え、状況次第では正常なメッセージでもDLQに移動してしまう可能性があるので注意が必要です。

これ地味に困るやつで、スロットリングによる DLQ 流入が起きていると「処理ロジックのバグでもないのにDLQにメッセージが入る」という状況になります。CloudWatch の Throttles メトリクスもあわせて見ておくと原因の切り分けがしやすくなります。

また、maxConcurrency の最小値は 2 です。1 にしたい場合は Lambda の予約済み同時実行数(ReservedConcurrentExecutions)で対応することになりますが、こちらは別の挙動上の注意点があって、正直まだ完全に理解しきれていないので、別途検証したいと思っています。

maxReceiveCount と DLQ リドライブの設計

DLQ に移動するまでの再試行回数を制御するのが maxReceiveCount です。AWSとしてはケースに応じて調整すべきパラメータで、一般論としては、maxReceiveCount が小さすぎると一時的なエラー(タイムアウトやネットワーク瞬断など)でもすぐDLQに流れてしまうので、ある程度余裕を持たせるのが良いとされています。

DLQ に入ったメッセージは、コンソールや API でリドライブ(元のキューに戻して再処理)できます。手動でやるのは面倒なので、定期的に DLQ を確認して自動でリドライブするような仕組みを入れておくと運用が楽になります。EventBridge + Lambda で組むのが個人的には手軽でいいかなと思っています。

# DLQからメッセージを元キューへリドライブする(SQS コンソール操作の API 相当)
import boto3

sqs = boto3.client('sqs')

# リドライブタスクを開始
response = sqs.start_message_move_task(
    SourceArn='arn:aws:sqs:ap-northeast-1:xxxxxxxxxxxx:my-dlq',
    DestinationArn='arn:aws:sqs:ap-northeast-1:xxxxxxxxxxxx:my-queue',
    MaxNumberOfMessagesPerSecond=10
)

task_handle = response['TaskHandle']
print(f"Redrive task started: {task_handle}")

start_message_move_task は比較的新しい API です。コンソールの「リドライブ開始」ボタンの裏側もこれを使っています。

まとめ:監視と設定は「動いてから」では遅い

今回のポイントをまとめておきます:

  • Visibility Timeout は Lambda タイムアウト × 6(+ バッチウィンドウを使うならその秒数) を基本にする
  • バッチ処理では ReportBatchItemFailures を必ず有効にする(FIFOは挙動が異なる)
  • DLQの監視は ApproximateNumberOfMessagesVisible を使う(NumberOfMessagesSent は罠)
  • ApproximateAgeOfOldestMessage も監視しておくと詰まりを早期検知できる
  • maxConcurrency を使うときはスロットリングによるDLQ流入に注意
  • maxReceiveCount は小さくしすぎない。DLQ リドライブは自動化しておくと運用が楽

動くだけなら第1回〜第3回でカバーできていると思いますが、本番に置くならこのあたりの設定と監視は最初から入れておいたほうが後で泣かずに済みます。自分も最初は「まあ動いてるし」でスルーして後から直したので。

※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

📚 シリーズ「AWS SQS × Lambda Python 自動化入門」(第4回 / 全4回)

← 前回の記事: 前回の記事はこちら

🎉 このシリーズは今回で完結です!

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

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