Amazon SQS と Lambda で Amazon Connect の StartOutboundVoiceContact API 呼び出し回数を抑える構成を試してみた

Amazon SQS と Lambda で Amazon Connect の StartOutboundVoiceContact API 呼び出し回数を抑える構成を試してみた

2026.05.18

はじめに

Amazon Connect の StartOutboundVoiceContact API を使うと、AWS Lambda などからアウトバウンド発信を開始できます。

今回、Amazon SQS に投入されたメッセージをもとに、Lambda から Amazon Connect の StartOutboundVoiceContact API を呼び出し、担当者へ電話通知する構成を検討していました。

ただし、短時間に複数のメッセージが SQS に投入された場合、Lambda から StartOutboundVoiceContact API を一斉に呼び出してしまう可能性があります。

AWS General Reference では、StartOutboundVoiceContact API のクォータは 1 秒あたり 2 リクエストと記載されています。

https://docs.aws.amazon.com/general/latest/gr/connect_region.html

cm-hirai-screenshot 2026-05-15 14.29.12

今回は、以下の構成で StartOutboundVoiceContact API の呼び出しペースを抑えられるか試しました。

SQS 標準キュー

Lambda

Amazon Connect StartOutboundVoiceContact

主な検証ポイントは、SQS に 10 件のメッセージをほぼ同時に投入したときに、Lambda から StartOutboundVoiceContact API が任意の連続する 1000 ミリ秒内で 3 回以上呼ばれていないかです。

結論

今回の検証では、SQS 標準キューに 10 件のメッセージをほぼ同時に投入しても、任意の連続する 1000 ミリ秒内で StartOutboundVoiceContact API が 3 回以上呼び出されることは確認されませんでした。

設定のポイントは以下です。

  • Lambda の SQS イベントソースマッピングでバッチサイズを 1 にする
  • Lambda の SQS イベントソースマッピングで最大同時実行数を 2 にする
  • Lambda では 1 回の起動で SQS メッセージを 1 件処理する
  • Lambda では StartOutboundVoiceContact API を 1 回呼び出す
  • API 呼び出し後に 1 秒 sleep してから処理を終了する

検証では、10 件すべての StartOutboundVoiceContact API 呼び出しが成功しました。

ただし、今回の方法は厳密な分散レートリミッターではありません。
SQS イベントソースマッピングの最大同時実行数と Lambda 内の sleep による簡易的な制御です。複数システムから同じ API を呼び出す場合など、より厳密な制御が必要な場合は、DynamoDB などを使った共有レートリミッターを検討する必要があります。

前提

今回は以下を前提とします。

  • Amazon Connect インスタンスは作成済み
  • 発信用の問い合わせフローは作成済み
  • Amazon Connect で発信元電話番号を利用できる状態
  • 検証リージョンは ap-northeast-1

今回の検証では、Amazon Connect の問い合わせフローの中身ではなく、SQS と Lambda によって StartOutboundVoiceContact API の呼び出しペースを抑えられるかに絞って確認しています。

作成したリソース

今回作成したリソースは以下です。

  • SQS DLQ:connect-rate-limited-call-dlq
  • SQS キュー:connect-rate-limited-call-queue
  • Lambda 関数:connect-rate-limited-call-dialer

SQS DLQ

まず、失敗したメッセージを退避するための Dead Letter Queue(デッドレターキュー、以降 DLQ)を作成しました。

DLQ を設定しておくことで、何度再試行しても処理できないメッセージをメインキューに残し続けず、後から原因を確認できます。

  • キュー名:connect-rate-limited-call-dlq
  • タイプ:標準

cm-hirai-screenshot 2026-05-15 11.23.21

SQS キュー

次に、Lambda が読み取るメインの SQS 標準キューを作成しました。

  • キュー名:connect-rate-limited-call-queue
  • タイプ:標準

cm-hirai-screenshot 2026-05-15 11.25.21

メインキューには、先ほど作成した connect-rate-limited-call-dlq を DLQ として設定しました。

cm-hirai-screenshot 2026-05-15 11.25.30

最大受信数は 5 にしています。SQS を Lambda のイベントソースとして使用する場合、AWS ドキュメントでは maxReceiveCount を 5 以上に設定することが推奨されています。今回は一時的なエラー時に数回再試行できるよう、推奨値の下限である 5 を指定しました。

また、Lambda 関数のタイムアウトは 30 秒にするため、SQS の可視性タイムアウトは 180 秒にしました。
SQS を Lambda のイベントソースとして使用する場合、可視性タイムアウトは Lambda 関数タイムアウトの 6 倍以上が推奨されています。

