SQS・Kafka本番2年で後悔した話——選定ミスと運用の現実

「なんとなくKafka強そう」で本番投入して後悔した経験、ありませんか?両方を並行運用して見えてきた選定基準・設計パターン・実装コードを現場目線で書きました。

先日、久しぶりにチームの新メンバーから「SQSとKafka、どっちを使えばいいですか?」と聞かれて、2年前の自分がまったく同じことで迷ってたのを思い出した。あのときは公式ドキュメントを読み込んで「なんとなくKafka強そう」でいきなり本番に突っ込んで、後悔した。

今は両方を同じプロダクトの別コンポーネントで並行運用してる。その経験からわかった選定基準と、本番でハマった落とし穴を書いておく。「SQS vs Kafka 完全比較」みたいな記事は既にたくさんあるし、うちのブログでも以前SQS vs Kafka 完全比較2026という形でスペック比較を書いた。今回はその先、実際に運用して気づいたことを中心に書く。

なぜ「どっちか一方」で解決しないのか

率直に言うと、SQSとKafkaは「同じ問題を解く別のツール」ではなく、解こうとしている問題の粒度が違う。ここを最初に理解できてなかったのが僕の失敗の根本だった。

SQSは「タスクキュー」として使う場面に強い。誰かがメッセージを積んで、誰かがそれを処理して消費する。処理済みのメッセージはもう要らない。冪等性さえ担保できれば、スケールは自動で吸収してくれるし、インフラの面倒をほとんど見なくていい。

Kafkaは「イベントログ」として使う場面に強い。何が起きたかを永続的に記録しておいて、複数のコンシューマが独立したペースで読む。イベントソーシングやCQRSと組み合わせると本領を発揮する。イベントソーシング×CQRS完全実装ガイドでも触れたけど、この設計をやるならKafka一択に近い。

SQS: Producer → [Queue] → Consumer(消費したら消える)
Kafka: Producer → [Topic/Partition] → Consumer Group A
                                     → Consumer Group B
                                     → Consumer Group C(独立して読める)

この違いを無視して「なんかよく使われてるから」でKafkaを選ぶと、運用コストに後悔することになる。個人的には「Kafkaを使うかどうか」よりも「Kafkaが必要な理由を説明できるか」を自問するほうが、選定ミスを防ぎやすいと思う。

xychart-beta
  title "SQS vs Kafka 運用コスト比較(チーム規模別・相対値)"
  x-axis ["3人以下", "5人", "10人", "20人以上"]
  y-axis "相対コスト" 0 --> 100
  bar [15, 20, 25, 30]
  line [60, 55, 45, 35]

※棒グラフ=SQS、折れ線=Kafka。小規模チームほどKafkaの運用コストが重くなる。数値はあくまで相対的なイメージ。

実装で見えた差:コードレベルの比較

実際に書いたコードで比べると差がわかりやすい。うちはGoをメインで使ってるので、Goで書く。

SQS(AWS SDK v2 for Go)

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/sqs"
    "github.com/aws/aws-sdk-go-v2/service/sqs/types"
)

type OrderEvent struct {
    OrderID   string    `json:"order_id"`
    UserID    string    `json:"user_id"`
    Amount    int64     `json:"amount"`
    CreatedAt time.Time `json:"created_at"`
}

func consumeSQS(ctx context.Context, client *sqs.Client, queueURL string) error {
    for {
        result, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            aws.String(queueURL),
            MaxNumberOfMessages: 10,
            WaitTimeSeconds:     20, // Long pollingは必須
            VisibilityTimeout:   30, // 処理時間の2倍を目安に設定
            MessageAttributeNames: []string{"All"},
        })
        if err != nil {
            return fmt.Errorf("receive message: %w", err)
        }

        for _, msg := range result.Messages {
            if err := processMessage(msg); err != nil {
                // 失敗したら可視性タイムアウトを短縮してリトライ促進
                log.Printf("process failed, message will retry: %v", err)
                continue
            }

            // 正常処理後は明示的に削除
            _, err = client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
                QueueUrl:      aws.String(queueURL),
                ReceiptHandle: msg.ReceiptHandle,
            })
            if err != nil {
                log.Printf("delete message failed: %v", err)
            }
        }
    }
}

func processMessage(msg types.Message) error {
    var event OrderEvent
    if err := json.Unmarshal([]byte(*msg.Body), &event); err != nil {
        return fmt.Errorf("unmarshal: %w", err)
    }
    // ビジネスロジック
    log.Printf("processing order: %s", event.OrderID)
    return nil
}

SQSでハマりやすいのはVisibilityTimeoutの設定ミス。処理時間より短く設定すると、処理中のメッセージが他のコンシューマに再配信されて重複処理が起きる。うちは最初30秒に設定してたのに、外部API呼び出しで平均45秒かかってることに気づかず、しばらく重複処理が発生してた。地味に気づきにくいバグで、ログを見ても「なぜか同じOrderIDが2回処理されてる」くらいにしか見えないのがつらかった。

