SQS Visibility Timeoutの完全解説:メッセージ二重処理の原因と対策

SQSキューから取得したメッセージが複数のコンシューマーに重複処理される——この問題はVisibility Timeoutの設定ミスが原因であることがほとんどだ。処理時間がタイムアウト値を超えた瞬間、SQSはそのメッセージを「未処理」と判断して再度可視化し、別のワーカーが同じメッセージを拾ってしまう。

TL;DR:Visibility Timeoutとは何か

項目内容
定義コンシューマーがメッセージを受信してから、他のコンシューマーに見えなくなる時間
デフォルト値30秒
設定範囲0秒〜43,200秒(12時間)
二重処理の原因処理時間がVisibility Timeoutを超えるとメッセージが再可視化される
対策処理時間に余裕を持たせた値を設定、または処理中に延長APIを呼ぶ
削除タイミング処理完了後に明示的にDeleteMessageを呼ぶ必要がある

Visibility Timeoutの仕組みを理解する

SQSはメッセージを受信した時点でそのメッセージを一時的に「不可視」にする。これはキューからメッセージを削除するわけではなく、他のコンシューマーから見えなくするだけだ。コンシューマーが処理を完了してDeleteMessageを呼べばメッセージは消える。しかしVisibility Timeoutが切れる前に削除されなければ、メッセージはキューに戻り、再度取得可能になる。

この設計は意図的なものだ。コンシューマーがクラッシュしたり応答しなくなった場合に、メッセージが失われないようにするためのフェイルセーフとして機能している。問題は、正常に動作しているコンシューマーの処理時間がタイムアウトを超えたときに、同じフェイルセーフが誤発動することだ。

sequenceDiagram participant CA as コンシューマーA participant SQS as SQS キュー participant CB as コンシューマーB CA->>SQS: ReceiveMessage SQS-->>CA: メッセージ返却
(Visibility Timeout開始) Note over SQS: メッセージ不可視 alt タイムアウト内に処理完了 CA->>CA: メッセージ処理 CA->>SQS: DeleteMessage SQS-->>SQS: メッセージ削除 else タイムアウト超過 Note over CA: 処理継続中... Note over SQS: Visibility Timeout切れ SQS-->>SQS: メッセージ再可視化 CB->>SQS: ReceiveMessage SQS-->>CB: 同じメッセージ返却 Note over CA,CB: 二重処理発生! end
  1. 受信:コンシューマーAがReceiveMessageを呼び、メッセージを取得する。SQSはそのメッセージをVisibility Timeout期間だけ不可視にする。
  2. 処理中:コンシューマーAがメッセージを処理している間、他のコンシューマーはそのメッセージを取得できない。
  3. タイムアウト超過:処理がVisibility Timeout内に完了しなかった場合、SQSはメッセージを再可視化する。コンシューマーBが同じメッセージを取得してしまう。
  4. 正常完了:処理がタイムアウト内に完了した場合、DeleteMessageでメッセージを削除する。これが唯一の正しい終了パスだ。

SQS Visibility Timeoutの設定方法

Visibility Timeoutはキューレベルで設定するか、ReceiveMessage呼び出し時にメッセージ単位で上書きできる。キューレベルの設定はすべてのメッセージに適用されるデフォルト値として機能する。

キューのVisibility Timeoutを確認する

aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue \
  --attribute-names VisibilityTimeout

キューのVisibility Timeoutを変更する

aws sqs set-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue \
  --attributes VisibilityTimeout=300

受信時にメッセージ単位で上書きする

aws sqs receive-message \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue \
  --visibility-timeout 120 \
  --max-number-of-messages 1

メッセージ単位の上書きは、処理時間がメッセージの内容によって大きく変わる場合に有効だ。ただし、受信時点で処理時間を正確に見積もれないケースも多い。

処理中にVisibility Timeoutを延長する

処理時間が事前に予測できない場合、ChangeMessageVisibility APIを使って処理中にタイムアウトを延長できる。これは「ハートビート」パターンとして実装されることが多い。

Visibility Timeoutの延長は、現在のタイムアウト残り時間に加算されるのではなく、呼び出した時点から新たにカウントが始まる。残り時間が少なくなってから延長しても問題ない。
aws sqs change-message-visibility \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue \
  --receipt-handle "AQEBwJnKyrHigUMZj6reyasLE56nd8oa..." \
  --visibility-timeout 300

