IoTデバイスに遠隔コマンドを送りたい、という要件はよくあります。しかしLTE-Mデバイスが eDRX(Extended Discontinuous Reception) で動作していると、Downlinkは「送ったのに届かない」という事態が起きやすくなります。

この記事では、実際にこの現象にハマった経験をもとに、以下の4点を実装コードと設計判断の背景を交えて解説します。

  • eDRXのPTWタイミングに合わせたコマンド送信設計
  • Lambda内でスリープしない非同期リトライ(SQS遅延再エンキュー)
  • 大規模並行Lambda × 外部APIのコールドスタート対策
  • SQS visibility timeoutの設計

eDRXとPTWの基礎

まず前提知識を整理します。

通常LTE  : 常時受信待機 → Downlinkはいつでも届く
PSM      : 深いスリープ → Downlinkは次のUplink時まで届かない
eDRX     : 間欠受信    → PTW(Paging Time Window)のときだけ届く

LTE-Mの標準eDRX周期: 5.12秒〜2621.44秒(NB-IoTでは最大10485.76秒)
実際のデバイスでよく使われる周期例: 81.92秒(3GPP標準の設定値のひとつ)

ポイントは、「SORACOM など中継サービスのAPIタイムアウト」と「eDRX周期」が競合すると、Downlinkが届かないケースが生じることです。

タイムアウトとの競合と「PTW手前狙い」

競合が起きる仕組み

SORACOM Downlink API に指定したタイムアウト: 100秒
  (固定仕様ではなく、リクエストパラメータ timeout で送信側が指定する値)
デバイスのeDRX周期: 81.92秒

[コマンドAへの応答]
    ↓ デバイスが応答後、即スリープ
[すぐにコマンドBを送信]
    ↓
    デバイスは次のPTWまで 最大81.92秒 起きない
    SORACOMは 100秒 でタイムアウト
    → ギリギリ間に合わないケースが発生

コマンドAの応答直後にコマンドBを送っても、デバイスはすでにスリープに入っています。次のPTWが来るまでにSORACOMのタイムアウト(100秒)が切れる可能性があります。

解決策:PTWの約6.92秒前に届くように遅延送信

// eDRX周期(81.92秒) - マージン(6.92秒) ≒ 75秒待機してから次コマンドを送信
const commandBWait = 75 * time.Second

即時送信ではタイムアウトと競合しうるが、75秒待機してPTW手前を狙えば期限まで余裕をもって応答が完了する

なぜコマンドA応答受信をt=0にできるのか

デバイスはPTW中にコマンドAへ応答したあと、即座に次のeDRXサイクルに入ります。つまり「コマンドA応答受信 ≒ 次のPTW開始までの81.92秒カウントダウン開始」と捉えられるため、この時点をt=0として遅延量を計算できます。

なぜPTWの手前6.92秒なのか

PTWの到来と同時かそれ以降にDownlink APIを呼んでも、SORACOMがデバイスへの接続を確立する前にデバイスは再びスリープに戻ってしまいます。そのため、PTW到来の数秒前にAPIへリクエストが届いている状態を作る必要があります。この「接続確立猶予」として、実測から6.92秒(81.92秒 – 75秒)を採用しました。

このマージン値は環境依存であり、実測なしに理論値だけで決めるのは難しいところです。まず小さめの値から試し、失敗率とログを見ながら増やしていくアプローチが現実的だと思います。

なお、マージンが小さすぎると依然として失敗が発生し、大きすぎると次のeDRXサイクルを待つことになります(さらに81.92秒の待機が必要になります)。実際のデバイスやネットワーク環境に合わせた調整が重要です。

75秒待機の実現方法は2通りある

この75秒の待機は、Lambda内で time.Sleepselect + time.After)する方法と、次節で紹介するSQSの DelaySeconds=75 で自エンキューして別のLambda invocationに送信を任せる方法のどちらでも実現できます。前者は同一invocation内で応答を続けて処理できるためロジックがシンプルになり、後者は待機中のLambda課金と並行実行枠を解放できます。待機が処理フローの中間にあり後続処理が応答に依存する場合は前者、待機後の送信が独立したコマンドとして完結する場合は後者、という使い分けが実用的です。

SQS遅延再エンキューによるリトライ設計

続いて、Downlinkの応答が空(デバイス未応答)だったときのリトライ設計です。

アンチパターン

// Lambda内でスリープ → その間ずっと課金・並行実行数も消費
resp, _ := soracomClient.SendDownlink(cmd)
if resp.Data == "" {
    time.Sleep(60 * time.Second) // ← これをやってはいけない
    soracomClient.SendDownlink(cmd)
}

Lambda は実行時間に応じて課金されます。また、並行実行数の上限も消費し続けるため、スケーラビリティを損ないます。

採用した設計:SQS遅延再エンキュー

空レスポンス受信時はDelaySeconds=60で再エンキューして即return、60秒後に別のLambda invocationがリトライする

const (
    emptyResponseRetryWait = 60 * time.Second
    maxEmptyResponseRetry  = 1
)

// 再エンキュー済みを示すセンチネルエラー(呼び出し側はこれを検知して正常終了する)
var errRequeued = errors.New("message requeued for retry")

// 空レスポンス時: リトライ回数をインクリメントして遅延付き再エンキュー
if msg.RetryCount >= maxEmptyResponseRetry {
    return bodyBytes, nil // リトライ上限到達: 空レスポンスのまま後続処理へ
}
retryMsg := msg
retryMsg.RetryCount++
body, _ := json.Marshal(retryMsg)
sqsClient.SendMessage(&sqs.SendMessageInput{
    QueueUrl:     &queueURL,
    MessageBody:  aws.String(string(body)),
    DelaySeconds: aws.Int64(int64(emptyResponseRetryWait.Seconds())),
})
return nil, errRequeued // Lambda は即終了

