0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発SaaS】Redmineの3,000件チケット移行でブラウザが落ちた話 → 100万件級でも壊れない自動分割設計にした

0
Last updated at Posted at 2026-09-08

概要

個人開発で、Redmine からのリスクゼロ移行・併走運用に対応したチーム向けタスク管理 SaaS Taski(タスキ) を開発・運用しています。

Redmine からのデータ移行機能を作り、実データで検証したところ、チケット約3,000件・添付ファイル約2,200件という「そこそこ大きいがバカでかくはない」規模で、ブラウザが固まって不安定になるという問題に遭遇しました。原因と対処法、そしてその対処が「もしチケットが100万件あったらどうなるか」までスケールするのかを、実装コードと合わせて共有します。

何が起きたか

移行フローはシンプルです。

  1. Node.js の CLI スクリプトが Redmine の REST API からチケット・コメント・Wiki・添付ファイルを取得
  2. 添付ファイルは Base64 エンコードして、チケットの JSON にそのまま埋め込む(別ファイル配信のインフラを用意しなくて済むため)
  3. 1つの JSON ファイルとして書き出す
  4. ブラウザ側(Taski のインポート画面)でその JSON ファイル全体を FileReader で読み込み、JSON.parse してから一括登録する
// 問題があった実装のイメージ
const exportData = {
  issues: allIssues.map((issue) => ({
    ...issue,
    attachments: issue.attachments.map((a) => ({
      filename: a.filename,
      // 添付ファイルの中身をBase64でチケットの中に丸ごと埋め込む
      content_base64: fetchAttachmentAsBase64(a.content_url),
    })),
  })),
};
fs.writeFileSync('export.json', JSON.stringify(exportData));

チケット数が数百件程度のテストでは何の問題もありませんでした。ところが実際の顧客データ(チケット約3,000件、添付約2,200件)で試したところ、書き出された JSON ファイルは数百MB規模に膨れ上がり、ブラウザ側で読み込んだ瞬間にタブが応答不能になりました。

根本原因

原因はシンプルで、添付ファイルを Base64 でチケットの中に埋め込む設計にしたことで、ファイルサイズがチケット数と添付数に対してほぼ線形に増加するためです。Base64 エンコードはバイナリよりデータ量が約1.37倍に膨らむこともあり、画像・PDF添付が多いプロジェクトほど深刻になります。

さらに、ブラウザ側は「1つの巨大な JSON 文字列を丸ごと JSON.parse してからメモリ上に展開する」という実装だったため、ファイルサイズがそのまま実効メモリ使用量に直結していました。数千件規模でこの状態になるということは、1万件・10万件を超えるプロジェクトでは確実に破綻する設計だったわけです。

対処法: エクスポート時点で自動分割する

サーバー側で分割してブラウザに逐次配信する構成に作り直すのが理想ですが、Taski の移行機能は「ユーザーがローカルで CLI を実行し、生成された JSON ファイルをブラウザにドラッグ&ドロップする」というインフラ不要の設計にしているため、分割の責務をエクスポート側(CLI スクリプト)に寄せる方針にしました。

// migrate-redmine.js の分割ロジック(実装を簡略化)
const CHUNK_SIZE = parseInt(ARGS['chunk-size'], 10) || 500; // 既定は1ファイルあたり500件

const chunks = [];
for (let i = 0; i < fullIssues.length; i += CHUNK_SIZE) {
  chunks.push(fullIssues.slice(i, i + CHUNK_SIZE));
}

chunks.forEach((issuesInChunk, idx) => {
  const outFile = `export.part${idx + 1}of${chunks.length}.json`;
  fs.writeFileSync(outFile, JSON.stringify({
    issues: issuesInChunk,
    chunk_index: idx + 1,
    chunk_count: chunks.length,
    // Wikiページは全ファイルに含めると重複登録されるため、1個目のファイルにのみ含める
    wiki_pages: idx === 0 ? wikiPages : [],
  }));
});

これで、チケット3,000件のプロジェクトなら export.part1of6.json 〜 export.part6of6.json の6ファイルに自動分割されます。ブラウザ側は毎回500件分の JSON しか読み込まないため、メモリ使用量は常に一定です。ユーザーは分割ファイルを1つずつ順番にインポート画面へ読み込ませるだけで、取り込み自体は既存の「同一IDの再取り込みはスキップする」仕組みでそのまま冪等に動きます。

では、チケットが100万件あったらどうなるか

ここからは実測ではなく設計上の考察です。正直に書きますが、実際に100万件規模の Redmine インスタンスでこの機能を検証したことはまだありません。手元で検証できた最大規模は前述の約3,000件です。

ただし、この分割方式のポイントは「1ファイルあたりの件数を固定し、ファイル数を件数に応じて増やす」という設計になっている点です。つまり:

  • チケット3,000件 → 6ファイル(500件 × 6)
  • チケット10万件 → 200ファイル
  • チケット100万件 → 2,000ファイル

1ファイルあたりのメモリ使用量は件数が増えても変わりません。 ボトルネックは「メモリでクラッシュするか」から、「2,000ファイルを人間が1つずつインポートし続けられるか」という、まったく別の運用上の問題に移ります。これは技術的には自動連続インポート(複数ファイルを一括選択してキューで順次処理する)で解決可能な話で、実際に次の改善候補として検討しています。

「メモリで落ちる」という物理的な壁を、「作業が退屈になる」という運用上の壁に変換できた、というのが今回の設計変更の実質的な成果だと捉えています。

まとめ

  • 添付ファイルを本体データに埋め込む設計は、件数に対してファイルサイズが線形に増加するため、初期段階では見えにくいスケール限界を持つ
  • ブラウザで1ファイル丸ごと処理する構成は、サーバー側でストリーミング処理する余地がない分、エクスポート側での分割が現実的な解決策になる
  • 「1チャンクあたりのリソースを固定し、チャンク数で全体量を吸収する」設計にしておくと、実測していない規模に対しても説明可能な形でスケーラビリティを主張できる(過信は禁物だが、少なくとも「なぜ壊れないと考えているか」を言語化できる)

同じように「個人開発でエクスポート/インポート機能を作っていたら、想定より大きいデータで急に壊れた」という経験がある方の参考になれば幸いです。

なお、この記事で紹介した分割ロジックは実際にTaskiに実装されています。手元のRedmine CSVをアップロードするだけで、インポート推奨回数や自動分割される見込みファイル数をその場で確認できる無料ツールも作りました(サーバー送信なし・登録不要)。よければ試してみてください。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?