https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html

Lambda 関数

Lambda 関数は以下の内容で作成しました。

  • 関数名:connect-rate-limited-call-dialer
  • ランタイム:Python 3.14
  • タイムアウト:30秒

Lambda の実行ロールには、SQS からメッセージを受け取るために AWSLambdaSQSQueueExecutionRole を付与しました。

また、Amazon Connect の StartOutboundVoiceContact API を実行できるように、以下のインラインポリシーを付与しました。
アカウント ID は例示として 111111111111 にしています。実際に利用する場合は、自身の AWS アカウント ID と Amazon Connect インスタンス ID に置き換えてください。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "StartOutboundVoiceContactInSpecificConnectInstance",
      "Effect": "Allow",
      "Action": "connect:StartOutboundVoiceContact",
      "Resource": "arn:aws:connect:ap-northeast-1:111111111111:instance/<connect-instance-id>/contact/*"
    }
  ]
}

Resource は、対象の Amazon Connect インスタンス配下の contact に絞っています。

Lambda の SQS トリガー設定

Lambda に SQS トリガーを追加しました。

今回の重要な設定は以下です。

  • バッチサイズ:1
  • バッチウィンドウ:0秒
  • 最大同時実行数:2

以下の画面で、SQS トリガーのバッチサイズと最大同時実行数を設定していることを確認できます。

cm-hirai-screenshot 2026-05-15 11.49.30

バッチサイズは、1 回の Lambda 起動で受け取る SQS メッセージ数を指定する設定です。今回は 1 にしています。
これにより、1 回の Lambda 起動で処理するメッセージは 1 件になります。

最大同時実行数は、この SQS イベントソースマッピングから呼び出される Lambda 関数インスタンス数の上限を指定する設定です。今回は 2 にしています。
これにより、SQS に大量のメッセージが積まれても、このキューを起点とする Lambda 実行は最大 2 並列に抑えられます。

SQS イベントソースマッピングの最大同時実行数は、設定できる最小値が 2 です。そのため、今回は最大同時実行数を 2 にし、Lambda 側で API 呼び出し後に 1 秒 sleep する構成にしました。

なお、最大同時実行数は「同時に動く Lambda の数」を制御するものであり、「1 秒あたりの API 呼び出し回数」を直接制御するものではありません。
Lambda の処理が短時間で終了すると、次の SQS メッセージがすぐ処理され、結果として API 呼び出し回数が増える可能性があります。

今回の構成では、以下のような動きを期待しています。

SQS 標準キュー

最大2並列でLambdaを起動

各LambdaはSQSメッセージを1件だけ処理

各LambdaはStartOutboundVoiceContact APIを1回だけ呼び出す

API呼び出し後に1秒sleepして終了

ここで重要なのは、最大同時実行数は「同時に動く Lambda の数」を制御するものであり、「1 秒あたりの API 呼び出し回数」を直接制御するものではない点です。

たとえば、最大同時実行数を 2 にしても、Lambda の処理が 100 ミリ秒で終わる場合、次の SQS メッセージがすぐ処理され、結果として 1 秒間に 2 回を超えて API が呼ばれる可能性があります。

そのため、今回の Lambda コードでは StartOutboundVoiceContact API 呼び出し後に 1 秒 sleep してから処理を終了するようにしました。

StartOutboundVoiceContact

1秒sleep

Lambda終了

この実装により、最大 2 並列で動いている状態でも、各 Lambda 実行がすぐに終了して次のメッセージ処理へ進むことを避けています。

SQS イベントソースマッピングのパラメータは以下のドキュメントに記載されています。

https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-parameters.html

補足として、Lambda 関数自体の予約済み同時実行数を使えば、関数全体の同時実行数を制限することもできます。
ただし、予約済み同時実行数は Lambda 関数全体に効く制限です。

今回は、この SQS キューから起動される Lambda 実行だけを制御したいため、SQS イベントソースマッピングの最大同時実行数を使いました。

Lambda コード

今回の Lambda コードは以下です。

Amazon Connect のインスタンス ID、問い合わせフロー ID、発信元として使用する Amazon Connect の電話番号は Lambda の環境変数から取得しています。

CONNECT_INSTANCE_ID = os.environ["CONNECT_INSTANCE_ID"]
CONTACT_FLOW_ID = os.environ["CONTACT_FLOW_ID"]
SOURCE_PHONE_NUMBER = os.environ["SOURCE_PHONE_NUMBER"]

環境依存の値をコードに直接書かず、Lambda の環境変数として管理するためです。