--receipt-handleReceiveMessageのレスポンスに含まれる値だ。メッセージIDではないので注意が必要だ。同じメッセージでも受信のたびに異なるreceipt handleが発行される。

ハートビートパターンの実装例(Python)

🔽 クリックして展開:ハートビートによるVisibility Timeout延長
import boto3
import threading
import time

sqs = boto3.client('sqs', region_name='us-east-1')
QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/my-queue'

def extend_visibility(queue_url, receipt_handle, interval, timeout_extension, stop_event):
    while not stop_event.is_set():
        time.sleep(interval)
        if not stop_event.is_set():
            sqs.change_message_visibility(
                QueueUrl=queue_url,
                ReceiptHandle=receipt_handle,
                VisibilityTimeout=timeout_extension
            )

response = sqs.receive_message(
    QueueUrl=QUEUE_URL,
    MaxNumberOfMessages=1,
    VisibilityTimeout=60
)

if 'Messages' in response:
    message = response['Messages'][0]
    receipt_handle = message['ReceiptHandle']

    stop_event = threading.Event()
    heartbeat = threading.Thread(
        target=extend_visibility,
        args=(QUEUE_URL, receipt_handle, 45, 60, stop_event)
    )
    heartbeat.start()

    try:
        # ここで実際の処理を行う
        process_message(message)

        sqs.delete_message(
            QueueUrl=QUEUE_URL,
            ReceiptHandle=receipt_handle
        )
    finally:
        stop_event.set()
        heartbeat.join()

延長間隔(上記では45秒)はVisibility Timeout値(60秒)より短く設定する。タイムアウトが切れる前に確実に延長できるよう、余裕を持たせることが重要だ。

二重処理が発生するパターンと診断

graph TD A[二重処理を検知] --> B{処理時間を計測} B --> C[処理時間 > Visibility Timeout?] C -->|Yes| D[Visibility Timeoutを延長] C -->|No| E{DeleteMessage成功?} E -->|No| F[ネットワーク遅延を確認] E -->|Yes| G{Lambda使用?} G -->|Yes| H[Lambda TLとVTの比率を確認
推奨: VT >= Lambda TL x 6] G -->|No| I[処理時間の変動を確認] I --> J[ハートビートパターンを実装] D --> K[冪等性の実装を確認] H --> K J --> K F --> K K --> L[DLQの設定を確認]
  1. 処理時間超過:最も一般的なケース。バッチ処理や外部API呼び出しで処理時間が伸びた場合に発生する。
  2. コンシューマークラッシュ:処理中にプロセスが落ちた場合、タイムアウト後にメッセージが再可視化される。これは意図した動作だ。
  3. ネットワーク遅延DeleteMessageの呼び出しが遅延し、タイムアウトが先に切れるケース。
  4. Lambda関数のタイムアウト:Lambda関数のタイムアウトがSQSのVisibility Timeoutより長い場合、関数がタイムアウトしてもメッセージは再処理される。

現在のキュー属性を確認して問題を特定する

aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue \
  --attribute-names All

出力のApproximateNumberOfMessagesNotVisibleが継続的に増加している場合、処理中のメッセージが積み上がっていることを示す。これは処理速度がメッセージ到着速度に追いついていないか、処理がハングしているサインだ。

実際の障害事例:「処理済みのはずが再処理された」

本番環境でこんな状況に遭遇したことがある。注文処理システムで同じ注文IDに対して決済が二重に実行されていた。ログを見ると、最初のコンシューマーは処理を完了してDeleteMessageも呼んでいた。なぜ二重処理が起きたのか。

調査してみると、Visibility Timeoutは30秒(デフォルト)のままで、外部決済APIの応答に平均25〜35秒かかっていた。つまり、処理は完了していたが、タイムアウトが切れるギリギリのタイミングで別のコンシューマーがメッセージを取得し、最初のコンシューマーのDeleteMessageより先に処理を開始していた。

CloudWatchメトリクスのApproximateAgeOfOldestMessageは正常に見えたが、NumberOfMessagesReceivedがメッセージ数の約1.8倍になっていた。これが二重受信の証拠だった。

修正は単純だった。Visibility Timeoutを300秒に変更し、決済API呼び出しのタイムアウトを60秒に明示的に設定した。それだけで二重処理は完全に止まった。

