WordPress・CMS設計

WP-CLIによる1万記事の移行で直面したメモリリークと解決の記録

WP-CLIで1万件規模のWordPressデータを移行していると、処理の途中で Fatal error: Allowed memory size of 536870912 bytes exhausted が出力され、コマンドが停止することがある。…

WP-CLIによる1万記事の移行で直面したメモリリークと解決の記録

512MBという上限だけを見ると、1件の投稿を処理するには十分な容量に思える。ところが、長時間動くCLIプロセスでは、読み込んだ投稿、実行済みのクエリ、プラグインが追加したデータ、オブジェクトキャッシュなどが同じPHPプロセス内に残り続ける。1件あたりの使用量が小さくても、それが何千回も積み重なると事情は変わる。

ここで問題になるのは、単純な意味での「メモリリーク」だけではない。PHPの参照が残っている、WordPressのキャッシュが増えている、デバッグ用の配列がSQL履歴を保持しているなど、プロセスが処理のために確保したメモリが期待したタイミングで再利用されない状態も、実運用上は同じように見える。

この記事では、WP-CLIによる大量データ移行でメモリが枯渇する原因を、SAVEQUERIES、オブジェクトキャッシュ、PHP側の変数管理、データベース書き込みの設計という層に分けて整理する。WordPress VIPの大量処理向けの考え方も参照しながら、一般的なWordPress環境で再現しやすい対策と、適用時に注意すべき点をまとめた。

WP-CLI実行時にメモリが枯渇する根本原因:SAVEQUERIES の罠

大量処理で最初に確認したいのは、SAVEQUERIES の設定である。

SAVEQUERIES が有効になっていると、WordPressのデータベースクラスである wpdb は、実行したクエリに関する情報を $wpdb->queries に保存する。保存対象にはSQL文だけでなく、クエリの実行時間や呼び出し元に関する情報も含まれる。

この機能は、クエリの計測やデバッグには便利だ。しかし、1回のリクエストで処理が終わる通常のWebアクセスと、何千件ものレコードを連続して扱うWP-CLIでは、メモリへの影響がまったく違う。

移行処理の中で投稿の取得、メタデータの読み込み、更新、タームの関連付けなどを繰り返すと、実行されるSQLの数は投稿件数を大きく上回る。各クエリの情報が $wpdb->queries に残れば、処理が進むほど履歴の配列も増える。投稿データ自体を効率よく解放していても、クエリ履歴が残っていればプロセス全体のメモリ使用量は下がらない。

大量移行で最初に確認するのは、PHPのメモリ上限ではなく、処理中に履歴を保持する設定が有効になっていないかどうかだ。

SAVEQUERIES は移行用プロセスでは無効にする

移行処理の実行時にクエリの分析が必要ないなら、SAVEQUERIES は無効にしておく。wp-config.php で設定する場合は、デバッグ用の環境と移行用の環境で設定を分ける方が安全だ。

すでにWordPressが初期化された後で定数を変更しても、期待どおりに動かない場合がある。wpdb が生成された後に設定を切り替えるのではなく、WordPressの読み込み前に設定が確定しているかを確認する必要がある。

また、wp-config.php に次のような設定が残っていないかも確認したい。

  • SAVEQUERIEStrue1 になっていないか
  • 開発用のデバッグ設定を本番の移行環境へそのまま持ち込んでいないか
  • プロファイリングやクエリ監視を行うプラグインが有効になっていないか
  • 移行用コマンドの中で、SQL履歴を独自に配列へ追加していないか

WP_DEBUG_LOG については、SAVEQUERIES と同じものとして扱わない方がよい。WP_DEBUG_LOG は基本的にPHPやWordPressのエラーをログファイルへ記録するための設定であり、標準機能として実行したすべてのエラーを常に大きな配列へ蓄積する設定ではない。

もちろん、エラーが大量に発生していればログファイルは増える。プラグインや独自コードがエラー情報を別途メモリに保存している可能性もある。その場合は調査が必要だが、標準の WP_DEBUG_LOG 自体に、$wpdb->queries と同じようなクエリ履歴の蓄積を期待して原因扱いするのは正確ではない。

オブジェクトキャッシュの肥大化を防ぐ逐次解放の実装

