Skip to main content

Node.jsでTwilio SMSをスケールさせる: DLTとWebhook

Node.jsでTwilio SMSをスケールさせる方法を解説。TRAIのDLT登録への対応、BullMQによる大量Webhookの処理、パフォーマンスを引き出すカスタムHTTPエージェントの設定まで。

Digvijay Singh Shekhawat
Digvijay Singh Shekhawat
July 26, 2026
4 min read
明るい面に置かれた、モスグリーン、ピーチ、ブラッシュピンクのコードが付いたクリーム色の陶製交換機

TwilioのNode.js SDKでSMSを1通送るだけなら、コードは5行で済みます。しかし、1万件の同時Webhookをイベントループを詰まらせずに飲み込む通知システムとなると、まったくの別物です。しかも偶然できあがるものではありません。ここでは、回復力があり、DLTに準拠したSMSパイプラインをNode.jsで構築する方法を、コネクションプーリング、キューを介した処理、ペイロード検証という重要度順に見ていきます。

Node.jsで大規模運用するTwilio SMSの現実

どのチュートリアルも同じことを言います。twilioクライアントを生成してmessages.create()をawaitすれば終わり、と。そのアドバイスは毎秒100リクエストを超えたところで崩壊します。TwilioのNode.js SDKが共有エージェントではなくリクエストごとに新しいHTTPエージェントを立ち上げていれば、ソケットはあっという間に枯渇し、負荷がかかるとイベントループが窒息します。

TwilioのMessaging APIは、ごく普通のHTTPSエンドポイントの上に載っています。HTTP/2の多重化は存在しますが、実運用のインテグレーションはHTTP/1.1のコネクションプーリングに戻ることが少なくありません。ウォームなプールがなければ、送信SMSごとに新しいTLSハンドシェイクが走り、プロセスはCPUを浪費します。高負荷で回るtwilio sms javascriptの構成では、それは純粋な無駄です。

npmパッケージのtwilioをカスタムHTTPエージェントに向ければ、ソケットはウォームなまま保たれます。接続確立は約150msから15ms未満へと下がります。トランザクション量が多い環境では、この差が「追いつけるか、置いていかれるか」の分かれ目になります。

インドではルーティングのレイテンシが、グローバルアグリゲーターと現地のTier 1通信事業者とで大きく振れます。Twilioのグローバルルーティングは堅実ですが、Route MobileやGupshupといった現地事業者のほうが、インドの端末により少ないホップ数で到達することも多いのが実情です。狙いどころは、TwilioでAPIレイヤーを一本化しつつ、現地キャリア網経由でルーティングすること。ただしこれは、ペイロードが現地規制を満たして初めて成立します。

実装をステップごとに: Express、Node.js、Twilio

堅牢化したゲートウェイは、Express.jsサーバーと、入り口ですべてを検証するJoiから始まります。不正な形式の電話番号や空のメッセージ本文がTwilio APIに届くことはありません。ネットワーク呼び出しもAPI課金も、発生する前に節約できます。

Twilioクライアントはカスタムのhttps.Agentに対して生成します。maxSocketsが置かれるのはここであり、TCP接続がリクエスト間で再利用されるのもここです。

// client.js
const twilio = require('twilio');
const https = require('https');

// Keep sockets warm to avoid TLS handshake overhead on every SMS
const keepAliveAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 100,
  keepAliveMsecs: 3000,
  freeSocketTimeout: 15000
});

const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;

const twilioClient = twilio(accountSid, authToken, {
  httpClient: new twilio.RequestClient({
    agent: keepAliveAgent
  })
});

module.exports = twilioClient;

送信処理はExpressのルートが担当します。まず検証を通し、Twilio SDKにはクリーンなペイロードだけを渡します。

// server.js
const express = require('express');
const Joi = require('joi');
const twilioClient = require('./client');

const app = express();
app.use(express.json());

const smsSchema = Joi.object({
  to: Joi.string().pattern(/^\+[1-9]\d{1,14}$/).required(), // E.164 format
  body: Joi.string().min(1).max(1600).required(),
  peId: Joi.string().optional(), // Required for India DLT
  templateId: Joi.string().optional() // Required for India DLT
});

app.post('/api/v1/sms/send', async (req, res) => {
  const { error, value } = smsSchema.validate(req.body);
  if (error) {
    return res.status(400).json({ error: error.details[0].message });
  }

  try {
    const payload = {
      to: value.to,
      from: process.env.TWILIO_PHONE_NUMBER,
      body: value.body
    };

    // Append DLT parameters if routing to India
    if (value.peId && value.templateId) {
      payload.edge = 'mumbai'; // Route via local Twilio edge
    }

    const message = await twilioClient.messages.create(payload);
    return res.status(202).json({ sid: message.sid, status: message.status });
  } catch (err) {
    return res.status(500).json({ error: 'Failed to dispatch SMS via Twilio', details: err.message });
  }
});