Visibility Timeoutは「処理にかかる最大時間」ではなく「処理にかかる最大時間+十分な余裕」で設定する。AWSは処理時間の6倍を推奨しているが、実際には最大処理時間の2〜3倍でも十分なケースが多い。

LambdaとSQSを組み合わせる場合の注意点

LambdaをSQSのイベントソースとして使う場合、Visibility TimeoutはLambda関数のタイムアウト値と連動させる必要がある。AWSのドキュメントでは、SQSのVisibility TimeoutをLambda関数タイムアウトの少なくとも6倍に設定することを推奨している。

# Lambda関数のタイムアウト確認
aws lambda get-function-configuration \
  --function-name my-sqs-processor \
  --query 'Timeout'
# SQSイベントソースマッピングの確認
aws lambda list-event-source-mappings \
  --function-name my-sqs-processor

Lambda関数のタイムアウトが30秒なら、SQSのVisibility Timeoutは最低でも180秒に設定する。Lambda関数がタイムアウトした場合、SQSはメッセージを再処理キューに戻す。この動作はDead Letter Queueの設定と組み合わせて、無限ループを防ぐことが重要だ。

Dead Letter Queueの設定確認

aws sqs get-queue-attributes \
  --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue \
  --attribute-names RedrivePolicy

RedrivePolicyが設定されていない場合、処理に失敗し続けるメッセージがキューに残り続ける。maxReceiveCountを適切に設定してDead Letter Queueに転送する構成を必ず入れておくべきだ。

Visibility Timeout設定のベストプラクティス

graph LR A[処理時間を計測
P99値を取得] --> B[Visibility Timeout設定
P99 x 余裕係数] B --> C{処理時間が
可変?} C -->|Yes| D[ハートビート実装
ChangeMessageVisibility] C -->|No| E[固定値で設定] D --> F[冪等性を実装] E --> F F --> G[DLQ設定
maxReceiveCount指定] G --> H[CloudWatchアラーム
二重受信を監視]
  1. 処理時間を計測してから設定する:推測で設定しない。実際の処理時間のP99値を計測し、その値に余裕を加えた値を設定する。
  2. 処理時間が可変な場合はハートビートを使う:ファイルサイズや外部依存によって処理時間が大きく変わる場合、固定値では対応できない。
  3. 冪等性を実装する:SQSはAt-Least-Once配信を保証している。Visibility Timeoutを適切に設定しても、ネットワーク障害などで二重配信が発生する可能性はゼロにはならない。処理側で冪等性を担保することが根本的な対策だ。
  4. Dead Letter Queueを必ず設定する:処理失敗メッセージの無限ループを防ぐ。
  5. FIFOキューの検討:厳密に一度だけの処理が必要な場合、SQS FIFOキューのExactly-Once Processing機能を検討する。ただしスループット制限があるため、ユースケースを確認すること。

SQS Visibility Timeoutに関するまとめと次のステップ

SQSのVisibility Timeoutは単純な設定値に見えるが、二重処理の根本原因になりやすい。処理時間の計測、適切なタイムアウト値の設定、ハートビートパターンの実装、そして冪等性の担保——この4つを組み合わせることで、SQSを使ったシステムの信頼性は大きく向上する。

次のステップとして以下を確認することを推奨する:

用語集

用語説明
Visibility Timeoutメッセージ受信後、他のコンシューマーからそのメッセージが見えなくなる期間。デフォルト30秒、最大12時間。
Receipt Handleメッセージ受信時に発行される一時的な識別子。DeleteMessageChangeMessageVisibilityの呼び出しに必要。
Dead Letter Queue (DLQ)指定回数の処理失敗後にメッセージを転送するキュー。無限再処理ループを防ぐ。
At-Least-Once配信SQS標準キューの配信保証。メッセージは少なくとも1回配信されるが、稀に複数回配信される可能性がある。
冪等性(Idempotency)同じ操作を複数回実行しても結果が変わらない性質。SQSの二重配信に対する根本的な対策。

コメント

このブログの人気の投稿

EC2 SSH接続タイムアウトの原因と修正方法 — セキュリティグループのインバウンドルール完全ガイド

S3パブリックアクセス拒否の原因と解決策:バケットレベルの「Block Public Access」が優先される仕組み

EC2インスタンスIDをメタデータから取得する方法 — IMDSv2が安全な理由