SQS/SNS連携で本番で踏んだ地雷と、3年かけて辿り着いた正解

SNSのFan-outパターンって教科書的には正しいはずなのに、本番運用してみると地雷だらけ。重複メッセージ、配信遅延、サブスクリプションの落とし穴。チームが実際に失敗した例と、それで学んだ設計パターンをまとめました。

SQS/SNS連携パターン、本番で学んだ設計の失敗と正解

先日チームで本番のメッセージング設計を見直すことになって、SQS/SNS連携のパターンについて改めて整理し直したんですよ。正直、これまで「とりあえずFan-outしておけばいい」くらいの理解だったんですが、実際に運用してみると地雷がいっぱい埋まってた。今回は、3年本番で踏んだ失敗と、それで辿り着いた設計パターンについて書きます。

最初の間違い:「Fan-outなら何でもOK」だった

うちのチームが最初にハマったのは、SNSのFan-outパターンを導入した当初の話です。複数のマイクロサービスに同じイベントを配信したいから、SNSトピックに複数のSQSキューをサブスクライブさせる——確かに教科書的には正しいんですよね。

でも実運用で問題が出ました。あるサービスのSQS処理が遅延すると、他のサービスのキューまで圧迫される。それに、SNSから配信されたメッセージが、あるキューでは処理されるのに、別のキューでは「メッセージが来ていない」みたいな不可解なバグが起きたんです。

これ、実はSNSのサブスクリプション設定の問題だったんです。デフォルトでは、サブスクリプション作成時点でのメッセージだけが配信されるので、後から追加したキューには過去のイベントが届かない。また、SNS側でのリトライ戦略とSQS側での失敗処理が完全にズレていて、重複メッセージが大量に溜まるという地獄を経験しました。

# ❌ これが最初の失敗パターン
# SNSトピックに複数SQSをサブスクライブ
# でも、各サービスの処理速度が違うと…

import boto3
import json
from datetime import datetime

sns = boto3.client('sns')
sqs = boto3.client('sqs')

# イベント発行
def publish_event(event_data):
    sns.publish(
        TopicArn='arn:aws:sns:ap-northeast-1:xxx:user-events',
        Message=json.dumps(event_data),
        MessageAttributes={
            'event_type': {'DataType': 'String', 'StringValue': 'user.created'}
        }
    )

# 受信側
def consume_from_queue(queue_url):
    while True:
        response = sqs.receive_message(
            QueueUrl=queue_url,
            MaxNumberOfMessages=10,
            WaitTimeSeconds=20
        )
        
        if 'Messages' not in response:
            continue
            
        for message in response['Messages']:
            # 処理が長いサービスAと短いサービスBがいると…
            process_heavy_task()  # 30秒かかる
            sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=message['ReceiptHandle'])

このコードの何が問題かというと、メッセージの処理時間が異なるサービス間で、SNSのFan-outを使うと、遅いサービスがボトルネックになるんです。SNS側ではリトライが発火したり、可視性タイムアウト(VisibilityTimeout)の設定が合致していなかったり、DLQへの送信ルールがバラバラだったり——もう地獄絵図です。

本当に必要だった3つの分岐

3年本番運用で気づいたのは、SQS/SNS連携には「3つの異なるユースケース」があるということです。

1. Fan-out型:複数のサービスが同じイベントを必要とする場合

これが一般的な使い方。ユーザー登録イベントが発火したら、メール送信、プロフィール初期化、分析サービスなど複数のサービスが独立して動く。

ここで重要なのは、各サービスが自分のペースで処理できる独立したキューを持つってことなんですよ。SNS→SQS構成で、各SQSキューの設定を完全に独立させるんです。

# ✅ 改善版:Fan-outの正しい実装
# 各サービスに独立したSQSを用意

import boto3
import json
import logging
from concurrent.futures import ThreadPoolExecutor
import time

sns = boto3.client('sns')
sqs = boto3.client('sqs')

logger = logging.getLogger(__name__)

