80万件の Contentful エントリのマイグレーション

こんにちは。株式会社Uzabaseの FO/EIチーム です。

Uzabaseでは一部サービスでContentfulを使用しているのですが、今回あるコンテントタイプを 数値型から文字列型に変更する必要が出てきました。対象エントリは約80万件です。
Contentfulのマイグレーション、特に大量データのマイグレーションはあまり情報がなく、ニッチかもしれませんが、同じ課題に直面している方の参考になればと思い、今回の移行で直面した課題と解決策を共有します。

出来るだけそのままをお伝えしたほうが良いかと思うので、上手く行かなかったものも含めお話いたします。

マイグレーション概要

マイグレーションの内容は、idという数値フィールドの文字列への変更です。
しかしContentfulでは既存のフィールドの型を変更出来ません。
ですのでマイグレーション方式としては、拡張、移行、縮小の3段階を踏む方式を採用しました。

やることとしては

  1. idV2という文字列フィールドを追加
  2. 既存のidの値をidV2にコピーする
  3. アプリケーションのidへの書き込みをid、idV2の両方に行うように変更
  4. アプリケーションのidへの参照をidV2を参照するように変更
  5. 元のidフィールドを削除

という流れになります。

このブログでは、主に2の「idの値をidV2にコピーする」部分の移行スクリプトについて、公式マイグレーションツールでうまくいかなかった理由と、自前スクリプトで解決した課題を説明します。

公式マイグレーションツールを試すものの断念

まずためしたのは、Contentfulが提供しているマイグレーションツールであるcontentful-migrationを使う方法です。
こちらで作成するコードは非常にシンプルで、20 行程度で書けます。

migration.transformEntries({
  contentType: 'expert',
  from: ['id'],
  to: ['idV2'],
  transformEntryForLocale: (fromFields, currentLocale) => {
    const numValue = fromFields['id']?.[currentLocale]
    if (numValue == null) return undefined
    return { idV2: String(numValue) }
  },
  shouldPublish: 'preserve',
})

テスト環境では動いたものの、内部の実装を確認しデータの多い本番環境では実施しませんでした。
contentful-migrationtransformEntries は内部で全件取得 → 逐次更新 → 逐次公開を行っており、大量データで全件取得処理に懸念があったためです。

https://github.com/contentful/contentful-migration/blob/main/src/lib/action/entry-transform.ts

自前スクリプトを作成する

さて、私達の環境ではマイグレーションツールで上手くいくか不安があった、停止や再開がコントロール出来ないため、直接CMAを叩くスクリプトを自前で書くことにしました。
ここでの課題は主に4点ありました。

  • レートリミット
  • 公開状態の保全
  • デッドライン管理
  • 本番環境の件数が多すぎてfilterやorder byが出来ない

です。

作成した移行スクリプト

まずは、最終的な移行スクリプトの全体構造をお見せします。ブログ用にある程度簡略化してあります。
最初からこの形で書いたわけではなく、試行錯誤の末にこの形に落ち着きました。 後続のセクションで、ここに至った経緯をお話します。

while (true) {
  assertBeforeDeadline('次バッチ取得前', deadlineMs)

  const entries = await withContentful429Retry('entry.getMany', () =>
    client.entry.getMany({
      query: { content_type: 'expert', skip, limit: batchSize },
    }),
  )
  if (entries.items.length === 0) break

  for (const entry of entries.items) {
    assertBeforeDeadline(`entry ${entry.sys.id}`, deadlineMs)
    if (entry.fields.idV2 != null) continue               // 冪等性

    const republish = shouldRepublishAfterUpdateLikePreserve(entry.sys)
    entry.fields.idV2 = { 'en-US': String(entry.fields.id?.['en-US']) }

    const updated = await withContentful429Retry(
      `entry.update(${entry.sys.id})`,
      () => client.entry.update({ entryId: entry.sys.id }, entry),
    )
    if (republish) {
      await withContentful429Retry(`entry.publish(${updated.sys.id})`, () =>
        client.entry.publish({ entryId: updated.sys.id }, updated),
      )
    }
    await sleep(interval)
  }
  skip += batchSize
}

課題 1: レートリミットエラー時のリトライ

Contentful CMAは429を返すときX-Contentful-RateLimit-Resetヘッダで待機秒数を教えてくれます。
parseRateLimitResetSeconds関数内でそれを取得し、待機時間を計算してリトライするようにしています。