Kafka(franz-go 2026年時点のおすすめ)

package main

import (
    "context"
    "encoding/json"
    "log"

    "github.com/twmb/franz-go/pkg/kgo"
)

func consumeKafka(ctx context.Context, brokers []string, topic, groupID string) error {
    cl, err := kgo.NewClient(
        kgo.SeedBrokers(brokers...),
        kgo.ConsumerGroup(groupID),
        kgo.ConsumeTopics(topic),
        // 2026年時点:AutoCommitを無効にして明示的にコミット
        kgo.DisableAutoCommit(),
    )
    if err != nil {
        return err
    }
    defer cl.Close()

    for {
        fetches := cl.PollFetches(ctx)
        if errs := fetches.Errors(); len(errs) > 0 {
            for _, err := range errs {
                log.Printf("fetch error: %v", err)
            }
        }

        var processedRecords []*kgo.Record
        fetches.EachRecord(func(record *kgo.Record) {
            var event OrderEvent
            if err := json.Unmarshal(record.Value, &event); err != nil {
                log.Printf("unmarshal failed: %v", err)
                return
            }

            if err := processEvent(event); err != nil {
                // Kafkaでは処理失敗してもオフセットを進めない選択肢がある
                log.Printf("process failed: %v", err)
                return
            }
            processedRecords = append(processedRecords, record)
        })

        // 成功したレコードのみコミット
        if len(processedRecords) > 0 {
            cl.MarkCommitRecords(processedRecords...)
            if err := cl.CommitMarkedOffsets(ctx); err != nil {
                log.Printf("commit failed: %v", err)
            }
        }
    }
}

func processEvent(event OrderEvent) error {
    log.Printf("processing order: %s, partition offset managed by consumer", event.OrderID)
    return nil
}

Kafkaはオフセット管理を明示的にやることで、「どこまで処理したか」を精密にコントロールできる。これがSQSとの大きな違いで、正直この柔軟さはKafkaならではだと思う。ただし、それだけ考えることが増えるのも事実で、「オフセットどこまで進めたっけ」問題は初見だとかなり混乱する。

DLQ(デッドレターキュー)設計で地雷を踏んだ話

ここ、本当に重要なんですよね。SQS/SNS連携パターンをチームで整理し直した話でも少し触れたけど、DLQ設計を後回しにすると後で必ず後悔する。

うちのチームで実際に踏んだ失敗を時系列で書く。

Phase 1(リリース直後): DLQを設定していたが、誰もアラートを見ていなかった。DLQにメッセージが溜まっても誰も気づかない。

Phase 2(3ヶ月後): DLQに10,000件溜まってることに気づく。原因究明のため全件確認が必要になるが、SQSのDLQはブラウザからの検索性が最悪。絶望した。

Phase 3(現在): DLQのメッセージ数がCloudWatchメトリクスで一定数を超えたらSlackに通知、かつDLQのメッセージ内容をS3に自動アーカイブする仕組みを入れた。

flowchart TB
    Producer[Producer] --> MainQueue[SQS Main Queue]
    MainQueue --> Consumer[Consumer Lambda/ECS]
    Consumer -->|成功| Delete[メッセージ削除]
    Consumer -->|失敗 maxReceiveCount=3| DLQ[DLQ]
    DLQ --> CWAlarm[CloudWatch Alarm]
    CWAlarm --> SNSTopic[SNS Topic]
    SNSTopic --> SlackAlert[Slack通知]
    DLQ --> ArchiveLambda[Archive Lambda]
    ArchiveLambda --> S3Archive[S3 アーカイブ]
    S3Archive --> Athena[Athena分析]

maxReceiveCountを何に設定するかも悩みどころで、うちは基本3回にしてる。1回だと外部サービスの一時的な障害で即DLQ送りになるし、10回だと問題のあるメッセージが長時間滞留してシステム全体を詰まらせる。3〜5回が多数派だと思うけど、ここは好み分かれるかも。

Kafkaの場合はDLQ相当の概念が標準にはなく、Dead Letter Topicを自前で実装するかKafka Connect + SMTを使う形になる。これが地味に面倒で、小規模なら「SQSにDLQ任せる」ほうが楽だと感じてる。

2026年時点のインフラ選択肢と実際のコスト感

項目SQS StandardSQS FIFOMSK (Kafka)Confluent CloudRedpanda Cloud
順序保証なしあり(グループ単位)あり(Partition内)ありあり
スループット事実上無制限3,000 msg/s(グループ)Partition数次第Partition数次第高い
最大保持期間14日14日設定次第(無制限も可)設定次第設定次第
月額コスト目安従量課金(安い)従量課金(少し高い)最低〜$200〜最低〜$100〜最低〜$80〜
運用負荷高(MSKでも)
マルチコンシューマ別Queueが必要別Queueが必要Consumer GroupConsumer GroupConsumer Group
再生(リプレイ)不可不可可能可能可能

