Cronで長期間動かすNode.jsプロセスでメモリリークを防ぐキャッシュクリアパターン

Cronで長期間動かすNode.jsプロセスでメモリリークを防ぐキャッシュクリアパターン

Cronで動かす長期実行Node.jsプロセスで、シングルトンのインスタンスプロパティにデータが蓄積してメモリリークが発生した問題と、finallyで明示的にキャッシュクリアする解決パターンを紹介します。
2026.07.26

はじめに

Cronスケジューラで24時間動き続けるNode.jsプロセスを作ったとき、最初は問題がありませんでした。しかし数日後、プロセスのメモリ使用量が徐々に増えていることに気づきました。

原因は、タスクを実行するクラスのインスタンスがプロセス起動時に1つだけ生成され、そのインスタンスプロパティに処理ごとのデータが蓄積されていたことです。

この記事では、その問題と解決策として採用した「明示的キャッシュクリア」パターンを紹介します。

全体像

nodejs-long-running-process-memory-management-accumulation

問題: シングルトンに状態が蓄積する

Cronで実行するタスクは、シングルトンとしてエクスポートしています。

// 型定義は省略
type ShipmentRecord = { id: string; status: string; /* ... */ };
type ArrivalRecord = { id: string; arrivedAt: Date; /* ... */ };

class DataSyncTask {
  private shippingRecords: ShipmentRecord[] = [];   // 処理ごとにデータが入る
  private arrivalRecords: ArrivalRecord[] = [];

  public async main() {
    await this.fetchShippingRecords(); // ← ここでデータを読み込む
    await this.createShippingCsv();
    // 処理が終わっても shippingRecords にデータが残ったまま
  }
}

export default new DataSyncTask(); // シングルトン

export default new DataSyncTask() でシングルトンにすると、プロセス全体で1つのインスタンスが共有されます。main() が呼ばれるたびにプロパティにデータが書き込まれますが、前回の実行データが残ったままになります。

毎時間 main() が呼ばれ、毎回数百件のレコードをプロパティに蓄積すると、数日でメモリ使用量が無視できないレベルになります。

解決策: finally で明示的にクリアする

nodejs-long-running-process-memory-management-lifecycle

data-sync-task.ts
class DataSyncTask {
  private shippingResult = { error: null as string | null, fileId: "" };
  private arrivalResult = { error: null as string | null, fileId: "" };
  private noticeShippingResult = { error: null as string | null, fileId: "" };
  private noticeArrivalResult = { error: null as string | null, fileId: "" };

  private shippingRecords: ShipmentRecord[] = [];
  private arrivalRecords: ArrivalRecord[] = [];
  private noticeShippingRecords: NoticeRecord[] = [];
  private noticeArrivalRecords: NoticeRecord[] = [];

  public async main() {
    try {
      await Promise.all([
        this.step1_syncShipping(),
        this.step2_syncArrival(),
        this.step3_syncNoticeShipping(),
        this.step4_syncNoticeArrival(),
      ]);
      await this.broadcastOutput();
    } finally {
      this.キャッシュクリア(); // 成功・失敗どちらでも必ず実行
    }
  }

  private キャッシュクリア() {
    // 状態オブジェクトを初期値にリセット
    this.shippingResult = { error: null, fileId: "" };
    this.arrivalResult = { error: null, fileId: "" };
    this.noticeShippingResult = { error: null, fileId: "" };
    this.noticeArrivalResult = { error: null, fileId: "" };

    // 配列を空にする
    this.shippingRecords = [];
    this.arrivalRecords = [];
    this.noticeShippingRecords = [];
    this.noticeArrivalRecords = [];
  }
}

finally に置く理由:

try {
  await this.処理();
} finally {
  this.キャッシュクリア(); // ← 成功でも失敗でも必ず実行される
}
  • 処理が正常終了した → クリアして次回実行に備える
  • 処理でエラーが発生した → クリアして不整合な状態を持ち越さない

どちらのケースでも「次の実行が始まるときにはプロパティが空」という保証が成り立ちます。

なぜガベージコレクションに任せないのか

「配列を [] で上書きしなくても、古い配列への参照がなくなればGCが回収するのでは?」という疑問があります。

理論的にはそうですが、シングルトンインスタンスのプロパティはインスタンスが生きている限り参照を保持し続けます

class DataSyncTask {
  private records: Record[] = []; // シングルトンが参照を持ち続ける

  private async step1() {
    this.records = await fetchRecords(); // 1000件のデータ
    // step1が終わっても、this.records は1000件のデータを参照したまま
  }
}

this.records はクラスインスタンスが保持しているので、クラスインスタンスが生きている限り(= プロセスが続く限り)GCの対象になりません。main() が何回呼ばれても、最後に代入したデータが残り続けます。

明示的に this.records = [] とすることで、古い配列への参照が切れてGCの対象になります。

インスタンスプロパティをキャッシュとして使う理由

「そもそもプロパティに状態を持たなければいいのでは?」という考え方もあります。

// メソッドの戻り値で引き渡すアプローチ
public async main() {
  const records = await this.fetchRecords();
  const output = await this.process(records);
  await this.save(output);
}

これはシンプルですが、処理ステップが増えるにつれてメソッドの引数が増えていきます。

// ステップが増えると引数が増える
private async step3(
  shippingRecords: ShipmentRecord[],
  arrivalRecords: ArrivalRecord[],
  noticeShippingRecords: NoticeRecord[],
  noticeArrivalRecords: NoticeRecord[],
) { ... }

インスタンスプロパティをキャッシュとして使うと、各ステップが必要なデータを this.〇〇 で参照できます。引数のバケツリレーが不要になり、main() がシンプルに保たれます。

public async main() {
  await Promise.all([
    this.step1_syncShipping(),       // this.shippingRecords を自分で使う
    this.step2_syncArrival(),        // this.arrivalRecords を自分で使う
  ]);
}

このパターンを採用する場合、明示的なキャッシュクリアとセットで使うことが前提です。

実際のメモリへの影響

Cronで20以上のタスクが動いている環境で、各タスクが処理するデータ量は:

  • 小さいもの: 数十件
  • 大きいもの: 数千件の配列や、中間データのMap

1回の実行で使うデータは数MB程度でも、クリアせずに蓄積すると数時間〜数日で数百MBになります。キャッシュクリア() を入れてから、メモリ使用量が実行後に元のレベルに戻るようになりました。

まとめ

長期実行するNode.jsプロセスでシングルトンを使うときのポイントです。

finally でキャッシュをクリアする: 成功・失敗を問わず必ず実行されるため、次回実行時に前回のデータが残らない。

GCに任せず明示的にリセットする: シングルトンのプロパティはインスタンスが生きている限りGCされない。= []= null で参照を切る。

キャッシュクリアメソッドをまとめる: 全プロパティのリセットを1つのメソッドにまとめることで、クリアし忘れを防ぐ。追加したプロパティは必ずこのメソッドにも追記する。

シングルトン + インスタンスプロパティキャッシュは便利なパターンですが、クリアとセットで使わないと長期実行で問題が出ます。

この記事をシェアする

関連記事