次に見るべきなのが、投稿やメタデータを取得した後のキャッシュである。

WordPressは、投稿、投稿メタ、ターム、コメントなどのデータをオブジェクトキャッシュへ保存する。キャッシュは同じリクエスト内で同じデータを何度も取得する処理を効率化するための仕組みだ。通常のWebリクエストであれば、リクエスト終了とともにPHPプロセス上のデータも破棄されるため、キャッシュの保持時間を細かく意識しなくても問題になりにくい。

一方、WP-CLIは一つのプロセスで長時間動き続ける。投稿を順番に読み込み、加工し、更新する処理では、取得済みのオブジェクトや関連データが次のループへ持ち越されることがある。

ここで注意したいのは、WordPressコアに一般的な wp_get_post() という関数があるわけではないという点だ。投稿を取得する代表的なAPIは get_post()WP_Queryget_posts() などである。これらのAPIは、結果として投稿データをWordPressのキャッシュ機構へ通すことがあるが、関数名を曖昧にしたまま説明すると、どの処理がどのキャッシュを使っているのか分からなくなる。

clean_post_cache() は投稿単位の後始末

投稿単位の処理が終わったら、clean_post_cache($post_id) を使って投稿関連のキャッシュを削除する方法がある。

この関数は、指定した投稿に関連するコアのキャッシュを無効化するためのものだ。投稿本体、投稿メタ、関連するタームなど、投稿の変更によって古くなる可能性があるデータを対象にする。

ただし、clean_post_cache() を呼び出せばPHPのメモリが必ず同じ量だけ解放される、と考えてはいけない。キャッシュの削除はWordPressのキャッシュキーを無効化する処理であり、PHP変数として別に保持している投稿配列や、プラグインが独自に保存したデータまで消すものではない。また、RedisやMemcachedなどの永続オブジェクトキャッシュを利用している場合、削除の作用範囲やコストも環境によって変わる。

それでも、投稿処理の境界を明確にするという意味では有効である。少なくとも、処理済みの投稿を次のループで不要に参照し続ける設計は避けられる。

ループ内では、次のような後始末を一つの単位として考えると整理しやすい。

  • 投稿の変換と更新を完了させる
  • 不要になった一時変数を unset() する
  • 投稿単位のキャッシュを clean_post_cache() で無効化する
  • 独自に作った配列やログ用のバッファを空にする
  • 一定件数ごとに、環境に応じたキャッシュのクリーンアップを行う

wp_cache_flush() は便利だが、無条件に使わない

コアやプラグインが追加したキャッシュが増え続ける場合、wp_cache_flush() をバッチの区切りで呼び出す方法も候補になる。これはオブジェクトキャッシュ全体を削除するため、投稿単位のキャッシュ削除では取りこぼす独自キャッシュにも作用する可能性がある。

ただし、グローバルなキャッシュをすべて消す処理なので、扱いには注意が必要だ。

[RedisやMemcachedを複数のWebリクエストと共有している場合、移行処理中の wp_cache](/articles/docker-composenoben-fan-yun/)_flush() が通常のアクセスに必要なキャッシュまで削除する可能性がある。結果として、サイト全体でキャッシュミスが増えたり、同じデータを再生成するためにデータベース負荷が上がったりする。

そのため、次のような環境では特に慎重に判断したい。

  • 移行処理と通常のWebアクセスが同じ本番環境で動いている
  • 永続オブジェクトキャッシュを複数のアプリケーションで共有している
  • キャッシュ削除を契機に重い集計処理が実行されるプラグインを使っている
  • 移行処理の途中でキャッシュを消すと、別の処理が古い状態を参照する可能性がある

単独の検証環境であれば、バッチ境界ごとの wp_cache_flush() は原因切り分けに役立つ。共有環境では、まず投稿単位のキャッシュ無効化と一時変数の解放を行い、それでもメモリが増え続ける場合に、影響範囲を確認したうえで一括削除を検討するのが現実的だ。

WP-CLIコマンドにおけるメモリ管理のベストプラクティス

キャッシュを削除しても、取得方法そのものが一括読み込みになっていれば、メモリ使用量は安定しない。