ログは CloudWatch Logs Insights で集計しやすいように JSON 形式で出力しています。
connect_api_start のログに api_start_epoch_ms を出力し、後で API 呼び出し開始時刻を確認できるようにしました。

api_start_epoch_ms は、Lambda が StartOutboundVoiceContact API を呼び出す直前の時刻をミリ秒単位のエポック時刻で出力したものです。
この値を使って、API 呼び出し開始時刻の間隔や、任意の 1000 ミリ秒内に 3 回以上呼び出されていないかを確認します。

import json
import os
import time
import hashlib

import boto3

connect = boto3.client("connect")

CONNECT_INSTANCE_ID = os.environ["CONNECT_INSTANCE_ID"]
CONTACT_FLOW_ID = os.environ["CONTACT_FLOW_ID"]
SOURCE_PHONE_NUMBER = os.environ["SOURCE_PHONE_NUMBER"]

# StartOutboundVoiceContact API 呼び出し後に待機する秒数。
# SQS event source mapping:
#   - Maximum concurrency = 2
#   - Batch size = 1
#
# 1回のLambda起動でSQSメッセージを1件処理し、
# API呼び出し後に1秒sleepしてから終了する。
POST_API_SLEEP_SECONDS = 1.0

def lambda_handler(event, context):
    print_json({
        "event": "lambda_invocation_start",
        "aws_request_id": context.aws_request_id,
        "record_count": len(event.get("Records", []))
    })

    for record in event.get("Records", []):
        body = parse_body(record)

        destination_phone_number = body.get("destinationPhoneNumber")
        if not destination_phone_number:
            raise ValueError("destinationPhoneNumber is required in SQS message body")

        client_token = body.get("clientToken") or make_client_token(record)
        message = body.get("message", "")

        api_start_epoch_ms = int(time.time() * 1000)

        print_json({
            "event": "connect_api_start",
            "api_start_epoch_ms": api_start_epoch_ms,
            "aws_request_id": context.aws_request_id,
            "sqs_message_id": record.get("messageId"),
            "client_token": client_token,
            "destination_phone_number": destination_phone_number,
            "message": message
        })

        try:
            response = connect.start_outbound_voice_contact(
                InstanceId=CONNECT_INSTANCE_ID,
                ContactFlowId=CONTACT_FLOW_ID,
                DestinationPhoneNumber=destination_phone_number,
                SourcePhoneNumber=SOURCE_PHONE_NUMBER,
                ClientToken=client_token,
                Attributes={
                    "source": "sqs-rate-limited-call",
                    "message": message[:500],
                    "clientToken": client_token
                }
            )

            print_json({
                "event": "connect_api_success",
                "api_start_epoch_ms": api_start_epoch_ms,
                "api_end_epoch_ms": int(time.time() * 1000),
                "aws_request_id": context.aws_request_id,
                "sqs_message_id": record.get("messageId"),
                "client_token": client_token,
                "contact_id": response.get("ContactId")
            })

        except Exception as e:
            print_json({
                "event": "connect_api_failed",
                "api_start_epoch_ms": api_start_epoch_ms,
                "api_end_epoch_ms": int(time.time() * 1000),
                "aws_request_id": context.aws_request_id,
                "sqs_message_id": record.get("messageId"),
                "client_token": client_token,
                "error": str(e)
            })

            # ここで握りつぶさない。
            # raise することで SQS メッセージは削除されず、
            # Visibility timeout 後に再処理される。
            raise

        finally:
            print_json({
                "event": "post_api_sleep_start",
                "sleep_seconds": POST_API_SLEEP_SECONDS,
                "aws_request_id": context.aws_request_id,
                "sqs_message_id": record.get("messageId")
            })

            time.sleep(POST_API_SLEEP_SECONDS)

            print_json({
                "event": "post_api_sleep_end",
                "aws_request_id": context.aws_request_id,
                "sqs_message_id": record.get("messageId")
            })

    print_json({
        "event": "lambda_invocation_end",
        "aws_request_id": context.aws_request_id
    })

def parse_body(record: dict) -> dict:
    body_text = record.get("body", "{}")

    try:
        body = json.loads(body_text)
        if isinstance(body, dict):
            return body
        return {}
    except json.JSONDecodeError:
        return {}

def make_client_token(record: dict) -> str:
    """
    clientToken が SQS メッセージ本文にない場合の fallback。
    同じSQSメッセージの再処理では同じClientTokenになるようにする。
    """
    message_id = record.get("messageId", "")
    body = record.get("body", "")
    raw = f"{message_id}:{body}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

def print_json(obj: dict):
    print(json.dumps(obj, ensure_ascii=False, separators=(",", ":")))

