ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

エンプラ向け大規模データ移行の自動化に成功した話

はじめに

こんにちは! ミツモアでBtoB SaaS「プロワン」の開発をしている、まゆです。 CTOからバトンを受け継ぎ、アドベントカレンダー2日目の担当です。 どうぞよろしくお願いします!

簡単に自己紹介をさせていただきます。 未経験からエンジニアになって約2年ちょっと、普段はフルスタックエンジニアとして開発に携わっています。 今回お話しする長期プロジェクトでは少し役割が異なり、1年半ほど主にPMとして奔走していました。

「エンタープライズ向けSaaSへの過去データ移行」 この言葉を聞いて、皆さんはどのくらいの規模を想像しますか?

私たちの会社は、BtoCの見積もりサービス「ミツモア」から始まり、その後BtoB SaaS「プロワン」へと事業を拡大してきました。プロワンのローンチ時は主に中小企業のお客様が中心だったため、データの規模もそれに応じていました。

そんな中、私たちは大手エンタープライズ企業への「プロワン」導入プロジェクトを担当することになりました。そこで直面したのが、1テーブルあたり最大約1億レコード・合計数億レコードという、これまで経験したことのない規模のデータ移行でした。

エンタープライズ企業への対応だったため、組織として確立されたノウハウもまだない中、私たちは「Node.jsスクリプトをEC2で実行する」という、これまでの手法の延長で計画を進めました。しかし、この見通しの甘さが、後に大きな問題を引き起こします。

この記事では、AWSセッションタイムアウトという壁に直面した私たちが、どのようにしてシンプルなシェルスクリプトで状況を打開し、納期を守り抜いたか、その具体的なプロセスを紹介します。

背景

プロジェクトの概要

  • 移行対象: フィールドサービス業務の過去10年分のマスターデータとトランザクションデータ
  • データ規模:
    • レコード数:数億レコード
    • CSVファイルサイズ:最大数十GB
    • テーブル数:20程度
  • 制約条件:
    • EC2インスタンスからの実行
    • データの整合性を保証
    • 外部キー制約によるデータ投入順序の厳守
      • 例:Divisionテーブル(部署)→ Userテーブル(ユーザー)→ Jobテーブル(仕事)の順に投入。ユーザーは部署に所属し、仕事には担当ユーザーが必須という依存関係があるためです。

データ移行の仕組み

S3にアップロードされたCSVファイルを、Node.js(TypeScript)で実装したインポートスクリプトで処理する方式を採用しました。

// import-job.ts の一部
export const importJob = async (params: ImportJobParams): Promise<void> => {
  // S3からCSVファイルをストリーム形式で取得
  const fileStreamHeaders = await awsTools.getFileWithStream(
    filePath,
    BUCKET_NAME,
  )

  // CSVをパースして一時テーブルにロード
  await pipeline(
    fileStreamHeaders,
    parseCSV({ headers: true }),
    // PostgreSQLのCOPYコマンドを使用した高速インポート
    copyFrom(`COPY ${TEMP_TABLE_NAME} FROM STDIN CSV HEADER`)
  )

  // データ変換・バリデーション・本番テーブルへの挿入
  // ...
}

スクリプト自体は、PostgreSQLのCOPYコマンドを活用した効率的な設計でした。しかし、問題はスクリプトの性能ではなく、その実行方法に潜んでいました。

直面した問題

このプロジェクトには、特有の難しさがありました。それは、移行対象データの複雑さです。

多数のテーブルが外部キーで密接に連携しているため、整合性を保った大規模なテストデータを事前に自前で用意することは極めて困難でした。そして、小規模なデータでの検証では、本番規模で初めて顕在化するようなパフォーマンスのボトルネックは捉えきれません。

そのため、お客様からご提供いただいたマスキング済みの実データを用いた大規模な移行リハーサルが、私たちにとって最初の本格的な検証の機会となりました。 そして、このリハーサルでスクリプトを実行したところ、想定外の問題が次々と発生したのです。

大量のデータ変換やバリデーションには数時間を要します。私たちが実行していたのは、下記のようなコマンドです。

# JobテーブルにJob.csvを投入
pnpm ts-node src/scripts/data_import.ts --tables Job --csvFileName Job.csv

