1. 結論(この記事で得られること)
Puppeteerを使った動的サイトスクレイピングで必ず直面するメモリリーク問題の、実務で使える対策を全て網羅します。
- Browser/Pageインスタンスの適切なライフサイクル管理
- Event Listenerの確実なクリーンアップ
- DOM参照の循環参照対策
- 長時間稼働時のメモリ監視パターン
正直に言うと、僕も最初にPuppeteerを本格導入した時は「なんでこんなにメモリが増え続けるんだ…」と深夜まで格闘した経験があります。でも今思えば、基本的なリソース管理の原則さえ押さえておけば防げる問題でした。
2. 前提(環境・読者層)
- Node.js v18+ / Puppeteer v21+
- スクレイピング経験はあるが、メモリリークで困っている方
- 本番環境で長時間稼働が必要なプロジェクト
想定読者:
- 「開発環境では動くけど、本番で数時間後にクラッシュする」
- 「Docker上で動かすと、いつの間にかメモリ使用量が1GB超えてる」
- 「複数ページを巡回する処理で、なぜかどんどん重くなる」
こんな経験がある方なら、この記事で確実に解決できます。
3. Before:よくあるつまずきポイント
危険パターン1: Page インスタンスの使い回し
// ❌ 危険:同一Pageを使い回し
const browser = await puppeteer.launch();
const page = await browser.newPage();
for (let i = 0; i < 1000; i++) {
await page.goto(`https://example.com/page/${i}`);
// DOMの蓄積、Event Listenerの蓄積...
}
危険パターン2: Browser の使い回し過多
// ❌ 危険:無制限にページを開く
const browser = await puppeteer.launch();
for (let url of urls) {
const page = await browser.newPage(); // ページが蓄積される
await page.goto(url);
// page.close() を忘れがち
}
危険パターン3: DOM参照の保持
// ❌ 危険:JSハンドルを解放しない
const elements = await page.$$('a');
// elementsを使った処理...
// elements.forEach(e => e.dispose()) を忘れる
なぜ危険か:
Puppeteerは内部でChromiumプロセスを管理していて、適切にクリーンアップしないと:
① DOMノードがメモリに残り続ける
② Event Listenerが蓄積される
③ 画像・CSS等のリソースキャッシュが増大する
④ 最終的にOOMキラーに殺される
4. After:基本的な解決パターン
解決パターン1: ページ単位でのクリーンアップ
// ✅ 安全:適切なリソース管理
async function scrapeSinglePage(browser, url) {
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle0' });
const data = await page.evaluate(() => {
return document.title; // 必要なデータのみ抽出
});
return data;
} finally {
await page.close(); // 必ず実行される
}
}
解決パターン2: Browser のバッチ再起動
// ✅ 安全:定期的なBrowser再起動
class ManagedBrowser {
constructor(batchSize = 50) {
this.batchSize = batchSize;
this.processedCount = 0;
this.browser = null;
}
async getBrowser() {
if (!this.browser || this.processedCount >= this.batchSize) {
await this.restart();
}
return this.browser;
}
async restart() {
if (this.browser) {
await this.browser.close();
}
this.browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage']
});
this.processedCount = 0;
}
}
解決パターン3: JSハンドルの適切な破棄
// ✅ 安全:JSハンドルの管理
async function extractLinks(page) {
const elements = await page.$$('a');
const links = [];
try {
for (const element of elements) {
const href = await element.getProperty('href');
links.push(await href.jsonValue());
await href.dispose(); // プロパティハンドルを解放
}
} finally {
// 全てのElementHandleを解放
await Promise.all(elements.map(e => e.dispose()));
}
return links;
}