get_posts()WP_Query で大量の投稿を一度に取得すると、結果セット全体が配列としてメモリへ載る。投稿本文、メタデータ、ターム、プラグインが追加したフィールドまで含めて取得すれば、件数に比例して消費量も増える。

1万件を一つの配列に入れてから処理するのではなく、ページネーションやIDの範囲で分割する方が安全だ。たとえば、一定数の投稿IDだけを取得し、そのIDを使って処理し、バッチが終わったら次の範囲へ進む。取得件数を小さくするだけでなく、処理済みの配列を次のループへ持ち越さないことが重要になる。

ジェネレータは万能ではない

yield を使ったジェネレータは、処理対象を一度に配列へ展開しないため、PHP側の一括保持を避ける手段になる。ただし、ジェネレータに渡す元データがすでに get_posts() などで全件取得されていれば、外側だけジェネレータにしても効果は限定的だ。

重要なのは、データベースからの取得単位も小さくすることだ。次のような設計であれば、ジェネレータを使わなくてもメモリの増加を抑えられる場合がある。

  • 1回のクエリで取得する投稿数を制限する
  • 必要なフィールドだけを取得する
  • 投稿本文やメタデータを必要以上に同時保持しない
  • バッチ処理が終わるたびに配列を破棄する
  • ページ番号だけに依存せず、IDを基準にした分割も検討する

投稿の更新によって検索結果が変わる処理では、ページ番号方式が不安定になる場合もある。更新前後で条件に該当する件数が変わると、次のページで投稿を飛ばしたり、同じ投稿を再処理したりする可能性があるからだ。移行対象が確定しているなら、先にIDの一覧を作るか、IDの大小を基準に処理範囲を進める方が追跡しやすい。

一時変数と循環参照を整理する

長時間動くPHPプロセスでは、ループの中で作った変数が次の反復でも参照可能な状態になっている。単純なスカラー値の解放を過度に気にする必要はないが、大きな配列や投稿オブジェクトを保持する変数は処理単位の最後で明示的に整理するとよい。

unset($post, $meta, $payload) のように、次の反復で使わない変数を破棄する。別の変数が同じ配列やオブジェクトを参照していれば、その時点でメモリが解放されるとは限らないため、参照の持ち方も確認する。

循環参照が疑われる場合は gc_collect_cycles() を使う余地がある。ただし、これは呼び出せば必ず高速化する処理ではない。ガベージコレクションにもCPUコストがあるため、毎件実行するのではなく、メモリの増加を観測しながら間隔を決めるべきだ。

確認には memory_get_usage(true)memory_get_peak_usage(true) が使える。移行処理のログへ、バッチ開始時と終了時の使用量を記録しておくと、キャッシュ削除やクエリの分割が効いているかを比較できる。

見るべきなのは、単発のピーク値だけではない。たとえば、各バッチの終了時に使用量が少しずつ戻るのか、一定の水準まで上がった後に横ばいになるのか、バッチをまたぐたびに階段状に増えるのかで、原因の見当が変わる。

メモリ使用量の推移疑うべき要因
クエリ数に比例して増え続けるSAVEQUERIES や独自のSQLログ
投稿処理のたびに増え、バッチ後も戻らない投稿・メタデータ・プラグインキャッシュ
バッチ終了時に戻るが、間隔を広げると急増するキャッシュの保持単位やバッチサイズ
特定の投稿処理だけで急増する特定プラグイン、本文変換、添付ファイル処理
使用量は安定しているのに処理が止まる実行時間制限、データベース待ち、外部サービス

この計測を行わずに、最初から memory_limit を大きくするだけでは、原因を先送りしているに過ぎない。上限を引き上げても、クエリ履歴やデータ配列が無制限に増える設計なら、いずれ別の環境で再発する。

大量データ処理を安定させるバッチ処理と一括操作の設計

投稿を1件ずつ更新する処理では、WordPressのフックが毎回実行される。wp_update_post() は単純なSQLの更新だけを行う関数ではなく、投稿の更新に伴う各種フックや関連データの処理を通過する。

そのため、処理件数が増えると、投稿更新そのものだけでなく、フックに接続されたプラグインの処理も積み重なる。検索インデックスの更新、キャッシュの再生成、関連コンテンツの集計、外部APIとの連携などが有効になっていれば、メモリだけでなく実行時間やデータベース負荷にも影響する。