class EventPublisher:
    def __init__(self, topic_arn):
        self.topic_arn = topic_arn
    
    def publish(self, event_type, data):
        """イベント発行(SNS)"""
        try:
            sns.publish(
                TopicArn=self.topic_arn,
                Message=json.dumps({
                    'event_type': event_type,
                    'data': data,
                    'timestamp': int(time.time())
                }),
                MessageAttributes={
                    'event_type': {
                        'DataType': 'String',
                        'StringValue': event_type
                    }
                }
            )
            logger.info(f"Event published: {event_type}")
        except Exception as e:
            logger.error(f"Failed to publish: {e}")
            raise

class QueueConsumer:
    def __init__(self, queue_url, dlq_url=None):
        self.queue_url = queue_url
        self.dlq_url = dlq_url
        self.max_retries = 3
    
    def consume(self, handler_func, max_messages=10):
        """キューからメッセージを消費"""
        while True:
            try:
                response = sqs.receive_message(
                    QueueUrl=self.queue_url,
                    MaxNumberOfMessages=min(max_messages, 10),
                    WaitTimeSeconds=20,
                    AttributeNames=['ApproximateReceiveCount']
                )
                
                if 'Messages' not in response:
                    time.sleep(1)
                    continue
                
                for message in response['Messages']:
                    self._process_message(message, handler_func)
                    
            except Exception as e:
                logger.error(f"Error consuming messages: {e}")
                time.sleep(5)  # Backoff
    
    def _process_message(self, message, handler_func):
        """メッセージ処理(エラーハンドリング付き)"""
        receipt_handle = message['ReceiptHandle']
        receive_count = int(message['Attributes']['ApproximateReceiveCount'])
        
        try:
            body = json.loads(message['Body'])
            logger.info(f"Processing message: {body['event_type']}")
            
            # ハンドラー実行
            handler_func(body)
            
            # 成功時は削除
            sqs.delete_message(QueueUrl=self.queue_url, ReceiptHandle=receipt_handle)
            logger.info(f"Message processed successfully")
            
        except Exception as e:
            logger.error(f"Handler error (attempt {receive_count}): {e}")
            
            # リトライ回数超過したらDLQへ
            if receive_count >= self.max_retries:
                if self.dlq_url:
                    self._send_to_dlq(message, e)
                
                # 元のキューから削除
                sqs.delete_message(QueueUrl=self.queue_url, ReceiptHandle=receipt_handle)
            else:
                # VisibilityTimeoutを延長して再試行
                sqs.change_message_visibility(
                    QueueUrl=self.queue_url,
                    ReceiptHandle=receipt_handle,
                    VisibilityTimeout=60 * (2 ** (receive_count - 1))  # Exponential backoff
                )
    
    def _send_to_dlq(self, message, error):
        """DLQへメッセージを転送"""
        try:
            sqs.send_message(
                QueueUrl=self.dlq_url,
                MessageBody=json.dumps({
                    'original_message': json.loads(message['Body']),
                    'error': str(error),
                    'timestamp': int(time.time())
                })
            )
            logger.warning(f"Message sent to DLQ")
        except Exception as e:
            logger.error(f"Failed to send to DLQ: {e}")

# 使用例
if __name__ == "__main__":
    publisher = EventPublisher('arn:aws:sns:ap-northeast-1:xxx:user-events')
    
    # イベント発行
    publisher.publish('user.created', {'user_id': 123, 'email': 'user@example.com'})
    
    # 各サービスが独立したキューで消費
    def email_handler(event):
        print(f"Sending email to {event['data']['email']}")
        time.sleep(2)  # 時間がかかる処理
    
    consumer = QueueConsumer(
        'https://sqs.ap-northeast-1.amazonaws.com/xxx/email-queue',
        'https://sqs.ap-northeast-1.amazonaws.com/xxx/email-dlq'
    )
    consumer.consume(email_handler)

2. 優先度キュー型:処理の優先度が異なる場合

これは意外と需要があるんですよ。例えば、有料ユーザーのイベントは高優先度で、無料ユーザーは低優先度——こういうときはSNS側で振り分けて、別々のSQSキューに送信するんです。

# CloudFormation でSNS → 複数SQS の優先度分け
AWSTemplateFormatVersion: '2010-09-09'