今回のコードでは、SQS メッセージ本文から発信先電話番号、ClientToken、メッセージを受け取ります。

ClientTokenStartOutboundVoiceContact の冪等性制御に使います。同じ ClientToken を使い回すと、Amazon Connect 側で同一リクエストの再試行として扱われ、新しい電話発信にならない可能性があります。複数回テストする場合は、ClientToken を変える必要があります。

https://docs.aws.amazon.com/ja_jp/connect/latest/APIReference/API_StartOutboundVoiceContact.html#connect-StartOutboundVoiceContact-request-ClientToken

動作確認

必要なリソースを作成し、Lambda の SQS トリガー設定まで完了したため、SQS にメッセージを投入して動作確認しました。

SQS メッセージ本文

SQS メッセージ本文は以下の形式にしました。

{
  "destinationPhoneNumber": "+8190xxxxxxxx",
  "clientToken": "manual-call-001",
  "message": "manual call 001"
}

各項目の意味は以下です。

項目 用途
destinationPhoneNumber 発信先電話番号
clientToken StartOutboundVoiceContact の冪等性制御に使う値
message Amazon Connect の問い合わせフローに属性として渡す値

destinationPhoneNumber は、利用する環境の電話番号に置き換えてください。記事中では電話番号の一部をマスクしています。

10件のメッセージをほぼ同時に投入する

AWS CloudShell から SQS に 10 件のメッセージをほぼ同時に投入しました。

QUEUE_URL=$(aws sqs get-queue-url \
  --queue-name connect-rate-limited-call-queue \
  --query 'QueueUrl' \
  --output text)

RUN_ID=$(date +%Y%m%d%H%M%S)

for i in $(seq 1 10); do
  aws sqs send-message \
    --queue-url "$QUEUE_URL" \
    --message-body "{\"destinationPhoneNumber\":\"+8190xxxxxxxx\",\"clientToken\":\"burst-call-${RUN_ID}-${i}\",\"message\":\"burst call ${i}\"}" \
    >/dev/null &
done

wait
echo "sent 10 messages. run_id=${RUN_ID}"

実行結果は以下です。

sent 10 messages. run_id=20260515044151

この RUN_ID を使って、後続の CloudWatch Logs Insights で今回の検証ログだけを絞り込みました。

CloudWatch Logs Insights で確認する

Lambda ログには、StartOutboundVoiceContact を呼び出す直前に以下のような JSON ログを出力しています。

{
  "event": "connect_api_start",
  "api_start_epoch_ms": 1778820122541,
  "client_token": "burst-call-20260515044151-10"
}

この connect_api_start が、API 呼び出し開始時刻を確認するためのログです。

API 呼び出し開始時刻を確認する

今回の RUN_ID で絞り込み、API 呼び出し開始時刻を確認しました。

fields @timestamp, event, api_start_epoch_ms, aws_request_id, client_token
| filter client_token like /burst-call-20260515044151/
| filter event = "connect_api_start"
| sort api_start_epoch_ms asc

cm-hirai-screenshot 2026-05-15 14.51.04
結果は以下です。

@timestamp,event,api_start_epoch_ms,aws_request_id,client_token
2026-05-15 04:42:02.541,connect_api_start,1778820122541,c3c8c856-2fb4-58d5-b32b-aad689e42d67,burst-call-20260515044151-10
2026-05-15 04:42:03.452,connect_api_start,1778820123452,b3c75d48-c1f8-588c-a75d-3fc867a0a17c,burst-call-20260515044151-9
2026-05-15 04:42:05.474,connect_api_start,1778820125474,1c921c3f-3edf-5a8e-abd5-7ebf9882ef3c,burst-call-20260515044151-1
2026-05-15 04:42:05.912,connect_api_start,1778820125912,a9f0739e-aa1c-529c-b0aa-111e0c41bb6c,burst-call-20260515044151-4
2026-05-15 04:42:07.802,connect_api_start,1778820127802,32c7bec8-a71e-5012-9ed8-dc480141f5a0,burst-call-20260515044151-3
2026-05-15 04:42:08.157,connect_api_start,1778820128155,a16fbd66-d755-5b82-bf7c-dfe671350272,burst-call-20260515044151-8
2026-05-15 04:42:10.430,connect_api_start,1778820130430,5506334b-716c-5163-abea-51cbd70e74d1,burst-call-20260515044151-6
2026-05-15 04:42:10.655,connect_api_start,1778820130655,106ff95e-20ff-55c6-a45e-9bde869dfc36,burst-call-20260515044151-5
2026-05-15 04:42:12.981,connect_api_start,1778820132981,8f90acc0-997e-5ea1-a565-ce403f1a5b13,burst-call-20260515044151-2
2026-05-15 04:42:13.069,connect_api_start,1778820133069,7bfb9ad0-8671-5c74-ba4c-f85020143584,burst-call-20260515044151-7