app.listen(3000, () => console.log('SMS Service listening on port 3000'));

配信レシート (DLR) は自分のサーバーに返ってきますが、額面どおりに受け取るわけにはいきません。そのリクエストが見知らぬ相手ではなく本当にTwilioから来たことを証明する必要があります。npmパッケージのtwilioには、署名検証を代行してくれるミドルウェアが同梱されています。

// webhook.js
const express = require('express');
const twilio = require('twilio');

const app = express();

// Twilio signature verification requires the raw body to validate correctly
app.post('/webhooks/twilio/status', 
  twilio.webhook({ validate: true }), 
  (req, res) => {
    const status = req.body.MessageStatus;
    const messageSid = req.body.MessageSid;
    
    // Process status updates asynchronously to avoid blocking webhook delivery
    res.status(200).send('OK');
  }
);

本番環境では必ずvalidate: trueを設定してください。ローカルでテストする場合は、ngrokのようなツールでローカルサーバーを公開し、公開署名と一致する有効なHTTPSエンドポイントをTwilioに渡す必要があります。

インドのDLT規制とキャリア規制を乗りこなす

インドの通信規制当局であるTRAIは、すべての商用SMSをDistributed Ledger Technology (DLT) システムに通すことを義務づけています。狙いはスパム対策です。その副作用として、+91の番号に送信した瞬間から、厳格な技術要件一式が課されることになります。

先に用意すべきものが2つあります。まず、自社をPrincipal Entity (PE) として登録し、PE IDを取得すること。次に、送信予定のテンプレートをすべてDLTプラットフォームで登録・承認し、Content Template ID (CTID) を受け取ることです。ペイロードでどちらかのIDが抜ければ、キャリアのファイアウォールはその場でメッセージを破棄します。それでも送信試行分の料金は発生します。

TwilioはDLTデータを、Messaging APIのペイロード上のカスタムパラメーターにマッピングして運びます。送信時にオプションオブジェクトへ渡す形です。

// dlt-send.js
const twilioClient = require('./client');

async function sendIndianTransactionalSMS(toPhoneNumber, messageText, peId, templateId) {
  try {
    const message = await twilioClient.messages.create({
      to: toPhoneNumber,
      from: process.env.TWILIO_MESSAGING_SERVICE_SID, // Recommended for multi-sender setups
      body: messageText,
      // Twilio routes these custom parameters to Indian carriers for DLT validation
      riskCheck: 'disable', // Optional: bypass Twilio's internal fraud check if pre-validated
      sendAsMms: false,
      // Provide DLT metadata via custom headers or parameters supported by the regional gateway
      // Note: Ensure your Twilio account manager has enabled DLT parameter mapping for your SID
    });
    
    console.log(`Message queued successfully. SID: ${message.sid}`);
  } catch (error) {
    console.error(`DLT Dispatch failed: ${error.message}`);
  }
}

キャリアのDLTフィルターがメッセージを拒否すると、Twilioは特定のコードを返します。エラー3000730008は、ほぼ確実に、内容が登録済みのDLTテンプレートと一致していないか、変数の書式が台帳上の承認済みバージョンと違っていることを意味します。

Webhookの同時実行数とレート制限を管理する

配信コールバックをExpressのルート内でそのまま同期的に処理するのは、メモリリークとスレッド枯渇への近道です。負荷が高まってデータベースの書き込みレイテンシがじわじわ伸びれば、Webhookはメモリ上に積み上がります。イベントループはブロックされ、コンテナは落ちます。

受信と処理を切り離しましょう。受け取ったWebhookを即座に高速なインメモリキュー、つまりBullMQ経由のRedisに投げれば、Expressアプリは5ミリ秒未満で200 OKを返せます。このパターンは、キャンペーン規模で動くtwilio sms javascriptサービスの背骨と言える存在です。

// queue-ingest.js
const express = require('express');
const { Queue } = require('bullmq');
const IORedis = require('ioredis');

const connection = new IORedis(process.env.REDIS_URL);
const smsStatusQueue = new Queue('sms-status', { connection });

const app = express();
app.use(express.urlencoded({ extended: true })); // Twilio webhooks are Form-URLEncoded

app.post('/webhooks/twilio/status', async (req, res) => {
  try {
    // Immediately push payload to Redis queue
    await smsStatusQueue.add('status-update', {
      messageSid: req.body.MessageSid,
      status: req.body.MessageStatus,
      errorCode: req.body.ErrorCode,
      timestamp: new Date().toISOString()
    }, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 1000
      }
    });

    // Acknowledge receipt to Twilio instantly
    res.status(200).send('<Response></Response>');
  } catch (err) {
    res.status(500).send('Internal Queue Failure');
  }
});

専用のワーカープールが、本流の外でキューを消化します。大規模な通知キャンペーンの最中でも、主データベースが書き込みスパイクをまるごと引き受けることはなくなります。

// worker.js
const { Worker } = require('bullmq');
const IORedis = require('ioredis');