Resources:
  UserEventsTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: user-events
      MessageRetentionPeriod: 1209600  # 14日

  HighPriorityQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: user-events-high-priority
      VisibilityTimeout: 300
      MessageRetentionPeriod: 1209600
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt HighPriorityDLQ.Arn
        maxReceiveCount: 3

  HighPriorityDLQ:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: user-events-high-priority-dlq
      MessageRetentionPeriod: 1209600

  LowPriorityQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: user-events-low-priority
      VisibilityTimeout: 600  # より長く設定
      MessageRetentionPeriod: 1209600
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt LowPriorityDLQ.Arn
        maxReceiveCount: 5  # より多く許可

  LowPriorityDLQ:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: user-events-low-priority-dlq
      MessageRetentionPeriod: 1209600

  # SNS → High Priority Queue のサブスクリプション
  HighPrioritySubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: sqs
      TopicArn: !Ref UserEventsTopic
      Endpoint: !GetAtt HighPriorityQueue.Arn
      FilterPolicy:
        user_tier:
          - premium
          - enterprise

  # SNS → Low Priority Queue のサブスクリプション
  LowPrioritySubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: sqs
      TopicArn: !Ref UserEventsTopic
      Endpoint: !GetAtt LowPriorityQueue.Arn
      FilterPolicy:
        user_tier:
          - free
          - trial

  # SQS がSNSからメッセージを受け取れるポリシー
  HighPriorityQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref HighPriorityQueue
      PolicyText:
        Statement:
          - Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: sqs:SendMessage
            Resource: !GetAtt HighPriorityQueue.Arn
            Condition:
              ArnEquals:
                aws:SourceArn: !Ref UserEventsTopic

  LowPriorityQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref LowPriorityQueue
      PolicyText:
        Statement:
          - Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: sqs:SendMessage
            Resource: !GetAtt LowPriorityQueue.Arn
            Condition:
              ArnEquals:
                aws:SourceArn: !Ref UserEventsTopic

3. 順序保証型:イベントの順序が重要な場合

これは地味に難しいんですよ。SNSはデフォルトでは順序を保証しません。ユーザーが「作成」→「更新」→「削除」の順で実行されるはずが、順番がバラバラに到達することもあります。

こういう場合は、SQS FIFO キューを使うのが正解。でも、SNSはFIFOトピックを使うと、複数キューへの配信時に順序が保証される特性があります。

# FIFO構成での発行
publisher = EventPublisher(topic_arn='arn:aws:sns:ap-northeast-1:xxx:user-events.fifo')

# FIFO送信には、MessageGroupIdが必須
sns.publish(
    TopicArn='arn:aws:sns:ap-northeast-1:xxx:user-events.fifo',
    Message=json.dumps(event_data),
    MessageGroupId=f'user-{user_id}',  # 同じユーザーなら同じグループ
    MessageDeduplicationId=f'{user_id}-{event_timestamp}'  # 重複排除
)

ただ、FIFOを使うとスループットが10倍以上落ちるんです。秒あたり300メッセージが上限。正直、本番で「本当に順序保証が必要か?」を厳密に検証してから導入すべき。

DLQ設計:地雷の宝庫

DLQ(Dead Letter Queue)の設定で、うちのチームが失敗したのは以下の3つですね。

1. DLQへの転送タイミングの誤解

最初は「エラーが発生したら即DLQへ」と思ってたんですが、これだと一時的なネットワーク障害とか、一過性のエラーまで全部DLQに送られちゃう。改善策は「ApproximateReceiveCount」を監視して、複数回失敗したときだけDLQへ送ることです。上のコード例で実装してます。

2. DLQ監視の放置

DLQに送られたメッセージって、放置されてることが多いんですよ。週1回はDLQを確認して、なぜ失敗したのかログを見るべき。CloudWatch Alarmで「DLQ に新しいメッセージが入った」というアラートを設定するのが重要です。

3. リテンション期間のズレ

メインキューのリテンション期間が14日で、DLQが4日だと、メインキューから削除される前にDLQの調査期限が終わっちゃう。両方を揃える、できれば同じ期間にするべきです。

スループット最適化:本番で見えた課題

SQS/SNS連携でスループットを上げるには、いくつかコツがあります。