コスト感は結構重要で、月間100万メッセージ程度ならSQSが圧倒的に安い。MSKはクラスターを立てた時点で最低でも月$200はかかる(ブローカー3台×最小インスタンス)。「とりあえずKafka使いたい」でMSK立てて、月$300払い続けてるケースを何度か見た。正直、その金額を払うだけの理由があるかどうか、最初にちゃんと問い直してほしい。

まだ検証中ではあるんだけど、Redpanda Cloudは2026年時点でかなり良くなっていて、Kafkaプロトコル互換を保ちながらコストが下がってる。小〜中規模でKafkaの機能が欲しい場合の有力候補になってきた。個人的にはもう少し様子を見てから本番で使いたい気持ちもある。

pie title メッセージキュー採用比率(2026年 自チーム調査N=12チーム)
    "SQS" : 45
    "MSK/Kafka" : 25
    "Redpanda" : 15
    "その他" : 15

スループット最適化:本番でやって効いた設定

SQSのバッチ処理最適化

SQSのスループットで一番効いたのが「バッチサイズを上げてLambdaのconcurrencyを調整する」という組み合わせだった。

// Lambda + SQSでバッチ処理する場合の設定(Go)
func handler(ctx context.Context, event events.SQSEvent) (events.SQSEventResponse, error) {
    var failures []events.SQSBatchItemFailure

    // goroutineで並行処理するが、外部DBへの接続数に注意
    sem := make(chan struct{}, 5) // 最大並行5
    var wg sync.WaitGroup
    var mu sync.Mutex

    for _, record := range event.Records {
        wg.Add(1)
        sem <- struct{}{}
        go func(r events.SQSMessage) {
            defer wg.Done()
            defer func() { <-sem }()

            if err := processRecord(ctx, r); err != nil {
                log.Printf("failed to process %s: %v", r.MessageId, err)
                mu.Lock()
                failures = append(failures, events.SQSBatchItemFailure{
                    ItemIdentifier: r.MessageId,
                })
                mu.Unlock()
            }
        }(record)
    }
    wg.Wait()

    // 部分的な失敗を返すことでDLQ送りの精度を上げる
    // Report Batch Item Failures を有効にした場合のみ
    return events.SQSEventResponse{BatchItemFailures: failures}, nil
}

ここでポイントなのが Report Batch Item Failures という機能で、バッチ内の一部メッセージだけ失敗した場合に、失敗したものだけをリトライ対象にできる。2023年くらいからGA状態だけど、意外と設定してないチームが多い印象。設定してないと全件失敗扱いになって冪等性で苦労することになるので、まだの人は今すぐ確認してほしい。

Kafkaのパーティション設計

Kafkaはパーティション数の設計を最初に間違えると後で変更コストが高い。うちは最初に少なく見積もりすぎて、後からパーティションを増やした際にキーのリバランスで数時間のメンテナンスが必要になった。これは本当につらかった。

目安の計算式:
パーティション数 = max(Consumer数, 目標スループット / 単一パーティションスループット)

単一パーティションスループット ≒ 10MB/s(メッセージサイズ・ブローカー性能による)

例:Consumer 20台、目標200MB/s の場合
→ max(20, 200/10) = max(20, 20) = 20パーティション
→ 余裕をみて30パーティション

初期設計時はConsumer数の2〜3倍で設定しておくのが無難だと思う。パーティション増やすのは簡単(ただしキー分散が変わる)、減らすのは実質不可能に近いので。バッチ処理の設計で3回本番を止めて学んだ「壊れにくい」作り方でも書いたけど、スループット設計の見積もりは「現在の2倍+ピーク係数」で考えておくとあとで後悔しにくい。

まとめ

2年間の運用で一番身にしみた教訓をまとめると:

  • SQSは「タスクを誰かに処理させたい」場面、Kafkaは「何が起きたかを複数の処理系に届けたい」場面。この原則を守ると選定ミスが減る
  • DLQを最初から設計する。後付けは地獄。アラートとアーカイブも初日に設定しておく
  • Kafkaは運用コストを必ずチームのキャパと照らし合わせる。SQSで解決できるなら無理にKafkaを選ぶ必要はない
  • SQSはVisibilityTimeout、KafkaはPartition数が設計ミスの2大原因。ここに時間をかける価値がある
  • **Report Batch Item Failures(SQS)と明示的オフセット管理(Kafka)**を活用すると、DLQの汚染が減って運用が楽になる

次のアクション: 既存システムのDLQにアラートが設定されているか今すぐ確認してほしい。設定されてない場合は今日中に入れることをおすすめする。次に、VisibilityTimeoutが実際の処理時間(P99)の2倍以上になっているかを確認する。この2点だけでも運用品質がかなり変わるはずだ。

皆さんのチームではSQSとKafkaをどう使い分けてますか?特にKafkaからSQSに戻した話とかあれば、ぜひ聞いてみたい。

U

Untanbaby

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

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

関連記事