このパターンのメリットは2点あります。

  • Lambda課金はコマンド送信〜応答受信の実時間のみ
  • 並行実行数の枠を無駄に占有しない

注意点:再エンキューのリトライ回数は MaxReceiveCount では数えられない

一見、リトライ回数の管理はSQSのredrive policy(MaxReceiveCount)に任せられそうに思えますが、それは誤りです。SendMessage による再エンキューはSQSにとってまったく新しいメッセージの送信であり、受信カウントはリセットされます。放置すると、空レスポンスが続く限り無限にリトライし続けることになります。

そのため、上のコードのようにメッセージボディに RetryCount フィールドを持たせて自前でカウントし、上限判定する必要があります。

一方、Lambdaがエラーを返して同一メッセージがSQSから再配信されるケース(API障害など)は MaxReceiveCount が正しくカウントします。つまりリトライは2系統あり、それぞれ管理主体が異なります。

リトライの種類 トリガー 回数管理
空レスポンスリトライ デバイス未応答(正常系の一部) メッセージボディ内のカウンタ(自前)
エラーリトライ Lambdaがエラーを返す SQS redrive policy の MaxReceiveCount

SQS visibility timeoutの設計

Lambda timeout: 600秒
SQS visibility timeout: 3600秒(600 × 6)

なぜ6倍か

Lambda がタイムアウトすると、SQS はメッセージを再可視化します。visibility timeout が Lambda timeout より短いと、Lambda 実行中にメッセージが再可視化されて二重処理が起きます。AWS は「Lambda timeout × 6以上」を推奨しています。

resource "aws_sqs_queue" "downlink" {
  visibility_timeout_seconds = 3600  # Lambda timeout(600s) × 6
  message_retention_seconds  = 86400
}

resource "aws_sqs_queue" "downlink_dlq" {
  message_retention_seconds = 1209600  # 14日間(事後調査のため)
}

DLQの設計

Lambdaがエラーを返し続け、同一メッセージの再配信回数が MaxReceiveCount(ここでは3回)を超えると、そのメッセージは DLQ(Dead Letter Queue)に移動します。DLQ の保持期間を長め(14日間)にしておくことで、障害発生時の事後分析が可能になります。

DLQ のコンシューマー Lambda では、すべてのリトライを使い切った旨をエラーログに記録し、ステータスを「失敗完了」として処理を終わらせます。

大規模並行Lambda × 外部認証APIのコールドスタート時の負荷集中

最大650並行実行のLambdaがすべて同時にコールドスタートすると、外部認証APIへのリクエストが一瞬で650本発生します。

解決策1:Lambdaインスタンス内トークンキャッシュ

var (
    cachedToken   string
    tokenExpireAt time.Time
    mu            sync.RWMutex
)

const (
    tokenTimeoutSeconds = 86400   // API側TTL: 24時間
    tokenCacheTTLRatio  = 0.95    // キャッシュTTL: 22.8時間(5%安全マージン)
)

func getToken() (string, error) {
    mu.RLock()
    if time.Now().Before(tokenExpireAt) {
        token := cachedToken
        mu.RUnlock()
        return token, nil // キャッシュヒット
    }
    mu.RUnlock()

    // キャッシュミス時のみAPIを呼ぶ(Double-checked locking)
    mu.Lock()
    defer mu.Unlock()
    if time.Now().Before(tokenExpireAt) {
        return cachedToken, nil
    }
    return fetchNewToken()
}

TTLをAPIの有効期限の95%に設定しているのは、時刻のズレや処理遅延によるトークン失効を防ぐための安全マージンです。

解決策2:キャッシュミス時のジッター

コールドスタート直後は多数のインスタンスが同時にキャッシュミスするため、全インスタンスが同時にAPIを叩いてしまいます。ジッターでリクエストを分散します。

const authJitterMax = 5 * time.Second

// キャッシュミス時のみジッター待機
jitter := time.Duration(rand.Int63n(int64(authJitterMax)))
time.Sleep(jitter)
// ジッター中に他インスタンスがキャッシュを更新していれば、
// ロック取得後の Double-checked locking で再利用できる

ジッターの最大値(ここでは5秒)は、「コールドスタートが集中する時間帯の最大並行数」と「外部APIのレートリミット」から逆算して決めます。

まとめ。設計時の注意点など。

設計判断を対応表にまとめます。

課題 解決策 キーとなる設計値
eDRX周期とAPIタイムアウトの競合 PTW手前狙いの遅延送信 eDRX周期 – マージン秒数
空レスポンス時のリトライ SQS遅延再エンキュー Lambda即終了 + DelaySeconds + ボディ内リトライカウンタ
エラー時のリトライ SQS redrive policy MaxReceiveCount 3回 → DLQ
二重処理の防止 visibility timeout を Lambda timeout × 6以上 600秒 × 6 = 3600秒
コールドスタート集中 トークンキャッシュ + ジッター TTL 95% + 最大5秒ジッター

最後に、設計時の注意点です。

  • eDRX周期と中継サービスのタイムアウトは、単体で見るとどちらも正常な値のため、両方を並べて初めて競合に気づける
  • PTW手前狙いのマージン値は環境依存のため、理論値ではなく実測とログをもとに調整する
  • リトライ待機をLambda内のスリープで行わない(課金と並行実行枠を浪費する)
  • SendMessage による再エンキューは新規メッセージになるため、リトライ回数はメッセージボディで自前管理する
  • visibility timeout は Lambda timeout の6倍以上にする(二重処理の防止)

eDRXデバイスへのDownlinkは「とりあえず送れば届く」とはいかず、デバイスの受信タイミングから逆算した設計が必要になります。同じ現象にハマった方の参考になれば幸いです。