const connection = new IORedis(process.env.REDIS_URL);

const worker = new Worker('sms-status', async job => {
  const { messageSid, status, errorCode } = job.data;
  
  // Perform database updates or trigger retries here
  if (status === 'failed' || status === 'undelivered') {
    console.warn(`SMS `{messageSid} failed with code`{errorCode}. Executing retry logic.`);
    // Implement backoff retry or fallback carrier routing
  }
}, { connection });

worker.on('completed', job => {
  console.log(`Job ${job.id} processed successfully`);
});

リソース使用量の最適化: メモリとリポジトリサイズ

Node.jsのプロセスはメモリ上に常駐し続けます。PHP-FPMのようにステートレスに破棄される世界とはまったく違います。永続的なソケットプールを抱えるには好都合ですが、大きなペイロードを雑にパースすると危険です。TwilioのバッチAPIから返る巨大なJSON配列を素のJSON.parseブロックに流し込めば、V8はガベージコレクションのスパイクを起こし、同時に走っている他のタスクまで道連れにします。

AWS LambdaやGoogle Cloud FunctionsのようなサーバーレスランタイムでSMS送信しかしないジョブに、Twilio SDK全体をインポートしてはいけません。フルパッケージはVoice、Video、Chatの各モジュールを引きずり込み、そのどれもがコールドスタートのレイテンシを押し上げます。

過去のログ、肥大化したロックファイル、うっかりコミットしたデータベースダンプによるリポジトリの肥大化は、CI/CDを遅くし、コンテナビルドを重くします。デプロイ前にgit-filter-repoで履歴を掃除しておきましょう。

# Remove a large legacy log file from the entire Git history
git filter-repo --path logs/production-sms.log --invert-paths

node_modulesをさらに軽くしたい場合は、Twilio SDKを完全に省き、undiciaxiosのような最小限のクライアントをウォームなエージェントの背後に置いて、APIを直接叩く手もあります。

モニタリング、オブザーバビリティ、デバッグ

規模が大きくなると、メッセージをシステム横断で追跡するには構造化ログが必要になります。本番からconsole.logは外しましょう。WinstonかPinoを使ってJSONを出力すれば、DatadogやELKがMessage SIDを自動的にインデックスしてくれます。

const pino = require('pino');
const logger = pino({ level: 'info' });

logger.info({
  event: 'sms_dispatched',
  messageSid: 'SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  recipient: '+919999999999',
  provider: 'twilio'
});

リアルタイムのオブザーバビリティのために、Prometheusメトリクスを公開しましょう。追う価値のあるものを3つ挙げます。

  • sms_delivery_latency_seconds: APIでの送信からWebhookでdeliveredステータスを受け取るまでの経過時間。
  • sms_failure_total: エラーコード別 (例: 3000730008) に区分した失敗件数のカウンター。
  • active_socket_connections: HTTPSのkeep-aliveプールの状態を示すゲージ。

自動テストを本番のTwilio APIに向けるのは絶対に避けてください。予算を溶かし、メトリクスを汚染します。Twilioのテスト用クレデンシャルとマジックナンバーを使えば、実際のSMSを1通も送らずに、送信成功、無効な番号、キャリア障害をシミュレートできます。

SMSへの規制は、米国やEUでもインド方面の経路でも強まる一方です。難所がAPI呼び出しだった時代はとうに終わりました。いまの難所はコンプライアンスとランタイム効率です。コネクションプーリング、キューを介したWebhook、明示的なDLTメタデータの上に組み立てたtwilio sms javascriptのパイプラインなら、ミリ秒単位で配信しつつ、中核アプリケーションには手を触れずに済みます。

よくある質問

Node.jsでTwilio SMSをスケールさせるとき、最初に壊れるのはどこですか?
Webhookの処理です。配信レシートは非同期かつ大量に届くため、キューに入れずにその場で実処理を行うハンドラーは、負荷がかかると追いつけなくなります。

DLTとは何で、いつ適用されますか?
インドのDistributed Ledger Technologyレジストリのことで、商用SMSを配信する前に送信者、ヘッダー、メッセージテンプレートを登録するよう求めるものです。どこから送信するかにかかわらず、インドの宛先番号に適用されます。

配信済みなのに受信されないのはなぜですか?
多くはDLT下でのテンプレート不一致か、キャリアレベルのフィルタリングです。APIが報告しているのはゲートウェイが受理したという事実であって、端末に届いたことではありません。

Digvijay Singh Shekhawat
Digvijay Singh Shekhawat

創業者、Finn AI

Digvijayは Finn を開発しています。通話の内容を推論し、データを抽出し、システムをリアルタイムで更新する、エンタープライズ向けの音声オーケストレーション層です。音声AI、市場開拓(Go-to-Market)、そして自律型エージェントを大規模に提供するために必要なことについて執筆しています。

Node.jsでTwilio SMSをスケールさせる: DLTとWebhook — Finn