この長時間の処理が引き金となり、下記の問題が発生しました。

  1. CPUの飽和: 大量データ処理によりCPU使用率が100%に張り付き、EC2インスタンスが応答不能になる
  2. セッションタイムアウト: 処理が終わる前にAWSのセッションが切れ、プロセスが強制終了してしまう
  3. ログの消失: セッションが切れると同時に、コンソール上の実行ログもすべて失われる。
  4. 進捗の不明瞭化: ログがないため、処理が正常に進んでいるのか、エラーで止まったのか判断できない

最初は1億レコードのCSVファイルをそのまま処理しようとしましたが、数時間待っても完了の兆しは見えませんでした。そこで、処理単位を小さくするためCSVファイルを500万レコードずつに分割したところ、ようやく個々の処理は完了するようになりました。

しかし、これは根本的な解決にはなりませんでした。500万レコードの処理ですらセッションタイムアウトに陥ることがあり、結局、進捗が不明な状態は変わらなかったのです。

さらに、分割した多数のCSVファイルを手動で一つずつ実行する必要が生じました。一つの処理が終わるのを待ち、次のコマンドを投入する…その間、担当者は画面に張り付きにならざるを得ず、他の開発業務が完全にストップしてしまいました。

最も深刻だったのは、エラー発生時の対応です。セッションと共にログが消えるため、エラーの原因調査が思うように進みませんでした。

幸い、これらの問題が発覚したのは本番移行前のリハーサル段階でした。しかし、このままでは本番移行の目処が全く立ちません。 刻一刻と迫る納期を前に、「リハーサルすら完了できないのに、本番を成功させられるのか」という焦燥感がチーム全体を包み込んでいました。

シェルスクリプトというシンプルな解決策

Node.jsのプロセス管理ツール(PM2など)の導入や、SQSのようなキューイングサービスを利用して実行基盤自体を改修する方法など取れる方法は他にもあったと思うのですが・・・

既存のアプリケーションに大きな変更を加えるのは、テスト工数の増加を考えるとリスクが高くスケジュールの変更が必要になるので難しいと判断しました。

そこで、「完成している実行スクリプトはそのままに、その“呼び出し方”だけを工夫する」というアプローチを選択しました。

その結果、最も迅速かつ確実に導入できるシェルスクリプトが最適解となりました。

Step 1: nohup&でセッションから切り離す

まず、セッションが切れてもプロセスが終了しないように、nohup&を使ってバックグラウンドで実行する方法に切り替えました。

# nohupと&を使用したバックグラウンド実行
nohup pnpm ts-node src/scripts/data_import.ts --csvFileName user.csv >> data_import/user/import-1.log 2>&1 &

# 実行後、プロセスIDが表示される
[1] 12345

ポイントは以下の通りです。

  • nohup: ターミナルのセッションが切れてもプロセスを継続させる。
  • &: コマンドをバックグラウンドで実行し、すぐに次の操作が可能になる。(プロセスID(PID)が返却されるのが重要)
  • >>: 実行ログをファイルに追記。これによりログが永続化される。
  • 返却されたPIDを使い、ps -p 12345tail -f [ログファイル]でいつでも進捗を確認できるようになった。

Step 2: 逐次実行を自動化するシェルスクリプト

次に、手動での連続実行から解放されるため、分割したファイルを順番に処理するシェルスクリプトを作成しました。

#!/bin/bash

# 処理対象のCSVファイルに対応するコマンド引数のリスト
declare -a script_options=(
    "--tables Job --csvDirectory data_import --csvFileName Job_00.csv"
    "--tables Job --csvDirectory data_import --csvFileName Job_01.csv"
    # ... Job_20.csvなど分割した数に応じて続いていく
)

# 各ファイルを順次処理
for opts in "${script_options[@]}"; do
    start_time=$(date +%Y-%m-%dT%H:%M:%S)
    echo "[$start_time] Processing: $opts"

    # CSVファイル名からログファイル名を動的に生成
    csv_file_name=$(echo "$opts" | grep -oP '(?<=--csvFileName\s)\S+')
    log_file="import/job/output-${csv_file_name}-$(date +%Y%m%d%H%M%S).log"

    # nohupでバックグラウンド実行
    nohup pnpm ts-node src/scripts/data_import.ts \
    $opts > "$log_file" 2>&1 &

    PID=$!
    echo "Started process with PID: $PID. Log file: $log_file"

    end_time=$(date +%Y-%m-%dT%H:%M:%S)
    echo "[$end_time] Finished: $opts"

    # データベースの現在の行数を出力して進捗を可視化
    count=$(psql "$DATABASE_URL" -c 'SELECT COUNT(*) FROM "Job";')
    echo "Current total rows in Job table: $count"
    echo "--------------------------------------------------"