wp_defer_term_counting() の役割を限定して理解する

大量更新で利用される wp_defer_term_counting(true) は、タームカウントの更新を遅延させるためのAPIである。投稿のターム関連付けを大量に変更する場合、各変更のたびにターム数を確定させるのではなく、処理の区切りでまとめて反映するという考え方だ。

ただし、このAPIを有効にしたからといって、投稿更新に関係するすべてのフックが止まるわけではない。wp_update_post() の処理全体が抑制されるわけでもなく、書き込みクエリが単純な UPDATE だけに収束するわけでもない。

遅延の対象はあくまでタームカウントである。投稿メタの更新、投稿更新フック、プラグイン独自の処理などは、別途実行される可能性がある。この違いを理解していないと、メモリ対策のつもりで有効化した設定に過大な効果を期待してしまう。

処理の最後には wp_defer_term_counting(false) を呼び、保留されていたタームカウントを確定させる。途中で例外やエラーが起きる可能性があるなら、終了処理が実行される構造にしておく必要がある。

コメント数を扱う処理では、同じ考え方で wp_defer_comment_counting() を検討できる。ただし、これもコメントカウントに関する遅延であり、投稿更新全体を軽量化する包括的なスイッチではない。

一括操作はデータの整合性と引き換えにしない

データベースへ直接SQLを発行して一括更新すれば、WordPressのAPIを何度も呼び出すより速くなる場合がある。しかし、WordPressのフックやキャッシュ、投稿メタとの整合性を自分で管理しなければならない。

特に、次のようなデータを直接変更する場合は注意が必要だ。

  • 投稿本体と投稿メタが連動している
  • ターム関連付けの変更にカウント更新が必要になる
  • 更新を検知するプラグインが有効になっている
  • 外部検索インデックスやキャッシュを再構築する必要がある
  • 更新後にWordPress APIから取得するデータが変わる

SQLの回数を減らせても、後からキャッシュを全削除したり、検索インデックスを作り直したりするなら、処理全体の負荷が下がるとは限らない。高速化の対象を「クエリの本数」だけに限定せず、移行後に必要な後処理まで含めて考える必要がある。

wp db import を使う大容量ダンプの復元と、WP-CLIコマンドで投稿を1件ずつ変換して更新する処理も分けて考えたい。前者はSQLダンプをデータベースへ投入する処理であり、PHP上で投稿オブジェクトを大量に保持する処理とはボトルネックが異なる。後者はWordPressのAPIやフックを通るため、PHPメモリ、キャッシュ、プラグイン処理が問題になりやすい。

バッチ境界で行う処理

実装上は、次のように処理の境界を決めると原因を追いやすい。

1. 移行対象を小さな単位で取得する。

2. 1件の変換と更新を実行する。

3. 更新後に不要な変数を破棄する。

4. clean_post_cache($post_id) で投稿単位のキャッシュを無効化する。

5. 一定件数ごとにメモリ使用量と処理件数を記録する。

6. 必要性と影響範囲を確認したうえで、キャッシュ全体のクリーンアップを行う。

7. バッチ終了時に配列や一時バッファを空にする。

8. 最後に遅延させていたカウント処理を確定する。

ここでいう「一定件数」は固定の正解ではない。投稿本文が大きいサイト、メタデータが多いサイト、プラグインが多くのフックを追加しているサイトでは、同じ件数でもメモリの増え方が変わる。

50〜100件程度を初期値として試すことはできるが、それを公式の保証値やすべての環境に適用できる基準と考えるのは危険だ。実際のデータサイズとプラグイン構成を反映した検証環境で、メモリ使用量、処理時間、データベース負荷を確認して決めるべきである。

実行時間とメモリ制限を混同しない

メモリ不足とタイムアウトは、同じ移行処理で起きることがある。しかし、設定箇所も対策も異なる。

set_time_limit(0) は、PHPスクリプトの実行時間制限を変更するための関数だ。CLIで実行する場合は、Webサーバーのリクエスト制限を受けにくいことが多いが、ホスティング事業者や実行基盤が独自のCPU時間、プロセス時間、ジョブ時間を制限している場合は別である。