# スループット最適化版
class HighThroughputConsumer:
    def __init__(self, queue_url, num_workers=10):
        self.queue_url = queue_url
        self.num_workers = num_workers
    
    def consume_parallel(self, handler_func):
        """複数ワーカーで並列処理"""
        with ThreadPoolExecutor(max_workers=self.num_workers) as executor:
            futures = []
            while True:
                # バッチで受信(最大10メッセージ)
                response = sqs.receive_message(
                    QueueUrl=self.queue_url,
                    MaxNumberOfMessages=10,
                    WaitTimeSeconds=20
                )
                
                if 'Messages' not in response:
                    time.sleep(0.5)
                    continue
                
                for message in response['Messages']:
                    # 非同期で処理
                    future = executor.submit(
                        self._handle_message,
                        message,
                        handler_func
                    )
                    futures.append(future)
                
                # 完了を待たずに次のバッチを取得(パイプライン化)
                while futures:
                    done = [f for f in futures if f.done()]
                    for f in done:
                        try:
                            f.result(timeout=0.1)
                        except Exception as e:
                            logger.error(f"Task failed: {e}")
                        futures.remove(f)
                    
                    if len(futures) < self.num_workers:
                        break
    
    def _handle_message(self, message, handler_func):
        try:
            body = json.loads(message['Body'])
            handler_func(body)
            sqs.delete_message(
                QueueUrl=self.queue_url,
                ReceiptHandle=message['ReceiptHandle']
            )
        except Exception as e:
            logger.error(f"Error: {e}")
            raise

重要なポイントをまとめると以下の通り。

項目推奨値理由
BatchSize10SQSの最大値。リクエスト削減
WaitTimeSeconds20Long Polling。無駄なリクエスト削減
ワーカー数10~20並列処理でスループット向上
VisibilityTimeout処理時間×1.5重複処理を防ぐ

AWS構成図:本番で安定する設計

flowchart TB
    subgraph Application[" Application Layer"]
        API["API Gateway<br/>Lambda"]
    end
    
    subgraph EventBridge["Event Distribution"]
        SNS["SNS Topic<br/>user-events"]
    end
    
    subgraph HighPriority["High Priority Path"]
        subgraph HP["High Priority Queue"]
            HPQUEUE["SQS Queue<br/>Premium Users"]
            HPDLQ["DLQ<br/>max_retries=3"]
        end
        HPWORKER["Lambda Consumer<br/>Workers=20<br/>Timeout=300s"]
    end
    
    subgraph LowPriority["Low Priority Path"]
        subgraph LP["Low Priority Queue"]
            LPQUEUE["SQS Queue<br/>Free Users"]
            LPDLQ["DLQ<br/>max_retries=5"]
        end
        LPWORKER["Lambda Consumer<br/>Workers=5<br/>Timeout=600s"]
    end
    
    subgraph Monitoring["Monitoring & Alerts"]
        CLOUDWATCH["CloudWatch<br/>Metrics"]
        ALARM["SNS Alarm<br/>DLQ Messages > 10"]
    end
    
    subgraph DataLake["Data Persistence"]
        DYNAMODB["DynamoDB<br/>Event Log"]
        S3["S3<br/>Archive<br/>7 days"]
    end
    
    API -->|publish event| SNS
    SNS -->|filter by user_tier| HPQUEUE
    SNS -->|filter by user_tier| LPQUEUE
    
    HPQUEUE -->|consume<br/>batch=10| HPWORKER
    HPWORKER -->|success| DYNAMODB
    HPWORKER -->|fail 3x| HPDLQ
    HPDLQ -->|alert| ALARM
    
    LPQUEUE -->|consume<br/>batch=10| LPWORKER
    LPWORKER -->|success| DYNAMODB
    LPWORKER -->|fail 5x| LPDLQ
    LPDLQ -->|alert| ALARM
    
    HPQUEUE -->|metrics| CLOUDWATCH
    LPQUEUE -->|metrics| CLOUDWATCH
    HPDLQ -->|metrics| CLOUDWATCH
    LPDLQ -->|metrics| CLOUDWATCH
    
    DYNAMODB -->|archive| S3
    ALARM -->|notify| Application

実際に本番で起きた問題と対策

問題1:メッセージ重複

SNS→SQS変換時に、同じメッセージが複数回キューに入ることがあります。これは「at least once」の配信保証が理由。対策は、メッセージにユニークなIDを付与して、消費側で重複排除することです。