done

echo "All files for Job table have been processed successfully."

実装のポイント

  1. プロセスIDによる進捗管理$!で直前のバックグラウンドプロセスのPIDを取得。これにより、別のターミナルからpsコマンドやtail -fで特定の処理の状況を正確に追跡できます。
  2. コマンド引数の配列管理: 処理対象を配列で管理することで、ファイルの追加・削除や、特定のファイルだけをコメントアウトして実行対象から外すといった操作が容易になりました。
  3. 進捗の可視化: 各処理の完了後にpsqlでテーブルの総行数を出力しました。これにより、誰がログを見ても「処理が着実に進んでいる」と分かり、安心できるようになりました。
  4. 確実なログ記録> "$log_file" 2>&1により、正常な出力(標準出力)とエラー出力(標準エラー出力)の両方を一つのログファイルに記録。エラー発生時の原因調査に必要な情報を漏らさず保存できるようになりました。

結果

  • 処理時間の短縮:手動実行と監視で推定7営業日以上かかっていた作業が、夜間も含めて自動実行が可能になり3営業日に短縮できました!
  • 人的コスト削減: 担当者1名が日中ほぼスクリプト実行に付きっきりの状態から、定期的なログ確認のみで済むようになりました。
  • エラー対応の迅速化:ログが確実に残るため、問題箇所の特定と、該当ファイルからの再実行が迅速に行えるようになった。
  • 生産性の向上: 自動化により、担当者が監視作業から解放され、他の開発業務に集中できるようになりました。
  • チームからの信頼と評価: 先輩エンジニアから「自分で課題を発見し、考えて行動し、結果を出した」点を高く評価してもらえました。
  • プロジェクトへの貢献: データ移行を計画通りに完了させ、プロジェクト全体の納期遵守に貢献できました。

学び

技術的な学び

  1. 基本の組み合わせの力: 最新技術でなくとも、nohup&といった基本的なコマンドを組み合わせることで、大きな課題を解決できる。
  2. ログ設計の価値: トラブルシューティングの効率は、いかに適切なログを残せるかにかかっている。
  3. 規模を前提とした設計とテスト:ローカル環境の少量データで成功したコードが、本番の大量データでは全く通用しない(例:SQLのインデックス非効率化など)ことを常に想定しておく必要がある。

マインドセットの学び

この経験から得た最も大きな学びは、経験が浅くても、主体的に問題解決に取り組むことの重要性です。

  • 目の前の問題から逃げず、まずは現状を冷静に整理する。
  • 複雑な解決策に飛びつく前に、シンプルな方法から試す。
  • 基本的なツールや知識を侮らず、応用できないか考えてみる。

まとめ

全体で数億レコードという大規模データ移行で直面した危機。

正直、経験の浅い自分では解決できないのではないかと不安でした。改善策を考えるより、非効率でも手動で実行し続けた方が確実なのでは、という迷いもありました。

しかし、諦めずに基本的なツールを組み合わせて解決策を実装し、プロジェクトを成功に導くことができました。その時、経験豊富な先輩エンジニアからかけてもらった言葉が、今でも心に残っています。

「シンプルな方法で一人で考えて試して解決まで持っていけるの本当にすごい!このスクリプトがなかったら絶対に終わらなかったよ!」

この言葉は、エンジニアになってまだ日が浅い私にとって、大きな自信となりました。

経験が浅くても、チームに貢献できる

この経験を通じて、私の考え方は大きく変わりました。

以前の私は、優秀な先輩エンジニアたちを前にして、「自分のような若手の意見は役に立たないかもしれない」と引け目を感じ、発言を飲み込んでしまうことがありました。

大切なのは経験の有無ではなく、目の前の問題に真摯に向き合い、「どうすればできるか」を考え抜く姿勢なのだと実感しました。

nohup&、そしてシェルスクリプト。これらは決して目新しい技術ではありません。しかし、適切な場面で適切に使うことで、大規模な問題にも十分立ち向かえるという貴重な経験となりました。

この記事が、同じようにデータ移行や長時間バッチ処理の課題に直面している方々の助けとなることを願っています。

そして、かつての私のように、経験の浅さから自分の意見に自信が持てずにいる若手エンジニアの皆さんの背中を少しでも押すことができたら、とても嬉しい限りです。


参考情報