async function withContentful429Retry<T>(
  label: string, fn: () => Promise<T>, options?: { maxAttempts?: number },
): Promise<T> {
  const maxAttempts = options?.maxAttempts ?? 8
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (err) {
      if (getHttpStatus(err) !== 429 || attempt === maxAttempts) throw err
      const resetSec = parseRateLimitResetSeconds(err)
      const baseMs = resetSec != null ? resetSec * 1000 : 1000 * attempt
      await sleep(Math.max(baseMs, 1000))
    }
  }
  throw new Error('unreachable')
}

429だけをリトライするようにしているのは、「待てば通る」のが429だけで、4xx/5xxはリトライしても無意味と考えたからです。
待機時間はヘッダを優先し、なければリニアバックオフ、最小1秒の下限で安全弁としています。

ただし、実際になんどかマイグレーションを実行してみると、500エラーが返ってきたレコードでも、再度実行すれば成功することがありました。
結果論ですが500エラーもリトライするようにしておけばよかったかもしれません。

課題 2: レートリミットへの影響

これについては、インターバルを環境変数で設定出来るようにしました。 営業時間中は長めに設定しレートリミットへの影響を抑えながら試す、実施実績から短くする等、
調整出来るおかげで色々と役にたつ場面が多かったです。

課題 3: 公開状態の保全 — version 比較で判定する

エントリを更新すると version が +1 され、「下書き変更あり」状態になります。公開済みなら再公開して元に戻す必要がありますが、移行前の下書き変更を勝手に公開してはいけません。

function shouldRepublishAfterUpdateLikePreserve(sys: {
  version: number; publishedVersion?: number; archivedVersion?: number;
}) {
  if (sys.archivedVersion != null) return false // アーカイブ済みは再公開しない
  if (sys.publishedVersion == null) return false // 未公開は再公開しない
  if (sys.version > sys.publishedVersion + 1) return false // 下書き変更がある場合は再公開しない
  return true
}

公開直後は version === publishedVersion + 1 になります。差が 2 以上なら移行前に下書き編集が入っているので、再公開しません。

課題 4-1: デッドライン管理 — 途中で止める

メンテナンスウィンドウに収まらない可能性があるため、環境変数でデッドラインを設定し、超過したら正常終了(終了コード 0)します。 ※ 呼び出し元でcatchして終了コード0で終了するようにしています。

function assertBeforeDeadline(context: string, deadlineMs: number | undefined): void {
  if (deadlineMs != null && Date.now() >= deadlineMs) {
    throw new MigrationDeadlineExceeded(context)
  }
}

課題 4-2: デッドライン管理 — 再開する

ここが一番試行錯誤した部分です。

80万件の更新実行にはかなり時間がかかります。
最終の結果で言うと、僕らの環境ではのべ実行時間は100時間以上になりました。

まず、実施の時間帯ですが、営業時間中のマイグレーションはレートリミットを消費してしまい本来の運用に支障が出る可能性があるためやめました。
そのため夜間にマイグレーションを実行し、途中で止めて再開することとしました。

再開のための方法は、当初はidV2の有無でフィルターして再開する方法を試しましたが、本番環境ではエラーになりました。 試しにorder byもやってみましたが、これも本番環境ではエラーとなりました。 これもデータ量のためかもしれません。

色々議論したのですが、最終的にはシンプルに「毎回全件実行する」に落ち着きました。 試しにidV2を付与済みのものは更新処理をskipするようにして再処理した場合の時間を計測したところ、仮に全てがスキップの場合2時間程度で終わることがわかりました。 getだけなら大した時間がかからないことがわかりましたので、再開のための特別な処理は入れず、冪等にして何度でも同じスクリプトを走らせることにしました。

おわりに

ここにあげたもの以外にも、予期せぬデータ(publishされてるが必須設定されてる項目に値が無いためpublish出来ない等)や、原因不明で500エラーになるものの、再度実行するとなぜか成功する等細かい予期せぬ問題がありました。
昔から存在する大量データの移行は、テスト環境で実行し想定を立てたとしても、本番でやってみないとわからない問題があるなと改めて感じました。 だからこそ、そういうものがある前提でログの残し方や途中で止めて再開する方法、見積もりを行うのが大事だなと思いました。

私達の経験したことが、どなたかの参考になれば幸いです。

🎓 学生の方へ | 1day インターンイベント開催!

Speeda ソフトウェアエンジニア職 のインターンイベントを開催します!

Speedaプロダクトチームではアジャイル開発手法の一種であるXP(エクストリームプログラミング)を実践しており、今回のインターンではプラクティスの中でも特徴的なペアプログラミングをみなさんに体験していただきます!

チーム開発を更に深めることができるペアプログラミングの経験は我々の文化を体験することや、今後のエンジニアとしての活動の幅を広げるきっかけになると思います。

少しでもご興味をお持ちいただけた方は、ぜひお気軽にエントリーください。

エントリーはこちら!

Page top