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

なぜコマンド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.Sleep(select + 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遅延再エンキュー

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