Node.js の常駐サーバーでは、重い処理を await せずに起動し、先に HTTP レスポンスを返すことがあります。
app.post('/domains/:id/republish-all', async (c) => {
const jobId = await createJob(c.req.param('id'));
void spawnPublishJob(pool, { jobId, tenantId });
return c.json({ job_id: jobId }, 202);
});
これをそのまま AWS Lambda の同期呼び出しへ持っていくと、202 Accepted は返るのに、後ろの処理が最後まで動かないことがあります。
常駐サーバーではレスポンス後も Node.js プロセスが残りますが、Lambda ではハンドラの Promise が解決したあと、その invocation にぶら下がった後続処理は保証されません。そのため、レスポンス後の fire-and-forget を実行保証として扱うのは危険です。
何が起きるか
Hono を hono/aws-lambda で動かしているとします。
import { handle } from 'hono/aws-lambda';
import { app } from './app.js';
export const handler = handle(app);
このとき spawnPublishJob() を await していないと、ハンドラは publish の完了を待たずに終わります。Lambda から見ると、その invocation で待つべき処理はもうありません。
すると、次のような状態が起きます。
- API は
202を返す - ジョブは
runningのまま残る - 完了更新だけ入らない
- 例外ログも特に見当たらない
ローカルでは再現しにくいのもやっかいです。@hono/node-server で動かすと、レスポンス後も Node.js プロセスが残るので、背景処理がそのまま完走しやすいからです。
なぜ止まるか
AWS Lambda の実行環境では、ハンドラが返した Promise が解決すると、その invocation の処理は完了したとみなされます。
常駐サーバーでは、レスポンスを返してもプロセスは残ります。Lambda ではそこが違います。
ここで大事なのは、必ず失敗するわけではないことです。たまたま完走することもあります。ただ、その成否をアプリケーション側で制御できません。
ローカルで見落としやすい理由
この問題は、ローカル環境や通常のテストではみつけにくい性質があります。
@hono/node-server で起動したサーバーは、レスポンスを返した後もプロセスが動き続けます。テストコードからハンドラを直接呼ぶ場合も同様で、Node.js プロセス自体は残るため、未完了の Promise が進み続けます。
その結果、202 とジョブ ID だけを見ているテストは通るのに、本番の Lambda では完了更新だけが抜ける、という差が出ます。アプリケーションロジックだけを追っていると、切り分けに時間がかかります。
別の実行基盤へ渡す
202 を早く返したいなら、publish 本体を HTTP ハンドラで実行しない方が安全です。たとえば Amazon SQS へ投げます。
import {
SendMessageCommand,
SQSClient,
} from '@aws-sdk/client-sqs';
const sqs = new SQSClient({});
app.post('/domains/:id/republish-all', async (c) => {
const tenantId = c.req.param('id');
const jobId = await createJob(tenantId);
await sqs.send(
new SendMessageCommand({
QueueUrl: process.env.PUBLISH_QUEUE_URL,
MessageBody: JSON.stringify({ jobId, tenantId }),
}),
);
return c.json({ job_id: jobId, status: 'queued' }, 202);
});
実処理は別の Lambda で受けます。
export const handler = async (event) => {
for (const record of event.Records) {
const { jobId, tenantId } = JSON.parse(record.body);
await markJobRunning(jobId);
try {
await spawnPublishJob(pool, { jobId, tenantId });
await markJobCompleted(jobId);
} catch (error) {
await markJobFailed(jobId, error);
throw error;
}
}
};
この構成なら、HTTP レスポンスと実処理の寿命が分かれます。202 を返す意味も素直です。
長時間処理や複数段階の処理なら、AWS Step Functions を使う判断も自然です。
設計上の扱い
この種の API では、処理を HTTP リクエストの一部として扱うのか、独立したジョブとして扱うのかを先に決めた方が整理しやすいです。
その場で結果を返したい処理なら、リクエストの一部として完了まで待ちます。202 Accepted を返したいなら、返しているのは「処理の完了」ではなく「ジョブを受け付けたこと」です。その場合は、ジョブ状態を後から確認する手段も必要です。
GET /publish-jobs/{jobId}
{
"job_id": "job-123",
"status": "running"
}
202 だけ返して確認手段がないと、クライアントからは「受け付けたのに終わらない」状態に見えます。
SQS 側で気を付けること
SQS に渡せば終わりではなく、ワーカー側は冪等にしておく必要があります。同じメッセージが再配信される可能性があるからです。
たとえば jobId を一意キーとして、すでに完了済みなら再実行しないようにします。
export const handler = async (event) => {
for (const record of event.Records) {
const { jobId, tenantId } = JSON.parse(record.body);
const job = await findJob(jobId);
if (job.status === 'completed') {
continue;
}
await spawnPublishJob(pool, { jobId, tenantId });
await markJobCompleted(jobId);
}
};
実際には、単に状態を読んで分岐するだけでなく、一意制約や条件付き更新も合わせて考えます。失敗が続くジョブのためにデッドレターキューも欲しくなります。
補足
また、常駐サーバーでも fire-and-forget が本質的に安全というわけではありません。デプロイやプロセス再起動のタイミングで落ちることはあります。Lambda はそれがもっと表に出やすいだけです。