LINE Webhook 先で動作している Lambda から外部 API へ転送しようとしてハマった話
こんにちは、高崎@クラスメソッドオペレーションズ です。
はじめに
TypeScript で実装されていた Lambda に、ある処理を追加すると想定通り動作しなかったケースと、その対処方針について検討した記事になります。
設計方針
構成と要件について
- 既存環境の構成は LINE プラットフォームの Webhook ボットサーバーを API Gateway + Lambda
- 要件は「Webhook で受信したパラメータを外部 API へ転送する処理を追加すること」
一見、API コールを追加するだけのシンプルな話に見えますが、LINE プラットフォームの仕様における Webhook 処理時間の制約を踏まえた検討が必要になります。
LINE の Webhook 処理時間の制約とは
法人ユーザー向け開発ガイドライン によると、Webhook イベントを受信してから 2 秒以内を目安に HTTP ステータスコード 200 を返すよう案内されています(2026/09/21 現在)。
なお、2 秒以内に HTTP ステータスコード 200 を返さないと、LINE プラットフォーム上で request_timeout として処理され、Webhook の再送を有効[1]にしていた場合は、Webhook イベントが再送される可能性があります。
追加の要件
外部 API の応答時間は自分たちではコントロールできないため、上記のような制約のある Webhook 処理に同期的に組み込むのは難しそうです。
そこで、「外部 API の処理結果は Webhook の応答に関与しない」という前提に立ち、「外部 API へは非同期に転送し、LINE プラットフォームへの応答はその完了を待たずに返す」という方針で検討を進めます。
動かしてみる
Webhook ハンドラに対して、以下のような差分を入れるイメージです。
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
export const eventHandler = async (
eventLambda: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
// ★★★ Webhook イベント処理(署名検証等)
// ★差分ここから★ 外部 API へ非同期で転送(結果を待たずに次へ進む)。
const rawBody: string = eventLambda.body || "";
if (rawBody !== "") {
void fetch("転送先の外部 API の URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: rawBody,
}).catch((error: unknown) => {
// 転送失敗はログに残すだけ。LINE への応答には影響させない。
console.error("外部 API への転送でエラーが発生しました", { error });
});
}
// ★差分ここまで★
// 転送の完了を待たずに OK レスポンスを返す。
return { statusCode: 200, body: "OK" };
};
fetch に対して意図的に void を用いて結果を待たずに非同期で進めることが狙いです。
ところが、これを Lambda 環境にデプロイして疎通確認をしたところ、外部 API 側にリクエストが一切届かない という現象が発生しました。
fetch 前後にログを仕込んで確認してもそのログは出力されていた一方で、外部 API 側の受信ログや catch 内のエラーログは出ておらず、結果を待たずに投げた処理が完了前に打ち切られている 状態でした。
Lambda はレスポンスを返した後の処理継続を保証しない
原因は、ハンドラ処理の完了(=レスポンス確定)と実行環境がフリーズする、という Lambda 特有の挙動でした。
AWS 公式ドキュメントにも以下のように明記されていました。
※太字部分は筆者によるものです。
Each phase starts with an event that Lambda sends to the runtime and to all registered extensions. The runtime and each extension indicate completion by sending a Next API request. Lambda freezes the execution environment when the runtime and each extension have completed and there are no pending events.
— Understanding the Lambda execution environment lifecycle
レスポンスを返した時点で実行環境がフリーズされるため、完了していないバックグラウンド処理の継続は保証されない と読み取れます。
今回のケースでは、レスポンスを返した時点で fetch の途中でフリーズされて継続が保証されない状態になったと考えられるため、同一の Lambda 上で外部 API を呼び出しつつ LINE プラットフォームへ即時に応答することは難しそうです。
対処方針の検討
外部 API への転送用 Lambda を新設し、Webhook ハンドラから起動する方針で考えます。
対応案を大きく 2 つ考えました。
1. 直接 Invoke する
転送処理だけを行う別の Lambda 関数を InvocationType: "Event"(非同期呼び出し)で Invoke する方式です。
export type ForwardEvent = { rawBody: string };
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
import { ForwardEvent } from "./forward-event";
// 転送用 Lambda を起動するクライアント(呼び出し側)
export class ForwardLambdaClient {
readonly #lambdaClient: LambdaClient;
readonly #functionName: string;
constructor({
lambdaClient,
functionName,
}: {
lambdaClient: LambdaClient;
functionName: string;
}) {
this.#lambdaClient = lambdaClient;
this.#functionName = functionName;
}
async invoke({ rawBody }: ForwardEvent): Promise<void> {
const payload: ForwardEvent = { rawBody };
await this.#lambdaClient.send(
new InvokeCommand({
FunctionName: this.#functionName,
InvocationType: "Event", // 非同期起動。呼び出し元は受付完了のみ待つ
Payload: JSON.stringify(payload),
}),
);
}
}
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { LambdaClient } from "@aws-sdk/client-lambda";
import { ForwardLambdaClient } from "./forward-lambda";
const lambdaClient = new LambdaClient({});
export const eventHandler = async (
eventLambda: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
// ★★★ 既存の Webhook イベント処理(署名検証等)
// 転送用 LambdaClient 作成
const forwardLambdaClient = new ForwardLambdaClient({
lambdaClient,
functionName: "定義した関数名"
});
// Webhook ハンドラ側:転送 Lambda の起動だけを「待ってから」レスポンスを返す
const rawBody: string = eventLambda.body || "";
if (rawBody !== "") {
await forwardLambdaClient
.invoke({ rawBody })
.catch((error: unknown) => {
console.error("転送用 Lambda の起動でエラーが発生しました", { error });
});
}
return { statusCode: 200, body: "OK" }; // ここでレスポンスを返す
}
Invoke の際に await を使用していますが、同期で待つのは転送用 Lambda の起動までで、その先の外部 API の応答までではありません。
検証環境で何度か確認したところ、Invoke 自体は数十 ms ほど [2] で完了しました。
あくまで一例であり保証値ではありませんが、2 秒制約に対する影響は小さいと考えられます。
import { Handler } from "aws-lambda";
import { ForwardEvent } from "./forward-event";
// 転送用 Lambda(別関数):ここで実際に外部 API を fetch し、完了まで await する
export const handler: Handler<ForwardEvent, void> = async (event) => {
const result = await fetch("転送先の外部 API の URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: event.rawBody,
});
if (!result.ok) {
throw new Error(`外部 API への転送に失敗しました(status: ${result.status})`);
}
};
IaC(AWS CDK)を用いている場合は、追加で下記の実装も必要になります。
import { aws_lambda, Duration } from "aws-cdk-lib";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
// 既存:Webhook Lambda の定義
const webhookLambda = new NodejsFunction(this, "WebhookLambda", {
entry: "エントリーのソースファイル",
timeout: Duration.seconds(30),
// environment などのその他の既存設定は説明上省略しています
});
// 転送用 Lambda の定義
const forwardLambda = new NodejsFunction(this, "ForwardLambda", {
entry: "エントリーのソースファイル",
timeout: Duration.seconds(30),
// environment などのその他の既存設定は説明上省略しています
});
// 非同期呼び出し失敗時の挙動を明示的に制御
new aws_lambda.EventInvokeConfig(this, "ForwardLambdaEventInvokeConfig", {
function: forwardLambda,
retryAttempts: 2,
// リトライ間隔やキュー内での待機時間を考慮し、
// プロジェクトの許容遅延に応じて設定する
maxEventAge: Duration.minutes(10),
});
// Webhook Lambda に、転送用 Lambda を呼び出す権限を付与
forwardLambda.grantInvoke(webhookLambda);
固定値(maxEventAge 10 分、retryAttempts 2 回)でハードコーディングしていますが、要件に応じて環境変数等に設定するのが良いと思います。
メリット
- 追加の AWS リソースが Lambda 関数 1 つのみで済み、構成がシンプル。
- 呼び出し元の実装差分が小さい。
- 標準で最大 2 回(合計 3 回)試行する自動リトライを備えている。
デメリット
- スロットリングが起きると、リトライ回数を使い切った時点でイベントが破棄される可能性がある。
- 転送処理が失敗した際の追跡・再送を行う場合、デッドレターキューのような仕組みが必要になる。
2. SQS を経由して起動する
Webhook ハンドラからは直接 Lambda を起動せず、一度 SQS キューにメッセージを積み、そのキューをトリガーに転送用 Lambda を起動する方式です。
中間サービスとしては SQS の他にも SNS や EventBridge が候補になりますが、今回は単一の Lambda を起動できれば十分なため、ここではキューによるバッファリングを持つ SQS を取り上げます。
Webhookハンドラ(Lambda)
→ SQSへメッセージ送信(rawBody)
→ SQSキュー
→ (SQSトリガー)転送用 Lambda
→ 外部APIへfetch
呼び出し元のコード自体は 1. とほぼ同じ形で、Lambda を Invoke する部分が SQS への SendMessage に置き換わるイメージです。[3]
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { ForwardEvent } from "./forward-event"; // 1. と同じものを使用
export class ForwardQueueClient {
readonly #sqsClient: SQSClient;
readonly #queueUrl: string;
constructor({
sqsClient,
queueUrl
} : {
sqsClient: SQSClient;
queueUrl: string;
}) {
this.#sqsClient = sqsClient;
this.#queueUrl = queueUrl;
}
async enqueue({ rawBody }: ForwardEvent): Promise<void> {
const payload: ForwardEvent = { rawBody };
await this.#sqsClient.send(
new SendMessageCommand({
QueueUrl: this.#queueUrl,
MessageBody: JSON.stringify(payload),
}),
);
}
}
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { SQSClient } from "@aws-sdk/client-sqs";
import { ForwardQueueClient } from "./forward-queue";
const sqsClient = new SQSClient({});
export const eventHandler = async (
eventLambda: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
// ★★★ 既存の Webhook イベント処理(署名検証等)
// 転送用メッセージを送る SQS クライアント作成
const forwardQueueClient = new ForwardQueueClient({
sqsClient,
queueUrl: "定義したキューの URL",
});
// キュー転送まで待ってからレスポンスを返す
const rawBody: string = eventLambda.body || "";
if (rawBody !== "") {
await forwardQueueClient
.enqueue({ rawBody })
.catch((error: unknown) => {
console.error("転送キューへの送信でエラーが発生しました", { error });
});
}
return { statusCode: 200, body: "OK" }; // ここでレスポンスを返す
}
転送用の Lambda も、SQS トリガーで受け取るイベント形式(SQSEvent)が異なるため、1. から少し実装が変わります。
後述の IaC にて reportBatchItemFailures: true を指定するので、戻り値として batchItemFailures を返す実装も必要です。
import type { SQSBatchItemFailure, SQSBatchResponse, SQSHandler } from "aws-lambda";
import type { ForwardEvent } from "./forward-event"; // 1. と同じものを使用
export const handler: SQSHandler = async (
event
): Promise<SQSBatchResponse> => {
const batchItemFailures: SQSBatchItemFailure[] = [];
for (const record of event.Records) {
try {
const parsed = JSON.parse(record.body) as Partial<ForwardEvent>;
if (parsed.rawBody == null || parsed.rawBody === "") {
throw new Error("転送メッセージの形式が不正です");
}
const { rawBody } = parsed;
const result = await fetch("転送先の外部 API の URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: rawBody,
});
if (!result.ok) {
throw new Error(`外部 API への転送に失敗しました(status: ${result.status})`);
}
} catch (error: unknown) {
console.error("転送に失敗しました", { error });
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
};
ForwardEvent 型については 1. と同じくメッセージ本文の型として使用しています。
IaC を追加する場合は、SQS キューの新規作成、Lambda のイベントソースマッピング、デッドレターキュー / 可視性タイムアウト / 再試行回数などの設定が追加で必要になります。
import { aws_lambda_event_sources, aws_sqs, Duration } from "aws-cdk-lib";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
// 既存:Webhook Lambda の定義
const webhookLambda = new NodejsFunction(this, "WebhookLambda", {
entry: "エントリーのソースファイル",
timeout: Duration.seconds(30),
});
// デッドレターキュー(処理に失敗し続けたメッセージの退避先)
const forwardDlq = new aws_sqs.Queue(this, "ForwardDlq", {
retentionPeriod: Duration.days(14), // 調査のため長めに保持
});
// 転送用メッセージを受けるメインキュー
const forwardQueue = new aws_sqs.Queue(this, "ForwardQueue", {
// 可視性タイムアウトは転送 Lambda の timeout 以上に設定する必要がある
//(処理中に再配信されて重複するのを防ぐため)
visibilityTimeout: Duration.seconds(180),
encryption: aws_sqs.QueueEncryption.SQS_MANAGED, // 保管時暗号化
deadLetterQueue: {
queue: forwardDlq,
maxReceiveCount: 3, // 3 回失敗したらデッドレターキューへ退避
},
});
// 転送用 Lambda の定義
const forwardLambda = new NodejsFunction(this, "ForwardLambda", {
entry: "エントリーのソースファイル",
timeout: Duration.seconds(30),
// environment などのその他の既存設定は説明上省略しています
});
// SQS をトリガーに転送用 Lambda を起動(イベントソースマッピング)
forwardLambda.addEventSource(
new aws_lambda_event_sources.SqsEventSource(forwardQueue, {
batchSize: 10,
reportBatchItemFailures: true, // バッチ内の一部失敗のみ再処理する
}),
);
// Webhook Lambda に、キューへメッセージを送信する権限を付与
forwardQueue.grantSendMessages(webhookLambda);
IaC の記述量が 1. と比べて増えていますね。
メリット
- デッドレターキューを使用することで、失敗したメッセージの追跡がしやすい。
- キューによってバッファリングされるため、外部 API への大量リクエストを制御しやすい。
- 呼び出し元と転送処理の結合度が 1. に比べて下がる。
デメリット
- 管理する AWS リソースが増える分、IaC の記述量やドキュメントの運用・整備コストが増える。
- キューからの配信が重複することを想定する必要がある。
判断基準
どちらを採用するかは、環境の規模やトラフィック、実際の運用体制や仕様によって最適解が変わりますが、以下の観点で判断するのが良いと思います。
- 最小構成でリリースし、必要に応じて拡張する運用にするか(→1. 寄り)
- リソース・コストを増やしてでも追跡・再送のしやすさといった運用性を優先するか(→2. 寄り)
おわりに
今回は、Lambda から外部 API へ非同期に処理を転送する追加実装で、想定通り動かなかったケースとその対処方針を取り上げました。
Lambda はレスポンスを返した後の処理継続を保証しないため、同一関数内のいわゆる「fire-and-forget」に頼らず、別の実行単位に切り出すことを検討する必要があります。
どの方式を採るにしても唯一の正解があるわけではなく、複数案のトレードオフを整理した上で、状況に応じて選択することが大切だと考えます。
この記事が皆様のお役に立てれば幸いです。
参考
- LINE Developers - 法人ユーザー向け開発ガイドライン(Webhookの応答時間について)
- Understanding the Lambda execution environment lifecycle - AWS Lambda
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 採用サイト をぜひご覧ください。※2026年1月 アノテーション㈱から社名変更しました。