一方、wp config set はWordPressの設定ファイルへ定数などを書き込むためのWP-CLIコマンドであり、PHPランタイムの max_execution_time を変更するコマンドではない。wp config set max_execution_time ... のように実行しても、PHPの ini 設定を変更したことにはならない。

実行時間の設定を変更できるかどうかは、PHPの起動方法とホスティング環境によって決まる。CLIの実行時に設定を渡せる環境なら、PHPの -d オプションで max_execution_timememory_limit を指定する方法がある。ただし、サーバー側で上書きが禁止されている場合や、プロセス自体に別の上限がある場合は有効にならない。

また、メモリ上限を引き上げる場合も、単に512MBを1GBへ変更すればよいとは限らない。上限を増やすと一時的に処理が進む可能性はあるが、無制限に増える配列やキャッシュが残ったままなら、より大きなメモリを消費して停止するだけになる。

設定を調整する前に、次の項目を分けて確認したい。

  • PHP CLIが実際に読み込んでいる memory_limit
  • PHP CLIに設定された max_execution_time
  • WP-CLIが読み込むWordPressとプラグインの構成
  • シェルやジョブ管理基盤の実行時間制限
  • データベース側のロックやタイムアウト
  • 移行処理が外部APIやストレージへ依存しているかどうか

WordPress VIP環境に学ぶメモリリーク対策の標準化

WordPress VIPの大量処理向けの実装から学べるのは、特定の関数を呼べばすべて解決するということではない。重要なのは、長時間動くCLIプロセスに対して、メモリの後始末を処理フローへ組み込む考え方である。

VIP環境には、バルク処理やインメモリデータのクリーンアップを支援する環境固有のヘルパーが用意されている場合がある。こうしたヘルパーは、クエリ履歴やオブジェクトキャッシュなど、通常の短いリクエストでは問題になりにくい状態を、バッチの区切りで整理するために使われる。

ただし、VIP固有の関数を一般的なWordPress環境へそのままコピーできるわけではない。WPCOM_VIP_CLI_Command に属する処理や、VIP向けのバルク操作ヘルパーは、利用可能な環境とバージョンが限定される。一般環境で使える標準APIと、VIP環境だけで使える補助機能は明確に分けて扱うべきだ。

一般的な環境で再現するなら、次のような責務分担になる。

  • クエリ履歴を必要以上に保存しない
  • 投稿単位の処理後にキャッシュを無効化する
  • 一時変数や独自の配列をバッチの境界で破棄する
  • キャッシュ全体の削除は共有環境への影響を確認してから行う
  • タームやコメントのカウント遅延は、その対象にだけ適用する
  • 実行前後のメモリ使用量を記録する
  • エラー時にどのIDまで完了したかを追跡できるようにする

このように、対策を一つの「メモリリーク修正」として扱うのではなく、取得、変換、更新、キャッシュ、ログ、再実行の単位に分けると、環境が変わっても調整しやすい。

特に、失敗したときに最初から全件をやり直す設計は避けたい。処理済みのIDやバッチ番号を記録しておけば、停止した位置以降から再開できる。再実行時に同じ投稿を処理してもデータが壊れないよう、変換処理を冪等にしておくことも重要だ。

事前検証で確認するべきこと

本番で1万件をいきなり処理するのではなく、データの特徴を含んだ小規模な検証を先に行う。単純な投稿だけで試して問題がなくても、画像、カスタムフィールド、ターム、ショートコード、特殊なHTML、プラグイン独自データを含む投稿で同じ結果になるとは限らない。

検証では、少なくとも次の項目を確認する。

  • 処理件数に対してメモリ使用量が一方向に増え続けていないか
  • バッチ終了時にメモリが一定範囲へ戻るか
  • SAVEQUERIES の有効・無効で推移が変わるか
  • 特定のプラグインを停止した場合に差が出るか
  • clean_post_cache() の前後で挙動が変わるか
  • wp_cache_flush() を使った場合に通常アクセスへ影響しないか
  • タームやコメントのカウントが移行後に整合しているか
  • 途中停止後に同じ対象を再実行できるか
  • 移行元と移行先で本文、メタ、ターム、ステータスが一致しているか

