はじめに
Futuristic Imagination LLC 代表の佐藤琢也です。僕自身、AIオウンドメディア18サイト(9言語展開・年間6,570記事)をNext.js + Gemini API + Vercel Cronで一人で自動運用しています。
日々、大量の記事を自動生成する中で直面したのが、Vercel Cronの「タイムアウト」と、処理の「重複実行バグ」です。これらは自動運用システムの安定稼働を阻害する最も厄介な問題と言えるでしょう。
この記事では、僕が実際に現場で培ったノウハウを元に、Vercel Cronで記事自動生成を行う際に発生しがちなこれらの問題を、どのように解決し、堅牢なシステムを構築しているか具体的なコード例を交えて解説します。
この記事を読めば、あなたのVercel Cron運用が劇的に安定し、夜間のエラー通知に悩まされることなく、安心して睡眠時間を確保できるはずです。
この記事でわかること
- Vercel Cronのタイムアウト問題を解決する具体的な実装パターン
- Cronジョブの重複実行を防ぐためのロック機構の作り方
- エラー発生時に即座に検知・通知するための仕組み
- 実際のNext.js + TypeScript環境での実装例
- AIを活用した記事自動生成システムの堅牢な運用戦略
Vercel Cronと記事自動生成の現状
僕の会社では、AIによる記事自動生成をNext.jsのAPI RoutesとVercel Cronで実現しています。WordPressからNext.jsへの移行支援や、SNS自動化、Gemini APIを活用したコンテンツパイプライン構築も得意としており、そのノウハウをMediaForge AIとしてB2B受託サービスでも展開しています。
Vercel Cronは手軽に定期実行処理を組める便利な機能ですが、無料枠では最大60秒、Proプランでも最大300秒という実行時間制限があります。AIによる記事生成は、プロンプトの設計、API呼び出し、内容の検証、画像生成、データベースへの保存、そしてISRによる再生成まで、一連の処理が意外と時間を要します。特に生成に時間がかかったり、APIエラーでリトライが発生したりすると、あっという間にタイムアウトしてしまうんです。
さらに厄介なのが、Vercel Cronが何らかの理由で複数回トリガーされてしまう「重複実行」です。これにより、同じ記事が何度も生成されたり、データベースの整合性が崩れたりといった予期せぬバグが発生する可能性があります。
これらの課題を解決するために、僕が実践しているアプローチを紹介します。
課題1: Vercel Cronのタイムアウト問題を克服する
AIによる記事生成は、通常60秒以内に収まることが多いですが、以下のようなケースでは簡単にタイムアウトします。
- AIモデルのレスポンスが遅い
- 複数のAPIを呼び出している
- 生成する記事のボリュームが大きい
- 予期せぬネットワークエラーでリトライ処理が入る
そこで、僕が採用しているのが「処理の分割」と「非同期実行の徹底」です。
アプローチ1: 処理を「キュー」で分割する
Vercel Cronでは、直接記事生成処理全体を実行するのではなく、記事生成の「タスク」をキューに積む役割に徹します。実際の記事生成処理は、別の非同期プロセス(例えば、別のAPI Routeや専用のWorker)で実行します。
Vercel Cron (Next.js API Route) の役割:
- 記事生成が必要なトピックを特定する。
- 各トピックをキュー(例: Redis, RDB, Cloudflare Workers KVなど)にタスクとして追加する。
- すぐにレスポンスを返す(タイムアウトを回避)。
記事生成Workerの役割:
- キューからタスクを一つずつ取り出す。
- Gemini APIを呼び出し、記事を生成する。
- データベースに保存し、ISRでデプロイする。
- 処理が完了したら、タスクをキューから削除する。
このアプローチにより、Cronの実行時間は大幅に短縮され、タイムアウトのリスクが劇的に減少します。
Vercel Cronのコード例 (pages/api/cron/generate-articles.ts)
ここでは簡易的にデータベースにタスクを追加する例を示します。実際にはRedisなどを使うことが多いです。
// pages/api/cron/generate-articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '../../../lib/prisma'; // あなたのPrismaクライアントをインポート
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
// Vercel Cronの認証(省略可能だが、セキュリティ強化のために推奨)
// if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
// return res.status(401).json({ message: 'Unauthorized' });
// }
try {
// 記事生成が必要なトピックを特定するロジック
// 例: 今日生成すべきトピックリストを取得
const topicsToGenerate = await getTopicsForToday(); // あなたのDBから取得する関数
if (topicsToGenerate.length === 0) {
return res.status(200).json({ message: 'No articles to generate today.' });
}
// 各トピックを記事生成キュー(ここではDBの `ArticleGenerationTask` テーブル)に追加
const tasks = topicsToGenerate.map(topic => ({
topicId: topic.id,
status: 'pending',
createdAt: new Date(),
}));
await prisma.articleGenerationTask.createMany({ data: tasks });
console.log(`[Cron] Added ${tasks.length} article generation tasks to the queue.`);
return res.status(200).json({ message: `Queued ${tasks.length} articles for generation.` });
} catch (error) {
console.error('Error in Vercel Cron for article generation:', error);
// エラー通知処理をここに含める(後述)
return res.status(500).json({ message: 'Internal Server Error', error: error.message });
}
}
// ダミー関数(実際にはDBから取得)
async function getTopicsForToday(): Promise<{ id: string; title: string }[]> {
// 例えば、今日は「AIの未来」と「Web3最新トレンド」の記事を生成すると仮定
return [
{ id: 'topic-ai-future', title: 'AIの未来とビジネス変革' },
{ id: 'topic-web3-trends', title: 'Web3最新トレンド2024' },
];
}
記事生成Workerのコード例 (pages/api/worker/process-article-queue.ts)
このAPI Routeは、キューにタスクがある限り非同期で実行され続けます。Vercel Cronからは直接呼び出さず、別のトリガー(例えば、Cloudflare Workersから定期的に叩く、またはキューにタスクが追加されたらイベント駆動で発火する)で実行することになります。
// pages/api/worker/process-article-queue.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '../../../lib/prisma';
import { generateArticleWithGemini } from '../../../lib/gemini-service'; // Gemini API呼び出し関数
export const config = {
api: {
bodyParser: false, // 必要に応じて変更
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
// 認証ロジックをここに追加(例えば、内部APIキーやJWT)
// if (req.headers.authorization !== `Bearer ${process.env.WORKER_AUTH_TOKEN}`) {
// return res.status(401).json({ message: 'Unauthorized' });
// }
let processedCount = 0;
try {
while (true) {
// キューからPending状態のタスクを一つ取得し、処理中としてロック
const task = await prisma.articleGenerationTask.findFirst({
where: { status: 'pending' },
orderBy: { createdAt: 'asc' },
});
if (!task) {
console.log('[Worker] No pending tasks found. Exiting.');
break; // キューが空になったら終了
}
// タスクを処理中に更新(楽観的ロック)
await prisma.articleGenerationTask.update({
where: { id: task.id },
data: { status: 'processing', startedAt: new Date() },
});
console.log(`[Worker] Processing task for topic: ${task.topicId}`);
try {
// --- ここから記事生成のメインロジック ---
const generatedContent = await generateArticleWithGemini(task.topicId); // Gemini API呼び出し
// 記事をDBに保存
await prisma.article.create({
data: {
title: generatedContent.title,
content: generatedContent.content,
topicId: task.topicId,
publishedAt: new Date(),
},
});
// ISRトリガーやIndexNow API送信など
// await triggerISRRevalidation('/blog');
// await sendIndexNowPing(generatedContent.slug);
// --- 記事生成ロジックここまで ---
// タスクを完了状態に更新
await prisma.articleGenerationTask.update({
where: { id: task.id },
data: { status: 'completed', completedAt: new Date() },
});
processedCount++;
} catch (innerError) {
console.error(`[Worker] Error processing task ${task.id}:`, innerError);
// エラーが発生した場合、タスクをエラー状態に更新し、リトライ可能なフラグを立てる
await prisma.articleGenerationTask.update({
where: { id: task.id },
data: { status: 'failed', errorMessage: innerError.message, retryCount: { increment: 1 } },
});
// エラー通知処理(後述)
}
}
return res.status(200).json({ message: `Processed ${processedCount} tasks.`, processedCount });
} catch (error) {
console.error('[Worker] Top-level error in worker process:', error);
// エラー通知処理(後述)
return res.status(500).json({ message: 'Internal Server Error', error: error.message });
}
}
// ダミー関数 (実際にはGemini APIを叩くロジック)
async function generateArticleWithGemini(topicId: string): Promise<{ title: string; content: string; slug: string }> {
console.log(`Generating article for ${topicId} with Gemini API...`);
await new Promise(resolve => setTimeout(resolve, Math.random() * 5000 + 5000)); // 5〜10秒のダミー処理
const title = `AIが書いた${topicId}に関する記事`;
const content = `これはAIが生成した記事のコンテンツです。${topicId}についての詳細がここに続きます。`;
return { title, content, slug: `article-${topicId}` };
}
このprocess-article-queue.tsはVercelの無料枠ではタイムアウトする可能性があるので、適切なトリガーと実行環境を検討してください。(例えば、Vercel Serverless Functionsの最大実行時間に合わせて、一度に処理するタスク数を制限する、または専用のEC2インスタンスなどで稼働させるなど)
課題2: Cronジョブの重複実行を完全に防ぐ
Vercel Cronは非常に安定していますが、ネットワークの問題やVercel側の内部的な問題で、ごく稀に同じCronが複数回トリガーされてしまうことがあります。これが自動生成システムで起きると、同じ記事が二重に生成されたり、データベースの制約違反エラーが発生したりと、運用に大きな影響が出ます。
そこで必須なのが、**排他制御(ロック機構)**の実装です。
アプローチ2: Redisを使ったロック機構
僕はRedis(Upstash Redisなど)を利用して、Cronジョブの実行中にロックをかける方法を最も信頼しています。
ロック機構の仕組み:
- Cronが起動したら、まずRedisに特定のキー(例:
cron:article-generation:lock)をセットしようと試みる。 - キーのセットに成功した場合のみ、Cronの処理を実行。この際、キーには有効期限(TTL: Time To Live)を設定しておく(例: 5分)。
- キーのセットに失敗した場合(既にロックが存在する場合)、今回のCron実行はスキップし、成功レスポンスを返す。
- Cronの処理が完了したら、ロックキーを削除する。
これにより、たとえ複数回トリガーされても、同時に実行されるのは常に1つのインスタンスのみとなります。
ロック機構の実装例 (with Upstash Redis)
// lib/redis.ts (Upstash Redis クライアントの初期化)
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// pages/api/cron/generate-articles.ts に追記
import { redis } from '../../../lib/redis'; // redis クライアントをインポート
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
const LOCK_KEY = 'cron:article-generation:lock';
const LOCK_TTL_SECONDS = 300; // 5分間ロックを保持(Cronの最大実行時間を考慮)
// ロックを取得しようと試みる
// SET NX PX は、キーが存在しない場合のみセットし、有効期限(ミリ秒)を設定する
const lockAcquired = await redis.set(LOCK_KEY, 'locked', 'EX', LOCK_TTL_SECONDS, 'NX');
if (!lockAcquired) {
console.warn(`[Cron] Lock already held for ${LOCK_KEY}. Skipping current execution.`);
return res.status(200).json({ message: 'Lock already held, skipping execution.' });
}
try {
// ... (既存のキューへのタスク追加ロジック) ...
// 例:
const topicsToGenerate = await getTopicsForToday();
// ...
await prisma.articleGenerationTask.createMany({ data: tasks });
console.log(`[Cron] Added ${tasks.length} article generation tasks to the queue.`);
return res.status(200).json({ message: `Queued ${tasks.length} articles for generation.` });
} catch (error) {
console.error('Error in Vercel Cron for article generation:', error);
// エラー発生時もロックは必ず解除
await redis.del(LOCK_KEY);
// エラー通知処理をここに含める(後述)
return res.status(500).json({ message: 'Internal Server Error', error: error.message });
} finally {
// 正常終了時もロックを解除
await redis.del(LOCK_KEY);
}
}
finally ブロックでロックを解除することで、処理が成功しても失敗しても、確実にロックが解除されるようにします。ただし、Vercel Cronがタイムアウトした場合、finallyが実行されない可能性もあるため、TTL設定が重要になります。TTLをCronの最大実行時間より少し長めに設定することで、万が一のタイムアウト時も、一定時間後には自動的にロックが解放され、次の実行を妨げないようにします。
課題3: エラー発生時の即時通知
自動生成システムは、人間が直接監視していない夜間や休日も稼働します。エラーが発生した際に、すぐにそれを検知し、適切な担当者に通知する仕組みは、安定運用の生命線です。
アプローチ3: Slack/Discordへの通知
僕のチームでは、エラー発生時にSlackやDiscordに通知を送ることで、即座に状況を把握できるようにしています。
エラー通知の実装例 (Slack Webhook)
// lib/notification-service.ts
import axios from 'axios';
export async function sendSlackNotification(message: string, webhookUrl: string = process.env.SLACK_WEBHOOK_URL!) {
if (!webhookUrl) {
console.warn('SLACK_WEBHOOK_URL is not set. Skipping Slack notification.');
return;
}
try {
await axios.post(webhookUrl, { text: message });
console.log('Slack notification sent successfully.');
} catch (error) {
console.error('Failed to send Slack notification:', error);
}
}
このsendSlackNotification関数を、先ほどのCronやWorkerのcatchブロック内で呼び出します。
// pages/api/cron/generate-articles.ts (修正箇所抜粋)
import { sendSlackNotification } from '../../../lib/notification-service';
// ...
} catch (error) {
console.error('Error in Vercel Cron for article generation:', error);
await redis.del(LOCK_KEY);
// エラー通知
await sendSlackNotification(`🚨 *Vercel Cron Error (Article Generation)* 🚨\n\`\`\`${error.message}\`\`\``);
return res.status(500).json({ message: 'Internal Server Error', error: error.message });
} finally {
await redis.del(LOCK_KEY);
}
これにより、Cronがタイムアウトしたり、内部で予期せぬエラーが発生したりした場合でも、即座にSlackに通知が飛び、対応が可能になります。エラーメッセージには、発生したAPI Route名やエラーの詳細を含めることで、迅速なデバッグに役立ちます。
まとめ
本記事では、Vercel CronでAI記事自動生成を安定稼働させるための、僕が実践している3つの主要な戦略について解説しました。
- タイムアウト対策: 処理をキューで分割し、非同期実行を徹底することで、Cronの実行時間を短縮しタイムアウトを防ぎます。
-
重複実行防止: Redisのロック機構を利用し、
SET NX PXコマンドで排他制御を行うことで、同じジョブが複数回実行されるのを防ぎます。 - エラー即時通知: Slack/DiscordなどのWebhookを利用し、エラー発生時に担当者にリアルタイムで通知することで、迅速な対応を可能にします。
これらの実装は、AIオウンドメディア18サイトを一人で運用し、年間6,570記事を安定して自動生成している僕の現場で培われた、最も効果的な方法です。ぜひあなたのプロジェクトにも取り入れてみてください。
「弊社自身がヘビーユーザーとして使いこなしている状態を作らないと、顧客に刺さらない」という哲学のもと、日々システムを磨き上げています。今回紹介したようなシステムの構築代行や、既存WordPressサイトのNext.jsへの移行、SNS自動化、Gemini APIパイプラインの構築など、AIとモダンWeb技術を活用した事業の自動化・効率化にご興味があれば、お気軽にご相談ください。
→ Futuristic Imagination LLC サービスページ
また、転職・副業・キャリアに関するShorts動画を毎日配信中です。日々の気づきや学びを共有していますので、ぜひご覧ください。
最先端のテクノロジーを駆使して、一緒に未来を切り拓いていきましょう!