任意の1000ミリ秒内に3回以上呼ばれていないか確認する

1 秒間に 3 回以上呼ばれていないかは、API 呼び出し開始時刻を昇順に並べ、3 件連続の差分で確認しました。

考え方は以下です。

3件目の API 呼び出し開始時刻 - 1件目の API 呼び出し開始時刻 < 1000ミリ秒

この条件に該当する場合、任意の連続する 1000 ミリ秒内に 3 回 API を呼び出していることになります。

今回の api_start_epoch_ms は以下でした。

1778820122541
1778820123452
1778820125474
1778820125912
1778820127802
1778820128155
1778820130430
1778820130655
1778820132981
1778820133069

3 件連続の差分を見ると以下です。

1件目~3件目:1778820125474 - 1778820122541 = 2933ミリ秒
2件目~4件目:1778820125912 - 1778820123452 = 2460ミリ秒
3件目~5件目:1778820127802 - 1778820125474 = 2328ミリ秒
4件目~6件目:1778820128155 - 1778820125912 = 2243ミリ秒
5件目~7件目:1778820130430 - 1778820127802 = 2628ミリ秒
6件目~8件目:1778820130655 - 1778820128155 = 2500ミリ秒
7件目~9件目:1778820132981 - 1778820130430 = 2551ミリ秒
8件目~10件目:1778820133069 - 1778820130655 = 2414ミリ秒

すべて 1000 ミリ秒を超えていました。

そのため、今回の検証データ上は、任意の連続する 1000 ミリ秒内で 3 回以上の StartOutboundVoiceContact API 呼び出しは確認されませんでした。

10件すべて成功したか確認する

今回の RUN_ID に該当する 10 件の成功・失敗件数も確認しました。

fields event, client_token, contact_id, error
| filter client_token like /burst-call-20260515044151/
| filter event in ["connect_api_success", "connect_api_failed"]
| stats count(*) as count by event

結果は以下です。

event,count
connect_api_success,10

connect_api_success は、StartOutboundVoiceContact API の呼び出しに成功したときに Lambda コードで出力しているログです。
今回の検証では 10 件すべて成功し、失敗ログである connect_api_failed はありませんでした。

SQS 標準キューなので処理順は保証されない

今回の client_token の処理順を見ると、以下のように順不同でした。

burst-call-20260515044151-10
burst-call-20260515044151-9
burst-call-20260515044151-1
burst-call-20260515044151-4
burst-call-20260515044151-3
burst-call-20260515044151-8
burst-call-20260515044151-6
burst-call-20260515044151-5
burst-call-20260515044151-2
burst-call-20260515044151-7

これは SQS 標準キューを使っているため、今回の検証では想定内です。

今回の目的は順序保証ではなく、以下を確認することでした。

  • 短時間のバーストを SQS で受ける
  • Lambda の同時実行数を SQS イベントソースマッピングで制御する
  • Amazon Connect API の呼び出しペースを抑える

順序保証が必要な場合は、SQS FIFO キューなど別の構成を検討する必要があります。

今回の方法で注意すること

今回の方法は、SQS イベントソースマッピングの最大同時実行数と、Lambda 内の sleep を組み合わせた簡易的な制御です。

今回の検証では、10 件の同時投入に対して、任意の連続する 1000 ミリ秒内で 3 回以上の API 呼び出しは確認されませんでした。

一方で、これは厳密なグローバルレートリミッターではありません。たとえば、以下のようなケースでは追加の考慮が必要です。

  • 他のシステムからも同じ Amazon Connect API を呼び出している
  • 複数の Lambda 関数や複数の SQS キューから同じ API を呼び出している
  • より厳密に API 呼び出しタイミングを一元管理したい

このような場合は、DynamoDB などを使って共有のレート制御を行う構成を検討するのがよさそうです。

まとめ

SQS 標準キューと Lambda を組み合わせて、Amazon Connect の StartOutboundVoiceContact API 呼び出しペースを抑える構成を試しました。

今回の検証では、10 件のメッセージをほぼ同時に投入しても、任意の連続する 1000 ミリ秒内で 3 回以上の API 呼び出しは確認されませんでした。

厳密なレート制御が必要な場合は追加の仕組みが必要ですが、Amazon Connect API のクォータを考慮した簡易的な制御としては有効そうです。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事