メモリ使用量のログは、最初から細かく出しすぎない方がよい。ログそのものを大量に配列へ保存すれば、別の蓄積要因になる。標準出力や外部ログへ必要な情報だけを書き、移行スクリプト内では直近の値だけを扱う方が安全だ。

また、移行対象の一部だけで成功しても、全体の完走を断定することはできない。データ分布、プラグインのフック、サーバー負荷、データベースの状態によって、後半で問題が出る可能性は残る。実測していない結果については、安定すると言い切るのではなく、条件付きで評価するべきだ。

まとめ:メモリを増やす前に、保持されるものを減らす

WP-CLIで大量データを移行するとき、メモリ不足の原因は一つとは限らない。

SAVEQUERIES による $wpdb->queries の蓄積、投稿やメタデータのキャッシュ、ループ内で保持される配列、一時変数、プラグインの独自処理が重なって、長時間のプロセスを圧迫する。まずはどの層でメモリが増えているのかを計測し、その結果に応じて対策を選ぶ必要がある。

基本となる順番は明快だ。

1. 移行処理で不要な SAVEQUERIES を無効にする。

2. 投稿を全件配列へ載せず、分割して取得する。

3. 投稿単位で不要な変数を破棄し、必要に応じて clean_post_cache() を使う。

4. wp_cache_flush() は共有キャッシュへの影響を確認してから使う。

5. wp_defer_term_counting()wp_defer_comment_counting() の対象範囲を正しく理解する。

6. memory_limit や実行時間の変更は、PHP CLIとホスティング環境の設定として行う。

7. バッチごとのメモリ推移と処理位置を記録する。

WP_DEBUG_LOG をクエリ履歴の蓄積と同じように扱ったり、存在しないAPI名を前提にキャッシュの説明を組み立てたりすると、原因の切り分けを誤る。wp_defer_term_counting() に投稿更新全体の抑制を期待するのも同じだ。

大量移行を安定させる近道は、メモリ上限を大きくすることではない。どのデータをいつまで保持するのか、どのキャッシュをどの範囲で消すのか、どこをバッチの境界にするのかを決めることである。VIPの実装パターンも、一般環境で使える標準APIも、根底にある考え方は同じだ。

長時間動くWP-CLIコマンドでは、1件の処理が正しく終わることだけでなく、処理を何千回繰り返しても状態が膨らみ続けないことが重要になる。そこを計測しながら設計できれば、1万件規模のWordPress記事移行でも、環境に合わせて安全な実行条件を組み立てられる。

よくある質問

WP-CLIの大量移行でメモリ不足になる主な原因は何ですか?
SAVEQUERIESによる$wpdb->queriesの蓄積、投稿やメタデータのキャッシュ、一時変数、プラグイン独自処理などが考えられます。長時間動くCLIプロセスでは、これらが同じPHPプロセス内に残り続けることがあります。
WP-CLIの移行処理でSAVEQUERIESを無効にする方法は?
クエリ分析が不要な移行処理では、wp-config.phpなどでSAVEQUERIESを無効にします。WordPressの読み込み後に変更すると期待どおりに動かない場合があるため、読み込み前に設定を確定させます。
clean_post_cache()でPHPのメモリは解放されますか?
clean_post_cache()は指定した投稿に関連するWordPressコアのキャッシュを無効化しますが、PHP変数として保持された投稿配列やプラグイン独自データまで削除するものではありません。不要な変数は別途unset()などで整理する必要があります。
wp_cache_flush()を大量移行で使っても問題ありませんか?
wp_cache_flush()はオブジェクトキャッシュ全体を削除するため、共有環境では通常のアクセスに必要なキャッシュまで消す可能性があります。まず投稿単位のキャッシュ無効化や一時変数の解放を行い、必要性と影響範囲を確認してから使用します。
WP-CLIで1万件の投稿を安全に処理するにはどうすればよいですか?
投稿を一度に全件取得せず、一定数のIDやID範囲ごとに分割して処理します。バッチごとに不要な配列や一時変数を破棄し、メモリ使用量と処理位置を記録すると、途中停止後の再開や原因の切り分けがしやすくなります。

参考情報