# メッセージIDによる重複排除
processed_ids = set()

def handle_with_dedup(event):
    message_id = event.get('message_id')
    
    # DynamoDBで見た履歴があるか確認
    try:
        dynamodb.get_item(
            TableName='processed-messages',
            Key={'message_id': {'S': message_id}}
        )
        logger.info(f"Message already processed: {message_id}")
        return  # スキップ
    except:
        pass
    
    # 処理実行
    process_event(event)
    
    # 処理済みとして記録
    dynamodb.put_item(
        TableName='processed-messages',
        Item={
            'message_id': {'S': message_id},
            'timestamp': {'N': str(int(time.time()))}
        },
        TimeToLive=int(time.time()) + 86400  # 24時間後に自動削除
    )

問題2:可視性タイムアウトの設定ミス

VisibilityTimeout が処理時間より短いと、処理中にメッセージが再度キューに現れて、別のワーカーが同じメッセージを拾う。結果、重複処理やデータ不整合が起きます。

対策は、処理時間 + 余裕 を設定すること。また、処理中に「あと30秒必要」と気づいたら、change_message_visibility で延長するのが良いです。

# 処理中にタイムアウトを延長
def process_with_visibility_extension(queue_url, receipt_handle, handler):
    try:
        # 重い処理の途中
        result = handler()  # 長時間かかる可能性
        
        # あと1分必要と判断
        sqs.change_message_visibility(
            QueueUrl=queue_url,
            ReceiptHandle=receipt_handle,
            VisibilityTimeout=60
        )
        
        # 継続処理
        finalize(result)
        
    except Exception as e:
        logger.error(f"Processing failed: {e}")
        raise

問題3:メッセージサイズの制限

SQS のメッセージサイズは256KBが上限。大きなペイロードを送りたいときは、S3に保存してURLだけをSQSに送るポインター方式を使います。

import uuid

def publish_large_event(event_data):
    payload = json.dumps(event_data)
    
    if len(payload.encode()) > 256 * 1024:  # 256KB超過
        # S3に保存
        key = f"events/{uuid.uuid4()}.json"
        s3.put_object(
            Bucket='event-storage',
            Key=key,
            Body=payload,
            ServerSideEncryption='AES256'
        )
        
        # SQSには参照情報だけ送信
        sns.publish(
            TopicArn='arn:aws:sns:...',
            Message=json.dumps({
                's3_bucket': 'event-storage',
                's3_key': key,
                'event_type': event_data.get('type'),
                'timestamp': int(time.time())
            })
        )
    else:
        # 通常送信
        sns.publish(
            TopicArn='arn:aws:sns:...',
            Message=payload
        )

チームで整理して気づいた、本当に使い分けるべきポイント

実は、SNS/SQS以外の選択肢も視野に入れるべきです。うちのチームでも「Kafkaではなくて本当にSQS/SNSで良いのか?」という議論を何度もしました。

要件SQS/SNS向きKafka向き
イベントの種類5~10個程度大量
順序保証不要必須
スループット1000msg/秒以下10000msg/秒以上
リプレイ機能不要必須
費用最適化重視重視不要

正直、「どちらを選ぶか」は最初の検討が大事。後から「Kafkaに乗り換えたい」となると、マイグレーションが地獄になります。

まとめ

SQS/SNS連携は見た目よりずっと奥が深い。本番で学んだ重要なポイントを最後にまとめますね。

  1. Fan-outは各サービスに独立したキューを用意する——スループットの競合を避けるため
  2. DLQの監視は放置するな——週1回は確認して、失敗パターンを分析する
  3. VisibilityTimeoutと処理時間の関係を厳密に計算する——重複処理の大半はここから生まれる
  4. メッセージ重複排除は必須——at least once配信は避けられないから
  5. 本当に必要な機能か冷静に判断する——FIFO、優先度分け、順序保証は全部コスト増

次のアクション、自分たちのシステムで「今使ってるSNS/SQS設定」を見直すチャンスです。もし「FIFOを使ってるけど、実は順序不要」「DLQのアラームを設定してない」みたいなことがあれば、改善の余地あり。正直、本番で何度も同じ失敗するより、設計段階で気づいておく方が絶対に得です。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事